code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View 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>
)
}

View File

@@ -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"] })
},
})
}

View File

@@ -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,

View 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",

View File

@@ -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>
)
}

View File

@@ -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}