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 (