Add ability manager (#1796)

This commit is contained in:
Luke Vella 2025-07-08 14:56:13 +03:00 committed by GitHub
parent 56e0ca0151
commit 4b4dfef3e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 563 additions and 289 deletions

View file

@ -20,6 +20,9 @@
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
"@aws-sdk/client-s3": "^3.645.0", "@aws-sdk/client-s3": "^3.645.0",
"@aws-sdk/s3-request-presigner": "^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-rate-limiter/redis": "^0.1.4",
"@hono/zod-validator": "^0.5.0", "@hono/zod-validator": "^0.5.0",
"@hookform/resolvers": "^3.3.1", "@hookform/resolvers": "^3.3.1",

View file

@ -330,9 +330,6 @@
"canceledEventsEmptyStateDescription": "Canceled events will show up here.", "canceledEventsEmptyStateDescription": "Canceled events will show up here.",
"setupFormTitle": "Setup", "setupFormTitle": "Setup",
"setupFormDescription": "Finish setting up your account.", "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", "pending": "Pending",
"helpUsImprove": "Help us improve", "helpUsImprove": "Help us improve",
"helpUsImproveDesc": "Take a few minutes to share your feedback and help us shape the future of Rallly.", "helpUsImproveDesc": "Take a few minutes to share your feedback and help us shape the future of Rallly.",
@ -361,9 +358,6 @@
"users": "Users", "users": "Users",
"userCount": "{count, number, ::compact-short}", "userCount": "{count, number, ::compact-short}",
"unlicensed": "Unlicensed", "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.", "emailDoesNotMatch": "The email address does not match.",
"noUsers": "No users found", "noUsers": "No users found",
"noUsersDescription": "Try adjusting your search", "noUsersDescription": "Try adjusting your search",
@ -395,5 +389,11 @@
"disableUserRegistrationDescription": "Prevent new users from registering an account.", "disableUserRegistrationDescription": "Prevent new users from registering an account.",
"authenticationAndSecurity": "Authentication & Security", "authenticationAndSecurity": "Authentication & Security",
"authenticationAndSecurityDescription": "Manage authentication and security settings", "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"
} }

View file

@ -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,
};
},
);

View file

@ -17,8 +17,9 @@ import { signOut } from "next-auth/react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useSafeAction } from "@/features/safe-action/client";
import { useTranslation } from "@/i18n/client"; import { useTranslation } from "@/i18n/client";
import { trpc } from "@/trpc/client"; import { deleteCurrentUserAction } from "./actions";
export function DeleteAccountDialog({ export function DeleteAccountDialog({
email, email,
@ -32,16 +33,18 @@ export function DeleteAccountDialog({
email: "", email: "",
}, },
}); });
const { t } = useTranslation();
const posthog = usePostHog(); const posthog = usePostHog();
const deleteAccount = trpc.user.delete.useMutation({
onSuccess() { const deleteUser = useSafeAction(deleteCurrentUserAction, {
onSuccess: () => {
posthog?.capture("delete account"); posthog?.capture("delete account");
signOut({ signOut({
redirectTo: "/login", redirectTo: "/login",
}); });
}, },
}); });
const { t } = useTranslation();
return ( return (
<Form {...form}> <Form {...form}>
@ -52,7 +55,7 @@ export function DeleteAccountDialog({
method="POST" method="POST"
action="/auth/logout" action="/auth/logout"
onSubmit={form.handleSubmit(async () => { onSubmit={form.handleSubmit(async () => {
await deleteAccount.mutateAsync(); await deleteUser.executeAsync();
})} })}
> >
<DialogHeader> <DialogHeader>
@ -111,7 +114,7 @@ export function DeleteAccountDialog({
</DialogClose> </DialogClose>
<Button <Button
type="submit" type="submit"
loading={deleteAccount.isPending} loading={deleteUser.isExecuting}
variant="destructive" variant="destructive"
> >
<Trans i18nKey="deleteAccount" defaults="Delete Account" /> <Trans i18nKey="deleteAccount" defaults="Delete Account" />

View file

@ -1,23 +1,23 @@
"use server"; "use server";
import { authActionClient } from "@/features/safe-action/server";
import { isInitialAdmin, requireUser } from "@/auth/queries"; import { ActionError } from "@/features/safe-action/server";
import { subject } from "@casl/ability";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
export async function makeAdmin() { export const makeMeAdminAction = authActionClient.action(async ({ ctx }) => {
const user = await requireUser(); if (ctx.ability.cannot("update", subject("User", ctx.user), "role")) {
throw new ActionError({
if (!isInitialAdmin(user.email)) { code: "UNAUTHORIZED",
return { success: false, error: "Unauthorized" }; message: "You are not authorized to update your role",
});
} }
await prisma.user.update({ await prisma.user.update({
where: { where: {
id: user.id, id: ctx.user.id,
}, },
data: { data: {
role: "admin", role: "admin",
}, },
}); });
});
return { success: true, error: null };
}

View file

@ -1,34 +1,24 @@
"use client"; "use client";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useSafeAction } from "@/features/safe-action/client";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTransition } from "react"; import { makeMeAdminAction } from "./actions";
import { makeAdmin } from "./actions";
export function MakeMeAdminButton() { export function MakeMeAdminButton() {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const makeMeAdmin = useSafeAction(makeMeAdminAction, {
const [isPending, startTransition] = useTransition(); onSuccess: () => {
router.replace("/control-panel");
},
});
return ( return (
<Button <Button
onClick={async () => { onClick={async () => {
startTransition(async () => { await makeMeAdmin.executeAsync();
const { success, error } = await makeAdmin();
if (success) {
router.replace("/control-panel");
}
if (error) {
toast({
title: "Error",
description: error,
});
}
});
}} }}
loading={isPending} loading={makeMeAdmin.isExecuting}
variant="primary" variant="primary"
> >
<Trans i18nKey="adminSetupCta" defaults="Make me an admin" /> <Trans i18nKey="adminSetupCta" defaults="Make me an admin" />

View file

@ -1,4 +1,4 @@
import { isInitialAdmin, requireUser } from "@/auth/queries"; import { requireUserAbility } from "@/auth/queries";
import { import {
EmptyState, EmptyState,
EmptyStateDescription, EmptyStateDescription,
@ -7,6 +7,8 @@ import {
EmptyStateTitle, EmptyStateTitle,
} from "@/components/empty-state"; } from "@/components/empty-state";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { getTranslation } from "@/i18n/server";
import { subject } from "@casl/ability";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { CrownIcon } from "lucide-react"; import { CrownIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@ -14,14 +16,15 @@ import { notFound, redirect } from "next/navigation";
import { MakeMeAdminButton } from "./make-me-admin-button"; import { MakeMeAdminButton } from "./make-me-admin-button";
export default async function AdminSetupPage() { export default async function AdminSetupPage() {
const user = await requireUser(); const { user, ability } = await requireUserAbility();
if (user.role === "admin") { if (ability.can("access", "ControlPanel")) {
// User is already an admin
redirect("/control-panel"); redirect("/control-panel");
} }
if (!isInitialAdmin(user.email)) { const canMakeAdmin = ability.can("update", subject("User", user), "role");
if (!canMakeAdmin) {
notFound(); notFound();
} }
@ -52,3 +55,10 @@ export default async function AdminSetupPage() {
</div> </div>
); );
} }
export async function generateMetadata() {
const { t } = await getTranslation();
return {
title: t("adminSetup"),
};
}

View file

@ -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,
},
});
}

View file

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

View file

@ -1,5 +1,7 @@
"use client"; "use client";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useSafeAction } from "@/features/safe-action/client";
import { deleteUserAction } from "@/features/user/actions";
import { useTranslation } from "@/i18n/client"; import { useTranslation } from "@/i18n/client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
@ -23,10 +25,8 @@ import {
} from "@rallly/ui/form"; } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input"; import { Input } from "@rallly/ui/input";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { deleteUser } from "../actions";
const useSchema = (email: string) => { const useSchema = (email: string) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -55,13 +55,20 @@ export function DeleteUserDialog({
}) { }) {
const router = useRouter(); const router = useRouter();
const schema = useSchema(email); const schema = useSchema(email);
const [isPending, startTransition] = useTransition();
const form = useForm({ const form = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
email: "", email: "",
}, },
}); });
const deleteUser = useSafeAction(deleteUserAction, {
onSuccess: () => {
router.refresh();
onOpenChange(false);
},
});
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
@ -78,12 +85,8 @@ export function DeleteUserDialog({
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(async (data) => { onSubmit={form.handleSubmit(async () => {
startTransition(async () => { await deleteUser.executeAsync({ userId });
await deleteUser({ userId, email: data.email });
router.refresh();
onOpenChange(false);
});
})} })}
> >
<FormField <FormField
@ -117,7 +120,11 @@ export function DeleteUserDialog({
<Trans i18nKey="cancel" defaults="Cancel" /> <Trans i18nKey="cancel" defaults="Cancel" />
</Button> </Button>
</DialogClose> </DialogClose>
<Button variant="destructive" loading={isPending} type="submit"> <Button
variant="destructive"
loading={deleteUser.isExecuting}
type="submit"
>
<Trans i18nKey="delete" defaults="Delete" /> <Trans i18nKey="delete" defaults="Delete" />
</Button> </Button>
</DialogFooter> </DialogFooter>

View file

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

View file

@ -3,6 +3,8 @@ import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { StackedListItem } from "@/components/stacked-list"; import { StackedListItem } from "@/components/stacked-list";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; 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 { userRoleSchema } from "@/features/user/schema";
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
@ -23,7 +25,6 @@ import { Icon } from "@rallly/ui/icon";
import { MoreHorizontal, TrashIcon, UserPenIcon } from "lucide-react"; import { MoreHorizontal, TrashIcon, UserPenIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTransition } from "react"; import { useTransition } from "react";
import { changeRole } from "./actions";
import { DeleteUserDialog } from "./dialogs/delete-user-dialog"; import { DeleteUserDialog } from "./dialogs/delete-user-dialog";
export function UserRow({ export function UserRow({
@ -40,6 +41,12 @@ export function UserRow({
role: "admin" | "user"; role: "admin" | "user";
}) { }) {
const router = useRouter(); const router = useRouter();
const changeRole = useSafeAction(changeRoleAction, {
onSuccess: () => {
router.refresh();
},
});
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { user } = useUser(); const { user } = useUser();
const deleteDialog = useDialog(); const deleteDialog = useDialog();
@ -82,11 +89,10 @@ export function UserRow({
value={role} value={role}
onValueChange={async (value) => { onValueChange={async (value) => {
startTransition(async () => { startTransition(async () => {
await changeRole({ await changeRole.executeAsync({
role: userRoleSchema.parse(value), role: userRoleSchema.parse(value),
userId, userId,
}); });
router.refresh();
}); });
}} }}
> >

View file

@ -2,6 +2,7 @@ import "../../style.css";
import { supportedLngs } from "@rallly/languages"; import { supportedLngs } from "@rallly/languages";
import { PostHogProvider, posthog } from "@rallly/posthog/client"; import { PostHogProvider, posthog } from "@rallly/posthog/client";
import { Toaster as SonnerToast } from "@rallly/ui/sonner";
import { Toaster } from "@rallly/ui/toaster"; import { Toaster } from "@rallly/ui/toaster";
import { TooltipProvider } from "@rallly/ui/tooltip"; import { TooltipProvider } from "@rallly/ui/tooltip";
import { LazyMotion, domAnimation } from "motion/react"; import { LazyMotion, domAnimation } from "motion/react";
@ -71,6 +72,7 @@ export default async function Root({
<body> <body>
<FeatureFlagsProvider value={{ storage: isStorageEnabled }}> <FeatureFlagsProvider value={{ storage: isStorageEnabled }}>
<Toaster /> <Toaster />
<SonnerToast />
<I18nProvider locale={locale}> <I18nProvider locale={locale}>
<TRPCProvider> <TRPCProvider>
<LazyMotion features={domAnimation}> <LazyMotion features={domAnimation}>

View file

@ -1,12 +1,12 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; 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 { PollLayout } from "@/components/layouts/poll-layout";
import { createSSRHelper } from "@/trpc/server/create-ssr-helper"; import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
export default async function Layout( export default async function Layout(
props: React.PropsWithChildren<{ params: { urlId: string } }>, props: React.PropsWithChildren<{ params: Promise<{ urlId: string }> }>,
) { ) {
const params = await props.params; const params = await props.params;
@ -26,6 +26,10 @@ export default async function Layout(
notFound(); notFound();
} }
if (!poll.adminUrlId) {
redirect(`/invite/${params.urlId}`);
}
return ( return (
<HydrationBoundary state={dehydrate(trpc.queryClient)}> <HydrationBoundary state={dehydrate(trpc.queryClient)}>
<PollLayout>{children}</PollLayout> <PollLayout>{children}</PollLayout>

View file

@ -1,3 +1,4 @@
import { defineAbilityFor } from "@/features/ability-manager";
import { getDefaultSpace } from "@/features/spaces/queries"; import { getDefaultSpace } from "@/features/spaces/queries";
import { getUser } from "@/features/user/queries"; import { getUser } from "@/features/user/queries";
import { auth } from "@/next-auth"; import { auth } from "@/next-auth";
@ -41,3 +42,13 @@ export const getActiveSpace = cache(async () => {
return await getDefaultSpace({ ownerId: user.id }); return await getDefaultSpace({ ownerId: user.id });
}); });
export const requireUserAbility = cache(async () => {
const user = await requireUser();
return {
user,
ability: defineAbilityFor(user, {
isInitialAdmin: isInitialAdmin(user.email),
}),
};
});

View 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();
};

View file

@ -0,0 +1 @@
export { defineAbilityFor } from "./ability";

View 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);
}
},
});
};

View 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 },
});
});

View file

@ -1,11 +1,9 @@
"use server"; "use server";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { posthog } from "@rallly/posthog/server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authActionClient } from "@/safe-action"; import { authActionClient } from "@/features/safe-action/server";
import { setupSchema } from "./schema"; import { setupSchema } from "./schema";
export const updateUserAction = authActionClient export const updateUserAction = authActionClient
@ -22,7 +20,7 @@ export const updateUserAction = authActionClient
}, },
}); });
posthog?.capture({ ctx.posthog?.capture({
event: "user_setup_completed", event: "user_setup_completed",
distinctId: ctx.user.id, distinctId: ctx.user.id,
properties: { properties: {
@ -34,8 +32,6 @@ export const updateUserAction = authActionClient
}, },
}); });
await posthog?.shutdown();
revalidatePath("/", "layout"); revalidatePath("/", "layout");
redirect("/"); redirect("/");

View file

@ -16,9 +16,9 @@ import { useForm } from "react-hook-form";
import { LanguageSelect } from "@/components/poll/language-selector"; import { LanguageSelect } from "@/components/poll/language-selector";
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select"; import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useSafeAction } from "@/features/safe-action/client";
import { useTimezone } from "@/features/timezone"; import { useTimezone } from "@/features/timezone";
import { useTranslation } from "@/i18n/client"; import { useTranslation } from "@/i18n/client";
import { useAction } from "next-safe-action/hooks";
import { updateUserAction } from "../actions"; import { updateUserAction } from "../actions";
import { type SetupFormValues, setupSchema } from "../schema"; import { type SetupFormValues, setupSchema } from "../schema";
@ -30,7 +30,7 @@ interface SetupFormProps {
export function SetupForm({ defaultValues }: SetupFormProps) { export function SetupForm({ defaultValues }: SetupFormProps) {
const { timezone } = useTimezone(); const { timezone } = useTimezone();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const userSetupAction = useAction(updateUserAction); const userSetupAction = useSafeAction(updateUserAction);
const form = useForm<SetupFormValues>({ const form = useForm<SetupFormValues>({
resolver: zodResolver(setupSchema), resolver: zodResolver(setupSchema),

View 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,
};
});

View file

@ -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 },
});
});

View file

@ -37,6 +37,9 @@ export const user = router({
}, },
}); });
}), }),
/**
* @deprecated - Use server actions instead
*/
delete: privateProcedure.mutation(async ({ ctx }) => { delete: privateProcedure.mutation(async ({ ctx }) => {
if (ctx.user.isGuest) { if (ctx.user.isGuest) {
throw new TRPCError({ throw new TRPCError({

View file

@ -39,9 +39,11 @@
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"lucide-react": "^0.479.0", "lucide-react": "^0.479.0",
"next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.42.1", "react-hook-form": "^7.42.1",
"sonner": "^2.0.6",
"tailwind-merge": "^1.12.0" "tailwind-merge": "^1.12.0"
}, },
"devDependencies": { "devDependencies": {

View 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
View file

@ -77,7 +77,7 @@ importers:
version: 8.1.0(typescript@5.8.3) version: 8.1.0(typescript@5.8.3)
'@vercel/analytics': '@vercel/analytics':
specifier: ^1.5.0 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: dayjs:
specifier: ^1.11.13 specifier: ^1.11.13
version: 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) version: 5.0.0(@types/react@19.1.2)(acorn@8.14.1)(react@19.1.0)
next-seo: next-seo:
specifier: ^6.1.0 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: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.1.0 version: 19.1.0
@ -172,6 +172,15 @@ importers:
'@aws-sdk/s3-request-presigner': '@aws-sdk/s3-request-presigner':
specifier: ^3.645.0 specifier: ^3.645.0
version: 3.797.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': '@hono-rate-limiter/redis':
specifier: ^0.1.4 specifier: ^0.1.4
version: 0.1.4(hono-rate-limiter@0.2.3(hono@4.7.10)) version: 0.1.4(hono-rate-limiter@0.2.3(hono@4.7.10))
@ -698,6 +707,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.479.0 specifier: ^0.479.0
version: 0.479.0(react@19.1.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: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.1.0 version: 19.1.0
@ -707,6 +719,9 @@ importers:
react-hook-form: react-hook-form:
specifier: ^7.42.1 specifier: ^7.42.1
version: 7.56.1(react@19.1.0) 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: tailwind-merge:
specifier: ^1.12.0 specifier: ^1.12.0
version: 1.14.0 version: 1.14.0
@ -1582,6 +1597,21 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@ -4523,6 +4553,18 @@ packages:
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} 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': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@ -7155,6 +7197,12 @@ packages:
react: '>=16.0.0' react: '>=16.0.0'
react-dom: '>=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: next@15.2.4:
resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==} resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@ -8155,6 +8203,12 @@ packages:
resolution: {integrity: sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==} resolution: {integrity: sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} 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: sortobject@4.17.0:
resolution: {integrity: sha512-gzx7USv55AFRQ7UCWJHHauwD/ptUHF9MLXCGO3f5M9zauDPZ/4a9H6/VVbOXefdpEoI1unwB/bArHIVMbWBHmA==} resolution: {integrity: sha512-gzx7USv55AFRQ7UCWJHHauwD/ptUHF9MLXCGO3f5M9zauDPZ/4a9H6/VVbOXefdpEoI1unwB/bArHIVMbWBHmA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -10415,6 +10469,22 @@ snapshots:
'@biomejs/cli-win32-x64@1.9.4': '@biomejs/cli-win32-x64@1.9.4':
optional: true 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': {} '@discoveryjs/json-ext@0.5.7': {}
'@emnapi/runtime@1.4.3': '@emnapi/runtime@1.4.3':
@ -13529,6 +13599,22 @@ snapshots:
'@types/node': 20.17.50 '@types/node': 20.17.50
optional: true 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': {} '@ungap/structured-clone@1.3.0': {}
'@upstash/core-analytics@0.0.9': '@upstash/core-analytics@0.0.9':
@ -13549,7 +13635,7 @@ snapshots:
dependencies: dependencies:
crypto-js: 4.2.0 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: 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) 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: 19.1.0
@ -16688,12 +16774,17 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(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: 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) 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: 19.1.0
react-dom: 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): 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: dependencies:
'@next/env': 15.2.4 '@next/env': 15.2.4
@ -18022,6 +18113,11 @@ snapshots:
ip-address: 9.0.5 ip-address: 9.0.5
smart-buffer: 4.2.0 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: {} sortobject@4.17.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}