diff --git a/apps/web/package.json b/apps/web/package.json index 7708635a8..f493a3b22 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 13a8afee6..b31fa3021 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -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" } diff --git a/apps/web/src/app/[locale]/(space)/settings/profile/actions.ts b/apps/web/src/app/[locale]/(space)/settings/profile/actions.ts new file mode 100644 index 000000000..1163dedc5 --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/settings/profile/actions.ts @@ -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, + }; + }, +); diff --git a/apps/web/src/app/[locale]/(space)/settings/profile/delete-account-dialog.tsx b/apps/web/src/app/[locale]/(space)/settings/profile/delete-account-dialog.tsx index b8dfe1080..9b705fdf6 100644 --- a/apps/web/src/app/[locale]/(space)/settings/profile/delete-account-dialog.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/profile/delete-account-dialog.tsx @@ -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 (
@@ -52,7 +55,7 @@ export function DeleteAccountDialog({ method="POST" action="/auth/logout" onSubmit={form.handleSubmit(async () => { - await deleteAccount.mutateAsync(); + await deleteUser.executeAsync(); })} > @@ -111,7 +114,7 @@ export function DeleteAccountDialog({ - - - - - - - - - - - - { - await deleteUser({ userId, email: data.email }); - router.refresh(); - })} - > - ( - - - - - - - - - - - - - )} - /> - - - - - - - - - - - ); -} diff --git a/apps/web/src/app/[locale]/control-panel/users/dialogs/delete-user-dialog.tsx b/apps/web/src/app/[locale]/control-panel/users/dialogs/delete-user-dialog.tsx index 5c234ebc4..dc05972bc 100644 --- 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 @@ -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 ( @@ -78,12 +85,8 @@ export function DeleteUserDialog({
{ - startTransition(async () => { - await deleteUser({ userId, email: data.email }); - router.refresh(); - onOpenChange(false); - }); + onSubmit={form.handleSubmit(async () => { + await deleteUser.executeAsync({ userId }); })} > - 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 deleted file mode 100644 index 9175d5e91..000000000 --- a/apps/web/src/app/[locale]/control-panel/users/user-dropdown.tsx +++ /dev/null @@ -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 ( - - - - - - { - 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 index 45b2debb7..b9999a3d1 100644 --- a/apps/web/src/app/[locale]/control-panel/users/user-row.tsx +++ b/apps/web/src/app/[locale]/control-panel/users/user-row.tsx @@ -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(); }); }} > diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx index e64ddbdde..2cb055fbe 100644 --- a/apps/web/src/app/[locale]/layout.tsx +++ b/apps/web/src/app/[locale]/layout.tsx @@ -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({ + diff --git a/apps/web/src/app/[locale]/poll/[urlId]/layout.tsx b/apps/web/src/app/[locale]/poll/[urlId]/layout.tsx index 9c81a8324..56c8eb11e 100644 --- a/apps/web/src/app/[locale]/poll/[urlId]/layout.tsx +++ b/apps/web/src/app/[locale]/poll/[urlId]/layout.tsx @@ -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 ( {children} diff --git a/apps/web/src/auth/queries.ts b/apps/web/src/auth/queries.ts index 21ae96fb4..6dcf296d9 100644 --- a/apps/web/src/auth/queries.ts +++ b/apps/web/src/auth/queries.ts @@ -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), + }), + }; +}); diff --git a/apps/web/src/features/ability-manager/ability.ts b/apps/web/src/features/ability-manager/ability.ts new file mode 100644 index 000000000..1bf87dafa --- /dev/null +++ b/apps/web/src/features/ability-manager/ability.ts @@ -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( + 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(); +}; diff --git a/apps/web/src/features/ability-manager/index.ts b/apps/web/src/features/ability-manager/index.ts new file mode 100644 index 000000000..76a1e9303 --- /dev/null +++ b/apps/web/src/features/ability-manager/index.ts @@ -0,0 +1 @@ +export { defineAbilityFor } from "./ability"; diff --git a/apps/web/src/features/safe-action/client.ts b/apps/web/src/features/safe-action/client.ts new file mode 100644 index 000000000..667bb9513 --- /dev/null +++ b/apps/web/src/features/safe-action/client.ts @@ -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); + } + }, + }); +}; diff --git a/apps/web/src/features/safe-action/server.ts b/apps/web/src/features/safe-action/server.ts new file mode 100644 index 000000000..a958c4a55 --- /dev/null +++ b/apps/web/src/features/safe-action/server.ts @@ -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 }, + }); +}); diff --git a/apps/web/src/features/setup/actions.ts b/apps/web/src/features/setup/actions.ts index 387d424a3..7030adfb7 100644 --- a/apps/web/src/features/setup/actions.ts +++ b/apps/web/src/features/setup/actions.ts @@ -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("/"); diff --git a/apps/web/src/features/setup/components/setup-form.tsx b/apps/web/src/features/setup/components/setup-form.tsx index 383a41a98..5d3c8c15b 100644 --- a/apps/web/src/features/setup/components/setup-form.tsx +++ b/apps/web/src/features/setup/components/setup-form.tsx @@ -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({ resolver: zodResolver(setupSchema), diff --git a/apps/web/src/features/user/actions.ts b/apps/web/src/features/user/actions.ts new file mode 100644 index 000000000..e4703c690 --- /dev/null +++ b/apps/web/src/features/user/actions.ts @@ -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, + }; + }); diff --git a/apps/web/src/safe-action.ts b/apps/web/src/safe-action.ts deleted file mode 100644 index 3ccf37fa7..000000000 --- a/apps/web/src/safe-action.ts +++ /dev/null @@ -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 }, - }); -}); diff --git a/apps/web/src/trpc/routers/user.ts b/apps/web/src/trpc/routers/user.ts index 1631bfd61..e9c13929d 100644 --- a/apps/web/src/trpc/routers/user.ts +++ b/apps/web/src/trpc/routers/user.ts @@ -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({ diff --git a/packages/ui/package.json b/packages/ui/package.json index c503bd489..8b759c079 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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": { diff --git a/packages/ui/src/sonner.tsx b/packages/ui/src/sonner.tsx new file mode 100644 index 000000000..2e5afa6d3 --- /dev/null +++ b/packages/ui/src/sonner.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as Sonner, toast } from "sonner"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster, toast }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5acf66a9e..65519cc00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}