From c5724f011897ad6ac215ccfcc907ff5a31d6893e Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Sat, 24 May 2025 14:59:05 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20admin=20control=20panel=20(#1?= =?UTF-8?q?726)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/locales/en/app.json | 42 +++- .../[locale]/(space)/admin-setup/actions.ts | 22 +++ .../admin-setup/make-me-admin-button.tsx | 26 +++ .../app/[locale]/(space)/admin-setup/page.tsx | 69 +++++++ apps/web/src/app/[locale]/(space)/layout.tsx | 2 + .../control-panel-sidebar-provider.tsx | 25 +++ .../src/app/[locale]/control-panel/layout.tsx | 43 ++++ .../[locale]/control-panel/license/page.tsx | 187 ++++++++++++++++++ .../app/[locale]/control-panel/nav-item.tsx | 24 +++ .../src/app/[locale]/control-panel/page.tsx | 114 +++++++++++ .../app/[locale]/control-panel/sidebar.tsx | 57 ++++++ .../[locale]/control-panel/users/actions.ts | 36 ++++ .../users/delete-user-button.tsx | 123 ++++++++++++ .../users/dialogs/delete-user-dialog.tsx | 129 ++++++++++++ .../app/[locale]/control-panel/users/page.tsx | 182 +++++++++++++++++ .../control-panel/users/user-dropdown.tsx | 43 ++++ .../[locale]/control-panel/users/user-row.tsx | 124 ++++++++++++ .../control-panel/users/user-search-input.tsx | 15 ++ .../control-panel/users/users-tabbed-view.tsx | 59 ++++++ .../licensing/components/license-key-form.tsx | 95 +++++++++ .../components/license-limit-warning.tsx | 42 ++++ .../components/remove-license-button.tsx | 77 ++++++++ apps/web/src/features/licensing/mutations.ts | 37 ++++ apps/web/src/features/licensing/queries.ts | 5 + .../navigation/command-menu/command-menu.tsx | 103 +++++++--- apps/web/src/features/user/schema.ts | 5 + packages/ui/src/dropdown-menu.tsx | 26 +-- 27 files changed, 1672 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/app/[locale]/(space)/admin-setup/actions.ts create mode 100644 apps/web/src/app/[locale]/(space)/admin-setup/make-me-admin-button.tsx create mode 100644 apps/web/src/app/[locale]/(space)/admin-setup/page.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/control-panel-sidebar-provider.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/layout.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/license/page.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/nav-item.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/page.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/sidebar.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/users/actions.ts create mode 100644 apps/web/src/app/[locale]/control-panel/users/delete-user-button.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/users/dialogs/delete-user-dialog.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/users/page.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/users/user-dropdown.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/users/user-row.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/users/user-search-input.tsx create mode 100644 apps/web/src/app/[locale]/control-panel/users/users-tabbed-view.tsx create mode 100644 apps/web/src/features/licensing/components/license-key-form.tsx create mode 100644 apps/web/src/features/licensing/components/license-limit-warning.tsx create mode 100644 apps/web/src/features/licensing/components/remove-license-button.tsx create mode 100644 apps/web/src/features/licensing/mutations.ts create mode 100644 apps/web/src/features/licensing/queries.ts create mode 100644 apps/web/src/features/user/schema.ts diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 4b3b6a151..5d97cdc0b 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -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 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 upgrade.", + "goTo": "Go to {page}", + "createNewPoll": "Create new poll", + "deleteUserTitle": "Delete User", + "deleteUserDesc": "Are you sure you want to delete this user?", + "deleteUserHint": "Type {email} to delete this user.", + "changeRole": "Change role", + "licenseType": "License Type", + "removeLicense": "Remove License", + "removeLicenseDescription": "Are you sure you want to remove this license?" } diff --git a/apps/web/src/app/[locale]/(space)/admin-setup/actions.ts b/apps/web/src/app/[locale]/(space)/admin-setup/actions.ts new file mode 100644 index 000000000..717d8d218 --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/admin-setup/actions.ts @@ -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", + }, + }); +} diff --git a/apps/web/src/app/[locale]/(space)/admin-setup/make-me-admin-button.tsx b/apps/web/src/app/[locale]/(space)/admin-setup/make-me-admin-button.tsx new file mode 100644 index 000000000..941a0da54 --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/admin-setup/make-me-admin-button.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/app/[locale]/(space)/admin-setup/page.tsx b/apps/web/src/app/[locale]/(space)/admin-setup/page.tsx new file mode 100644 index 000000000..dfa995153 --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/admin-setup/page.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/app/[locale]/(space)/layout.tsx b/apps/web/src/app/[locale]/(space)/layout.tsx index d3144cbd1..d7780de0b 100644 --- a/apps/web/src/app/[locale]/(space)/layout.tsx +++ b/apps/web/src/app/[locale]/(space)/layout.tsx @@ -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({ +
{children}
diff --git a/apps/web/src/app/[locale]/control-panel/control-panel-sidebar-provider.tsx b/apps/web/src/app/[locale]/control-panel/control-panel-sidebar-provider.tsx new file mode 100644 index 000000000..837f16319 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/control-panel-sidebar-provider.tsx @@ -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 ( + { + setValue(open ? "expanded" : "collapsed"); + }} + > + {children} + + ); +} diff --git a/apps/web/src/app/[locale]/control-panel/layout.tsx b/apps/web/src/app/[locale]/control-panel/layout.tsx new file mode 100644 index 000000000..64699b15e --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/layout.tsx @@ -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 ( + + + + + +
+ {children} +
+
+
+ ); +} + +export async function generateMetadata() { + const { t } = await getTranslation(); + return { + title: { + template: `%s | ${t("controlPanel", { + defaultValue: "Control Panel", + })}`, + default: t("controlPanel", { + defaultValue: "Control Panel", + }), + }, + }; +} diff --git a/apps/web/src/app/[locale]/control-panel/license/page.tsx b/apps/web/src/app/[locale]/control-panel/license/page.tsx new file mode 100644 index 000000000..08f904495 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/license/page.tsx @@ -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
{children}
; +} + +function DescriptionListTitle({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +function DescriptionListValue({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +export default async function LicensePage() { + const { license } = await loadData(); + return ( + + + + + + + + + + + {license ? ( +
+ + + + + + {license.type} + + ( + + ) + + + + + + + + {license.licenseKey} + + + + + + + {license.licenseeName ?? "-"} + + + + + + {license.licenseeEmail ?? "-"} + + + + + + {dayjs(license.issuedAt).format("YYYY-MM-DD")} + + +
+ +
+
+ ) : ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} +
+
+ ); +} + +export async function generateMetadata() { + const { t } = await getTranslation(); + return { + title: t("license", { + defaultValue: "License", + }), + }; +} diff --git a/apps/web/src/app/[locale]/control-panel/nav-item.tsx b/apps/web/src/app/[locale]/control-panel/nav-item.tsx new file mode 100644 index 000000000..5212f11bd --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/nav-item.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/[locale]/control-panel/page.tsx b/apps/web/src/app/[locale]/control-panel/page.tsx new file mode 100644 index 000000000..89985cd71 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/page.tsx @@ -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 ( + + + + + + + + + + +
+

+ +

+ + + +
+
+ + + + + + +
+
+ userLimit, + })} + > + + / + {userLimit === Number.POSITIVE_INFINITY + ? "unlimited" + : userLimit} + +
+
+ +
+ + +
+ + + + {tier ? ( + + {tier} + + ) : ( + + + + )} +
+ + + + +
+
+
+
+
+ ); +} + +export async function generateMetadata() { + return { + title: "Control Panel", + }; +} diff --git a/apps/web/src/app/[locale]/control-panel/sidebar.tsx b/apps/web/src/app/[locale]/control-panel/sidebar.tsx new file mode 100644 index 000000000..1fdfa2546 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/sidebar.tsx @@ -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) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/app/[locale]/control-panel/users/actions.ts b/apps/web/src/app/[locale]/control-panel/users/actions.ts new file mode 100644 index 000000000..be396b878 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/users/actions.ts @@ -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) { + 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, + }, + }); +} diff --git a/apps/web/src/app/[locale]/control-panel/users/delete-user-button.tsx b/apps/web/src/app/[locale]/control-panel/users/delete-user-button.tsx new file mode 100644 index 000000000..d08f8417d --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/users/delete-user-button.tsx @@ -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 ( + + + + + + + + + + + + + +
+ { + await deleteUser({ userId, email: data.email }); + router.refresh(); + })} + > + ( + + + + + + + + + + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/control-panel/users/dialogs/delete-user-dialog.tsx b/apps/web/src/app/[locale]/control-panel/users/dialogs/delete-user-dialog.tsx new file mode 100644 index 000000000..5c234ebc4 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/users/dialogs/delete-user-dialog.tsx @@ -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 ( + + + + + + + + + + +
+ { + startTransition(async () => { + await deleteUser({ userId, email: data.email }); + router.refresh(); + onOpenChange(false); + }); + })} + > + ( + + + + + + + + + , + }} + /> + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/control-panel/users/page.tsx b/apps/web/src/app/[locale]/control-panel/users/page.tsx new file mode 100644 index 000000000..f2d4d281d --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/users/page.tsx @@ -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 ( + + +
+ + + + + + +
+
+ +
+ + + {allUsers.length > 0 ? ( +
+ + {allUsers.map((user) => ( + + ))} + + +
+ ) : ( + + + + + + + + + + + + )} +
+
+
+
+ ); +} + +export async function generateMetadata() { + const { t } = await getTranslation(); + return { + title: t("users", { + defaultValue: "Users", + }), + }; +} diff --git a/apps/web/src/app/[locale]/control-panel/users/user-dropdown.tsx b/apps/web/src/app/[locale]/control-panel/users/user-dropdown.tsx new file mode 100644 index 000000000..9175d5e91 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/users/user-dropdown.tsx @@ -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 ( + + + + + + { + await deleteUser({ userId, email }); + router.refresh(); + }} + > + + + + + + ); +} diff --git a/apps/web/src/app/[locale]/control-panel/users/user-row.tsx b/apps/web/src/app/[locale]/control-panel/users/user-row.tsx new file mode 100644 index 000000000..6410a7204 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/users/user-row.tsx @@ -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 ( + <> + +
+ +
+
+
{name}
+
{email}
+
+
+ {role} + + + + + + + + + + + + + + { + startTransition(async () => { + await changeRole({ + role: userRoleSchema.parse(value), + userId, + }); + router.refresh(); + }); + }} + > + + + + + + + + + + + { + deleteDialog.trigger(); + }} + disabled={isYou} + > + + + + + +
+
+ + + ); +} diff --git a/apps/web/src/app/[locale]/control-panel/users/user-search-input.tsx b/apps/web/src/app/[locale]/control-panel/users/user-search-input.tsx new file mode 100644 index 000000000..0420ebb04 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/users/user-search-input.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/app/[locale]/control-panel/users/users-tabbed-view.tsx b/apps/web/src/app/[locale]/control-panel/users/users-tabbed-view.tsx new file mode 100644 index 000000000..26bbc4cd0 --- /dev/null +++ b/apps/web/src/app/[locale]/control-panel/users/users-tabbed-view.tsx @@ -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 ( + + + + + + + + + + + + + + {children} + + + ); +} diff --git a/apps/web/src/features/licensing/components/license-key-form.tsx b/apps/web/src/features/licensing/components/license-key-form.tsx new file mode 100644 index 000000000..444fee29c --- /dev/null +++ b/apps/web/src/features/licensing/components/license-key-form.tsx @@ -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; + +export function LicenseKeyForm() { + const { t } = useTranslation(); + const router = useRouter(); + const form = useForm({ + 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 ( +
+ + { + return ( + + + + + + + + + + ); + }} + /> + +
+ +
+ + + ); +} diff --git a/apps/web/src/features/licensing/components/license-limit-warning.tsx b/apps/web/src/features/licensing/components/license-limit-warning.tsx new file mode 100644 index 000000000..dd5104eb7 --- /dev/null +++ b/apps/web/src/features/licensing/components/license-limit-warning.tsx @@ -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 ( +
+ + ), + }} + /> +
+ ); +} diff --git a/apps/web/src/features/licensing/components/remove-license-button.tsx b/apps/web/src/features/licensing/components/remove-license-button.tsx new file mode 100644 index 000000000..f4862ad67 --- /dev/null +++ b/apps/web/src/features/licensing/components/remove-license-button.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/features/licensing/mutations.ts b/apps/web/src/features/licensing/mutations.ts new file mode 100644 index 000000000..2490c80a7 --- /dev/null +++ b/apps/web/src/features/licensing/mutations.ts @@ -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", + }; +} diff --git a/apps/web/src/features/licensing/queries.ts b/apps/web/src/features/licensing/queries.ts new file mode 100644 index 000000000..d327263bf --- /dev/null +++ b/apps/web/src/features/licensing/queries.ts @@ -0,0 +1,5 @@ +import { prisma } from "@rallly/database"; + +export async function getLicense() { + return prisma.instanceLicense.findFirst(); +} diff --git a/apps/web/src/features/navigation/command-menu/command-menu.tsx b/apps/web/src/features/navigation/command-menu/command-menu.tsx index 8ae0fbebe..3d078f6aa 100644 --- a/apps/web/src/features/navigation/command-menu/command-menu.tsx +++ b/apps/web/src/features/navigation/command-menu/command-menu.tsx @@ -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 ( +
+ }} + /> +
+ ); +} + 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 ( <> - - {/* */} @@ -64,50 +83,80 @@ export function CommandMenu() { }> handleSelect("/new")}> - + - - + + handleSelect("/")}> - + handleSelect("/polls")}> - + handleSelect("/events")}> - + - {/* handleSelect("/teams")}> - - - - handleSelect("/spaces")}> - - - */} } + heading={} > handleSelect("/settings/profile")}> - + handleSelect("/settings/preferences")}> - + handleSelect("/settings/billing")}> - + + {user.role === "admin" && ( + + } + > + handleSelect("/control-panel")}> + + + + + + handleSelect("/control-panel/users")} + > + + + + + + handleSelect("/control-panel/license")} + > + + + + + + + )} diff --git a/apps/web/src/features/user/schema.ts b/apps/web/src/features/user/schema.ts new file mode 100644 index 000000000..27948ee95 --- /dev/null +++ b/apps/web/src/features/user/schema.ts @@ -0,0 +1,5 @@ +import z from "zod"; + +export const userRoleSchema = z.enum(["admin", "user"]); + +export type UserRole = z.infer; diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx index 56561318e..e99ef3c16 100644 --- a/packages/ui/src/dropdown-menu.tsx +++ b/packages/ui/src/dropdown-menu.tsx @@ -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, + React.ComponentRef, React.ComponentPropsWithoutRef >(({ ...props }, ref) => ( , + React.ComponentRef, React.ComponentPropsWithoutRef & { inset?: boolean; } @@ -39,7 +39,7 @@ const DropdownMenuSubTrigger = React.forwardRef< , + React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( , + React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( @@ -87,7 +87,7 @@ const DropdownMenuContent = React.forwardRef< DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef & { inset?: boolean; } @@ -106,7 +106,7 @@ const DropdownMenuItem = React.forwardRef< DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, children, checked, ...props }, ref) => ( , + React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - + + + {children} @@ -154,7 +156,7 @@ const DropdownMenuRadioItem = React.forwardRef< DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef & { inset?: boolean; } @@ -172,7 +174,7 @@ const DropdownMenuLabel = React.forwardRef< DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, + React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => (