mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-01 23:48:53 +02:00
✨ Add ability manager (#1796)
This commit is contained in:
parent
56e0ca0151
commit
4b4dfef3e5
27 changed files with 563 additions and 289 deletions
|
@ -20,6 +20,9 @@
|
|||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@aws-sdk/client-s3": "^3.645.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.645.0",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@casl/prisma": "^1.5.1",
|
||||
"@casl/react": "^5.0.0",
|
||||
"@hono-rate-limiter/redis": "^0.1.4",
|
||||
"@hono/zod-validator": "^0.5.0",
|
||||
"@hookform/resolvers": "^3.3.1",
|
||||
|
|
|
@ -330,9 +330,6 @@
|
|||
"canceledEventsEmptyStateDescription": "Canceled events will show up here.",
|
||||
"setupFormTitle": "Setup",
|
||||
"setupFormDescription": "Finish setting up your account.",
|
||||
"errorNotAuthenticated": "Not authenticated",
|
||||
"errorInvalidFields": "Invalid fields. Please check your input.",
|
||||
"errorDatabaseUpdateFailed": "Database error: Failed to update settings.",
|
||||
"pending": "Pending",
|
||||
"helpUsImprove": "Help us improve",
|
||||
"helpUsImproveDesc": "Take a few minutes to share your feedback and help us shape the future of Rallly.",
|
||||
|
@ -361,9 +358,6 @@
|
|||
"users": "Users",
|
||||
"userCount": "{count, number, ::compact-short}",
|
||||
"unlicensed": "Unlicensed",
|
||||
"deleteUser": "Delete User",
|
||||
"areYouSureYouWantToDeleteThisUser": "Are you sure you want to delete this user?",
|
||||
"typeTheEmailAddressOfTheUserYouWantToDelete": "Type the email address of the user you want to delete.",
|
||||
"emailDoesNotMatch": "The email address does not match.",
|
||||
"noUsers": "No users found",
|
||||
"noUsersDescription": "Try adjusting your search",
|
||||
|
@ -395,5 +389,11 @@
|
|||
"disableUserRegistrationDescription": "Prevent new users from registering an account.",
|
||||
"authenticationAndSecurity": "Authentication & Security",
|
||||
"authenticationAndSecurityDescription": "Manage authentication and security settings",
|
||||
"youHaveUnsavedChanges": "You have unsaved changes"
|
||||
"youHaveUnsavedChanges": "You have unsaved changes",
|
||||
"unexpectedError": "Unexpected Error",
|
||||
"unexpectedErrorDescription": "There was an unexpected error. Please try again later.",
|
||||
"actionErrorUnauthorized": "You are not authorized to perform this action",
|
||||
"actionErrorNotFound": "The resource was not found",
|
||||
"actionErrorForbidden": "You are not allowed to perform this action",
|
||||
"actionErrorInternalServerError": "An internal server error occurred"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
"use server";
|
||||
|
||||
import { ActionError, authActionClient } from "@/features/safe-action/server";
|
||||
import { subject } from "@casl/ability";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
export const deleteCurrentUserAction = authActionClient.action(
|
||||
async ({ ctx }) => {
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { spaces: { include: { subscription: true } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ActionError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.ability.cannot("delete", subject("User", user))) {
|
||||
throw new ActionError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to delete this user",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
);
|
|
@ -17,8 +17,9 @@ import { signOut } from "next-auth/react";
|
|||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { trpc } from "@/trpc/client";
|
||||
import { deleteCurrentUserAction } from "./actions";
|
||||
|
||||
export function DeleteAccountDialog({
|
||||
email,
|
||||
|
@ -32,16 +33,18 @@ export function DeleteAccountDialog({
|
|||
email: "",
|
||||
},
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const posthog = usePostHog();
|
||||
const deleteAccount = trpc.user.delete.useMutation({
|
||||
onSuccess() {
|
||||
|
||||
const deleteUser = useSafeAction(deleteCurrentUserAction, {
|
||||
onSuccess: () => {
|
||||
posthog?.capture("delete account");
|
||||
signOut({
|
||||
redirectTo: "/login",
|
||||
});
|
||||
},
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
|
@ -52,7 +55,7 @@ export function DeleteAccountDialog({
|
|||
method="POST"
|
||||
action="/auth/logout"
|
||||
onSubmit={form.handleSubmit(async () => {
|
||||
await deleteAccount.mutateAsync();
|
||||
await deleteUser.executeAsync();
|
||||
})}
|
||||
>
|
||||
<DialogHeader>
|
||||
|
@ -111,7 +114,7 @@ export function DeleteAccountDialog({
|
|||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={deleteAccount.isPending}
|
||||
loading={deleteUser.isExecuting}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trans i18nKey="deleteAccount" defaults="Delete Account" />
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
"use server";
|
||||
|
||||
import { isInitialAdmin, requireUser } from "@/auth/queries";
|
||||
import { authActionClient } from "@/features/safe-action/server";
|
||||
import { ActionError } from "@/features/safe-action/server";
|
||||
import { subject } from "@casl/ability";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
export async function makeAdmin() {
|
||||
const user = await requireUser();
|
||||
|
||||
if (!isInitialAdmin(user.email)) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
export const makeMeAdminAction = authActionClient.action(async ({ ctx }) => {
|
||||
if (ctx.ability.cannot("update", subject("User", ctx.user), "role")) {
|
||||
throw new ActionError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update your role",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
id: ctx.user.id,
|
||||
},
|
||||
data: {
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, error: null };
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,34 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { makeAdmin } from "./actions";
|
||||
import { makeMeAdminAction } from "./actions";
|
||||
|
||||
export function MakeMeAdminButton() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const makeMeAdmin = useSafeAction(makeMeAdminAction, {
|
||||
onSuccess: () => {
|
||||
router.replace("/control-panel");
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
startTransition(async () => {
|
||||
const { success, error } = await makeAdmin();
|
||||
if (success) {
|
||||
router.replace("/control-panel");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
await makeMeAdmin.executeAsync();
|
||||
}}
|
||||
loading={isPending}
|
||||
loading={makeMeAdmin.isExecuting}
|
||||
variant="primary"
|
||||
>
|
||||
<Trans i18nKey="adminSetupCta" defaults="Make me an admin" />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { isInitialAdmin, requireUser } from "@/auth/queries";
|
||||
import { requireUserAbility } from "@/auth/queries";
|
||||
import {
|
||||
EmptyState,
|
||||
EmptyStateDescription,
|
||||
|
@ -7,6 +7,8 @@ import {
|
|||
EmptyStateTitle,
|
||||
} from "@/components/empty-state";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { getTranslation } from "@/i18n/server";
|
||||
import { subject } from "@casl/ability";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { CrownIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
@ -14,14 +16,15 @@ import { notFound, redirect } from "next/navigation";
|
|||
import { MakeMeAdminButton } from "./make-me-admin-button";
|
||||
|
||||
export default async function AdminSetupPage() {
|
||||
const user = await requireUser();
|
||||
const { user, ability } = await requireUserAbility();
|
||||
|
||||
if (user.role === "admin") {
|
||||
// User is already an admin
|
||||
if (ability.can("access", "ControlPanel")) {
|
||||
redirect("/control-panel");
|
||||
}
|
||||
|
||||
if (!isInitialAdmin(user.email)) {
|
||||
const canMakeAdmin = ability.can("update", subject("User", user), "role");
|
||||
|
||||
if (!canMakeAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
@ -52,3 +55,10 @@ export default async function AdminSetupPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { t } = await getTranslation();
|
||||
return {
|
||||
title: t("adminSetup"),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { requireAdmin } from "@/auth/queries";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { z } from "zod";
|
||||
|
||||
const inputSchema = z.object({
|
||||
userId: z.string(),
|
||||
role: z.enum(["admin", "user"]),
|
||||
});
|
||||
|
||||
export async function changeRole(input: z.infer<typeof inputSchema>) {
|
||||
await requireAdmin();
|
||||
const { userId, role } = inputSchema.parse(input);
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser({
|
||||
userId,
|
||||
email,
|
||||
}: { userId: string; email: string }) {
|
||||
await requireAdmin();
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,123 +0,0 @@
|
|||
"use client";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@rallly/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@rallly/ui/form";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { Input } from "@rallly/ui/input";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { deleteUser } from "./actions";
|
||||
|
||||
const useSchema = (email: string) => {
|
||||
const { t } = useTranslation();
|
||||
return z.object({
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.refine((value) => value.toLowerCase() === email.toLowerCase(), {
|
||||
message: t("emailDoesNotMatch", {
|
||||
defaultValue: "The email address does not match.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export function DeleteUserButton({
|
||||
userId,
|
||||
email,
|
||||
}: { userId: string; email: string }) {
|
||||
const router = useRouter();
|
||||
const schema = useSchema(email);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" size="icon" variant="ghost">
|
||||
<Icon>
|
||||
<TrashIcon />
|
||||
</Icon>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey="deleteUser" defaults="Delete User" />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
i18nKey="areYouSureYouWantToDeleteThisUser"
|
||||
defaults="Are you sure you want to delete this user?"
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
await deleteUser({ userId, email: data.email });
|
||||
router.refresh();
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey="email" defaults="Email" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={email} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans
|
||||
i18nKey="typeTheEmailAddressOfTheUserYouWantToDelete"
|
||||
defaults="Type the email address of the user you want to delete."
|
||||
/>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="mt-6">
|
||||
<DialogClose asChild>
|
||||
<Button>
|
||||
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" type="submit">
|
||||
<Trans i18nKey="delete" defaults="Delete" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { deleteUserAction } from "@/features/user/actions";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
|
@ -23,10 +25,8 @@ import {
|
|||
} from "@rallly/ui/form";
|
||||
import { Input } from "@rallly/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { deleteUser } from "../actions";
|
||||
|
||||
const useSchema = (email: string) => {
|
||||
const { t } = useTranslation();
|
||||
|
@ -55,13 +55,20 @@ export function DeleteUserDialog({
|
|||
}) {
|
||||
const router = useRouter();
|
||||
const schema = useSchema(email);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUser = useSafeAction(deleteUserAction, {
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
|
@ -78,12 +85,8 @@ export function DeleteUserDialog({
|
|||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
startTransition(async () => {
|
||||
await deleteUser({ userId, email: data.email });
|
||||
router.refresh();
|
||||
onOpenChange(false);
|
||||
});
|
||||
onSubmit={form.handleSubmit(async () => {
|
||||
await deleteUser.executeAsync({ userId });
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
|
@ -117,7 +120,11 @@ export function DeleteUserDialog({
|
|||
<Trans i18nKey="cancel" defaults="Cancel" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" loading={isPending} type="submit">
|
||||
<Button
|
||||
variant="destructive"
|
||||
loading={deleteUser.isExecuting}
|
||||
type="submit"
|
||||
>
|
||||
<Trans i18nKey="delete" defaults="Delete" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@rallly/ui/dropdown-menu";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { MoreHorizontal, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { deleteUser } from "./actions";
|
||||
|
||||
export function UserDropdown({
|
||||
userId,
|
||||
email,
|
||||
}: { userId: string; email: string }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Icon>
|
||||
<MoreHorizontal />
|
||||
</Icon>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await deleteUser({ userId, email });
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<Trans i18nKey="delete" defaults="Delete" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
|
@ -3,6 +3,8 @@ import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
|||
import { StackedListItem } from "@/components/stacked-list";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useUser } from "@/components/user-provider";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { changeRoleAction } from "@/features/user/actions";
|
||||
import { userRoleSchema } from "@/features/user/schema";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
|
@ -23,7 +25,6 @@ import { Icon } from "@rallly/ui/icon";
|
|||
import { MoreHorizontal, TrashIcon, UserPenIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { changeRole } from "./actions";
|
||||
import { DeleteUserDialog } from "./dialogs/delete-user-dialog";
|
||||
|
||||
export function UserRow({
|
||||
|
@ -40,6 +41,12 @@ export function UserRow({
|
|||
role: "admin" | "user";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const changeRole = useSafeAction(changeRoleAction, {
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { user } = useUser();
|
||||
const deleteDialog = useDialog();
|
||||
|
@ -82,11 +89,10 @@ export function UserRow({
|
|||
value={role}
|
||||
onValueChange={async (value) => {
|
||||
startTransition(async () => {
|
||||
await changeRole({
|
||||
await changeRole.executeAsync({
|
||||
role: userRoleSchema.parse(value),
|
||||
userId,
|
||||
});
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -2,6 +2,7 @@ import "../../style.css";
|
|||
|
||||
import { supportedLngs } from "@rallly/languages";
|
||||
import { PostHogProvider, posthog } from "@rallly/posthog/client";
|
||||
import { Toaster as SonnerToast } from "@rallly/ui/sonner";
|
||||
import { Toaster } from "@rallly/ui/toaster";
|
||||
import { TooltipProvider } from "@rallly/ui/tooltip";
|
||||
import { LazyMotion, domAnimation } from "motion/react";
|
||||
|
@ -71,6 +72,7 @@ export default async function Root({
|
|||
<body>
|
||||
<FeatureFlagsProvider value={{ storage: isStorageEnabled }}>
|
||||
<Toaster />
|
||||
<SonnerToast />
|
||||
<I18nProvider locale={locale}>
|
||||
<TRPCProvider>
|
||||
<LazyMotion features={domAnimation}>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { PollLayout } from "@/components/layouts/poll-layout";
|
||||
import { createSSRHelper } from "@/trpc/server/create-ssr-helper";
|
||||
|
||||
export default async function Layout(
|
||||
props: React.PropsWithChildren<{ params: { urlId: string } }>,
|
||||
props: React.PropsWithChildren<{ params: Promise<{ urlId: string }> }>,
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
|
@ -26,6 +26,10 @@ export default async function Layout(
|
|||
notFound();
|
||||
}
|
||||
|
||||
if (!poll.adminUrlId) {
|
||||
redirect(`/invite/${params.urlId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(trpc.queryClient)}>
|
||||
<PollLayout>{children}</PollLayout>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { defineAbilityFor } from "@/features/ability-manager";
|
||||
import { getDefaultSpace } from "@/features/spaces/queries";
|
||||
import { getUser } from "@/features/user/queries";
|
||||
import { auth } from "@/next-auth";
|
||||
|
@ -41,3 +42,13 @@ export const getActiveSpace = cache(async () => {
|
|||
|
||||
return await getDefaultSpace({ ownerId: user.id });
|
||||
});
|
||||
|
||||
export const requireUserAbility = cache(async () => {
|
||||
const user = await requireUser();
|
||||
return {
|
||||
user,
|
||||
ability: defineAbilityFor(user, {
|
||||
isInitialAdmin: isInitialAdmin(user.email),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
|
95
apps/web/src/features/ability-manager/ability.ts
Normal file
95
apps/web/src/features/ability-manager/ability.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import type { PureAbility } from "@casl/ability";
|
||||
import { AbilityBuilder } from "@casl/ability";
|
||||
import {
|
||||
type PrismaQuery,
|
||||
type Subjects,
|
||||
createPrismaAbility,
|
||||
} from "@casl/prisma";
|
||||
import type {
|
||||
Comment,
|
||||
Participant,
|
||||
Poll,
|
||||
Space,
|
||||
SpaceMember,
|
||||
Subscription,
|
||||
UserRole,
|
||||
} from "@prisma/client";
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| "create"
|
||||
| "read"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "manage"
|
||||
| "finalize"
|
||||
| "access";
|
||||
|
||||
export type Subject =
|
||||
| Subjects<{
|
||||
User: User;
|
||||
Poll: Poll;
|
||||
Space: Space;
|
||||
Comment: Comment;
|
||||
Participant: Participant;
|
||||
SpaceMember: SpaceMember;
|
||||
Subscription: Subscription;
|
||||
}>
|
||||
| "ControlPanel";
|
||||
|
||||
export type AppAbility = PureAbility<[string, Subject], PrismaQuery>;
|
||||
|
||||
export const defineAbilityFor = (
|
||||
user?: User,
|
||||
options?: {
|
||||
isInitialAdmin?: boolean;
|
||||
},
|
||||
) => {
|
||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
|
||||
createPrismaAbility,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return build();
|
||||
}
|
||||
|
||||
if (user.role === "admin") {
|
||||
can("access", "ControlPanel");
|
||||
can("update", "User", ["role"]);
|
||||
cannot("update", "User", ["role"], { id: user.id });
|
||||
can("delete", "User");
|
||||
}
|
||||
|
||||
if (user.role === "user") {
|
||||
if (options?.isInitialAdmin) {
|
||||
can("update", "User", ["role"], {
|
||||
id: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
can("delete", "User", {
|
||||
id: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
can("update", "User", ["email", "name"], { id: user.id });
|
||||
cannot("delete", "User", {
|
||||
spaces: {
|
||||
some: {
|
||||
subscription: {
|
||||
is: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return build();
|
||||
};
|
1
apps/web/src/features/ability-manager/index.ts
Normal file
1
apps/web/src/features/ability-manager/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { defineAbilityFor } from "./ability";
|
41
apps/web/src/features/safe-action/client.ts
Normal file
41
apps/web/src/features/safe-action/client.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
import { useAction } from "next-safe-action/hooks";
|
||||
|
||||
export const useSafeAction: typeof useAction = (action, options) => {
|
||||
const { t } = useTranslation();
|
||||
return useAction(action, {
|
||||
...options,
|
||||
onError: ({ error }) => {
|
||||
if (error.serverError) {
|
||||
let translatedDescription = "An unexpected error occurred";
|
||||
|
||||
switch (error.serverError) {
|
||||
case "UNAUTHORIZED":
|
||||
translatedDescription = t("actionErrorUnauthorized", {
|
||||
defaultValue: "You are not authorized to perform this action",
|
||||
});
|
||||
break;
|
||||
case "NOT_FOUND":
|
||||
translatedDescription = t("actionErrorNotFound", {
|
||||
defaultValue: "The resource was not found",
|
||||
});
|
||||
break;
|
||||
case "FORBIDDEN":
|
||||
translatedDescription = t("actionErrorForbidden", {
|
||||
defaultValue: "You are not allowed to perform this action",
|
||||
});
|
||||
break;
|
||||
case "INTERNAL_SERVER_ERROR":
|
||||
translatedDescription = t("actionErrorInternalServerError", {
|
||||
defaultValue: "An internal server error occurred",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
toast.error(translatedDescription);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
57
apps/web/src/features/safe-action/server.ts
Normal file
57
apps/web/src/features/safe-action/server.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
"server-only";
|
||||
import { requireUserAbility } from "@/auth/queries";
|
||||
import { posthog } from "@rallly/posthog/server";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
import { createSafeActionClient } from "next-safe-action";
|
||||
|
||||
type ActionErrorCode =
|
||||
| "UNAUTHORIZED"
|
||||
| "NOT_FOUND"
|
||||
| "FORBIDDEN"
|
||||
| "INTERNAL_SERVER_ERROR";
|
||||
|
||||
export class ActionError extends Error {
|
||||
code: ActionErrorCode;
|
||||
constructor({
|
||||
code,
|
||||
message,
|
||||
cause,
|
||||
}: {
|
||||
code: ActionErrorCode;
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
super(`[${code}]: ${message}`);
|
||||
this.name = "ActionError";
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export const actionClient = createSafeActionClient({
|
||||
handleServerError: async (error) => {
|
||||
if (error instanceof ActionError) {
|
||||
return error.code;
|
||||
}
|
||||
|
||||
return "INTERNAL_SERVER_ERROR";
|
||||
},
|
||||
}).use(({ next }) => {
|
||||
const result = next({
|
||||
ctx: {
|
||||
posthog,
|
||||
},
|
||||
});
|
||||
|
||||
waitUntil(Promise.all([posthog?.shutdown()]));
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
export const authActionClient = actionClient.use(async ({ next }) => {
|
||||
const { user, ability } = await requireUserAbility();
|
||||
|
||||
return next({
|
||||
ctx: { user, ability },
|
||||
});
|
||||
});
|
|
@ -1,11 +1,9 @@
|
|||
"use server";
|
||||
|
||||
import { prisma } from "@rallly/database";
|
||||
import { posthog } from "@rallly/posthog/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { authActionClient } from "@/safe-action";
|
||||
import { authActionClient } from "@/features/safe-action/server";
|
||||
import { setupSchema } from "./schema";
|
||||
|
||||
export const updateUserAction = authActionClient
|
||||
|
@ -22,7 +20,7 @@ export const updateUserAction = authActionClient
|
|||
},
|
||||
});
|
||||
|
||||
posthog?.capture({
|
||||
ctx.posthog?.capture({
|
||||
event: "user_setup_completed",
|
||||
distinctId: ctx.user.id,
|
||||
properties: {
|
||||
|
@ -34,8 +32,6 @@ export const updateUserAction = authActionClient
|
|||
},
|
||||
});
|
||||
|
||||
await posthog?.shutdown();
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
redirect("/");
|
||||
|
|
|
@ -16,9 +16,9 @@ import { useForm } from "react-hook-form";
|
|||
import { LanguageSelect } from "@/components/poll/language-selector";
|
||||
import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useSafeAction } from "@/features/safe-action/client";
|
||||
import { useTimezone } from "@/features/timezone";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { useAction } from "next-safe-action/hooks";
|
||||
|
||||
import { updateUserAction } from "../actions";
|
||||
import { type SetupFormValues, setupSchema } from "../schema";
|
||||
|
@ -30,7 +30,7 @@ interface SetupFormProps {
|
|||
export function SetupForm({ defaultValues }: SetupFormProps) {
|
||||
const { timezone } = useTimezone();
|
||||
const { i18n } = useTranslation();
|
||||
const userSetupAction = useAction(updateUserAction);
|
||||
const userSetupAction = useSafeAction(updateUserAction);
|
||||
|
||||
const form = useForm<SetupFormValues>({
|
||||
resolver: zodResolver(setupSchema),
|
||||
|
|
89
apps/web/src/features/user/actions.ts
Normal file
89
apps/web/src/features/user/actions.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
"use server";
|
||||
import { ActionError, authActionClient } from "@/features/safe-action/server";
|
||||
import { subject } from "@casl/ability";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { z } from "zod";
|
||||
import { getUser } from "./queries";
|
||||
|
||||
export const changeRoleAction = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
role: z.enum(["user", "admin"]),
|
||||
}),
|
||||
)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const { userId, role } = parsedInput;
|
||||
|
||||
const targetUser = await getUser(userId);
|
||||
|
||||
if (!targetUser) {
|
||||
throw new ActionError({
|
||||
code: "NOT_FOUND",
|
||||
message: `User ${userId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUser.role === role) {
|
||||
throw new ActionError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User is already this role",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.ability.cannot("update", subject("User", targetUser), "role")) {
|
||||
throw new ActionError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Current user is not authorized to update role",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: targetUser.id,
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const deleteUserAction = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const userId = parsedInput.userId;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { spaces: { include: { subscription: true } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ActionError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.ability.cannot("delete", subject("User", user))) {
|
||||
throw new ActionError({
|
||||
code: "FORBIDDEN",
|
||||
message: "This user cannot be deleted",
|
||||
cause: ctx.ability.relevantRuleFor("delete", subject("User", user)),
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
});
|
|
@ -1,12 +0,0 @@
|
|||
import { requireUser } from "@/auth/queries";
|
||||
import { createSafeActionClient } from "next-safe-action";
|
||||
|
||||
export const actionClient = createSafeActionClient();
|
||||
|
||||
export const authActionClient = actionClient.use(async ({ next }) => {
|
||||
const user = await requireUser();
|
||||
|
||||
return next({
|
||||
ctx: { user },
|
||||
});
|
||||
});
|
|
@ -37,6 +37,9 @@ export const user = router({
|
|||
},
|
||||
});
|
||||
}),
|
||||
/**
|
||||
* @deprecated - Use server actions instead
|
||||
*/
|
||||
delete: privateProcedure.mutation(async ({ ctx }) => {
|
||||
if (ctx.user.isGuest) {
|
||||
throw new TRPCError({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue