Let staff delete accounts from the Users page

DELETE /api/users/{id}/ with guards: nobody deletes the account they are
signed in as (400); staff can delete regular/uploader accounts only,
while admins can also delete staff and admins (403 for staff targets
otherwise, and the last admin can never be deleted). Deleting a user
removes their follows, tokens and staged uploads — including the staged
files on disk — while library items survive and simply lose their owner
(uploaded_by is SET_NULL), as does download/match/similarity history.

The Users page gets a per-row delete button behind the shared confirm
dialog, hidden wherever the backend would refuse (own row, or a
staff/admin target when the actor is not an admin).

Verified against the dev server: staff 204 for a regular account, 400
for self, 403 for an admin; admin 204; a plain account gets 403. After
deleting a user that owned J-81 and had a staged file, the file was gone
and J-81 survived with a null owner.
This commit is contained in:
2026-09-17 23:35:56 -05:00
parent 99f617d296
commit 2df001b477
2 changed files with 82 additions and 2 deletions
+44
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
@@ -7,6 +8,7 @@ import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format";
import type { AdminUser, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { confirmAction } from "@/store/confirm";
import { toast } from "@/store/toasts";
const ROLE_OPTIONS = [
@@ -17,6 +19,7 @@ const ROLE_OPTIONS = [
function UserRow({ profile }: { profile: AdminUser }) {
const queryClient = useQueryClient();
const actor = useAuth((state) => state.user);
const [avatarJId, setAvatarJId] = useState(profile.avatar_j_id ?? "");
const mutation = useMutation({
@@ -33,6 +36,35 @@ function UserRow({ profile }: { profile: AdminUser }) {
onError: (err) => toast.error(errorMessage(err)),
});
const deleteMutation = useMutation({
mutationFn: () => api(`/api/users/${profile.id}/`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["users"] });
toast.ok(`${profile.username} deleted.`);
},
onError: (error) => toast.error(errorMessage(error)),
});
// Mirrors the backend rules: nobody deletes themselves, and staff can only
// delete accounts that are not staff/admin.
const canDelete = Boolean(
actor &&
actor.id !== profile.id &&
(actor.is_superuser ||
!(profile.is_superuser || profile.is_staff || profile.role === "staff")),
);
async function handleDelete() {
const confirmed = await confirmAction({
title: `Delete ${profile.username}?`,
description:
"The account, its follows and staged uploads are removed. Library items stay, with their owner unassigned.",
confirmLabel: "Delete account",
danger: true,
});
if (confirmed) deleteMutation.mutate();
}
return (
<div className="flex flex-col gap-3 rounded-lg border border-ctp-surface0 bg-ctp-base p-3 lg:flex-row lg:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
@@ -99,6 +131,18 @@ function UserRow({ profile }: { profile: AdminUser }) {
>
Save avatar
</Button>
{canDelete ? (
<Button
variant="danger"
className="px-2 py-1.5"
disabled={deleteMutation.isPending}
onClick={() => void handleDelete()}
title={`Delete the account ${profile.username}`}
aria-label={`Delete ${profile.username}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</div>
);