From 2df001b477c330f09f022be213861988824ebea7 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 23:35:56 -0500 Subject: [PATCH] Let staff delete accounts from the Users page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/apps/accounts/views.py | 40 +++++++++++++++++++-- frontend/src/features/users/UsersPage.tsx | 44 +++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index 6255b31..16ac423 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -1,3 +1,5 @@ +import logging + from rest_framework import mixins, status, viewsets from rest_framework.authtoken.models import Token from rest_framework.permissions import AllowAny, IsAuthenticated @@ -18,6 +20,8 @@ from .serializers import ( UserUpdateSerializer, ) +logger = logging.getLogger(__name__) + class RegisterView(APIView): permission_classes = [AllowAny] @@ -144,12 +148,13 @@ class PreferencesView(APIView): class UserViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, viewsets.GenericViewSet, ): - """Staff user directory: roles and J-ID avatars.""" + """Staff user directory: roles, J-ID avatars, and account deletion.""" permission_classes = [IsAppStaff] - http_method_names = ["get", "patch", "head", "options"] + http_method_names = ["get", "patch", "delete", "head", "options"] def get_queryset(self): queryset = User.objects.annotate(uploads_count=Count("uploads")).order_by("id") @@ -192,3 +197,34 @@ class UserViewSet( ) partial_update = update + + def destroy(self, request, *args, **kwargs): + target = self.get_object() + actor = request.user + if target.pk == actor.pk: + return Response( + {"detail": "You cannot delete the account you are signed in as."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not actor.is_superuser and target.is_app_staff: + return Response( + {"detail": "Only an admin can delete staff or admin accounts."}, + status=status.HTTP_403_FORBIDDEN, + ) + if ( + target.is_superuser + and User.objects.filter(is_superuser=True).count() <= 1 + ): + return Response( + {"detail": "This is the last admin account and cannot be deleted."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + username = target.username + # Staged upload files live on disk; the row cascade would orphan them. + for temp in target.temp_uploads.all(): + if temp.file: + temp.file.delete(save=False) + target.delete() + logger.info("Account %s deleted by %s", username, actor.username) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/frontend/src/features/users/UsersPage.tsx b/frontend/src/features/users/UsersPage.tsx index 09a9ee1..2999b8a 100644 --- a/frontend/src/features/users/UsersPage.tsx +++ b/frontend/src/features/users/UsersPage.tsx @@ -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 (
@@ -99,6 +131,18 @@ function UserRow({ profile }: { profile: AdminUser }) { > Save avatar + {canDelete ? ( + + ) : null}
);