mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-16 07:55:29 +02:00
✨ Add ability manager (#1796)
This commit is contained in:
parent
56e0ca0151
commit
4b4dfef3e5
27 changed files with 563 additions and 289 deletions
|
@ -20,6 +20,9 @@
|
|||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@aws-sdk/client-s3": "^3.645.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.645.0",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@casl/prisma": "^1.5.1",
|
||||
"@casl/react": "^5.0.0",
|
||||
"@hono-rate-limiter/redis": "^0.1.4",
|
||||
"@hono/zod-validator": "^0.5.0",
|
||||
"@hookform/resolvers": "^3.3.1",
|
||||
|
|
|
@ -330,9 +330,6 @@
|
|||
"canceledEventsEmptyStateDescription": "Canceled events will show up here.",
|
||||
"setupFormTitle": "Setup",
|
||||
"setupFormDescription": "Finish setting up your account.",
|
||||
"errorNotAuthenticated": "Not authenticated",
|
||||
"errorInvalidFields": "Invalid fields. Please check your input.",
|
||||
"errorDatabaseUpdateFailed": "Database error: Failed to update settings.",
|
||||
"pending": "Pending",
|
||||
"helpUsImprove": "Help us improve",
|
||||
"helpUsImproveDesc": "Take a few minutes to share your feedback and help us shape the future of Rallly.",
|
||||
|
@ -361,9 +358,6 @@
|
|||
"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",
|
||||
|
@ -395,5 +389,11 @@
|
|||
"disableUserRegistrationDescription": "Prevent new users from registering an account.",
|
||||
"authenticationAndSecurity": "Authentication & Security",
|
||||
"authenticationAndSecurityDescription": "Manage authentication and security settings",
|
||||
"youHaveUnsavedChanges": "You have unsaved changes"
|
||||
"youHaveUnsavedChanges": "You have unsaved changes",
|
||||
"unexpectedError": "Unexpected Error",
|
||||
"unexpectedErrorDescription": "There was an unexpected error. Please try again later.",
|
||||
"actionErrorUnauthorized": "You are not authorized to perform this action",
|
||||
"actionErrorNotFound": "The resource was not found",
|
||||
"actionErrorForbidden": "You are not allowed to perform this action",
|
||||
"actionErrorInternalServerError": "An internal server error occurred"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
"use server";
|
||||
|
||||
import { ActionError, authActionClient } from "@/features/safe-action/server";
|
||||
import { subject } from "@casl/ability";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
export const deleteCurrentUserAction = authActionClient.action(
|
||||
async ({ ctx }) => {
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { spaces: { include: { subscription: true } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ActionError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.ability.cannot("delete", subject("User", user))) {
|
||||
throw new ActionError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to delete this user",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
);
|
|
@ -17,8 +17,9 @@ import { signOut } from "next-auth/react";
|
|||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { trpc } from "@/trpc/client";
|
||||
import { deleteCurrentUserAction } from "./actions";
|
||||
|
||||
export function DeleteAccountDialog({
|
||||
email,
|
||||
|
@ -32,16 +33,18 @@ export function DeleteAccountDialog({
|
|||
email: "",
|
||||
},
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const posthog = usePostHog();
|
||||
const deleteAccount = trpc.user.delete.useMutation({
|
||||
onSuccess() {
|
||||
|
||||
const deleteUser = useSafeAction(deleteCurrentUserAction, {
|
||||
onSuccess: () => {
|
||||
posthog?.capture("delete account");
|
||||
signOut({
|
||||
redirectTo: "/login",
|
||||
});
|
||||
},
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
|
@ -52,7 +55,7 @@ export function DeleteAccountDialog({
|
|||
method="POST"
|
||||
action="/auth/logout"
|
||||
onSubmit={form.handleSubmit(async () => {
|
||||
await deleteAccount.mutateAsync();
|
||||
await deleteUser.executeAsync();
|
||||
})}
|
||||
>
|
||||
<DialogHeader>
|
||||
|
@ -111,7 +114,7 @@ export function DeleteAccountDialog({
|
|||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={deleteAccount.isPending}
|
||||
loading={deleteUser.isExecuting}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trans i18nKey="deleteAccount" defaults="Delete Account" />
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
"use server";
|
||||
|
||||
import { isInitialAdmin, requireUser } from "@/auth/queries";
|
||||
import { authActionClient } from "@/features/safe-action/server";
|
||||
import { ActionError } from "@/features/safe-action/server";
|
||||
import { subject } from "@casl/ability";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
export async function makeAdmin() {
|
||||
const user = await requireUser();
|
||||
|
||||
if (!isInitialAdmin(user.email)) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
export const makeMeAdminAction = authActionClient.action(async ({ ctx }) => {
|
||||
if (ctx.ability.cannot("update", subject("User", ctx.user), "role")) {
|
||||
throw new ActionError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update your role",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
id: ctx.user.id,
|
||||
},
|
||||
data: {
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, error: null };
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,34 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { makeAdmin } from "./actions";
|
||||
import { makeMeAdminAction } from "./actions";
|
||||
|
||||
export function MakeMeAdminButton() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const makeMeAdmin = useSafeAction(makeMeAdminAction, {
|
||||
onSuccess: () => {
|
||||
router.replace("/control-panel");
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
startTransition(async () => {
|
||||
const { success, error } = await makeAdmin();
|
||||
if (success) {
|
||||
router.replace("/control-panel");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
await makeMeAdmin.executeAsync();
|
||||
}}
|
||||
loading={isPending}
|
||||
loading={makeMeAdmin.isExecuting}
|
||||
variant="primary"
|
||||
>
|
||||
<Trans i18nKey="adminSetupCta" defaults="Make me an admin" />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { isInitialAdmin, requireUser } from "@/auth/queries";
|
||||
import { requireUserAbility } from "@/auth/queries";
|
||||
import {
|
||||
EmptyState,
|
||||
EmptyStateDescription,
|
||||
|
@ -7,6 +7,8 @@ import {
|
|||
EmptyStateTitle,
|
||||
} from "@/components/empty-state";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { getTranslation } from "@/i18n/server";
|
||||
import { subject } from "@casl/ability";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { CrownIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
@ -14,14 +16,15 @@ import { notFound, redirect } from "next/navigation";
|
|||
import { MakeMeAdminButton } from "./make-me-admin-button";
|
||||
|
||||
export default async function AdminSetupPage() {
|
||||
const user = await requireUser();
|
||||
const { user, ability } = await requireUserAbility();
|
||||
|
||||
if (user.role === "admin") {
|
||||
// User is already an admin
|
||||
if (ability.can("access", "ControlPanel")) {
|
||||
redirect("/control-panel");
|
||||
}
|
||||
|
||||
if (!isInitialAdmin(user.email)) {
|
||||
const canMakeAdmin = ability.can("update", subject("User", user), "role");
|
||||
|
||||
if (!canMakeAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
@ -52,3 +55,10 @@ export default async function AdminSetupPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { t } = await getTranslation();
|
||||
return {
|
||||
title: t("adminSetup"),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { requireAdmin } from "@/auth/queries";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { z } from "zod";
|
||||
|
||||
const inputSchema = z.object({
|
||||
userId: z.string(),
|
||||
role: z.enum(["admin", "user"]),
|
||||
});
|
||||
|
||||
export async function changeRole(input: z.infer<typeof inputSchema>) {
|
||||
await requireAdmin();
|
||||
const { userId, role } = inputSchema.parse(input);
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser({
|
||||
userId,
|
||||
email,
|
||||
}: { userId: string; email: string }) {
|
||||
await requireAdmin();
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,123 +0,0 @@
|
|||
"use client";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@rallly/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@rallly/ui/form";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { Input } from "@rallly/ui/input";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { deleteUser } from "./actions";
|
||||
|
||||
const useSchema = (email: string) => {
|
||||
const { t } = useTranslation();
|
||||
return z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.refine((value) => value.toLowerCase() === email.toLowerCase(), {
|
||||
message: t("emailDoesNotMatch", {
|
||||
defaultValue: "The email address does not match.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export function DeleteUserButton({
|
||||
userId,
|
||||
email,
|
||||
}: { userId: string; email: string }) {
|
||||
const router = useRouter();
|
||||
const schema = useSchema(email);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" size="icon" variant="ghost">
|
||||
<Icon>
|
||||
<TrashIcon />
|
||||
</Icon>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey="deleteUser" defaults="Delete User" />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
i18nKey="areYouSureYouWantToDeleteThisUser"
|
||||
defaults="Are you sure you want to delete this user?"
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
await deleteUser({ userId, email: data.email });
|
||||
router.refresh();
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey="email" defaults="Email" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={email} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans
|
||||
i18nKey="typeTheEmailAddressOfTheUserYouWantToDelete"
|
||||
defaults="Type the email address of the user you want to delete."
|
||||
/>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="mt-6">
|
||||
<DialogClose asChild>
|
||||
<Button>
|
||||
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" type="submit">
|
||||
<Trans i18nKey="delete" defaults="Delete" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { deleteUserAction } from "@/features/user/actions";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
|
@ -23,10 +25,8 @@ import {
|
|||
} 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();
|
||||
|
@ -55,13 +55,20 @@ export function DeleteUserDialog({
|
|||
}) {
|
||||
const router = useRouter();
|
||||
const schema = useSchema(email);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUser = useSafeAction(deleteUserAction, {
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
|
@ -78,12 +85,8 @@ export function DeleteUserDialog({
|
|||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
startTransition(async () => {
|
||||
await deleteUser({ userId, email: data.email });
|
||||
router.refresh();
|
||||
onOpenChange(false);
|
||||
});
|
||||
onSubmit={form.handleSubmit(async () => {
|
||||
await deleteUser.executeAsync({ userId });
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
|
@ -117,7 +120,11 @@ export function DeleteUserDialog({
|
|||
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" loading={isPending} type="submit">
|
||||
<Button
|
||||
variant="destructive"
|
||||
loading={deleteUser.isExecuting}
|
||||
type="submit"
|
||||
>
|
||||
<Trans i18nKey="delete" defaults="Delete" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@rallly/ui/dropdown-menu";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { MoreHorizontal, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { deleteUser } from "./actions";
|
||||
|
||||
export function UserDropdown({
|
||||
userId,
|
||||
email,
|
||||
}: { userId: string; email: string }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Icon>
|
||||
<MoreHorizontal />
|
||||
</Icon>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await deleteUser({ userId, email });
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<Trans i18nKey="delete" defaults="Delete" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
|
@ -3,6 +3,8 @@ 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 { useSafeAction } from "@/features/safe-action/client";
|
||||
import { changeRoleAction } from "@/features/user/actions";
|
||||
import { userRoleSchema } from "@/features/user/schema";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
|
@ -23,7 +25,6 @@ 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({
|
||||
|
@ -40,6 +41,12 @@ export function UserRow({
|
|||
role: "admin" | "user";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const changeRole = useSafeAction(changeRoleAction, {
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { user } = useUser();
|
||||
const deleteDialog = useDialog();
|
||||
|
@ -82,11 +89,10 @@ export function UserRow({
|
|||
value={role}
|
||||
onValueChange={async (value) => {
|
||||
startTransition(async () => {
|
||||
await changeRole({
|
||||
await changeRole.executeAsync({
|
||||
role: userRoleSchema.parse(value),
|
||||
userId,
|
||||
});
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -2,6 +2,7 @@ import "../../style.css";
|
|||
|
||||
import { supportedLngs } from "@rallly/languages";
|
||||
import { PostHogProvider, posthog } from "@rallly/posthog/client";
|
||||
import { Toaster as SonnerToast } from "@rallly/ui/sonner";
|
||||
import { Toaster } from "@rallly/ui/toaster";
|
||||
import { TooltipProvider } from "@rallly/ui/tooltip";
|
||||
import { LazyMotion, domAnimation } from "motion/react";
|
||||
|
@ -71,6 +72,7 @@ export default async function Root({
|
|||
<body>
|
||||
<FeatureFlagsProvider value={{ storage: isStorageEnabled }}>
|
||||
<Toaster />
|
||||
<SonnerToast />
|
||||
<I18nProvider locale={locale}>
|
||||
<TRPCProvider>
|
||||
<LazyMotion features={domAnimation}>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { PollLayout } from "@/components/layouts/poll-layout";
|
||||
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
|
||||
|
||||
export default async function Layout(
|
||||
props: React.PropsWithChildren<{ params: { urlId: string } }>,
|
||||
props: React.PropsWithChildren<{ params: Promise<{ urlId: string }> }>,
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
|
@ -26,6 +26,10 @@ export default async function Layout(
|
|||
notFound();
|
||||
}
|
||||
|
||||
if (!poll.adminUrlId) {
|
||||
redirect(`/invite/${params.urlId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(trpc.queryClient)}>
|
||||
<PollLayout>{children}</PollLayout>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { defineAbilityFor } from "@/features/ability-manager";
|
||||
import { getDefaultSpace } from "@/features/spaces/queries";
|
||||
import { getUser } from "@/features/user/queries";
|
||||
import { auth } from "@/next-auth";
|
||||
|
@ -41,3 +42,13 @@ export const getActiveSpace = cache(async () => {
|
|||
|
||||
return await getDefaultSpace({ ownerId: user.id });
|
||||
});
|
||||
|
||||
export const requireUserAbility = cache(async () => {
|
||||
const user = await requireUser();
|
||||
return {
|
||||
user,
|
||||
ability: defineAbilityFor(user, {
|
||||
isInitialAdmin: isInitialAdmin(user.email),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
|
95
apps/web/src/features/ability-manager/ability.ts
Normal file
95
apps/web/src/features/ability-manager/ability.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import type { PureAbility } from "@casl/ability";
|
||||
import { AbilityBuilder } from "@casl/ability";
|
||||
import {
|
||||
type PrismaQuery,
|
||||
type Subjects,
|
||||
createPrismaAbility,
|
||||
} from "@casl/prisma";
|
||||
import type {
|
||||
Comment,
|
||||
Participant,
|
||||
Poll,
|
||||
Space,
|
||||
SpaceMember,
|
||||
Subscription,
|
||||
UserRole,
|
||||
} from "@prisma/client";
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| "create"
|
||||
| "read"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "manage"
|
||||
| "finalize"
|
||||
| "access";
|
||||
|
||||
export type Subject =
|
||||
| Subjects<{
|
||||
User: User;
|
||||
Poll: Poll;
|
||||
Space: Space;
|
||||
Comment: Comment;
|
||||
Participant: Participant;
|
||||
SpaceMember: SpaceMember;
|
||||
Subscription: Subscription;
|
||||
}>
|
||||
| "ControlPanel";
|
||||
|
||||
export type AppAbility = PureAbility<[string, Subject], PrismaQuery>;
|
||||
|
||||
export const defineAbilityFor = (
|
||||
user?: User,
|
||||
options?: {
|
||||
isInitialAdmin?: boolean;
|
||||
},
|
||||
) => {
|
||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
|
||||
createPrismaAbility,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return build();
|
||||
}
|
||||
|
||||
if (user.role === "admin") {
|
||||
can("access", "ControlPanel");
|
||||
can("update", "User", ["role"]);
|
||||
cannot("update", "User", ["role"], { id: user.id });
|
||||
can("delete", "User");
|
||||
}
|
||||
|
||||
if (user.role === "user") {
|
||||
if (options?.isInitialAdmin) {
|
||||
can("update", "User", ["role"], {
|
||||
id: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
can("delete", "User", {
|
||||
id: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
can("update", "User", ["email", "name"], { id: user.id });
|
||||
cannot("delete", "User", {
|
||||
spaces: {
|
||||
some: {
|
||||
subscription: {
|
||||
is: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return build();
|
||||
};
|
1
apps/web/src/features/ability-manager/index.ts
Normal file
1
apps/web/src/features/ability-manager/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { defineAbilityFor } from "./ability";
|
41
apps/web/src/features/safe-action/client.ts
Normal file
41
apps/web/src/features/safe-action/client.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
import { useAction } from "next-safe-action/hooks";
|
||||
|
||||
export const useSafeAction: typeof useAction = (action, options) => {
|
||||
const { t } = useTranslation();
|
||||
return useAction(action, {
|
||||
...options,
|
||||
onError: ({ error }) => {
|
||||
if (error.serverError) {
|
||||
let translatedDescription = "An unexpected error occurred";
|
||||
|
||||
switch (error.serverError) {
|
||||
case "UNAUTHORIZED":
|
||||
translatedDescription = t("actionErrorUnauthorized", {
|
||||
defaultValue: "You are not authorized to perform this action",
|
||||
});
|
||||
break;
|
||||
case "NOT_FOUND":
|
||||
translatedDescription = t("actionErrorNotFound", {
|
||||
defaultValue: "The resource was not found",
|
||||
});
|
||||
break;
|
||||
case "FORBIDDEN":
|
||||
translatedDescription = t("actionErrorForbidden", {
|
||||
defaultValue: "You are not allowed to perform this action",
|
||||
});
|
||||
break;
|
||||
case "INTERNAL_SERVER_ERROR":
|
||||
translatedDescription = t("actionErrorInternalServerError", {
|
||||
defaultValue: "An internal server error occurred",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
toast.error(translatedDescription);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
57
apps/web/src/features/safe-action/server.ts
Normal file
57
apps/web/src/features/safe-action/server.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
"server-only";
|
||||
import { requireUserAbility } from "@/auth/queries";
|
||||
import { posthog } from "@rallly/posthog/server";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { createSafeActionClient } from "next-safe-action";
|
||||
|
||||
type ActionErrorCode =
|
||||
| "UNAUTHORIZED"
|
||||
| "NOT_FOUND"
|
||||
| "FORBIDDEN"
|
||||
| "INTERNAL_SERVER_ERROR";
|
||||
|
||||
export class ActionError extends Error {
|
||||
code: ActionErrorCode;
|
||||
constructor({
|
||||
code,
|
||||
message,
|
||||
cause,
|
||||
}: {
|
||||
code: ActionErrorCode;
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
super(`[${code}]: ${message}`);
|
||||
this.name = "ActionError";
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export const actionClient = createSafeActionClient({
|
||||
handleServerError: async (error) => {
|
||||
if (error instanceof ActionError) {
|
||||
return error.code;
|
||||
}
|
||||
|
||||
return "INTERNAL_SERVER_ERROR";
|
||||
},
|
||||
}).use(({ next }) => {
|
||||
const result = next({
|
||||
ctx: {
|
||||
posthog,
|
||||
},
|
||||
});
|
||||
|
||||
waitUntil(Promise.all([posthog?.shutdown()]));
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
export const authActionClient = actionClient.use(async ({ next }) => {
|
||||
const { user, ability } = await requireUserAbility();
|
||||
|
||||
return next({
|
||||
ctx: { user, ability },
|
||||
});
|
||||
});
|
|
@ -1,11 +1,9 @@
|
|||
"use server";
|
||||
|
||||
import { prisma } from "@rallly/database";
|
||||
import { posthog } from "@rallly/posthog/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { authActionClient } from "@/safe-action";
|
||||
import { authActionClient } from "@/features/safe-action/server";
|
||||
import { setupSchema } from "./schema";
|
||||
|
||||
export const updateUserAction = authActionClient
|
||||
|
@ -22,7 +20,7 @@ export const updateUserAction = authActionClient
|
|||
},
|
||||
});
|
||||
|
||||
posthog?.capture({
|
||||
ctx.posthog?.capture({
|
||||
event: "user_setup_completed",
|
||||
distinctId: ctx.user.id,
|
||||
properties: {
|
||||
|
@ -34,8 +32,6 @@ export const updateUserAction = authActionClient
|
|||
},
|
||||
});
|
||||
|
||||
await posthog?.shutdown();
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
redirect("/");
|
||||
|
|
|
@ -16,9 +16,9 @@ import { useForm } from "react-hook-form";
|
|||
import { LanguageSelect } from "@/components/poll/language-selector";
|
||||
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { useTimezone } from "@/features/timezone";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { useAction } from "next-safe-action/hooks";
|
||||
|
||||
import { updateUserAction } from "../actions";
|
||||
import { type SetupFormValues, setupSchema } from "../schema";
|
||||
|
@ -30,7 +30,7 @@ interface SetupFormProps {
|
|||
export function SetupForm({ defaultValues }: SetupFormProps) {
|
||||
const { timezone } = useTimezone();
|
||||
const { i18n } = useTranslation();
|
||||
const userSetupAction = useAction(updateUserAction);
|
||||
const userSetupAction = useSafeAction(updateUserAction);
|
||||
|
||||
const form = useForm<SetupFormValues>({
|
||||
resolver: zodResolver(setupSchema),
|
||||
|
|
89
apps/web/src/features/user/actions.ts
Normal file
89
apps/web/src/features/user/actions.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
"use server";
|
||||
import { ActionError, authActionClient } from "@/features/safe-action/server";
|
||||
import { subject } from "@casl/ability";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { z } from "zod";
|
||||
import { getUser } from "./queries";
|
||||
|
||||
export const changeRoleAction = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
role: z.enum(["user", "admin"]),
|
||||
}),
|
||||
)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const { userId, role } = parsedInput;
|
||||
|
||||
const targetUser = await getUser(userId);
|
||||
|
||||
if (!targetUser) {
|
||||
throw new ActionError({
|
||||
code: "NOT_FOUND",
|
||||
message: `User ${userId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUser.role === role) {
|
||||
throw new ActionError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User is already this role",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.ability.cannot("update", subject("User", targetUser), "role")) {
|
||||
throw new ActionError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Current user is not authorized to update role",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: targetUser.id,
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const deleteUserAction = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const userId = parsedInput.userId;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { spaces: { include: { subscription: true } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ActionError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.ability.cannot("delete", subject("User", user))) {
|
||||
throw new ActionError({
|
||||
code: "FORBIDDEN",
|
||||
message: "This user cannot be deleted",
|
||||
cause: ctx.ability.relevantRuleFor("delete", subject("User", user)),
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
});
|
|
@ -1,12 +0,0 @@
|
|||
import { requireUser } from "@/auth/queries";
|
||||
import { createSafeActionClient } from "next-safe-action";
|
||||
|
||||
export const actionClient = createSafeActionClient();
|
||||
|
||||
export const authActionClient = actionClient.use(async ({ next }) => {
|
||||
const user = await requireUser();
|
||||
|
||||
return next({
|
||||
ctx: { user },
|
||||
});
|
||||
});
|
|
@ -37,6 +37,9 @@ export const user = router({
|
|||
},
|
||||
});
|
||||
}),
|
||||
/**
|
||||
* @deprecated - Use server actions instead
|
||||
*/
|
||||
delete: privateProcedure.mutation(async ({ ctx }) => {
|
||||
if (ctx.user.isGuest) {
|
||||
throw new TRPCError({
|
||||
|
|
|
@ -39,9 +39,11 @@
|
|||
"clsx": "^1.2.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.479.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^1.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
32
packages/ui/src/sonner.tsx
Normal file
32
packages/ui/src/sonner.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, toast } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-white group-[.toaster]:text-gray-950 group-[.toaster]:border-gray-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-gray-950 dark:group-[.toaster]:text-gray-50 dark:group-[.toaster]:border-gray-800",
|
||||
description:
|
||||
"group-[.toast]:text-gray-500 dark:group-[.toast]:text-gray-400",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-gray-900 group-[.toast]:text-gray-50 dark:group-[.toast]:bg-gray-50 dark:group-[.toast]:text-gray-900",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-gray-100 group-[.toast]:text-gray-500 dark:group-[.toast]:bg-gray-800 dark:group-[.toast]:text-gray-400",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster, toast };
|
104
pnpm-lock.yaml
generated
104
pnpm-lock.yaml
generated
|
@ -77,7 +77,7 @@ importers:
|
|||
version: 8.1.0(typescript@5.8.3)
|
||||
'@vercel/analytics':
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
|
||||
version: 1.5.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
|
@ -116,7 +116,7 @@ importers:
|
|||
version: 5.0.0(@types/react@19.1.2)(acorn@8.14.1)(react@19.1.0)
|
||||
next-seo:
|
||||
specifier: ^6.1.0
|
||||
version: 6.6.0(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
version: 6.6.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
|
@ -172,6 +172,15 @@ importers:
|
|||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.645.0
|
||||
version: 3.797.0
|
||||
'@casl/ability':
|
||||
specifier: ^6.7.3
|
||||
version: 6.7.3
|
||||
'@casl/prisma':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1(@casl/ability@6.7.3)(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))
|
||||
'@casl/react':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(@casl/ability@6.7.3)(react@19.1.0)
|
||||
'@hono-rate-limiter/redis':
|
||||
specifier: ^0.1.4
|
||||
version: 0.1.4(hono-rate-limiter@0.2.3(hono@4.7.10))
|
||||
|
@ -698,6 +707,9 @@ importers:
|
|||
lucide-react:
|
||||
specifier: ^0.479.0
|
||||
version: 0.479.0(react@19.1.0)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
|
@ -707,6 +719,9 @@ importers:
|
|||
react-hook-form:
|
||||
specifier: ^7.42.1
|
||||
version: 7.56.1(react@19.1.0)
|
||||
sonner:
|
||||
specifier: ^2.0.6
|
||||
version: 2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwind-merge:
|
||||
specifier: ^1.12.0
|
||||
version: 1.14.0
|
||||
|
@ -1582,6 +1597,21 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@casl/ability@6.7.3':
|
||||
resolution: {integrity: sha512-A4L28Ko+phJAsTDhRjzCOZWECQWN2jzZnJPnROWWHjJpyMq1h7h9ZqjwS2WbIUa3Z474X1ZPSgW0f1PboZGC0A==}
|
||||
|
||||
'@casl/prisma@1.5.1':
|
||||
resolution: {integrity: sha512-SkR0LaBSLVA0aXiYwFDTXIzfQgovpJHr7sXneuKkaw44dlFe0MwtpCMjZs7aCSIJK4SP1yGnHQcKFY7briMf9A==}
|
||||
peerDependencies:
|
||||
'@casl/ability': ^5.3.0 || ^6.0.0
|
||||
'@prisma/client': ^2.14.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
|
||||
|
||||
'@casl/react@5.0.0':
|
||||
resolution: {integrity: sha512-jiwr6uOBnQA7h0gs+RJIbFVF24Dw6JLiUPL4pfU0OEjWSJFCcYBz6RPU21XNciWL6xwFDOds81cHosuElxfdmw==}
|
||||
peerDependencies:
|
||||
'@casl/ability': ^4.0.0 || ^5.1.0 || ^6.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7':
|
||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
@ -4523,6 +4553,18 @@ packages:
|
|||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
'@ucast/core@1.10.2':
|
||||
resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==}
|
||||
|
||||
'@ucast/js@3.0.4':
|
||||
resolution: {integrity: sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==}
|
||||
|
||||
'@ucast/mongo2js@1.4.0':
|
||||
resolution: {integrity: sha512-vR9RJ3BHlkI3RfKJIZFdVktxWvBCQRiSTeJSWN9NPxP5YJkpfXvcBWAMLwvyJx4HbB+qib5/AlSDEmQiuQyx2w==}
|
||||
|
||||
'@ucast/mongo@2.4.3':
|
||||
resolution: {integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==}
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
|
@ -7155,6 +7197,12 @@ packages:
|
|||
react: '>=16.0.0'
|
||||
react-dom: '>=16.0.0'
|
||||
|
||||
next-themes@0.4.6:
|
||||
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@15.2.4:
|
||||
resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
|
@ -8155,6 +8203,12 @@ packages:
|
|||
resolution: {integrity: sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
sonner@2.0.6:
|
||||
resolution: {integrity: sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
sortobject@4.17.0:
|
||||
resolution: {integrity: sha512-gzx7USv55AFRQ7UCWJHHauwD/ptUHF9MLXCGO3f5M9zauDPZ/4a9H6/VVbOXefdpEoI1unwB/bArHIVMbWBHmA==}
|
||||
engines: {node: '>=10'}
|
||||
|
@ -10415,6 +10469,22 @@ snapshots:
|
|||
'@biomejs/cli-win32-x64@1.9.4':
|
||||
optional: true
|
||||
|
||||
'@casl/ability@6.7.3':
|
||||
dependencies:
|
||||
'@ucast/mongo2js': 1.4.0
|
||||
|
||||
'@casl/prisma@1.5.1(@casl/ability@6.7.3)(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@casl/ability': 6.7.3
|
||||
'@prisma/client': 6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3)
|
||||
'@ucast/core': 1.10.2
|
||||
'@ucast/js': 3.0.4
|
||||
|
||||
'@casl/react@5.0.0(@casl/ability@6.7.3)(react@19.1.0)':
|
||||
dependencies:
|
||||
'@casl/ability': 6.7.3
|
||||
react: 19.1.0
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7': {}
|
||||
|
||||
'@emnapi/runtime@1.4.3':
|
||||
|
@ -13529,6 +13599,22 @@ snapshots:
|
|||
'@types/node': 20.17.50
|
||||
optional: true
|
||||
|
||||
'@ucast/core@1.10.2': {}
|
||||
|
||||
'@ucast/js@3.0.4':
|
||||
dependencies:
|
||||
'@ucast/core': 1.10.2
|
||||
|
||||
'@ucast/mongo2js@1.4.0':
|
||||
dependencies:
|
||||
'@ucast/core': 1.10.2
|
||||
'@ucast/js': 3.0.4
|
||||
'@ucast/mongo': 2.4.3
|
||||
|
||||
'@ucast/mongo@2.4.3':
|
||||
dependencies:
|
||||
'@ucast/core': 1.10.2
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@upstash/core-analytics@0.0.9':
|
||||
|
@ -13549,7 +13635,7 @@ snapshots:
|
|||
dependencies:
|
||||
crypto-js: 4.2.0
|
||||
|
||||
'@vercel/analytics@1.5.0(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)':
|
||||
'@vercel/analytics@1.5.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)':
|
||||
optionalDependencies:
|
||||
next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
|
@ -16688,12 +16774,17 @@ snapshots:
|
|||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
next-seo@6.6.0(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
next-seo@6.6.0(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@next/env': 15.2.4
|
||||
|
@ -18022,6 +18113,11 @@ snapshots:
|
|||
ip-address: 9.0.5
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
sonner@2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
sortobject@4.17.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue