mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-07 11:37:26 +02:00
✨ Add admin control panel (#1726)
This commit is contained in:
parent
1b3b3aac50
commit
c5724f0118
27 changed files with 1672 additions and 40 deletions
|
@ -342,5 +342,45 @@
|
||||||
"tooManyAttempts": "Too many attempts, please try again later.",
|
"tooManyAttempts": "Too many attempts, please try again later.",
|
||||||
"unknownError": "Something went wrong",
|
"unknownError": "Something went wrong",
|
||||||
"livePollCount": "{count} live",
|
"livePollCount": "{count} live",
|
||||||
"upcomingEventCount": "{count} upcoming"
|
"upcomingEventCount": "{count} upcoming",
|
||||||
|
"adminSetupCta": "Make me an admin",
|
||||||
|
"adminSetup": "Admin Setup",
|
||||||
|
"adminSetupTitle": "Are you the admin?",
|
||||||
|
"adminSetupDescription": "Click the button below to make yourself an admin user.",
|
||||||
|
"controlPanel": "Control Panel",
|
||||||
|
"license": "License",
|
||||||
|
"licenseKey": "License Key",
|
||||||
|
"licenseeName": "Licensee Name",
|
||||||
|
"licenseeEmail": "Licensee Email",
|
||||||
|
"purchaseDate": "Purchase Date",
|
||||||
|
"seatCount": "{count, plural, one {# seat} other {# seats}}",
|
||||||
|
"noLicenseKey": "No license key found",
|
||||||
|
"noLicenseKeyDescription": "This instance doesn’t have a license key yet.",
|
||||||
|
"purchaseLicense": "Purchase license",
|
||||||
|
"addLicenseKey": "Add license key",
|
||||||
|
"users": "Users",
|
||||||
|
"userCount": "{count, number, ::compact-short}",
|
||||||
|
"unlicensed": "Unlicensed",
|
||||||
|
"deleteUser": "Delete User",
|
||||||
|
"areYouSureYouWantToDeleteThisUser": "Are you sure you want to delete this user?",
|
||||||
|
"typeTheEmailAddressOfTheUserYouWantToDelete": "Type the email address of the user you want to delete.",
|
||||||
|
"emailDoesNotMatch": "The email address does not match.",
|
||||||
|
"noUsers": "No users found",
|
||||||
|
"noUsersDescription": "Try adjusting your search",
|
||||||
|
"admin": "Admin",
|
||||||
|
"user": "User",
|
||||||
|
"searchUsers": "Search users...",
|
||||||
|
"userRoleAll": "All",
|
||||||
|
"userRoleUser": "Users",
|
||||||
|
"userRoleAdmin": "Admins",
|
||||||
|
"licenseLimitWarning": "You have exceeded the limits of your license. Please <a>upgrade</a>.",
|
||||||
|
"goTo": "Go to <b>{page}</b>",
|
||||||
|
"createNewPoll": "Create new poll",
|
||||||
|
"deleteUserTitle": "Delete User",
|
||||||
|
"deleteUserDesc": "Are you sure you want to delete this user?",
|
||||||
|
"deleteUserHint": "Type <b>{email}</b> to delete this user.",
|
||||||
|
"changeRole": "Change role",
|
||||||
|
"licenseType": "License Type",
|
||||||
|
"removeLicense": "Remove License",
|
||||||
|
"removeLicenseDescription": "Are you sure you want to remove this license?"
|
||||||
}
|
}
|
||||||
|
|
22
apps/web/src/app/[locale]/(space)/admin-setup/actions.ts
Normal file
22
apps/web/src/app/[locale]/(space)/admin-setup/actions.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { hasAdmins } from "@/features/user/queries";
|
||||||
|
import { requireUser } from "@/next-auth";
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
|
||||||
|
export async function makeAdmin() {
|
||||||
|
if (await hasAdmins()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await requireUser();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { makeAdmin } from "./actions";
|
||||||
|
|
||||||
|
export function MakeMeAdminButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
await makeAdmin();
|
||||||
|
router.replace("/control-panel");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
loading={isPending}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<Trans i18nKey="adminSetupCta" defaults="Make me an admin" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
69
apps/web/src/app/[locale]/(space)/admin-setup/page.tsx
Normal file
69
apps/web/src/app/[locale]/(space)/admin-setup/page.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import { PageIcon } from "@/app/components/page-icons";
|
||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
PageContent,
|
||||||
|
PageHeader,
|
||||||
|
PageTitle,
|
||||||
|
} from "@/app/components/page-layout";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
EmptyStateDescription,
|
||||||
|
EmptyStateFooter,
|
||||||
|
EmptyStateIcon,
|
||||||
|
EmptyStateTitle,
|
||||||
|
} from "@/components/empty-state";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { hasAdmins } from "@/features/user/queries";
|
||||||
|
import { isSelfHosted } from "@/utils/constants";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { CrownIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { MakeMeAdminButton } from "./make-me-admin-button";
|
||||||
|
|
||||||
|
export default async function AdminSetupPage() {
|
||||||
|
if (!isSelfHosted) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await hasAdmins()) {
|
||||||
|
redirect("/control-panel");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader>
|
||||||
|
<PageTitle>
|
||||||
|
<PageIcon color="indigo">
|
||||||
|
<CrownIcon />
|
||||||
|
</PageIcon>
|
||||||
|
<Trans i18nKey="adminSetup" defaults="Admin Setup" />
|
||||||
|
</PageTitle>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<EmptyState className="h-full">
|
||||||
|
<EmptyStateIcon>
|
||||||
|
<CrownIcon />
|
||||||
|
</EmptyStateIcon>
|
||||||
|
<EmptyStateTitle>
|
||||||
|
<Trans i18nKey="adminSetupTitle" defaults="Are you the admin?" />
|
||||||
|
</EmptyStateTitle>
|
||||||
|
<EmptyStateDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="adminSetupDescription"
|
||||||
|
defaults="Click the button below to make yourself an admin user."
|
||||||
|
/>
|
||||||
|
</EmptyStateDescription>
|
||||||
|
<EmptyStateFooter className="flex gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">
|
||||||
|
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<MakeMeAdminButton />
|
||||||
|
</EmptyStateFooter>
|
||||||
|
</EmptyState>
|
||||||
|
</PageContent>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { CommandMenu } from "@/features/navigation/command-menu";
|
||||||
import { getOnboardedUser } from "@/features/setup/queries";
|
import { getOnboardedUser } from "@/features/setup/queries";
|
||||||
import { TimezoneProvider } from "@/features/timezone/client/context";
|
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||||
|
|
||||||
|
import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning";
|
||||||
import { AppSidebar } from "./components/sidebar/app-sidebar";
|
import { AppSidebar } from "./components/sidebar/app-sidebar";
|
||||||
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
|
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
|
||||||
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
|
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
|
||||||
|
@ -46,6 +47,7 @@ export default async function Layout({
|
||||||
</Button>
|
</Button>
|
||||||
</TopBarRight>
|
</TopBarRight>
|
||||||
</TopBar>
|
</TopBar>
|
||||||
|
<LicenseLimitWarning />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="flex flex-1 flex-col p-4 md:p-8">{children}</div>
|
<div className="flex flex-1 flex-col p-4 md:p-8">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SidebarProvider } from "@rallly/ui/sidebar";
|
||||||
|
import { useLocalStorage } from "react-use";
|
||||||
|
|
||||||
|
export function ControlPanelSidebarProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useLocalStorage(
|
||||||
|
"control-panel-sidebar_state",
|
||||||
|
"expanded",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SidebarProvider
|
||||||
|
open={value === "expanded"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setValue(open ? "expanded" : "collapsed");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
43
apps/web/src/app/[locale]/control-panel/layout.tsx
Normal file
43
apps/web/src/app/[locale]/control-panel/layout.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning";
|
||||||
|
import { CommandMenu } from "@/features/navigation/command-menu";
|
||||||
|
import { hasAdmins } from "@/features/user/queries";
|
||||||
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
import { SidebarInset } from "@rallly/ui/sidebar";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ControlPanelSidebarProvider } from "./control-panel-sidebar-provider";
|
||||||
|
import { ControlPanelSidebar } from "./sidebar";
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: { children: React.ReactNode }) {
|
||||||
|
if (!(await hasAdmins())) {
|
||||||
|
redirect("/admin-setup");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlPanelSidebarProvider>
|
||||||
|
<CommandMenu />
|
||||||
|
<ControlPanelSidebar />
|
||||||
|
<SidebarInset>
|
||||||
|
<LicenseLimitWarning />
|
||||||
|
<div className="min-w-0 p-4 md:p-8 flex-1 flex-col flex">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</ControlPanelSidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await getTranslation();
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
template: `%s | ${t("controlPanel", {
|
||||||
|
defaultValue: "Control Panel",
|
||||||
|
})}`,
|
||||||
|
default: t("controlPanel", {
|
||||||
|
defaultValue: "Control Panel",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
187
apps/web/src/app/[locale]/control-panel/license/page.tsx
Normal file
187
apps/web/src/app/[locale]/control-panel/license/page.tsx
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
import { PageIcon } from "@/app/components/page-icons";
|
||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
PageContent,
|
||||||
|
PageHeader,
|
||||||
|
PageTitle,
|
||||||
|
} from "@/app/components/page-layout";
|
||||||
|
import { requireAdmin } from "@/auth/queries";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
EmptyStateDescription,
|
||||||
|
EmptyStateFooter,
|
||||||
|
EmptyStateIcon,
|
||||||
|
EmptyStateTitle,
|
||||||
|
} from "@/components/empty-state";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { LicenseKeyForm } from "@/features/licensing/components/license-key-form";
|
||||||
|
import { RemoveLicenseButton } from "@/features/licensing/components/remove-license-button";
|
||||||
|
import { getLicense } from "@/features/licensing/queries";
|
||||||
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@rallly/ui/dialog";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { KeySquareIcon, PlusIcon, ShoppingBagIcon } from "lucide-react";
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
await requireAdmin();
|
||||||
|
const license = await getLicense();
|
||||||
|
return { license };
|
||||||
|
}
|
||||||
|
|
||||||
|
function DescriptionList({ children }: { children: React.ReactNode }) {
|
||||||
|
return <dl>{children}</dl>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DescriptionListTitle({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <dt className="text-xs mb-1 text-muted-foreground">{children}</dt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DescriptionListValue({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <dd className="text-sm mb-4 font-mono">{children}</dd>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LicensePage() {
|
||||||
|
const { license } = await loadData();
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader>
|
||||||
|
<PageTitle>
|
||||||
|
<PageIcon color="darkGray">
|
||||||
|
<KeySquareIcon />
|
||||||
|
</PageIcon>
|
||||||
|
<Trans i18nKey="license" defaults="License" />
|
||||||
|
</PageTitle>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
{license ? (
|
||||||
|
<div>
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionListTitle>
|
||||||
|
<Trans i18nKey="licenseType" defaults="License Type" />
|
||||||
|
</DescriptionListTitle>
|
||||||
|
<DescriptionListValue>
|
||||||
|
<span className="capitalize text-primary">{license.type}</span>
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
(
|
||||||
|
<Trans
|
||||||
|
i18nKey="seatCount"
|
||||||
|
defaults="{count, plural, one {# seat} other {# seats}}"
|
||||||
|
values={{ count: license.seats }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</DescriptionListValue>
|
||||||
|
<DescriptionListTitle>
|
||||||
|
<Trans i18nKey="licenseKey" defaults="License Key" />
|
||||||
|
</DescriptionListTitle>
|
||||||
|
<DescriptionListValue>
|
||||||
|
<span className="font-mono select-all text-sm">
|
||||||
|
{license.licenseKey}
|
||||||
|
</span>
|
||||||
|
</DescriptionListValue>
|
||||||
|
<DescriptionListTitle>
|
||||||
|
<Trans i18nKey="licenseeName" defaults="Licensee Name" />
|
||||||
|
</DescriptionListTitle>
|
||||||
|
<DescriptionListValue>
|
||||||
|
{license.licenseeName ?? "-"}
|
||||||
|
</DescriptionListValue>
|
||||||
|
<DescriptionListTitle>
|
||||||
|
<Trans i18nKey="licenseeEmail" defaults="Licensee Email" />
|
||||||
|
</DescriptionListTitle>
|
||||||
|
<DescriptionListValue>
|
||||||
|
{license.licenseeEmail ?? "-"}
|
||||||
|
</DescriptionListValue>
|
||||||
|
<DescriptionListTitle>
|
||||||
|
<Trans i18nKey="purchaseDate" defaults="Purchase Date" />
|
||||||
|
</DescriptionListTitle>
|
||||||
|
<DescriptionListValue>
|
||||||
|
{dayjs(license.issuedAt).format("YYYY-MM-DD")}
|
||||||
|
</DescriptionListValue>
|
||||||
|
</DescriptionList>
|
||||||
|
<div className="mt-6">
|
||||||
|
<RemoveLicenseButton licenseId={license.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState className="h-full">
|
||||||
|
<EmptyStateIcon>
|
||||||
|
<KeySquareIcon />
|
||||||
|
</EmptyStateIcon>
|
||||||
|
<EmptyStateTitle>
|
||||||
|
<Trans i18nKey="noLicenseKey" defaults="No license key found" />
|
||||||
|
</EmptyStateTitle>
|
||||||
|
<EmptyStateDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="noLicenseKeyDescription"
|
||||||
|
defaults="This instance doesn’t have a license key yet."
|
||||||
|
/>
|
||||||
|
</EmptyStateDescription>
|
||||||
|
<EmptyStateFooter className="flex gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
href="https://support.rallly.co/self-hosting/pricing"
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<ShoppingBagIcon />
|
||||||
|
</Icon>
|
||||||
|
<Trans
|
||||||
|
i18nKey="purchaseLicense"
|
||||||
|
defaults="Purchase license"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="primary">
|
||||||
|
<Icon>
|
||||||
|
<PlusIcon />
|
||||||
|
</Icon>
|
||||||
|
<Trans i18nKey="addLicenseKey" defaults="Add license key" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent size="sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans
|
||||||
|
i18nKey="addLicenseKey"
|
||||||
|
defaults="Add license key"
|
||||||
|
/>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<LicenseKeyForm />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</EmptyStateFooter>
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</PageContent>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await getTranslation();
|
||||||
|
return {
|
||||||
|
title: t("license", {
|
||||||
|
defaultValue: "License",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
24
apps/web/src/app/[locale]/control-panel/nav-item.tsx
Normal file
24
apps/web/src/app/[locale]/control-panel/nav-item.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SidebarMenuButton, SidebarMenuItem } from "@rallly/ui/sidebar";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export function NavItem({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isActive = pathname === href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild isActive={isActive}>
|
||||||
|
<Link href={href}>{children}</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
114
apps/web/src/app/[locale]/control-panel/page.tsx
Normal file
114
apps/web/src/app/[locale]/control-panel/page.tsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import { PageIcon } from "@/app/components/page-icons";
|
||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
PageContent,
|
||||||
|
PageHeader,
|
||||||
|
PageTitle,
|
||||||
|
} from "@/app/components/page-layout";
|
||||||
|
import { requireAdmin } from "@/auth/queries";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { getLicense } from "@/features/licensing/queries";
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
import { cn } from "@rallly/ui";
|
||||||
|
import { Tile, TileGrid, TileTitle } from "@rallly/ui/tile";
|
||||||
|
import { HomeIcon, KeySquareIcon, UsersIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const [userCount, license] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
getLicense(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userCount,
|
||||||
|
userLimit: license?.seats ?? 1,
|
||||||
|
tier: license?.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const { userCount, userLimit, tier } = await loadData();
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader>
|
||||||
|
<PageTitle>
|
||||||
|
<PageIcon color="indigo">
|
||||||
|
<HomeIcon />
|
||||||
|
</PageIcon>
|
||||||
|
<Trans i18nKey="home" defaults="Home" />
|
||||||
|
</PageTitle>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-muted-foreground text-sm">
|
||||||
|
<Trans i18nKey="homeNavTitle" defaults="Navigation" />
|
||||||
|
</h2>
|
||||||
|
<TileGrid>
|
||||||
|
<Tile asChild>
|
||||||
|
<Link href="/control-panel/users">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<PageIcon color="darkGray">
|
||||||
|
<UsersIcon />
|
||||||
|
</PageIcon>
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="users" defaults="Users" />
|
||||||
|
</TileTitle>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn({
|
||||||
|
"text-destructive":
|
||||||
|
userLimit !== null && userCount > userLimit,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Trans
|
||||||
|
i18nKey="userCount"
|
||||||
|
defaults="{count, number, ::compact-short}"
|
||||||
|
values={{ count: userCount }}
|
||||||
|
/>
|
||||||
|
/
|
||||||
|
{userLimit === Number.POSITIVE_INFINITY
|
||||||
|
? "unlimited"
|
||||||
|
: userLimit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Tile>
|
||||||
|
<Tile asChild>
|
||||||
|
<Link href="/control-panel/license">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<PageIcon color="darkGray">
|
||||||
|
<KeySquareIcon />
|
||||||
|
</PageIcon>
|
||||||
|
{tier ? (
|
||||||
|
<span className="text-sm text-primary capitalize">
|
||||||
|
{tier}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
<Trans i18nKey="unlicensed" defaults="Unlicensed" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TileTitle>
|
||||||
|
<Trans i18nKey="license" defaults="License" />
|
||||||
|
</TileTitle>
|
||||||
|
</Link>
|
||||||
|
</Tile>
|
||||||
|
</TileGrid>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
return {
|
||||||
|
title: "Control Panel",
|
||||||
|
};
|
||||||
|
}
|
57
apps/web/src/app/[locale]/control-panel/sidebar.tsx
Normal file
57
apps/web/src/app/[locale]/control-panel/sidebar.tsx
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
} from "@rallly/ui/sidebar";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
HomeIcon,
|
||||||
|
KeySquareIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
|
||||||
|
import { NavItem } from "./nav-item";
|
||||||
|
|
||||||
|
export async function ControlPanelSidebar({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
return (
|
||||||
|
<Sidebar variant="inset" {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<NavItem href="/">
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
<Trans i18nKey="back" defaults="Back" />
|
||||||
|
</NavItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>
|
||||||
|
<Trans i18nKey="controlPanel" defaults="Control Panel" />
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
<NavItem href="/control-panel">
|
||||||
|
<HomeIcon className="size-4" />
|
||||||
|
<Trans i18nKey="home" defaults="Home" />
|
||||||
|
</NavItem>
|
||||||
|
<NavItem href="/control-panel/users">
|
||||||
|
<UsersIcon className="size-4" />
|
||||||
|
<Trans i18nKey="users" defaults="Users" />
|
||||||
|
</NavItem>
|
||||||
|
<NavItem href="/control-panel/license">
|
||||||
|
<KeySquareIcon className="size-4" />
|
||||||
|
<Trans i18nKey="license" defaults="License" />
|
||||||
|
</NavItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
36
apps/web/src/app/[locale]/control-panel/users/actions.ts
Normal file
36
apps/web/src/app/[locale]/control-panel/users/actions.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/auth/queries";
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const inputSchema = z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
role: z.enum(["admin", "user"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function changeRole(input: z.infer<typeof inputSchema>) {
|
||||||
|
await requireAdmin();
|
||||||
|
const { userId, role } = inputSchema.parse(input);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
}: { userId: string; email: string }) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,123 @@
|
||||||
|
"use client";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useTranslation } from "@/i18n/client";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@rallly/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@rallly/ui/form";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import { Input } from "@rallly/ui/input";
|
||||||
|
import { TrashIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { deleteUser } from "./actions";
|
||||||
|
|
||||||
|
const useSchema = (email: string) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.refine((value) => value.toLowerCase() === email.toLowerCase(), {
|
||||||
|
message: t("emailDoesNotMatch", {
|
||||||
|
defaultValue: "The email address does not match.",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeleteUserButton({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
}: { userId: string; email: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const schema = useSchema(email);
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" size="icon" variant="ghost">
|
||||||
|
<Icon>
|
||||||
|
<TrashIcon />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans i18nKey="deleteUser" defaults="Delete User" />
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="areYouSureYouWantToDeleteThisUser"
|
||||||
|
defaults="Are you sure you want to delete this user?"
|
||||||
|
/>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(async (data) => {
|
||||||
|
await deleteUser({ userId, email: data.email });
|
||||||
|
router.refresh();
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans i18nKey="email" defaults="Email" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={email} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="typeTheEmailAddressOfTheUserYouWantToDelete"
|
||||||
|
defaults="Type the email address of the user you want to delete."
|
||||||
|
/>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button>
|
||||||
|
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button variant="destructive" type="submit">
|
||||||
|
<Trans i18nKey="delete" defaults="Delete" />
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
"use client";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useTranslation } from "@/i18n/client";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@rallly/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@rallly/ui/form";
|
||||||
|
import { Input } from "@rallly/ui/input";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { deleteUser } from "../actions";
|
||||||
|
|
||||||
|
const useSchema = (email: string) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return z.object({
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.refine((value) => value.toLowerCase() === email.toLowerCase(), {
|
||||||
|
message: t("emailDoesNotMatch", {
|
||||||
|
defaultValue: "The email address does not match.",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeleteUserDialog({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const schema = useSchema(email);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans i18nKey="deleteUserTitle" defaults="Delete User" />
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="deleteUserDesc"
|
||||||
|
defaults="Are you sure you want to delete this user?"
|
||||||
|
/>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(async (data) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteUser({ userId, email: data.email });
|
||||||
|
router.refresh();
|
||||||
|
onOpenChange(false);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans i18nKey="email" defaults="Email" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={email} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="deleteUserHint"
|
||||||
|
defaults="Type <b>{email}</b> to delete this user."
|
||||||
|
values={{ email }}
|
||||||
|
components={{
|
||||||
|
b: <b className="whitespace-nowrap font-normal" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button>
|
||||||
|
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button variant="destructive" loading={isPending} type="submit">
|
||||||
|
<Trans i18nKey="delete" defaults="Delete" />
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
182
apps/web/src/app/[locale]/control-panel/users/page.tsx
Normal file
182
apps/web/src/app/[locale]/control-panel/users/page.tsx
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
import { PageIcon } from "@/app/components/page-icons";
|
||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
PageContent,
|
||||||
|
PageHeader,
|
||||||
|
PageTitle,
|
||||||
|
} from "@/app/components/page-layout";
|
||||||
|
import { requireAdmin } from "@/auth/queries";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
EmptyStateDescription,
|
||||||
|
EmptyStateIcon,
|
||||||
|
EmptyStateTitle,
|
||||||
|
} from "@/components/empty-state";
|
||||||
|
import { Pagination } from "@/components/pagination";
|
||||||
|
import { StackedList } from "@/components/stacked-list";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { getTranslation } from "@/i18n/server";
|
||||||
|
import { type Prisma, prisma } from "@rallly/database";
|
||||||
|
import { UsersIcon } from "lucide-react";
|
||||||
|
import z from "zod";
|
||||||
|
import { UserRow } from "./user-row";
|
||||||
|
import { UserSearchInput } from "./user-search-input";
|
||||||
|
import { UsersTabbedView } from "./users-tabbed-view";
|
||||||
|
|
||||||
|
async function loadData({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
q,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
q?: string;
|
||||||
|
role?: "admin" | "user";
|
||||||
|
}) {
|
||||||
|
const adminUser = await requireAdmin();
|
||||||
|
|
||||||
|
const where: Prisma.UserWhereInput = {};
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
where.OR = [
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
contains: q,
|
||||||
|
mode: "insensitive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: {
|
||||||
|
contains: q,
|
||||||
|
mode: "insensitive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
where.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allUsers = await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
image: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
take: pageSize,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
where,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalUsers = await prisma.user.count({
|
||||||
|
where,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
adminUser,
|
||||||
|
allUsers: allUsers.map((user) => ({
|
||||||
|
...user,
|
||||||
|
image: user.image ?? undefined,
|
||||||
|
})),
|
||||||
|
totalUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParamsSchema = z.object({
|
||||||
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().min(1).default(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleSchema = z.enum(["admin", "user"]).optional().catch(undefined);
|
||||||
|
|
||||||
|
export default async function AdminPage(props: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const { page, pageSize } = searchParamsSchema.parse(searchParams);
|
||||||
|
|
||||||
|
const { adminUser, allUsers, totalUsers } = await loadData({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
q: searchParams.q ? String(searchParams.q) : undefined,
|
||||||
|
role: roleSchema.parse(searchParams.role),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||||
|
const totalItems = allUsers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<PageTitle>
|
||||||
|
<PageIcon color="darkGray">
|
||||||
|
<UsersIcon />
|
||||||
|
</PageIcon>
|
||||||
|
<Trans i18nKey="users" defaults="Users" />
|
||||||
|
</PageTitle>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<UserSearchInput />
|
||||||
|
<UsersTabbedView>
|
||||||
|
{allUsers.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<StackedList className="text-sm">
|
||||||
|
{allUsers.map((user) => (
|
||||||
|
<UserRow
|
||||||
|
key={user.id}
|
||||||
|
name={user.name}
|
||||||
|
email={user.email}
|
||||||
|
userId={user.id}
|
||||||
|
image={user.image}
|
||||||
|
role={user.role}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</StackedList>
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalItems}
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState className="py-16">
|
||||||
|
<EmptyStateIcon>
|
||||||
|
<UsersIcon />
|
||||||
|
</EmptyStateIcon>
|
||||||
|
<EmptyStateTitle>
|
||||||
|
<Trans i18nKey="noUsers" defaults="No users found" />
|
||||||
|
</EmptyStateTitle>
|
||||||
|
<EmptyStateDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="noUsersDescription"
|
||||||
|
defaults="Try adjusting your search"
|
||||||
|
/>
|
||||||
|
</EmptyStateDescription>
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</UsersTabbedView>
|
||||||
|
</div>
|
||||||
|
</PageContent>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await getTranslation();
|
||||||
|
return {
|
||||||
|
title: t("users", {
|
||||||
|
defaultValue: "Users",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@rallly/ui/dropdown-menu";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import { MoreHorizontal, TrashIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { deleteUser } from "./actions";
|
||||||
|
|
||||||
|
export function UserDropdown({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
}: { userId: string; email: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Icon>
|
||||||
|
<MoreHorizontal />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteUser({ userId, email });
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
<Trans i18nKey="delete" defaults="Delete" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
124
apps/web/src/app/[locale]/control-panel/users/user-row.tsx
Normal file
124
apps/web/src/app/[locale]/control-panel/users/user-row.tsx
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
"use client";
|
||||||
|
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
||||||
|
import { StackedListItem } from "@/components/stacked-list";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useUser } from "@/components/user-provider";
|
||||||
|
import { userRoleSchema } from "@/features/user/schema";
|
||||||
|
import { cn } from "@rallly/ui";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { useDialog } from "@rallly/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@rallly/ui/dropdown-menu";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import { MoreHorizontal, TrashIcon, UserPenIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { changeRole } from "./actions";
|
||||||
|
import { DeleteUserDialog } from "./dialogs/delete-user-dialog";
|
||||||
|
|
||||||
|
export function UserRow({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
userId,
|
||||||
|
image,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
userId: string;
|
||||||
|
image?: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const { user } = useUser();
|
||||||
|
const deleteDialog = useDialog();
|
||||||
|
|
||||||
|
const isYou = userId === user?.id;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackedListItem
|
||||||
|
className={cn({
|
||||||
|
"opacity-50": isPending,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<OptimizedAvatarImage src={image} name={name} size="md" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold">{name}</div>
|
||||||
|
<div className="text-muted-foreground truncate">{email}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="capitalize">{role}</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Icon>
|
||||||
|
<MoreHorizontal />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger disabled={isYou}>
|
||||||
|
<Icon>
|
||||||
|
<UserPenIcon />
|
||||||
|
</Icon>
|
||||||
|
<Trans i18nKey="changeRole" defaults="Change role" />
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={role}
|
||||||
|
onValueChange={async (value) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
await changeRole({
|
||||||
|
role: userRoleSchema.parse(value),
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="admin">
|
||||||
|
<Trans i18nKey="admin" defaults="Admin" />
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="user">
|
||||||
|
<Trans i18nKey="user" defaults="User" />
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
deleteDialog.trigger();
|
||||||
|
}}
|
||||||
|
disabled={isYou}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
<Trans i18nKey="delete" defaults="Delete" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</StackedListItem>
|
||||||
|
<DeleteUserDialog
|
||||||
|
{...deleteDialog.dialogProps}
|
||||||
|
userId={userId}
|
||||||
|
email={email}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SearchInput } from "@/app/components/search-input";
|
||||||
|
import { useTranslation } from "@/i18n/client";
|
||||||
|
|
||||||
|
export function UserSearchInput() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<SearchInput
|
||||||
|
placeholder={t("searchUsers", {
|
||||||
|
defaultValue: "Search users...",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
|
||||||
|
import { cn } from "@rallly/ui";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function UsersTabbedView({ children }: { children: React.ReactNode }) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const name = "role";
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = React.useTransition();
|
||||||
|
const [tab, setTab] = React.useState(searchParams.get(name) ?? "all");
|
||||||
|
const handleTabChange = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set(name, value);
|
||||||
|
|
||||||
|
params.delete("page");
|
||||||
|
|
||||||
|
setTab(value);
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
const newUrl = `?${params.toString()}`;
|
||||||
|
router.replace(newUrl, { scroll: false });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">
|
||||||
|
<Trans i18nKey="userRoleAll" defaults="All" />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="user">
|
||||||
|
<Trans i18nKey="userRoleUser" defaults="Users" />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="admin">
|
||||||
|
<Trans i18nKey="userRoleAdmin" defaults="Admins" />
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent
|
||||||
|
tabIndex={-1}
|
||||||
|
value={tab}
|
||||||
|
key={tab}
|
||||||
|
className={cn(
|
||||||
|
"transition-opacity",
|
||||||
|
isPending ? "opacity-50 delay-200 pointer-events-none" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useTranslation } from "@/i18n/client";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@rallly/ui/form";
|
||||||
|
import { Input } from "@rallly/ui/input";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { validateLicenseKey } from "../actions/validate-license";
|
||||||
|
import { checkLicenseKey } from "../helpers/check-license-key";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
licenseKey: z.string().trim().min(1).refine(checkLicenseKey, {
|
||||||
|
message: "Invalid license key",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LicenseKeyFormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export function LicenseKeyForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<LicenseKeyFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
licenseKey: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LicenseKeyFormValues) => {
|
||||||
|
try {
|
||||||
|
const { valid } = await validateLicenseKey(data.licenseKey);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
form.setError("licenseKey", {
|
||||||
|
message: "Invalid license key",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
form.setError("licenseKey", {
|
||||||
|
message: "An error occurred while validating the license key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
name="licenseKey"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans i18nKey="licenseKey" defaults="License Key" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
placeholder="RLYV4-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { getLicense } from "@/features/licensing/queries";
|
||||||
|
import { getUserCount } from "@/features/user/queries";
|
||||||
|
import { isSelfHosted } from "@/utils/constants";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export async function LicenseLimitWarning() {
|
||||||
|
if (!isSelfHosted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [license, userCount] = await Promise.all([
|
||||||
|
getLicense(),
|
||||||
|
getUserCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userLimit = license?.seats ?? 1;
|
||||||
|
|
||||||
|
if (!userLimit || userCount <= userLimit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-muted p-2 text-center text-sm rounded-md m-1 text-muted-foreground">
|
||||||
|
<Trans
|
||||||
|
i18nKey="licenseLimitWarning"
|
||||||
|
defaults="You have exceeded the limits of your license. Please <a>upgrade</a>."
|
||||||
|
components={{
|
||||||
|
a: (
|
||||||
|
<Link
|
||||||
|
prefetch={false}
|
||||||
|
href="https://support.rallly.co/self-hosting/licensing"
|
||||||
|
target="_blank"
|
||||||
|
className="text-link"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
useDialog,
|
||||||
|
} from "@rallly/ui/dialog";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { removeInstanceLicense } from "../mutations";
|
||||||
|
|
||||||
|
export function RemoveLicenseButton({
|
||||||
|
licenseId,
|
||||||
|
}: {
|
||||||
|
licenseId: string;
|
||||||
|
}) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
const dialog = useDialog();
|
||||||
|
return (
|
||||||
|
<Dialog {...dialog.dialogProps}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => dialog.trigger()}>
|
||||||
|
<Icon>
|
||||||
|
<XIcon />
|
||||||
|
</Icon>
|
||||||
|
<Trans i18nKey="removeLicense" defaults="Remove License" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent size="sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans i18nKey="removeLicense" defaults="Remove License" />
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="removeLicenseDescription"
|
||||||
|
defaults="Are you sure you want to remove this license?"
|
||||||
|
/>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button>
|
||||||
|
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
loading={isPending}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
await removeInstanceLicense({
|
||||||
|
licenseId,
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
dialog.dismiss();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="removeLicense" defaults="Remove License" />
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
37
apps/web/src/features/licensing/mutations.ts
Normal file
37
apps/web/src/features/licensing/mutations.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/auth/queries";
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
|
||||||
|
export async function removeInstanceLicense({
|
||||||
|
licenseId,
|
||||||
|
}: {
|
||||||
|
licenseId: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "You must be an admin to delete a license",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.instanceLicense.delete({
|
||||||
|
where: {
|
||||||
|
id: licenseId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete license",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "License deleted successfully",
|
||||||
|
};
|
||||||
|
}
|
5
apps/web/src/features/licensing/queries.ts
Normal file
5
apps/web/src/features/licensing/queries.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
|
||||||
|
export async function getLicense() {
|
||||||
|
return prisma.instanceLicense.findFirst();
|
||||||
|
}
|
|
@ -9,7 +9,12 @@ import {
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@rallly/ui/command";
|
} from "@rallly/ui/command";
|
||||||
import { DialogDescription, DialogTitle, useDialog } from "@rallly/ui/dialog";
|
import { DialogDescription, DialogTitle, useDialog } from "@rallly/ui/dialog";
|
||||||
import { PlusIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
KeySquareIcon,
|
||||||
|
PlusIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -23,10 +28,32 @@ import {
|
||||||
} from "@/app/components/page-icons";
|
} from "@/app/components/page-icons";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
|
||||||
|
import { useUser } from "@/components/user-provider";
|
||||||
|
import { useTranslation } from "@/i18n/client";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
import { CommandGlobalShortcut } from "./command-global-shortcut";
|
import { CommandGlobalShortcut } from "./command-global-shortcut";
|
||||||
|
|
||||||
|
function NavigationCommandLabel({
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Trans
|
||||||
|
i18nKey="goTo"
|
||||||
|
defaults="Go to <b>{page}</b>"
|
||||||
|
values={{ page: label }}
|
||||||
|
components={{ b: <b className="font-medium" /> }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CommandMenu() {
|
export function CommandMenu() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { user } = useUser();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { trigger, dialogProps, dismiss } = useDialog();
|
const { trigger, dialogProps, dismiss } = useDialog();
|
||||||
|
|
||||||
const handleSelect = (route: string) => {
|
const handleSelect = (route: string) => {
|
||||||
|
@ -37,14 +64,6 @@ export function CommandMenu() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CommandGlobalShortcut trigger={trigger} />
|
<CommandGlobalShortcut trigger={trigger} />
|
||||||
|
|
||||||
{/* <Button variant="ghost" onClick={trigger}>
|
|
||||||
<Icon>
|
|
||||||
<SearchIcon />
|
|
||||||
</Icon>
|
|
||||||
<Trans i18nKey="search" defaults="Search" />
|
|
||||||
<CommandShortcutSymbol symbol="K" />
|
|
||||||
</Button> */}
|
|
||||||
<CommandDialog {...dialogProps}>
|
<CommandDialog {...dialogProps}>
|
||||||
<DialogTitle className="sr-only">
|
<DialogTitle className="sr-only">
|
||||||
<Trans i18nKey="commandMenu" defaults="Command Menu" />
|
<Trans i18nKey="commandMenu" defaults="Command Menu" />
|
||||||
|
@ -64,50 +83,80 @@ export function CommandMenu() {
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup heading={<Trans i18nKey="polls" defaults="Actions" />}>
|
<CommandGroup heading={<Trans i18nKey="polls" defaults="Actions" />}>
|
||||||
<CommandItem onSelect={() => handleSelect("/new")}>
|
<CommandItem onSelect={() => handleSelect("/new")}>
|
||||||
<PageIcon size="sm">
|
<Icon>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</PageIcon>
|
</Icon>
|
||||||
<Trans i18nKey="create" defaults="Create" />
|
<Trans i18nKey="createNewPoll" defaults="Create new poll" />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Navigation">
|
<CommandGroup heading="Navigation">
|
||||||
<CommandItem onSelect={() => handleSelect("/")}>
|
<CommandItem onSelect={() => handleSelect("/")}>
|
||||||
<HomePageIcon size="sm" />
|
<HomePageIcon size="sm" />
|
||||||
<Trans i18nKey="home" defaults="Home" />
|
<NavigationCommandLabel label={t("home")} />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={() => handleSelect("/polls")}>
|
<CommandItem onSelect={() => handleSelect("/polls")}>
|
||||||
<PollPageIcon size="sm" />
|
<PollPageIcon size="sm" />
|
||||||
<Trans i18nKey="polls" defaults="Polls" />
|
<NavigationCommandLabel label={t("polls")} />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={() => handleSelect("/events")}>
|
<CommandItem onSelect={() => handleSelect("/events")}>
|
||||||
<EventPageIcon size="sm" />
|
<EventPageIcon size="sm" />
|
||||||
<Trans i18nKey="events" defaults="Events" />
|
<NavigationCommandLabel label={t("events")} />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{/* <CommandItem onSelect={() => handleSelect("/teams")}>
|
|
||||||
<TeamsPageIcon />
|
|
||||||
<Trans i18nKey="teams" defaults="Teams" />
|
|
||||||
</CommandItem>
|
|
||||||
<CommandItem onSelect={() => handleSelect("/spaces")}>
|
|
||||||
<SpacesPageIcon />
|
|
||||||
<Trans i18nKey="spaces" defaults="Spaces" />
|
|
||||||
</CommandItem> */}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
heading={<Trans i18nKey="account" defaults="Account" />}
|
heading={<Trans i18nKey="settings" defaults="Settings" />}
|
||||||
>
|
>
|
||||||
<CommandItem onSelect={() => handleSelect("/settings/profile")}>
|
<CommandItem onSelect={() => handleSelect("/settings/profile")}>
|
||||||
<ProfilePageIcon size="sm" />
|
<ProfilePageIcon size="sm" />
|
||||||
<Trans i18nKey="profile" defaults="Profile" />
|
<NavigationCommandLabel label={t("profile")} />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={() => handleSelect("/settings/preferences")}>
|
<CommandItem onSelect={() => handleSelect("/settings/preferences")}>
|
||||||
<PreferencesPageIcon size="sm" />
|
<PreferencesPageIcon size="sm" />
|
||||||
<Trans i18nKey="preferences" defaults="Preferences" />
|
<NavigationCommandLabel label={t("preferences")} />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={() => handleSelect("/settings/billing")}>
|
<CommandItem onSelect={() => handleSelect("/settings/billing")}>
|
||||||
<BillingPageIcon size="sm" />
|
<BillingPageIcon size="sm" />
|
||||||
<Trans i18nKey="billing" defaults="Billing" />
|
<NavigationCommandLabel label={t("billing")} />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
{user.role === "admin" && (
|
||||||
|
<CommandGroup
|
||||||
|
heading={
|
||||||
|
<Trans i18nKey="controlPanel" defaults="Control Panel" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CommandItem onSelect={() => handleSelect("/control-panel")}>
|
||||||
|
<PageIcon size="sm">
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</PageIcon>
|
||||||
|
<NavigationCommandLabel label={t("controlPanel")} />
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => handleSelect("/control-panel/users")}
|
||||||
|
>
|
||||||
|
<PageIcon size="sm">
|
||||||
|
<UsersIcon />
|
||||||
|
</PageIcon>
|
||||||
|
<NavigationCommandLabel
|
||||||
|
label={t("users", {
|
||||||
|
defaultValue: "Users",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => handleSelect("/control-panel/license")}
|
||||||
|
>
|
||||||
|
<PageIcon size="sm">
|
||||||
|
<KeySquareIcon />
|
||||||
|
</PageIcon>
|
||||||
|
<NavigationCommandLabel
|
||||||
|
label={t("license", {
|
||||||
|
defaultValue: "License",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
</>
|
</>
|
||||||
|
|
5
apps/web/src/features/user/schema.ts
Normal file
5
apps/web/src/features/user/schema.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const userRoleSchema = z.enum(["admin", "user"]);
|
||||||
|
|
||||||
|
export type UserRole = z.infer<typeof userRoleSchema>;
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { CheckIcon, ChevronRightIcon, PlusCircleIcon } from "lucide-react";
|
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
|
@ -10,7 +10,7 @@ import { cn } from "./lib/utils";
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
const DropdownMenuTrigger = React.forwardRef<
|
const DropdownMenuTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||||
>(({ ...props }, ref) => (
|
>(({ ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Trigger
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
@ -31,7 +31,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 outline-none",
|
"focus:bg-accent data-[state=open]:bg-accent data-[disabled]:opacity-50 text-sm gap-2 flex cursor-default select-none items-center rounded-sm px-2 py-1.5 outline-none",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
@ -53,7 +53,7 @@ DropdownMenuSubTrigger.displayName =
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
@ -69,7 +69,7 @@ DropdownMenuSubContent.displayName =
|
||||||
DropdownMenuPrimitive.SubContent.displayName;
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
@ -87,7 +87,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -106,7 +106,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
@ -132,7 +132,7 @@ DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
@ -145,7 +145,9 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<PlusCircleIcon className="size-2 fill-current" />
|
<Icon>
|
||||||
|
<CheckIcon />
|
||||||
|
</Icon>
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
@ -154,7 +156,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -172,7 +174,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue