feat: spa account deletion
This commit is contained in:
110
spa/src/components/delete-account-dialog.tsx
Normal file
110
spa/src/components/delete-account-dialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (password) onSubmit(password)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="delete-password">
|
||||||
|
{passwordLabel}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="delete-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
{error != null && (
|
||||||
|
<p role="alert" className="text-xs text-red-400">
|
||||||
|
{t(deletionErrorKey(error))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => handleOpenChange(false)} disabled={pending}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!password || pending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { diaryEntryDtoSchema, paginatedSchema } from "@/lib/api/common"
|
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({
|
export const userSummaryDtoSchema = z.object({
|
||||||
id: z.string().uuid(),
|
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"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,6 +172,17 @@ export async function del<T = void>(path: string): Promise<T> {
|
|||||||
return request<T>(buildUrl(path), { method: "DELETE" })
|
return request<T>(buildUrl(path), { method: "DELETE" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function delWithBody<T = void>(
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
return request<T>(buildUrl(path), {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function upload<T>(
|
export async function upload<T>(
|
||||||
path: string,
|
path: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
|||||||
@@ -193,7 +193,29 @@
|
|||||||
"exportDesc": "Download your diary",
|
"exportDesc": "Download your diary",
|
||||||
"exportCsv": "CSV",
|
"exportCsv": "CSV",
|
||||||
"exportJson": "JSON",
|
"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": {
|
"goals": {
|
||||||
"yearGoal": "{{year}} Goal",
|
"yearGoal": "{{year}} Goal",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
||||||
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation } from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
@@ -13,13 +14,15 @@ import {
|
|||||||
ShieldBan,
|
ShieldBan,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useAuth, useIsAdmin } from "@/components/auth-provider"
|
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 { useSettings, useUpdateSettings } from "@/features/goals"
|
||||||
import type { UpdateUserSettingsRequest } from "@/features/goals"
|
import type { UpdateUserSettingsRequest } from "@/features/goals"
|
||||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
@@ -121,6 +124,69 @@ function SettingsPage() {
|
|||||||
<LogOut className="size-4" />
|
<LogOut className="size-4" />
|
||||||
{t("settings.logOut")}
|
{t("settings.logOut")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<DangerZone />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 px-1 text-xs font-medium text-red-400">{t("settings.dangerZone")}</p>
|
||||||
|
<div className="rounded-xl border border-red-400/40 bg-card p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-red-400">
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{t("settings.deleteAccount")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("settings.deleteAccountDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="border-red-400/60 text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
{t("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DeleteAccountDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
title={t("settings.deleteAccount")}
|
||||||
|
description={t("settings.deleteAccountWarning")}
|
||||||
|
passwordLabel={t("settings.deleteAccountPassword")}
|
||||||
|
confirmLabel={t("settings.deleteAccountConfirm")}
|
||||||
|
pending={del.isPending}
|
||||||
|
error={del.error}
|
||||||
|
onSubmit={submit}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { BackButton } from "@/components/back-button"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||||
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
|
||||||
import { GoalCard } from "@/components/goal-card"
|
import { GoalCard } from "@/components/goal-card"
|
||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth, useIsAdmin } from "@/components/auth-provider"
|
||||||
import { useUserProfile } from "@/features/users"
|
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 { useFollow, useUnfollow, useCancelFollow, useRelationship } from "@/features/social"
|
||||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import type { FollowState } from "@/features/social"
|
import type { FollowState } from "@/features/social"
|
||||||
@@ -35,6 +37,10 @@ function UserProfilePage() {
|
|||||||
|
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const isAdmin = useIsAdmin()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const adminDelete = useAdminDeleteUser()
|
||||||
useDocumentTitle(data?.username)
|
useDocumentTitle(data?.username)
|
||||||
|
|
||||||
if (isPending) return <ProfileSkeleton />
|
if (isPending) return <ProfileSkeleton />
|
||||||
@@ -142,6 +148,42 @@ function UserProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && !data.is_federated && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="border-red-400/60 text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 size-3.5" />
|
||||||
|
{t("deleteUser.action")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DeleteAccountDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
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" })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
onOpenChange={setConfirmOpen}
|
onOpenChange={setConfirmOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user