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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user