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({
+
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+ <>
+
+
+
+
+
+
+ {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 (
+
+
+ );
+}
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) => (