mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-17 16:27:28 +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",
|
"@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",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { 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" />
|
||||||
|
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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";
|
"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>
|
||||||
|
|
|
@ -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 { 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();
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
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";
|
"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("/");
|
||||||
|
|
|
@ -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),
|
||||||
|
|
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 }) => {
|
delete: privateProcedure.mutation(async ({ ctx }) => {
|
||||||
if (ctx.user.isGuest) {
|
if (ctx.user.isGuest) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
32
packages/ui/src/sonner.tsx
Normal file
32
packages/ui/src/sonner.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Toaster as Sonner, toast } from "sonner";
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-white group-[.toaster]:text-gray-950 group-[.toaster]:border-gray-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-gray-950 dark:group-[.toaster]:text-gray-50 dark:group-[.toaster]:border-gray-800",
|
||||||
|
description:
|
||||||
|
"group-[.toast]:text-gray-500 dark:group-[.toast]:text-gray-400",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-gray-900 group-[.toast]:text-gray-50 dark:group-[.toast]:bg-gray-50 dark:group-[.toast]:text-gray-900",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-gray-100 group-[.toast]:text-gray-500 dark:group-[.toast]:bg-gray-800 dark:group-[.toast]:text-gray-400",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster, toast };
|
104
pnpm-lock.yaml
generated
104
pnpm-lock.yaml
generated
|
@ -77,7 +77,7 @@ importers:
|
||||||
version: 8.1.0(typescript@5.8.3)
|
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: {}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue