From 78ae11fb99e58039a38ebe836ae13572626acd20 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Fri, 27 Feb 2026 08:25:46 +0000 Subject: [PATCH] feat: add reactivate and delete buttons for admin disclosure view After revoking or expiry, admins now see "Wieder aufleben" (reactivate with new 24h window) and "Verwerfen" (hard delete) buttons. Rejected disclosures also show "Verwerfen". Backend: PUT .../reactivate and DELETE endpoints for admin disclosures. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/admin.py | 55 +++++++++++++++++++++- backend/app/services/disclosure_service.py | 28 +++++++++++ frontend/src/hooks/useDisclosures.ts | 22 +++++++++ frontend/src/pages/DisclosuresPage.tsx | 48 ++++++++++++++++++- frontend/src/services/disclosureService.ts | 9 ++++ 5 files changed, 159 insertions(+), 3 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 0ba45b9..a28d508 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -19,7 +19,8 @@ from app.schemas.disclosure import ( from app.schemas.user import UserCreate, UserResponse, UserUpdate from app.services.audit_service import log_action from app.services.disclosure_service import ( - get_pending_count, review_disclosure_request, revoke_disclosure, + admin_delete_disclosure, get_pending_count, reactivate_disclosure, + review_disclosure_request, revoke_disclosure, ) router = APIRouter() @@ -411,3 +412,55 @@ def admin_revoke_disclosure( reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at, expires_at=dr.expires_at, created_at=dr.created_at, ) + + +@router.put("/disclosure-requests/{request_id}/reactivate", response_model=DisclosureRequestResponse) +def admin_reactivate_disclosure( + request_id: int, + request: Request, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Admin: reactivate an expired/revoked disclosure (new 24h window).""" + try: + dr = reactivate_disclosure(db, request_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + log_action( + db, user_id=admin.id, action="disclosure_reactivated", + entity_type="disclosure_request", entity_id=request_id, + new_values={"expires_at": dr.expires_at.isoformat() if dr.expires_at else None}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + + return DisclosureRequestResponse( + id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id, + requester_username=dr.requester.username if dr.requester else None, + fall_id=dr.case.fall_id if dr.case else None, + reason=dr.reason, status=dr.status, + reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at, + expires_at=dr.expires_at, created_at=dr.created_at, + ) + + +@router.delete("/disclosure-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT) +def admin_delete_disclosure_request( + request_id: int, + request: Request, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Admin: permanently delete a non-active disclosure request.""" + try: + admin_delete_disclosure(db, request_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + log_action( + db, user_id=admin.id, action="disclosure_admin_deleted", + entity_type="disclosure_request", entity_id=request_id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) diff --git a/backend/app/services/disclosure_service.py b/backend/app/services/disclosure_service.py index 58a593d..5854a5e 100644 --- a/backend/app/services/disclosure_service.py +++ b/backend/app/services/disclosure_service.py @@ -140,6 +140,34 @@ def revoke_disclosure(db: Session, request_id: int, user_id: int, *, admin: bool return dr +def reactivate_disclosure(db: Session, request_id: int) -> DisclosureRequest: + """Reactivate an expired/revoked disclosure by granting a new 24h window.""" + dr = db.query(DisclosureRequest).filter(DisclosureRequest.id == request_id).first() + if not dr: + raise ValueError("Disclosure request not found") + if dr.status != "approved": + raise ValueError("Only approved disclosures can be reactivated") + if dr.expires_at and dr.expires_at > _utcnow_naive(): + raise ValueError("Disclosure is still active") + dr.expires_at = _utcnow_naive() + timedelta(hours=24) + db.commit() + db.refresh(dr) + return dr + + +def admin_delete_disclosure(db: Session, request_id: int) -> None: + """Admin: permanently delete any non-active disclosure request.""" + dr = db.query(DisclosureRequest).filter(DisclosureRequest.id == request_id).first() + if not dr: + raise ValueError("Disclosure request not found") + if dr.status == "pending": + raise ValueError("Pending requests cannot be deleted") + if dr.status == "approved" and dr.expires_at and dr.expires_at > _utcnow_naive(): + raise ValueError("Active disclosures cannot be deleted") + db.delete(dr) + db.commit() + + def delete_disclosure_request(db: Session, request_id: int, user_id: int) -> None: """Permanently delete a non-active disclosure request owned by the user.""" dr = db.query(DisclosureRequest).filter(DisclosureRequest.id == request_id).first() diff --git a/frontend/src/hooks/useDisclosures.ts b/frontend/src/hooks/useDisclosures.ts index 8733a8f..1b9bdb3 100644 --- a/frontend/src/hooks/useDisclosures.ts +++ b/frontend/src/hooks/useDisclosures.ts @@ -3,6 +3,7 @@ import { getDisclosureRequests, getMyDisclosureRequests, reviewDisclosure, revokeDisclosure, adminRevokeDisclosure, deleteDisclosure, requestDisclosure, + adminDeleteDisclosure, adminReactivateDisclosure, } from '@/services/disclosureService' export function useDisclosures(status?: string) { @@ -74,3 +75,24 @@ export function useRequestDisclosure() { }, }) } + +export function useAdminDeleteDisclosure() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => adminDeleteDisclosure(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['disclosures'] }) + }, + }) +} + +export function useAdminReactivateDisclosure() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => adminReactivateDisclosure(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['disclosures'] }) + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + }, + }) +} diff --git a/frontend/src/pages/DisclosuresPage.tsx b/frontend/src/pages/DisclosuresPage.tsx index c4358e3..24ffa33 100644 --- a/frontend/src/pages/DisclosuresPage.tsx +++ b/frontend/src/pages/DisclosuresPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Check, X, ShieldOff } from 'lucide-react' +import { Check, X, ShieldOff, RotateCcw, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' @@ -7,7 +7,10 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' -import { useDisclosures, useReviewDisclosure, useAdminRevokeDisclosure } from '@/hooks/useDisclosures' +import { + useDisclosures, useReviewDisclosure, useAdminRevokeDisclosure, + useAdminDeleteDisclosure, useAdminReactivateDisclosure, +} from '@/hooks/useDisclosures' import type { DisclosureRequest } from '@/types' type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected' @@ -32,12 +35,20 @@ function isActiveApproved(dr: DisclosureRequest): boolean { return new Date(dr.expires_at) > new Date() } +function isExpiredApproved(dr: DisclosureRequest): boolean { + if (dr.status !== 'approved') return false + if (!dr.expires_at) return false + return new Date(dr.expires_at) <= new Date() +} + export function DisclosuresPage() { const [statusFilter, setStatusFilter] = useState('all') const queryStatus = statusFilter === 'all' ? undefined : statusFilter const { data: requests = [], isLoading } = useDisclosures(queryStatus) const reviewMutation = useReviewDisclosure() const revokeMutation = useAdminRevokeDisclosure() + const deleteMutation = useAdminDeleteDisclosure() + const reactivateMutation = useAdminReactivateDisclosure() const handleReview = (id: number, status: 'approved' | 'rejected') => { reviewMutation.mutate({ id, status }) @@ -126,6 +137,39 @@ export function DisclosuresPage() { Entziehen )} + {isExpiredApproved(dr) && ( + <> + + + + )} + {dr.status === 'rejected' && ( + + )} diff --git a/frontend/src/services/disclosureService.ts b/frontend/src/services/disclosureService.ts index d75ed1a..25963f2 100644 --- a/frontend/src/services/disclosureService.ts +++ b/frontend/src/services/disclosureService.ts @@ -40,3 +40,12 @@ export async function adminRevokeDisclosure(requestId: number): Promise { await api.delete(`/cases/disclosure-requests/${requestId}`) } + +export async function adminDeleteDisclosure(requestId: number): Promise { + await api.delete(`/admin/disclosure-requests/${requestId}`) +} + +export async function adminReactivateDisclosure(requestId: number): Promise { + const res = await api.put(`/admin/disclosure-requests/${requestId}/reactivate`) + return res.data +}