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 +}