diff --git a/spa/src/components/delete-account-dialog.tsx b/spa/src/components/delete-account-dialog.tsx new file mode 100644 index 00000000..d9c670c5 --- /dev/null +++ b/spa/src/components/delete-account-dialog.tsx @@ -0,0 +1,110 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { ApiError } from "@/lib/api/client" + +function deletionErrorKey(err: unknown): string { + if (err instanceof ApiError) { + switch (err.status) { + case 401: + return "deleteUser.errors.wrongPassword" + case 403: + return "deleteUser.errors.forbidden" + case 404: + return "deleteUser.errors.notFound" + case 409: + return "deleteUser.errors.lastAdmin" + } + } + return "deleteUser.errors.generic" +} + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + passwordLabel: string + confirmLabel: string + pending: boolean + error: unknown + onSubmit: (password: string) => void +} + +export function DeleteAccountDialog({ + open, + onOpenChange, + title, + description, + passwordLabel, + confirmLabel, + pending, + error, + onSubmit, +}: Props) { + const { t } = useTranslation() + const [password, setPassword] = useState("") + + const handleOpenChange = (next: boolean) => { + if (!next) setPassword("") + onOpenChange(next) + } + + return ( + + +
{ + e.preventDefault() + if (password) onSubmit(password) + }} + > + + {title} + {description} + +
+ + setPassword(e.target.value)} + disabled={pending} + /> + {error != null && ( +

+ {t(deletionErrorKey(error))} +

+ )} +
+ + + + +
+
+
+ ) +} diff --git a/spa/src/features/users.ts b/spa/src/features/users.ts index fa370de9..1e17d0a5 100644 --- a/spa/src/features/users.ts +++ b/spa/src/features/users.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { diaryEntryDtoSchema, paginatedSchema } from "@/lib/api/common" -import { get, post, put, putForm } from "@/lib/api/client" +import { delWithBody, get, post, put, putForm } from "@/lib/api/client" export const userSummaryDtoSchema = z.object({ id: z.string().uuid(), @@ -228,3 +228,23 @@ export function useUpdateProfileFields() { }, }) } + +export type DeleteAccountRequest = { password: string } + +export function useDeleteOwnAccount() { + return useMutation({ + mutationFn: (data: DeleteAccountRequest) => delWithBody("/users/me", data), + }) +} + +export function useAdminDeleteUser() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, password }: DeleteAccountRequest & { id: string }) => + delWithBody(`/users/${id}`, { password }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: userKeys.all }) + qc.invalidateQueries({ queryKey: ["activity-feed"] }) + }, + }) +} diff --git a/spa/src/lib/api/client.ts b/spa/src/lib/api/client.ts index 7ec90614..c07fac1b 100644 --- a/spa/src/lib/api/client.ts +++ b/spa/src/lib/api/client.ts @@ -172,6 +172,17 @@ export async function del(path: string): Promise { return request(buildUrl(path), { method: "DELETE" }) } +export async function delWithBody( + path: string, + body: unknown, +): Promise { + return request(buildUrl(path), { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) +} + export async function upload( path: string, file: File, diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index c3818df8..87908e25 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -193,7 +193,29 @@ "exportDesc": "Download your diary", "exportCsv": "CSV", "exportJson": "JSON", - "exporting": "Exporting..." + "exporting": "Exporting...", + "dangerZone": "Danger zone", + "deleteAccount": "Delete account", + "deleteAccountDesc": "Permanently delete your account and everything in it", + "deleteAccountWarning": "This is permanent and cannot be undone. Your reviews, watchlist, goals and follows will be removed, fediverse servers will be notified that your account is gone, and your username can never be reused.", + "deleteAccountPassword": "Enter your password to confirm", + "deleteAccountConfirm": "Delete my account" + }, + "deleteUser": { + "action": "Delete user", + "title": "Delete {{name}}?", + "warning": "This permanently removes this user, their reviews, watchlist, goals and follows, and notifies fediverse servers. Their username can never be reused.", + "adminPassword": "Enter your admin password to confirm", + "confirm": "Delete user", + "password": "Password", + "done": "User deleted", + "errors": { + "wrongPassword": "Wrong password", + "lastAdmin": "You are the last admin and cannot be deleted", + "forbidden": "Only admins can delete other users", + "notFound": "User not found", + "generic": "Could not delete account" + } }, "goals": { "yearGoal": "{{year}} Goal", diff --git a/spa/src/routes/_app/settings/index.tsx b/spa/src/routes/_app/settings/index.tsx index d0281c64..e787537f 100644 --- a/spa/src/routes/_app/settings/index.tsx +++ b/spa/src/routes/_app/settings/index.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { useState } from "react" import { useTranslation } from "react-i18next" import { useMutation } from "@tanstack/react-query" import { @@ -13,13 +14,15 @@ import { ShieldBan, Sparkles, Target, + Trash2, Upload, User, } from "lucide-react" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { useAuth, useIsAdmin } from "@/components/auth-provider" -import { reindexSearch } from "@/features/users" +import { DeleteAccountDialog } from "@/components/delete-account-dialog" +import { reindexSearch, useDeleteOwnAccount } from "@/features/users" import { useSettings, useUpdateSettings } from "@/features/goals" import type { UpdateUserSettingsRequest } from "@/features/goals" import { useDocumentTitle } from "@/hooks/use-document-title" @@ -121,6 +124,69 @@ function SettingsPage() { {t("settings.logOut")} + + + + ) +} + +function DangerZone() { + const { t } = useTranslation() + const { logout } = useAuth() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + const del = useDeleteOwnAccount() + + const handleOpenChange = (next: boolean) => { + if (!next) del.reset() + setOpen(next) + } + + const submit = (password: string) => { + del.mutate( + { password }, + { + onSuccess: () => { + logout() + navigate({ to: "/login" }) + }, + }, + ) + } + + return ( +
+

{t("settings.dangerZone")}

+
+
+ + + +
+

{t("settings.deleteAccount")}

+

{t("settings.deleteAccountDesc")}

+
+ +
+
+
) } diff --git a/spa/src/routes/_app/users.$id.tsx b/spa/src/routes/_app/users.$id.tsx index 9fb99f1f..2c70fd21 100644 --- a/spa/src/routes/_app/users.$id.tsx +++ b/spa/src/routes/_app/users.$id.tsx @@ -1,14 +1,16 @@ -import { createFileRoute } from "@tanstack/react-router" +import { createFileRoute, useNavigate } from "@tanstack/react-router" import { useState } from "react" import { useTranslation } from "react-i18next" -import { Clock, ExternalLink, UserCheck, UserPlus } from "lucide-react" +import { Clock, ExternalLink, Trash2, UserCheck, UserPlus } from "lucide-react" import { BackButton } from "@/components/back-button" import { Button } from "@/components/ui/button" import { ConfirmDialog } from "@/components/confirm-dialog" import { ProfileView, ProfileSkeleton } from "@/components/profile-view" import { GoalCard } from "@/components/goal-card" -import { useAuth } from "@/components/auth-provider" -import { useUserProfile } from "@/features/users" +import { useAuth, useIsAdmin } from "@/components/auth-provider" +import { DeleteAccountDialog } from "@/components/delete-account-dialog" +import { toast } from "sonner" +import { useAdminDeleteUser, useUserProfile } from "@/features/users" import { useFollow, useUnfollow, useCancelFollow, useRelationship } from "@/features/social" import { useDocumentTitle } from "@/hooks/use-document-title" import type { FollowState } from "@/features/social" @@ -35,6 +37,10 @@ function UserProfilePage() { const [search, setSearch] = useState("") const [confirmOpen, setConfirmOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const isAdmin = useIsAdmin() + const navigate = useNavigate() + const adminDelete = useAdminDeleteUser() useDocumentTitle(data?.username) if (isPending) return @@ -142,6 +148,42 @@ function UserProfilePage() { )} + {isAdmin && !data.is_federated && ( + + )} + { + if (!o) adminDelete.reset() + setDeleteOpen(o) + }} + title={t("deleteUser.title", { name: data.display_name ?? data.username })} + description={t("deleteUser.warning")} + passwordLabel={t("deleteUser.adminPassword")} + confirmLabel={t("deleteUser.confirm")} + pending={adminDelete.isPending} + error={adminDelete.error} + onSubmit={(password) => + adminDelete.mutate( + { id, password }, + { + onSuccess: () => { + toast.success(t("deleteUser.done")) + setDeleteOpen(false) + navigate({ to: "/social" }) + }, + }, + ) + } + />