Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
111
spa/src/components/delete-account-dialog.tsx
Normal file
111
spa/src/components/delete-account-dialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
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"
|
||||
autoFocus
|
||||
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 { 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(),
|
||||
@@ -112,6 +112,7 @@ export const userProfileResponseSchema = z.object({
|
||||
trends: userTrendsDtoSchema.optional(),
|
||||
goals: z.array(goalDtoSchema).optional(),
|
||||
is_federated: z.boolean().optional().default(false),
|
||||
is_service_account: z.boolean().optional().default(false),
|
||||
handle: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
@@ -228,3 +229,26 @@ 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: (_data, { id }) => {
|
||||
qc.removeQueries({
|
||||
predicate: (q) => q.queryKey[0] === userKeys.all[0] && q.queryKey[1] === id,
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: userKeys.all })
|
||||
qc.invalidateQueries({ queryKey: ["activity-feed"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,6 +68,15 @@ async function tryRefresh(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function isInvalidCredentials(res: Response): Promise<boolean> {
|
||||
try {
|
||||
const body = await res.clone().json()
|
||||
return body?.code === "INVALID_CREDENTIALS"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T = void>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
@@ -81,6 +90,11 @@ async function request<T = void>(
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
// A failed re-auth check (wrong password) is not an expired session:
|
||||
// no refresh, no retry, no logout.
|
||||
if (await isInvalidCredentials(res)) {
|
||||
throw new ApiError(res.status, await res.text())
|
||||
}
|
||||
if (url.includes("/auth/refresh") || url.includes("/auth/login")) {
|
||||
clearAuth()
|
||||
window.location.href = "/app/login"
|
||||
@@ -172,6 +186,17 @@ export async function del<T = void>(path: string): Promise<T> {
|
||||
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>(
|
||||
path: string,
|
||||
file: File,
|
||||
|
||||
@@ -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": "The last admin account cannot be deleted",
|
||||
"forbidden": "Only admins can delete other users",
|
||||
"notFound": "User not found",
|
||||
"generic": "Could not delete account"
|
||||
}
|
||||
},
|
||||
"goals": {
|
||||
"yearGoal": "{{year}} Goal",
|
||||
|
||||
@@ -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() {
|
||||
<LogOut className="size-4" />
|
||||
{t("settings.logOut")}
|
||||
</Button>
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DangerZone() {
|
||||
const { t } = useTranslation()
|
||||
const { logout } = useAuth()
|
||||
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()
|
||||
// Full page load: the classic public home, not the SPA login.
|
||||
window.location.assign("/")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <ProfileSkeleton />
|
||||
@@ -142,6 +148,42 @@ function UserProfilePage() {
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{isAdmin && !data.is_federated && !data.is_service_account && (
|
||||
<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
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
|
||||
Reference in New Issue
Block a user