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",
"@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",

View file

@ -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"
}

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 { 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" />

View file

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

View file

@ -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" />

View file

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

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

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

View file

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

View file

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

View file

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

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";
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("/");

View file

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

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 }) => {
if (ctx.user.isGuest) {
throw new TRPCError({

View file

@ -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": {

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)
'@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: {}