dak.c2s/docs/plans/2026-02-26-dak-datenschutz-implementation.md
CCS Admin 00bc92322f docs: add DAK-Datenschutz implementation plan (11 tasks)
Backend-enforced field masking for dak_mitarbeiter, disclosure request
workflow with 24h expiry, admin approval page, and frontend adaptations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:59:29 +00:00

1009 lines
31 KiB
Markdown

# DAK-Datenschutz Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Hide personal data (nachname, vorname, geburtsdatum) from dak_mitarbeiter users at the API level, with a time-limited disclosure request mechanism for KVNR error resolution.
**Architecture:** Backend-enforced field masking via a helper function that strips sensitive fields from Case responses for non-admin users. New DisclosureRequest model with admin approval workflow and 24h expiry. Notifications via existing Notification model. Frontend hides columns/fields based on role and disclosure status.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (mapped_column), Alembic, Pydantic v2, React 19, TypeScript, shadcn/ui
---
### Task 1: Alembic Migration — disclosure_requests table + Notification constraint
**Files:**
- Create: `backend/alembic/versions/xxxx_add_disclosure_requests.py` (manual migration on Hetzner)
**Step 1: Write migration SQL**
The migration creates the `disclosure_requests` table and updates the Notification CHECK constraint to include the new `disclosure_request` type.
```sql
CREATE TABLE disclosure_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
case_id INT NOT NULL,
requester_id INT NOT NULL,
reason VARCHAR(500) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
reviewed_by INT NULL,
reviewed_at DATETIME NULL,
expires_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_dr_case FOREIGN KEY (case_id) REFERENCES cases(id),
CONSTRAINT fk_dr_requester FOREIGN KEY (requester_id) REFERENCES users(id),
CONSTRAINT fk_dr_reviewer FOREIGN KEY (reviewed_by) REFERENCES users(id),
CONSTRAINT chk_dr_status CHECK (status IN ('pending', 'approved', 'rejected')),
INDEX idx_dr_case_status (case_id, status),
INDEX idx_dr_requester (requester_id),
INDEX idx_dr_status (status)
);
```
Also update the Notification CHECK constraint:
```sql
ALTER TABLE notifications DROP CONSTRAINT chk_notif;
ALTER TABLE notifications ADD CONSTRAINT chk_notif CHECK (
notification_type IN (
'new_cases_uploaded','icd_entered','icd_uploaded',
'report_ready','coding_completed','disclosure_request','disclosure_resolved'
)
);
```
**Step 2: Run migration on Hetzner 1**
```bash
ssh hetzner1 "mysql -u dak_user -p dak_portal < /tmp/disclosure_migration.sql"
```
**Step 3: Commit migration file**
```bash
git add backend/alembic/versions/
git commit -m "feat: add disclosure_requests table and update notification types"
```
---
### Task 2: Backend Model — DisclosureRequest
**Files:**
- Modify: `backend/app/models/audit.py` (add DisclosureRequest class after Notification)
**Step 1: Add DisclosureRequest model**
Add after the Notification class in `backend/app/models/audit.py`:
```python
class DisclosureRequest(Base):
__tablename__ = "disclosure_requests"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
case_id: Mapped[int] = mapped_column(
Integer, ForeignKey("cases.id"), nullable=False
)
requester_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
reason: Mapped[str] = mapped_column(String(500), nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, server_default="pending"
)
reviewed_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
reviewed_at: Mapped[Optional[dt.datetime]] = mapped_column(
DateTime, nullable=True
)
expires_at: Mapped[Optional[dt.datetime]] = mapped_column(
DateTime, nullable=True
)
created_at: Mapped[dt.datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
# Relationships
case: Mapped["Case"] = relationship(foreign_keys=[case_id])
requester: Mapped["User"] = relationship(foreign_keys=[requester_id])
reviewer: Mapped[Optional["User"]] = relationship(foreign_keys=[reviewed_by])
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'approved', 'rejected')",
name="chk_dr_status",
),
Index("idx_dr_case_status", "case_id", "status"),
Index("idx_dr_requester", "requester_id"),
Index("idx_dr_status", "status"),
)
```
Also update the Notification CHECK constraint string to include the new types:
```python
# In Notification.__table_args__, change:
"'new_cases_uploaded','icd_entered','icd_uploaded',"
"'report_ready','coding_completed')"
# to:
"'new_cases_uploaded','icd_entered','icd_uploaded',"
"'report_ready','coding_completed','disclosure_request','disclosure_resolved')"
```
**Step 2: Commit**
```bash
git add backend/app/models/audit.py
git commit -m "feat: add DisclosureRequest model and update notification types"
```
---
### Task 3: Backend Schemas — Disclosure + CaseResponse masking
**Files:**
- Modify: `backend/app/schemas/case.py` (add disclosure_granted field, masking helper)
- Create: `backend/app/schemas/disclosure.py` (request/response schemas)
**Step 1: Add disclosure schemas**
Create `backend/app/schemas/disclosure.py`:
```python
"""Pydantic schemas for disclosure request endpoints."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class DisclosureRequestCreate(BaseModel):
"""Payload for creating a disclosure request."""
reason: str
class DisclosureRequestResponse(BaseModel):
"""Response for a single disclosure request."""
id: int
case_id: int
requester_id: int
requester_username: Optional[str] = None
fall_id: Optional[str] = None
reason: str
status: str
reviewed_by: Optional[int] = None
reviewed_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
created_at: datetime
model_config = {"from_attributes": True}
class DisclosureRequestUpdate(BaseModel):
"""Payload for approving/rejecting a disclosure request."""
status: str # "approved" or "rejected"
class DisclosureCountResponse(BaseModel):
"""Count of pending disclosure requests."""
pending_count: int
```
**Step 2: Add disclosure fields to CaseResponse**
In `backend/app/schemas/case.py`, add two optional fields to `CaseResponse`:
```python
# Add after updated_at:
disclosure_granted: bool = False
disclosure_expires_at: Optional[datetime] = None
```
**Step 3: Create masking helper function**
Add a helper function at the bottom of `backend/app/schemas/case.py`:
```python
SENSITIVE_FIELDS = ("nachname", "vorname", "geburtsdatum", "anrede")
def mask_case_for_mitarbeiter(case_dict: dict, disclosure_granted: bool = False) -> dict:
"""Remove sensitive personal data fields for dak_mitarbeiter users.
If disclosure_granted is True, the fields remain visible.
"""
if not disclosure_granted:
for field in SENSITIVE_FIELDS:
case_dict[field] = None
case_dict["disclosure_granted"] = disclosure_granted
return case_dict
```
**Step 4: Commit**
```bash
git add backend/app/schemas/case.py backend/app/schemas/disclosure.py
git commit -m "feat: add disclosure schemas and case response masking helper"
```
---
### Task 4: Backend Service — Disclosure logic
**Files:**
- Create: `backend/app/services/disclosure_service.py`
**Step 1: Create disclosure service**
```python
"""Disclosure request service — create, review, check active grants."""
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy.orm import Session
from app.models.audit import DisclosureRequest, Notification
from app.models.case import Case
from app.models.user import User
def has_active_disclosure(db: Session, case_id: int, user_id: int) -> tuple[bool, Optional[datetime]]:
"""Check if user has an active (approved, non-expired) disclosure for a case.
Returns (granted: bool, expires_at: datetime | None).
"""
dr = (
db.query(DisclosureRequest)
.filter(
DisclosureRequest.case_id == case_id,
DisclosureRequest.requester_id == user_id,
DisclosureRequest.status == "approved",
DisclosureRequest.expires_at > datetime.now(timezone.utc),
)
.first()
)
if dr:
return True, dr.expires_at
return False, None
def has_pending_request(db: Session, case_id: int, user_id: int) -> bool:
"""Check if user already has a pending disclosure request for a case."""
return (
db.query(DisclosureRequest)
.filter(
DisclosureRequest.case_id == case_id,
DisclosureRequest.requester_id == user_id,
DisclosureRequest.status == "pending",
)
.first()
) is not None
def create_disclosure_request(
db: Session, case_id: int, user_id: int, reason: str
) -> DisclosureRequest:
"""Create a new disclosure request and notify admins."""
dr = DisclosureRequest(
case_id=case_id,
requester_id=user_id,
reason=reason,
)
db.add(dr)
db.flush() # get dr.id
# Notify all active admins
case = db.query(Case).filter(Case.id == case_id).first()
fall_id = case.fall_id if case else str(case_id)
requester = db.query(User).filter(User.id == user_id).first()
requester_name = requester.username if requester else str(user_id)
admins = db.query(User).filter(User.role == "admin", User.is_active == True).all() # noqa: E712
for admin in admins:
db.add(Notification(
recipient_id=admin.id,
notification_type="disclosure_request",
title=f"Freigabe-Anfrage für Fall {fall_id}",
message=f"{requester_name} bittet um Einsicht in Personendaten. Begründung: {reason}",
related_entity_type="disclosure_request",
related_entity_id=dr.id,
))
db.commit()
db.refresh(dr)
return dr
def review_disclosure_request(
db: Session, request_id: int, admin_id: int, new_status: str
) -> DisclosureRequest:
"""Approve or reject a 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("Request already reviewed")
dr.status = new_status
dr.reviewed_by = admin_id
dr.reviewed_at = datetime.now(timezone.utc)
if new_status == "approved":
dr.expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
# Notify requester
case = db.query(Case).filter(Case.id == dr.case_id).first()
fall_id = case.fall_id if case else str(dr.case_id)
status_text = "genehmigt" if new_status == "approved" else "abgelehnt"
db.add(Notification(
recipient_id=dr.requester_id,
notification_type="disclosure_resolved",
title=f"Freigabe-Anfrage {status_text}",
message=f"Ihre Anfrage für Fall {fall_id} wurde {status_text}.",
related_entity_type="disclosure_request",
related_entity_id=dr.id,
))
db.commit()
db.refresh(dr)
return dr
def get_pending_requests(db: Session) -> list[DisclosureRequest]:
"""Return all pending disclosure requests with related data."""
return (
db.query(DisclosureRequest)
.filter(DisclosureRequest.status == "pending")
.order_by(DisclosureRequest.created_at.desc())
.all()
)
def get_pending_count(db: Session) -> int:
"""Return count of pending disclosure requests."""
return (
db.query(DisclosureRequest)
.filter(DisclosureRequest.status == "pending")
.count()
)
```
**Step 2: Commit**
```bash
git add backend/app/services/disclosure_service.py
git commit -m "feat: add disclosure service with create, review, and check logic"
```
---
### Task 5: Backend Endpoints — Case masking + Disclosure API
**Files:**
- Modify: `backend/app/api/cases.py` (mask sensitive fields, restrict search)
- Modify: `backend/app/api/admin.py` (add disclosure endpoints)
- Modify: `backend/app/main.py` (no change needed — admin router already mounted)
**Step 1: Add masking to case endpoints in `backend/app/api/cases.py`**
Import the masking helper and disclosure service:
```python
from app.schemas.case import SENSITIVE_FIELDS, mask_case_for_mitarbeiter
from app.services.disclosure_service import has_active_disclosure
```
Create a helper function to serialize a list of cases with masking:
```python
def _serialize_cases(cases: list[Case], user: User, db: Session) -> list[dict]:
"""Serialize cases, masking sensitive fields for dak_mitarbeiter."""
if user.role == "admin":
return [CaseResponse.model_validate(c).model_dump() for c in cases]
result = []
for c in cases:
case_dict = CaseResponse.model_validate(c).model_dump()
granted, expires_at = has_active_disclosure(db, c.id, user.id)
case_dict = mask_case_for_mitarbeiter(case_dict, disclosure_granted=granted)
if expires_at:
case_dict["disclosure_expires_at"] = expires_at
result.append(case_dict)
return result
```
Modify `list_cases` endpoint:
- For `dak_mitarbeiter`: remove `nachname` and `vorname` from the search OR clause
- Use `_serialize_cases` instead of returning raw cases
Modify `get_case` endpoint:
- Use masking for single case response
Modify `list_pending_icd` endpoint:
- Use masking for case list response
**Step 2: Restrict search for dak_mitarbeiter**
In `list_cases`, change the search filter:
```python
if search:
like_pattern = f"%{search}%"
if user.role == "admin":
query = query.filter(
or_(
Case.nachname.ilike(like_pattern),
Case.vorname.ilike(like_pattern),
Case.fall_id.ilike(like_pattern),
Case.kvnr.ilike(like_pattern),
)
)
else:
# dak_mitarbeiter can only search by fall_id and kvnr
query = query.filter(
or_(
Case.fall_id.ilike(like_pattern),
Case.kvnr.ilike(like_pattern),
)
)
```
**Step 3: Add disclosure request endpoint to cases.py**
```python
from app.schemas.disclosure import DisclosureRequestCreate, DisclosureRequestResponse
from app.services.disclosure_service import (
create_disclosure_request, has_active_disclosure, has_pending_request,
)
@router.post("/{case_id}/disclosure-request", response_model=DisclosureRequestResponse)
def request_disclosure(
case_id: int,
payload: DisclosureRequestCreate,
request: Request,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Request disclosure of personal data for a case (dak_mitarbeiter)."""
# Verify case exists
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
# Check for existing pending request
if has_pending_request(db, case_id, user.id):
raise HTTPException(status.HTTP_409_CONFLICT, "Anfrage bereits ausstehend")
# Check for active disclosure
granted, _ = has_active_disclosure(db, case_id, user.id)
if granted:
raise HTTPException(status.HTTP_409_CONFLICT, "Freigabe bereits aktiv")
dr = create_disclosure_request(db, case_id, user.id, payload.reason)
log_action(
db, user_id=user.id, action="disclosure_requested",
entity_type="case", entity_id=case_id,
new_values={"reason": payload.reason},
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,
reason=dr.reason, status=dr.status, created_at=dr.created_at,
)
```
**Step 4: Add admin disclosure endpoints to `backend/app/api/admin.py`**
```python
from app.models.audit import DisclosureRequest
from app.schemas.disclosure import (
DisclosureCountResponse, DisclosureRequestResponse, DisclosureRequestUpdate,
)
from app.services.disclosure_service import (
get_pending_count, get_pending_requests, review_disclosure_request,
)
@router.get("/disclosure-requests", response_model=list[DisclosureRequestResponse])
def list_disclosure_requests(
status_filter: Optional[str] = Query(None, alias="status"),
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""List disclosure requests (default: pending)."""
query = db.query(DisclosureRequest)
if status_filter:
query = query.filter(DisclosureRequest.status == status_filter)
else:
query = query.filter(DisclosureRequest.status == "pending")
requests = query.order_by(DisclosureRequest.created_at.desc()).all()
result = []
for dr in requests:
result.append(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,
))
return result
@router.get("/disclosure-requests/count", response_model=DisclosureCountResponse)
def disclosure_request_count(
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Return count of pending disclosure requests."""
return DisclosureCountResponse(pending_count=get_pending_count(db))
@router.put("/disclosure-requests/{request_id}", response_model=DisclosureRequestResponse)
def review_disclosure(
request_id: int,
payload: DisclosureRequestUpdate,
request: Request,
admin: User = Depends(require_admin),
db: Session = Depends(get_db),
):
"""Approve or reject a disclosure request."""
if payload.status not in ("approved", "rejected"):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY,
"Status must be 'approved' or 'rejected'")
try:
dr = review_disclosure_request(db, request_id, admin.id, payload.status)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
log_action(
db, user_id=admin.id, action=f"disclosure_{payload.status}",
entity_type="disclosure_request", entity_id=request_id,
new_values={"status": payload.status, "case_id": dr.case_id},
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,
)
```
**Step 5: Commit**
```bash
git add backend/app/api/cases.py backend/app/api/admin.py
git commit -m "feat: add case masking for dak_mitarbeiter and disclosure endpoints"
```
---
### Task 6: Frontend Types & Services
**Files:**
- Modify: `frontend/src/types/index.ts` (add disclosure types, update Case)
- Create: `frontend/src/services/disclosureService.ts`
**Step 1: Update Case interface**
In `frontend/src/types/index.ts`, add to the Case interface:
```typescript
// Add after updated_at in Case interface:
disclosure_granted?: boolean
disclosure_expires_at?: string | null
```
Add new types:
```typescript
// Disclosure Requests
export interface DisclosureRequest {
id: number
case_id: number
requester_id: number
requester_username: string | null
fall_id: string | null
reason: string
status: 'pending' | 'approved' | 'rejected'
reviewed_by: number | null
reviewed_at: string | null
expires_at: string | null
created_at: string
}
export interface DisclosureRequestCreate {
reason: string
}
export interface DisclosureCountResponse {
pending_count: number
}
```
**Step 2: Create disclosure service**
Create `frontend/src/services/disclosureService.ts`:
```typescript
import api from './api'
import type { DisclosureRequest, DisclosureCountResponse } from '@/types'
export async function requestDisclosure(caseId: number, reason: string): Promise<DisclosureRequest> {
const res = await api.post<DisclosureRequest>(`/cases/${caseId}/disclosure-request`, { reason })
return res.data
}
export async function getDisclosureRequests(status?: string): Promise<DisclosureRequest[]> {
const params = status ? { status } : {}
const res = await api.get<DisclosureRequest[]>('/admin/disclosure-requests', { params })
return res.data
}
export async function getDisclosureCount(): Promise<number> {
const res = await api.get<DisclosureCountResponse>('/admin/disclosure-requests/count')
return res.data.pending_count
}
export async function reviewDisclosure(requestId: number, status: 'approved' | 'rejected'): Promise<DisclosureRequest> {
const res = await api.put<DisclosureRequest>(`/admin/disclosure-requests/${requestId}`, { status })
return res.data
}
```
**Step 3: Commit**
```bash
git add frontend/src/types/index.ts frontend/src/services/disclosureService.ts
git commit -m "feat: add disclosure types and service functions"
```
---
### Task 7: Frontend — CasesPage masking
**Files:**
- Modify: `frontend/src/pages/CasesPage.tsx` (hide columns, adjust search)
**Step 1: Hide Nachname/Vorname columns for dak_mitarbeiter**
In CasesPage, use the existing `useAuth()` hook to get `isAdmin`. Conditionally render the table headers and cells:
```tsx
// In the TableHeader, wrap Nachname/Vorname heads:
{isAdmin && <TableHead>Nachname</TableHead>}
{isAdmin && <TableHead>Vorname</TableHead>}
// In each TableRow, wrap corresponding cells:
{isAdmin && <TableCell className="font-medium">{c.nachname}</TableCell>}
{isAdmin && <TableCell>{c.vorname || '-'}</TableCell>}
```
**Step 2: Adjust search placeholder**
```tsx
<Input
placeholder={isAdmin ? "Suche nach Name, Fall-ID, KVNR..." : "Suche nach Fall-ID, KVNR..."}
...
/>
```
**Step 3: Commit**
```bash
git add frontend/src/pages/CasesPage.tsx
git commit -m "feat: hide personal data columns for dak_mitarbeiter in case list"
```
---
### Task 8: Frontend — CaseDetail disclosure request
**Files:**
- Modify: `frontend/src/pages/CasesPage.tsx` (CaseDetail component)
- Modify: `frontend/src/pages/cases/fieldConfig.ts` (add visibility property)
**Step 1: Add `visibleTo` property to FieldConfig**
In `frontend/src/pages/cases/fieldConfig.ts`, add to FieldConfig interface:
```typescript
visibleTo?: 'admin' | 'all' // defaults to 'all' if not set
```
Mark sensitive fields:
```typescript
{ key: 'anrede', label: 'Anrede', type: 'select', editableBy: 'admin', visibleTo: 'admin', options: ANREDE_OPTIONS },
{ key: 'vorname', label: 'Vorname', type: 'text', editableBy: 'admin', visibleTo: 'admin', placeholder: 'Vorname' },
{ key: 'nachname', label: 'Nachname', type: 'text', editableBy: 'admin', visibleTo: 'admin', placeholder: 'Nachname' },
{ key: 'geburtsdatum', label: 'Geburtsdatum', type: 'date', editableBy: 'admin', visibleTo: 'admin' },
```
**Step 2: Filter fields in CaseDetail rendering**
In the CaseDetail component, filter section fields by visibility:
```tsx
{CASE_SECTIONS.map((section) => {
const visibleFields = section.fields.filter((field) => {
if (field.visibleTo === 'admin' && !isAdmin && !caseData.disclosure_granted) return false
return true
})
if (visibleFields.length === 0) return null
return (
<div key={section.title} className="border-t pt-3 space-y-3">
...
{visibleFields.map((field) => (...))}
</div>
)
})}
```
**Step 3: Add disclosure request button in CaseDetail**
Add after the status badges section in CaseDetail, when user is not admin:
```tsx
{!isAdmin && !caseData.disclosure_granted && (
<DisclosureRequestButton caseId={caseData.id} />
)}
{caseData.disclosure_granted && caseData.disclosure_expires_at && (
<Alert>
<AlertDescription>
Personendaten sichtbar bis {formatDateTime(caseData.disclosure_expires_at)}
</AlertDescription>
</Alert>
)}
```
Create the DisclosureRequestButton component within CasesPage.tsx:
```tsx
function DisclosureRequestButton({ caseId }: { caseId: number }) {
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const submit = async () => {
if (!reason.trim()) return
setLoading(true)
setError('')
try {
await requestDisclosure(caseId, reason.trim())
setSuccess(true)
setOpen(false)
} catch (err: any) {
setError(err.response?.data?.detail || 'Fehler beim Senden der Anfrage')
} finally {
setLoading(false)
}
}
if (success) {
return <p className="text-sm text-muted-foreground">Freigabe-Anfrage gesendet.</p>
}
return (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
Personendaten anfordern
</Button>
{open && (
<div className="space-y-2 border rounded-lg p-3">
<p className="text-sm text-muted-foreground">
Begründung für die Einsicht in Personendaten:
</p>
<Input
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="z.B. KVNR-Fehler, Identifikation nötig"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={submit} disabled={loading || !reason.trim()}>
{loading ? 'Senden...' : 'Anfrage senden'}
</Button>
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</div>
</div>
)}
</>
)
}
```
**Step 4: Commit**
```bash
git add frontend/src/pages/CasesPage.tsx frontend/src/pages/cases/fieldConfig.ts
git commit -m "feat: add disclosure request UI and field visibility for dak_mitarbeiter"
```
---
### Task 9: Frontend — Admin Disclosures Page
**Files:**
- Create: `frontend/src/pages/DisclosuresPage.tsx`
**Step 1: Create the admin disclosures page**
```tsx
import { useState, useEffect } from 'react'
import { Check, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import type { DisclosureRequest } from '@/types'
import { getDisclosureRequests, reviewDisclosure } from '@/services/disclosureService'
export function DisclosuresPage() {
const [requests, setRequests] = useState<DisclosureRequest[]>([])
const [loading, setLoading] = useState(true)
const load = () => {
setLoading(true)
getDisclosureRequests('pending')
.then(setRequests)
.catch(() => setRequests([]))
.finally(() => setLoading(false))
}
useEffect(() => { load() }, [])
const handleReview = async (id: number, status: 'approved' | 'rejected') => {
await reviewDisclosure(id, status)
load()
}
return (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Freigabe-Anfragen</h1>
{loading ? (
<div className="space-y-2">
{[1,2,3].map(i => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : requests.length === 0 ? (
<p className="text-muted-foreground">Keine offenen Anfragen.</p>
) : (
<div className="space-y-3">
{requests.map((dr) => (
<Card key={dr.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
Fall {dr.fall_id || dr.case_id}
</CardTitle>
<Badge variant="outline">{dr.status}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm">
<span className="text-muted-foreground">Angefragt von:</span>{' '}
{dr.requester_username || `User #${dr.requester_id}`}
</p>
<p className="text-sm">
<span className="text-muted-foreground">Begründung:</span>{' '}
{dr.reason}
</p>
<p className="text-xs text-muted-foreground">
{new Date(dr.created_at).toLocaleString('de-DE')}
</p>
<div className="flex gap-2 pt-1">
<Button size="sm" onClick={() => handleReview(dr.id, 'approved')}>
<Check className="size-4 mr-1" /> Genehmigen
</Button>
<Button size="sm" variant="destructive" onClick={() => handleReview(dr.id, 'rejected')}>
<X className="size-4 mr-1" /> Ablehnen
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}
```
**Step 2: Commit**
```bash
git add frontend/src/pages/DisclosuresPage.tsx
git commit -m "feat: add admin disclosures page for reviewing data access requests"
```
---
### Task 10: Frontend — Routing & Sidebar
**Files:**
- Modify: `frontend/src/App.tsx` (add route)
- Modify: `frontend/src/components/layout/Sidebar.tsx` (add admin nav item)
**Step 1: Add route in App.tsx**
Import DisclosuresPage and add the route alongside other admin routes:
```tsx
import { DisclosuresPage } from './pages/DisclosuresPage'
// Add route:
<Route path="/admin/disclosures" element={<DisclosuresPage />} />
```
**Step 2: Add sidebar entry**
In `frontend/src/components/layout/Sidebar.tsx`, import `ShieldCheck` from lucide-react and add to adminNavItems:
```typescript
import { ShieldCheck } from 'lucide-react'
const adminNavItems: NavItem[] = [
{ label: 'Freigabe-Anfragen', to: '/admin/disclosures', icon: ShieldCheck },
{ label: 'Benutzer', to: '/admin/users', icon: Users },
{ label: 'Einladungen', to: '/admin/invitations', icon: Mail },
{ label: 'Audit-Log', to: '/admin/audit', icon: History },
]
```
**Step 3: Commit**
```bash
git add frontend/src/App.tsx frontend/src/components/layout/Sidebar.tsx
git commit -m "feat: add disclosure admin route and sidebar entry"
```
---
### Task 11: Build & Deploy
**Step 1: Build frontend**
```bash
cd frontend && pnpm build
```
Expected: Build succeeds without errors.
**Step 2: Run migration on Hetzner 1**
Execute the SQL migration on the production database.
**Step 3: Deploy to Hetzner 1**
```bash
# Push and merge
git push origin develop
git checkout main && git merge develop && git push origin main
# Deploy
ssh hetzner1 "cd /opt/dak-portal && git pull origin main"
ssh hetzner1 "cd /opt/dak-portal/frontend && pnpm install && pnpm build && cp -r dist/* /var/www/vhosts/complexcaresolutions.de/dak.complexcaresolutions.de/dist/"
ssh hetzner1 "systemctl restart dak-backend"
```
**Step 4: Verify**
- Login as admin: all data visible, disclosure admin page accessible
- Login as dak_mitarbeiter: Nachname/Vorname/Geburtsdatum hidden in list and detail
- Search only works for Fall-ID/KVNR as dak_mitarbeiter
- Disclosure request flow works end-to-end