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

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