Add admin control panel (#1726)

This commit is contained in:
Luke Vella 2025-05-24 14:59:05 +01:00 committed by GitHub
parent 1b3b3aac50
commit c5724f0118
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1672 additions and 40 deletions

View file

@ -342,5 +342,45 @@
"tooManyAttempts": "Too many attempts, please try again later.",
"unknownError": "Something went wrong",
"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 doesnt 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?"
}

View 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",
},
});
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -8,6 +8,7 @@ import { CommandMenu } from "@/features/navigation/command-menu";
import { getOnboardedUser } from "@/features/setup/queries";
import { TimezoneProvider } from "@/features/timezone/client/context";
import { LicenseLimitWarning } from "@/features/licensing/components/license-limit-warning";
import { AppSidebar } from "./components/sidebar/app-sidebar";
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
import { TopBar, TopBarLeft, TopBarRight } from "./components/top-bar";
@ -46,6 +47,7 @@ export default async function Layout({
</Button>
</TopBarRight>
</TopBar>
<LicenseLimitWarning />
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col p-4 md:p-8">{children}</div>
</div>

View file

@ -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>
);
}

View 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",
}),
},
};
}

View 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 doesnt 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",
}),
};
}

View 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>
);
}

View 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",
};
}

View 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>
);
}

View 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,
},
});
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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",
}),
};
}

View file

@ -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>
);
}

View 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}
/>
</>
);
}

View file

@ -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...",
})}
/>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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",
};
}

View file

@ -0,0 +1,5 @@
import { prisma } from "@rallly/database";
export async function getLicense() {
return prisma.instanceLicense.findFirst();
}

View file

@ -9,7 +9,12 @@ import {
CommandList,
} from "@rallly/ui/command";
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 {
@ -23,10 +28,32 @@ import {
} from "@/app/components/page-icons";
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";
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() {
const router = useRouter();
const { user } = useUser();
const { t } = useTranslation();
const { trigger, dialogProps, dismiss } = useDialog();
const handleSelect = (route: string) => {
@ -37,14 +64,6 @@ export function CommandMenu() {
return (
<>
<CommandGlobalShortcut trigger={trigger} />
{/* <Button variant="ghost" onClick={trigger}>
<Icon>
<SearchIcon />
</Icon>
<Trans i18nKey="search" defaults="Search" />
<CommandShortcutSymbol symbol="K" />
</Button> */}
<CommandDialog {...dialogProps}>
<DialogTitle className="sr-only">
<Trans i18nKey="commandMenu" defaults="Command Menu" />
@ -64,50 +83,80 @@ export function CommandMenu() {
</CommandEmpty>
<CommandGroup heading={<Trans i18nKey="polls" defaults="Actions" />}>
<CommandItem onSelect={() => handleSelect("/new")}>
<PageIcon size="sm">
<Icon>
<PlusIcon />
</PageIcon>
<Trans i18nKey="create" defaults="Create" />
</Icon>
<Trans i18nKey="createNewPoll" defaults="Create new poll" />
</CommandItem>
</CommandGroup>
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => handleSelect("/")}>
<HomePageIcon size="sm" />
<Trans i18nKey="home" defaults="Home" />
<NavigationCommandLabel label={t("home")} />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/polls")}>
<PollPageIcon size="sm" />
<Trans i18nKey="polls" defaults="Polls" />
<NavigationCommandLabel label={t("polls")} />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/events")}>
<EventPageIcon size="sm" />
<Trans i18nKey="events" defaults="Events" />
<NavigationCommandLabel label={t("events")} />
</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
heading={<Trans i18nKey="account" defaults="Account" />}
heading={<Trans i18nKey="settings" defaults="Settings" />}
>
<CommandItem onSelect={() => handleSelect("/settings/profile")}>
<ProfilePageIcon size="sm" />
<Trans i18nKey="profile" defaults="Profile" />
<NavigationCommandLabel label={t("profile")} />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/settings/preferences")}>
<PreferencesPageIcon size="sm" />
<Trans i18nKey="preferences" defaults="Preferences" />
<NavigationCommandLabel label={t("preferences")} />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/settings/billing")}>
<BillingPageIcon size="sm" />
<Trans i18nKey="billing" defaults="Billing" />
<NavigationCommandLabel label={t("billing")} />
</CommandItem>
</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>
</CommandDialog>
</>

View file

@ -0,0 +1,5 @@
import z from "zod";
export const userRoleSchema = z.enum(["admin", "user"]);
export type UserRole = z.infer<typeof userRoleSchema>;

View file

@ -1,7 +1,7 @@
"use client";
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 { Icon } from "./icon";
@ -10,7 +10,7 @@ import { cn } from "./lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => (
<DropdownMenuPrimitive.Trigger
@ -31,7 +31,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
@ -39,7 +39,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
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",
className,
)}
@ -53,7 +53,7 @@ DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
@ -69,7 +69,7 @@ DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
@ -87,7 +87,7 @@ const DropdownMenuContent = React.forwardRef<
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
@ -106,7 +106,7 @@ const DropdownMenuItem = React.forwardRef<
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
@ -132,7 +132,7 @@ DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<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">
<DropdownMenuPrimitive.ItemIndicator>
<PlusCircleIcon className="size-2 fill-current" />
<Icon>
<CheckIcon />
</Icon>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@ -154,7 +156,7 @@ const DropdownMenuRadioItem = React.forwardRef<
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
@ -172,7 +174,7 @@ const DropdownMenuLabel = React.forwardRef<
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator