mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-06 09:59:00 +02:00
✨ Add admin control panel (#1726)
This commit is contained in:
parent
1b3b3aac50
commit
c5724f0118
27 changed files with 1672 additions and 40 deletions
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@rallly/ui/form";
|
||||
import { Input } from "@rallly/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { validateLicenseKey } from "../actions/validate-license";
|
||||
import { checkLicenseKey } from "../helpers/check-license-key";
|
||||
|
||||
const formSchema = z.object({
|
||||
licenseKey: z.string().trim().min(1).refine(checkLicenseKey, {
|
||||
message: "Invalid license key",
|
||||
}),
|
||||
});
|
||||
|
||||
type LicenseKeyFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function LicenseKeyForm() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const form = useForm<LicenseKeyFormValues>({
|
||||
defaultValues: {
|
||||
licenseKey: "",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LicenseKeyFormValues) => {
|
||||
try {
|
||||
const { valid } = await validateLicenseKey(data.licenseKey);
|
||||
|
||||
if (!valid) {
|
||||
form.setError("licenseKey", {
|
||||
message: "Invalid license key",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError("licenseKey", {
|
||||
message: "An error occurred while validating the license key",
|
||||
});
|
||||
}
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
name="licenseKey"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey="licenseKey" defaults="License Key" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="font-mono"
|
||||
disabled={form.formState.isSubmitting}
|
||||
placeholder="RLYV4-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={form.formState.isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import { Trans } from "@/components/trans";
|
||||
import { getLicense } from "@/features/licensing/queries";
|
||||
import { getUserCount } from "@/features/user/queries";
|
||||
import { isSelfHosted } from "@/utils/constants";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function LicenseLimitWarning() {
|
||||
if (!isSelfHosted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [license, userCount] = await Promise.all([
|
||||
getLicense(),
|
||||
getUserCount(),
|
||||
]);
|
||||
|
||||
const userLimit = license?.seats ?? 1;
|
||||
|
||||
if (!userLimit || userCount <= userLimit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted p-2 text-center text-sm rounded-md m-1 text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="licenseLimitWarning"
|
||||
defaults="You have exceeded the limits of your license. Please <a>upgrade</a>."
|
||||
components={{
|
||||
a: (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href="https://support.rallly.co/self-hosting/licensing"
|
||||
target="_blank"
|
||||
className="text-link"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
useDialog,
|
||||
} from "@rallly/ui/dialog";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { removeInstanceLicense } from "../mutations";
|
||||
|
||||
export function RemoveLicenseButton({
|
||||
licenseId,
|
||||
}: {
|
||||
licenseId: string;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const dialog = useDialog();
|
||||
return (
|
||||
<Dialog {...dialog.dialogProps}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => dialog.trigger()}>
|
||||
<Icon>
|
||||
<XIcon />
|
||||
</Icon>
|
||||
<Trans i18nKey="removeLicense" defaults="Remove License" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey="removeLicense" defaults="Remove License" />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
i18nKey="removeLicenseDescription"
|
||||
defaults="Are you sure you want to remove this license?"
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button>
|
||||
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
loading={isPending}
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
await removeInstanceLicense({
|
||||
licenseId,
|
||||
});
|
||||
router.refresh();
|
||||
dialog.dismiss();
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="removeLicense" defaults="Remove License" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
37
apps/web/src/features/licensing/mutations.ts
Normal file
37
apps/web/src/features/licensing/mutations.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
"use server";
|
||||
|
||||
import { requireAdmin } from "@/auth/queries";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
export async function removeInstanceLicense({
|
||||
licenseId,
|
||||
}: {
|
||||
licenseId: string;
|
||||
}) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: "You must be an admin to delete a license",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.instanceLicense.delete({
|
||||
where: {
|
||||
id: licenseId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to delete license",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "License deleted successfully",
|
||||
};
|
||||
}
|
5
apps/web/src/features/licensing/queries.ts
Normal file
5
apps/web/src/features/licensing/queries.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
|
||||
export async function getLicense() {
|
||||
return prisma.instanceLicense.findFirst();
|
||||
}
|
|
@ -9,7 +9,12 @@ import {
|
|||
CommandList,
|
||||
} 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>
|
||||
</>
|
||||
|
|
5
apps/web/src/features/user/schema.ts
Normal file
5
apps/web/src/features/user/schema.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import z from "zod";
|
||||
|
||||
export const userRoleSchema = z.enum(["admin", "user"]);
|
||||
|
||||
export type UserRole = z.infer<typeof userRoleSchema>;
|
Loading…
Add table
Add a link
Reference in a new issue