diff options
| -rw-r--r-- | apps/web/app/settings/info/page.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/settings/DeleteAccount.tsx | 182 | ||||
| -rw-r--r-- | packages/open-api/karakeep-openapi-spec.json | 6 | ||||
| -rw-r--r-- | packages/shared-react/hooks/users.ts | 10 | ||||
| -rw-r--r-- | packages/shared/types/users.ts | 1 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 53 |
6 files changed, 252 insertions, 2 deletions
diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx index 52a6ce9d..96deab96 100644 --- a/apps/web/app/settings/info/page.tsx +++ b/apps/web/app/settings/info/page.tsx @@ -1,4 +1,5 @@ import { ChangePassword } from "@/components/settings/ChangePassword"; +import { DeleteAccount } from "@/components/settings/DeleteAccount"; import UserDetails from "@/components/settings/UserDetails"; import UserOptions from "@/components/settings/UserOptions"; @@ -8,6 +9,7 @@ export default async function InfoPage() { <UserDetails /> <ChangePassword /> <UserOptions /> + <DeleteAccount /> </div> ); } diff --git a/apps/web/components/settings/DeleteAccount.tsx b/apps/web/components/settings/DeleteAccount.tsx new file mode 100644 index 00000000..6ebafff9 --- /dev/null +++ b/apps/web/components/settings/DeleteAccount.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AlertTriangle, Eye, EyeOff, Trash2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { + useDeleteAccount, + useWhoAmI, +} from "@karakeep/shared-react/hooks/users"; + +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; + +const createDeleteAccountSchema = (isLocalUser: boolean) => + z.object({ + password: isLocalUser + ? z.string().min(1, "Password is required") + : z.string().optional(), + }); + +export function DeleteAccount() { + const router = useRouter(); + const [showPassword, setShowPassword] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const { data: user } = useWhoAmI(); + + const isLocalUser = user?.localUser ?? false; + const deleteAccountSchema = createDeleteAccountSchema(isLocalUser); + + const form = useForm<z.infer<typeof deleteAccountSchema>>({ + resolver: zodResolver(deleteAccountSchema), + defaultValues: { + password: "", + }, + }); + + const deleteAccountMutation = useDeleteAccount({ + onSuccess: () => { + toast({ + description: "Your account has been successfully deleted.", + }); + // Redirect to home page after successful deletion + router.push("/"); + setIsDialogOpen(false); + }, + onError: (error) => { + if (error.data?.code === "UNAUTHORIZED") { + toast({ + description: "Invalid password. Please try again.", + variant: "destructive", + }); + } else { + toast({ + description: "Failed to delete account. Please try again.", + variant: "destructive", + }); + } + }, + }); + + const onSubmit = (values: z.infer<typeof deleteAccountSchema>) => { + deleteAccountMutation.mutate({ password: values.password }); + }; + + return ( + <Card className="border-destructive/20"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-xl text-destructive"> + <AlertTriangle className="h-5 w-5" /> + Danger Zone + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="space-y-2"> + <h3 className="text-lg font-medium">Delete Account</h3> + <p className="text-sm text-muted-foreground"> + Permanently delete your account and all associated data. This action + cannot be undone. + </p> + </div> + + <ActionConfirmingDialog + open={isDialogOpen} + setOpen={setIsDialogOpen} + title="Delete Account" + description={ + <div className="space-y-4"> + <div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/5 p-4"> + <AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" /> + <div className="space-y-2"> + <p className="font-medium text-destructive"> + This action is irreversible + </p> + <p className="text-sm text-muted-foreground"> + All your bookmarks, lists, tags, highlights, and other data + will be permanently deleted. This cannot be undone. + </p> + </div> + </div> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-4" + > + {isLocalUser && ( + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel> + Enter your password to confirm deletion + </FormLabel> + <div className="relative"> + <FormControl> + <Input + type={showPassword ? "text" : "password"} + placeholder="Enter your password" + className="pr-10" + {...field} + /> + </FormControl> + <Button + type="button" + variant="ghost" + size="sm" + className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" + onClick={() => setShowPassword(!showPassword)} + > + {showPassword ? ( + <EyeOff className="h-4 w-4" /> + ) : ( + <Eye className="h-4 w-4" /> + )} + </Button> + </div> + <FormMessage /> + </FormItem> + )} + /> + )} + </form> + </Form> + </div> + } + actionButton={() => ( + <ActionButton + variant="destructive" + loading={deleteAccountMutation.isPending} + onClick={form.handleSubmit(onSubmit)} + > + <Trash2 className="mr-2 h-4 w-4" /> + Delete Account + </ActionButton> + )} + > + <Button variant="destructive" className="w-full"> + <Trash2 className="mr-2 h-4 w-4" /> + Delete Account + </Button> + </ActionConfirmingDialog> + </CardContent> + </Card> + ); +} diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json index 69bf27f7..3d0fc721 100644 --- a/packages/open-api/karakeep-openapi-spec.json +++ b/packages/open-api/karakeep-openapi-spec.json @@ -3003,10 +3003,14 @@ "email": { "type": "string", "nullable": true + }, + "localUser": { + "type": "boolean" } }, "required": [ - "id" + "id", + "localUser" ] } } diff --git a/packages/shared-react/hooks/users.ts b/packages/shared-react/hooks/users.ts index e896f8e4..31018f0b 100644 --- a/packages/shared-react/hooks/users.ts +++ b/packages/shared-react/hooks/users.ts @@ -12,3 +12,13 @@ export function useUpdateUserSettings( }, }); } + +export function useDeleteAccount( + ...opts: Parameters<typeof api.users.deleteAccount.useMutation> +) { + return api.users.deleteAccount.useMutation(opts[0]); +} + +export function useWhoAmI() { + return api.users.whoami.useQuery(); +} diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index e4b4315b..758b757d 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -35,6 +35,7 @@ export const zWhoAmIResponseSchema = z.object({ id: z.string(), name: z.string().nullish(), email: z.string().nullish(), + localUser: z.boolean(), }); export const zUserStatsResponseSchema = z.object({ diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index 8d6db6c7..4531875c 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -289,6 +289,52 @@ export const usersAppRouter = router({ } await deleteUserAssets({ userId: input.userId }); }), + deleteAccount: authedProcedure + .input( + z.object({ + password: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + invariant(ctx.user.email, "A user always has an email specified"); + + // Check if user has a password (local account) + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + }); + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + // If user has a password, verify it before allowing account deletion + if (user.password) { + if (!input.password) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Password is required for local accounts", + }); + } + + try { + await validatePassword(ctx.user.email, input.password); + } catch { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid password", + }); + } + } + + // Delete the user account + const res = await ctx.db.delete(users).where(eq(users.id, ctx.user.id)); + if (res.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + // Delete user assets + await deleteUserAssets({ userId: ctx.user.id }); + }), whoami: authedProcedure .output(zWhoAmIResponseSchema) .query(async ({ ctx }) => { @@ -301,7 +347,12 @@ export const usersAppRouter = router({ if (!userDb) { throw new TRPCError({ code: "UNAUTHORIZED" }); } - return { id: ctx.user.id, name: ctx.user.name, email: ctx.user.email }; + return { + id: ctx.user.id, + name: ctx.user.name, + email: ctx.user.email, + localUser: userDb.password !== null, + }; }), stats: authedProcedure .output(zUserStatsResponseSchema) |
