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 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-27 08:25:46 +00:00
parent 547bfa3ea5
commit 78ae11fb99
5 changed files with 159 additions and 3 deletions

View file

@ -19,7 +19,8 @@ from app.schemas.disclosure import (
from app.schemas.user import UserCreate, UserResponse, UserUpdate from app.schemas.user import UserCreate, UserResponse, UserUpdate
from app.services.audit_service import log_action from app.services.audit_service import log_action
from app.services.disclosure_service import ( 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() router = APIRouter()
@ -411,3 +412,55 @@ def admin_revoke_disclosure(
reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at, reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at,
expires_at=dr.expires_at, created_at=dr.created_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"),
)

View file

@ -140,6 +140,34 @@ def revoke_disclosure(db: Session, request_id: int, user_id: int, *, admin: bool
return dr 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: def delete_disclosure_request(db: Session, request_id: int, user_id: int) -> None:
"""Permanently delete a non-active disclosure request owned by the user.""" """Permanently delete a non-active disclosure request owned by the user."""
dr = db.query(DisclosureRequest).filter(DisclosureRequest.id == request_id).first() dr = db.query(DisclosureRequest).filter(DisclosureRequest.id == request_id).first()

View file

@ -3,6 +3,7 @@ import {
getDisclosureRequests, getMyDisclosureRequests, getDisclosureRequests, getMyDisclosureRequests,
reviewDisclosure, revokeDisclosure, adminRevokeDisclosure, reviewDisclosure, revokeDisclosure, adminRevokeDisclosure,
deleteDisclosure, requestDisclosure, deleteDisclosure, requestDisclosure,
adminDeleteDisclosure, adminReactivateDisclosure,
} from '@/services/disclosureService' } from '@/services/disclosureService'
export function useDisclosures(status?: string) { 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'] })
},
})
}

View file

@ -1,5 +1,5 @@
import { useState } from 'react' 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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@ -7,7 +7,10 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table' } 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' import type { DisclosureRequest } from '@/types'
type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected' type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected'
@ -32,12 +35,20 @@ function isActiveApproved(dr: DisclosureRequest): boolean {
return new Date(dr.expires_at) > new Date() 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() { export function DisclosuresPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all') const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const queryStatus = statusFilter === 'all' ? undefined : statusFilter const queryStatus = statusFilter === 'all' ? undefined : statusFilter
const { data: requests = [], isLoading } = useDisclosures(queryStatus) const { data: requests = [], isLoading } = useDisclosures(queryStatus)
const reviewMutation = useReviewDisclosure() const reviewMutation = useReviewDisclosure()
const revokeMutation = useAdminRevokeDisclosure() const revokeMutation = useAdminRevokeDisclosure()
const deleteMutation = useAdminDeleteDisclosure()
const reactivateMutation = useAdminReactivateDisclosure()
const handleReview = (id: number, status: 'approved' | 'rejected') => { const handleReview = (id: number, status: 'approved' | 'rejected') => {
reviewMutation.mutate({ id, status }) reviewMutation.mutate({ id, status })
@ -126,6 +137,39 @@ export function DisclosuresPage() {
Entziehen Entziehen
</Button> </Button>
)} )}
{isExpiredApproved(dr) && (
<>
<Button
size="sm"
variant="outline"
disabled={reactivateMutation.isPending}
onClick={() => reactivateMutation.mutate(dr.id)}
>
<RotateCcw className="size-4 mr-1" />
Wieder aufleben
</Button>
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(dr.id)}
>
<Trash2 className="size-4 mr-1" />
Verwerfen
</Button>
</>
)}
{dr.status === 'rejected' && (
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(dr.id)}
>
<Trash2 className="size-4 mr-1" />
Verwerfen
</Button>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>

View file

@ -40,3 +40,12 @@ export async function adminRevokeDisclosure(requestId: number): Promise<Disclosu
export async function deleteDisclosure(requestId: number): Promise<void> { export async function deleteDisclosure(requestId: number): Promise<void> {
await api.delete(`/cases/disclosure-requests/${requestId}`) await api.delete(`/cases/disclosure-requests/${requestId}`)
} }
export async function adminDeleteDisclosure(requestId: number): Promise<void> {
await api.delete(`/admin/disclosure-requests/${requestId}`)
}
export async function adminReactivateDisclosure(requestId: number): Promise<DisclosureRequest> {
const res = await api.put<DisclosureRequest>(`/admin/disclosure-requests/${requestId}/reactivate`)
return res.data
}