refactor/tighten-architecture #26

Merged
GKaszewski merged 104 commits from refactor/tighten-architecture into master 2026-10-26 09:36:27 +00:00
6 changed files with 278 additions and 7 deletions
Showing only changes of commit 4714216093 - Show all commits

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

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

View File

@@ -172,6 +172,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": "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",

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

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 && (
<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}