diff --git a/docs/plans/2026-02-26-kontoverwaltung-design.md b/docs/plans/2026-02-26-kontoverwaltung-design.md new file mode 100644 index 0000000..7e99306 --- /dev/null +++ b/docs/plans/2026-02-26-kontoverwaltung-design.md @@ -0,0 +1,101 @@ +# Kontoverwaltung für DAK-Mitarbeiter — Design + +**Datum:** 2026-02-26 +**Status:** Genehmigt + +## Zusammenfassung + +Neue Kontoverwaltungsseite (`/account`) für alle authentifizierten User. Tab-basiertes Layout mit drei Bereichen: Profil, Sicherheit und Zwei-Faktor-Authentifizierung. Sidebar-Eintrag "Kontoverwaltung" für alle User sichtbar. + +## Route & Navigation + +- Route: `/account` (innerhalb `ProtectedRoute`/`AppLayout`) +- Seite: `AccountPage.tsx` +- Sidebar: "Kontoverwaltung" mit `UserCog`-Icon, sichtbar für alle authentifizierten User +- Position: unterhalb der Main-Navigation, oberhalb der Admin-Sektion +- Header-Dropdown: Link "Konto verwalten" als Quick-Access + +## Tab-Struktur + +### Tab 1 — Profil + +| Feld | Typ | Bemerkung | +|------|-----|-----------| +| Profilbild | Avatar-Upload (Drag & Drop / Klick) | Max 2MB, JPG/PNG, `/uploads/avatars/{user_id}_{timestamp}.{ext}` | +| Vorname | Text-Input | `first_name`, neu | +| Nachname | Text-Input | `last_name`, neu | +| Anzeigename | Text-Input | `display_name`, auto-generiert aus Vor+Nachname, überschreibbar | +| Benutzername | Text-Input | `username`, bestehend | +| E-Mail | Text-Input | `email`, bestehend | + +Speichern-Button am Ende. Erfolgsmeldung nach Speichern. + +### Tab 2 — Sicherheit + +| Bereich | Funktion | +|---------|----------| +| Passwort ändern | Aktuelles Passwort, Neues Passwort, Bestätigung. Min 8 Zeichen. Nutzt `POST /api/auth/change-password` | +| Aktive Sessions | Info: Letzter Login-Zeitpunkt (`last_login`) | + +### Tab 3 — Zwei-Faktor-Authentifizierung + +| Status | Anzeige | +|--------|---------| +| Deaktiviert | Erklärungs-Text + "2FA aktivieren"-Button → Setup-Flow | +| Setup-Flow | QR-Code → Authenticator scannen → Bestätigungscode eingeben | +| Aktiviert | Status-Badge "Aktiv" + "2FA deaktivieren"-Button (Passwort-Bestätigung) | + +## Backend-Erweiterungen + +### Neue Felder im User-Model (Alembic-Migration) + +- `first_name: Optional[str]` (max 100) +- `last_name: Optional[str]` (max 100) +- `display_name: Optional[str]` (max 200) +- `avatar_url: Optional[str]` (max 500) + +### Neue Endpoints + +| Methode | Pfad | Beschreibung | +|---------|------|-------------| +| PUT | `/api/auth/profile` | Eigene Profildaten ändern | +| POST | `/api/auth/avatar` | Profilbild hochladen (multipart) | +| DELETE | `/api/auth/avatar` | Profilbild löschen | +| DELETE | `/api/auth/mfa` | MFA selbst deaktivieren (Passwort nötig) | +| DELETE | `/api/admin/users/{id}/mfa` | MFA durch Admin zurücksetzen | + +### Avatar-Speicherung + +- Verzeichnis: `backend/uploads/avatars/` +- Dateiname: `{user_id}_{timestamp}.{ext}` +- StaticFiles Mount: `/uploads/` +- Max 2MB, nur JPG/PNG + +## Datenfluss + +### Profil speichern +``` +React Hook Form + Zod → PUT /api/auth/profile → Uniqueness-Check → DB Update → re-fetch /api/auth/me → AuthContext aktualisiert +``` + +### Avatar hochladen +``` +File-Input → POST /api/auth/avatar (multipart) → Validierung (Typ/Größe) → Speicherung → DB avatar_url Update → Avatar in Header/Sidebar aktualisiert +``` + +### 2FA aktivieren +``` +POST /api/auth/mfa/setup → QR-Code anzeigen → User gibt Code ein → POST /api/auth/mfa/verify → mfa_enabled = true +``` + +### 2FA deaktivieren +``` +Dialog: Passwort eingeben → DELETE /api/auth/mfa → mfa_enabled = false +``` + +## Fehlerbehandlung + +- E-Mail/Username vergeben → 409 Conflict → Fehlermeldung am Feld +- Falsches Passwort → 401 → Fehlermeldung +- Ungültiger MFA-Code → 400 → "Code ungültig" +- Avatar zu groß / falsches Format → Client-Validierung + Backend-Fallback diff --git a/docs/plans/2026-02-26-kontoverwaltung-implementation.md b/docs/plans/2026-02-26-kontoverwaltung-implementation.md new file mode 100644 index 0000000..205686d --- /dev/null +++ b/docs/plans/2026-02-26-kontoverwaltung-implementation.md @@ -0,0 +1,1136 @@ +# Kontoverwaltung Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Account management page (`/account`) with profile editing, avatar upload, password change, and 2FA setup/disable for all authenticated users. + +**Architecture:** Tab-based single page using existing shadcn/Tabs component. Backend extends existing auth router with profile/avatar/MFA-disable endpoints. New DB fields via Alembic migration. Avatar files stored on server filesystem. + +**Tech Stack:** FastAPI, SQLAlchemy, Alembic, React 19, React Router v7, React Hook Form + Zod, shadcn/radix-ui, Tailwind CSS v4, Axios + +--- + +### Task 1: Alembic Migration — New User Fields + +**Files:** +- Create: `backend/alembic/versions/xxxx_add_profile_fields.py` (auto-generated) +- Modify: `backend/app/models/user.py:25-78` + +**Step 1: Add new columns to User model** + +In `backend/app/models/user.py`, add after `email` field (line 30): + +```python +first_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) +last_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) +display_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) +avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) +``` + +**Step 2: Generate Alembic migration** + +```bash +cd /home/frontend/dak_c2s/backend +alembic revision --autogenerate -m "add profile fields to users" +``` + +**Step 3: Run migration** + +```bash +cd /home/frontend/dak_c2s/backend +alembic upgrade head +``` + +Expected: 4 new nullable columns in `users` table. + +**Step 4: Commit** + +```bash +git add backend/app/models/user.py backend/alembic/versions/ +git commit -m "feat: add first_name, last_name, display_name, avatar_url to User model" +``` + +--- + +### Task 2: Backend Schemas — Profile Update & Extended UserResponse + +**Files:** +- Modify: `backend/app/schemas/user.py` +- Modify: `backend/app/schemas/auth.py` + +**Step 1: Extend UserResponse with new fields** + +In `backend/app/schemas/user.py`, update `UserResponse`: + +```python +class UserResponse(BaseModel): + """Public representation of a user (returned by API endpoints).""" + + id: int + username: str + email: str + first_name: Optional[str] = None + last_name: Optional[str] = None + display_name: Optional[str] = None + avatar_url: Optional[str] = None + role: str + mfa_enabled: bool + is_active: bool + last_login: Optional[datetime] = None + created_at: datetime + + model_config = {"from_attributes": True} +``` + +**Step 2: Add ProfileUpdate schema** + +In `backend/app/schemas/user.py`, add: + +```python +class ProfileUpdate(BaseModel): + """Self-service profile update (authenticated user edits own data).""" + + first_name: Optional[str] = None + last_name: Optional[str] = None + display_name: Optional[str] = None + username: Optional[str] = None + email: Optional[EmailStr] = None +``` + +**Step 3: Add MFADisableRequest schema** + +In `backend/app/schemas/auth.py`, add: + +```python +class MFADisableRequest(BaseModel): + """Password confirmation required to disable MFA.""" + + password: str +``` + +**Step 4: Commit** + +```bash +git add backend/app/schemas/ +git commit -m "feat: add ProfileUpdate schema and extend UserResponse with profile fields" +``` + +--- + +### Task 3: Backend — Profile Update Endpoint + +**Files:** +- Modify: `backend/app/api/auth.py` +- Modify: `backend/app/services/auth_service.py` + +**Step 1: Add `update_profile` service function** + +In `backend/app/services/auth_service.py`, add: + +```python +def update_profile( + db: Session, + user: User, + username: str | None = None, + email: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + display_name: str | None = None, +) -> User: + """Update the authenticated user's own profile fields.""" + if username and username != user.username: + existing = db.query(User).filter(User.username == username, User.id != user.id).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already taken", + ) + user.username = username + + if email and email != user.email: + existing = db.query(User).filter(User.email == email, User.id != user.id).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already taken", + ) + user.email = email + + if first_name is not None: + user.first_name = first_name + if last_name is not None: + user.last_name = last_name + if display_name is not None: + user.display_name = display_name + + db.commit() + db.refresh(user) + return user +``` + +**Step 2: Add PUT /profile endpoint in auth router** + +In `backend/app/api/auth.py`, add import for `ProfileUpdate` from `app.schemas.user` and `update_profile` from service. Then add: + +```python +@router.put("/profile", response_model=UserResponse) +def update_profile_endpoint( + data: ProfileUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update the authenticated user's profile.""" + update_data = data.model_dump(exclude_unset=True) + user = update_profile(db, current_user, **update_data) + return UserResponse.model_validate(user) +``` + +**Step 3: Commit** + +```bash +git add backend/app/api/auth.py backend/app/services/auth_service.py +git commit -m "feat: add PUT /api/auth/profile endpoint for self-service profile update" +``` + +--- + +### Task 4: Backend — Avatar Upload & Delete + +**Files:** +- Modify: `backend/app/api/auth.py` +- Create: `backend/uploads/avatars/.gitkeep` + +**Step 1: Create uploads directory** + +```bash +mkdir -p /home/frontend/dak_c2s/backend/uploads/avatars +touch /home/frontend/dak_c2s/backend/uploads/avatars/.gitkeep +``` + +**Step 2: Mount static files in main.py** + +In `backend/app/main.py`, add before the SPA mount (before line 46): + +```python +# --- Serve uploaded files --- +_uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads") +if os.path.isdir(_uploads_dir): + app.mount("/uploads", StaticFiles(directory=_uploads_dir), name="uploads") +``` + +**Step 3: Add avatar upload endpoint** + +In `backend/app/api/auth.py`, add imports: + +```python +import os +import time +from fastapi import UploadFile, File +from app.config import get_settings +``` + +Then add endpoints: + +```python +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png"} +MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB + +@router.post("/avatar", response_model=UserResponse) +async def upload_avatar( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Upload or replace the authenticated user's avatar image.""" + if file.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=400, detail="Only JPG and PNG images are allowed") + + contents = await file.read() + if len(contents) > MAX_AVATAR_SIZE: + raise HTTPException(status_code=400, detail="Image must be smaller than 2MB") + + ext = "jpg" if file.content_type == "image/jpeg" else "png" + filename = f"{current_user.id}_{int(time.time())}.{ext}" + + uploads_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "uploads", "avatars", + ) + os.makedirs(uploads_dir, exist_ok=True) + + # Delete old avatar file if exists + if current_user.avatar_url: + old_file = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + current_user.avatar_url.lstrip("/"), + ) + if os.path.isfile(old_file): + os.remove(old_file) + + filepath = os.path.join(uploads_dir, filename) + with open(filepath, "wb") as f: + f.write(contents) + + current_user.avatar_url = f"/uploads/avatars/{filename}" + db.commit() + db.refresh(current_user) + return UserResponse.model_validate(current_user) + + +@router.delete("/avatar", response_model=UserResponse) +def delete_avatar( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove the authenticated user's avatar image.""" + if current_user.avatar_url: + old_file = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + current_user.avatar_url.lstrip("/"), + ) + if os.path.isfile(old_file): + os.remove(old_file) + current_user.avatar_url = None + db.commit() + db.refresh(current_user) + return UserResponse.model_validate(current_user) +``` + +**Step 4: Add `uploads/` to `.gitignore` (keep only .gitkeep)** + +In `backend/.gitignore` (or root `.gitignore`), add: + +``` +uploads/avatars/* +!uploads/avatars/.gitkeep +``` + +**Step 5: Commit** + +```bash +git add backend/app/main.py backend/app/api/auth.py backend/uploads/avatars/.gitkeep +git commit -m "feat: add avatar upload/delete endpoints with static file serving" +``` + +--- + +### Task 5: Backend — MFA Disable Endpoints + +**Files:** +- Modify: `backend/app/api/auth.py` +- Modify: `backend/app/services/auth_service.py` +- Modify: `backend/app/api/admin.py` + +**Step 1: Add `disable_mfa` service function** + +In `backend/app/services/auth_service.py`, add: + +```python +def disable_mfa(db: Session, user: User, password: str) -> None: + """Disable MFA on the user's account after verifying their password.""" + if not verify_password(password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect password", + ) + user.mfa_secret = None + user.mfa_enabled = False + db.commit() +``` + +**Step 2: Add DELETE /mfa endpoint in auth router** + +In `backend/app/api/auth.py`, add import for `MFADisableRequest` and `disable_mfa`. Then: + +```python +@router.delete("/mfa") +def mfa_disable( + data: MFADisableRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Disable MFA on the authenticated user's account (requires password).""" + disable_mfa(db, current_user, data.password) + return {"detail": "MFA disabled"} +``` + +**Step 3: Add admin MFA reset endpoint** + +In `backend/app/api/admin.py`, add: + +```python +@router.delete("/users/{user_id}/mfa") +def admin_reset_mfa( + user_id: int, + request: Request, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Admin: reset MFA on a user's account.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.mfa_secret = None + user.mfa_enabled = False + db.commit() + + log_action( + db, + user_id=admin.id, + action="mfa_reset", + entity_type="user", + entity_id=user.id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return {"detail": "MFA reset"} +``` + +**Step 4: Commit** + +```bash +git add backend/app/api/auth.py backend/app/services/auth_service.py backend/app/api/admin.py +git commit -m "feat: add MFA disable (self-service + admin reset) endpoints" +``` + +--- + +### Task 6: Frontend — Update Types & Auth Service + +**Files:** +- Modify: `frontend/src/types/index.ts` +- Modify: `frontend/src/services/authService.ts` +- Modify: `frontend/src/context/AuthContext.tsx` + +**Step 1: Extend User interface** + +In `frontend/src/types/index.ts`, update `User` interface (line 1-10): + +```typescript +export interface User { + id: number + username: string + email: string + first_name: string | null + last_name: string | null + display_name: string | null + avatar_url: string | null + role: 'admin' | 'dak_mitarbeiter' + mfa_enabled: boolean + is_active: boolean + last_login: string | null + created_at: string +} +``` + +Also update `UserResponse` interface (line 195-204) to match. + +**Step 2: Add new types** + +In `frontend/src/types/index.ts`, add: + +```typescript +export interface ProfileUpdatePayload { + first_name?: string + last_name?: string + display_name?: string + username?: string + email?: string +} + +export interface ChangePasswordPayload { + old_password: string + new_password: string +} + +export interface MFASetupResponse { + secret: string + qr_uri: string +} + +export interface MFAVerifyPayload { + secret: string + code: string +} +``` + +**Step 3: Add auth service functions** + +In `frontend/src/services/authService.ts`, add: + +```typescript +export async function updateProfile(data: ProfileUpdatePayload): Promise { + const response = await api.put('/auth/profile', data) + return response.data +} + +export async function uploadAvatar(file: File): Promise { + const formData = new FormData() + formData.append('file', file) + const response = await api.post('/auth/avatar', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export async function deleteAvatar(): Promise { + const response = await api.delete('/auth/avatar') + return response.data +} + +export async function changePassword(data: ChangePasswordPayload): Promise { + await api.post('/auth/change-password', data) +} + +export async function setupMFA(): Promise { + const response = await api.post('/auth/mfa/setup') + return response.data +} + +export async function verifyMFA(data: MFAVerifyPayload): Promise { + await api.post('/auth/mfa/verify', data) +} + +export async function disableMFA(password: string): Promise { + await api.delete('/auth/mfa', { data: { password } }) +} +``` + +**Step 4: Add `refreshUser` to AuthContext** + +In `frontend/src/context/AuthContext.tsx`, add to `AuthContextType`: + +```typescript +refreshUser: () => Promise +``` + +Add the function: + +```typescript +const refreshUserFn = async () => { + const updatedUser = await authService.getMe() + setUser(updatedUser) +} +``` + +Include in provider value: `refreshUser: refreshUserFn` + +**Step 5: Commit** + +```bash +git add frontend/src/types/index.ts frontend/src/services/authService.ts frontend/src/context/AuthContext.tsx +git commit -m "feat: add profile/avatar/MFA types and service functions" +``` + +--- + +### Task 7: Frontend — AccountPage with Profile Tab + +**Files:** +- Create: `frontend/src/pages/AccountPage.tsx` + +**Step 1: Create AccountPage with Profile tab** + +Create `frontend/src/pages/AccountPage.tsx`: + +```tsx +import { useState, useRef } from 'react' +import { useAuth } from '@/context/AuthContext' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Separator } from '@/components/ui/separator' +import { UserCog, Shield, Smartphone, Upload, Trash2, Check, Loader2 } from 'lucide-react' +import * as authService from '@/services/authService' +import type { ProfileUpdatePayload } from '@/types' + +export function AccountPage() { + return ( +
+
+

Kontoverwaltung

+

+ Verwalten Sie Ihr Profil, Ihre Sicherheitseinstellungen und die Zwei-Faktor-Authentifizierung. +

+
+ + + + + + Profil + + + + Sicherheit + + + + Zwei-Faktor + + + + + + + + + + + + + +
+ ) +} +``` + +The three tab components (`ProfileTab`, `SecurityTab`, `MFATab`) are defined within this same file — see Tasks 7a, 7b, 7c. + +**Step 1a: ProfileTab component** (in the same file) + +```tsx +function ProfileTab() { + const { user, refreshUser } = useAuth() + const [saving, setSaving] = useState(false) + const [success, setSuccess] = useState('') + const [error, setError] = useState('') + const fileInputRef = useRef(null) + const [avatarUploading, setAvatarUploading] = useState(false) + + const [form, setForm] = useState({ + first_name: user?.first_name ?? '', + last_name: user?.last_name ?? '', + display_name: user?.display_name ?? '', + username: user?.username ?? '', + email: user?.email ?? '', + }) + + const handleSave = async () => { + setSaving(true) + setError('') + setSuccess('') + try { + await authService.updateProfile(form) + await refreshUser() + setSuccess('Profil erfolgreich gespeichert.') + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Fehler beim Speichern.') + } finally { + setSaving(false) + } + } + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setAvatarUploading(true) + setError('') + try { + await authService.uploadAvatar(file) + await refreshUser() + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Fehler beim Hochladen.') + } finally { + setAvatarUploading(false) + } + } + + const handleAvatarDelete = async () => { + setAvatarUploading(true) + try { + await authService.deleteAvatar() + await refreshUser() + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Fehler beim Löschen.') + } finally { + setAvatarUploading(false) + } + } + + const initials = user?.username + ?.split(/[\s._-]/) + .map((p) => p[0]) + .join('') + .toUpperCase() + .slice(0, 2) ?? '??' + + const avatarSrc = user?.avatar_url + ? `${import.meta.env.VITE_API_URL ?? ''}${user.avatar_url}` + : undefined + + return ( + + + Profildaten + Aktualisieren Sie Ihre persönlichen Informationen. + + + {error && {error}} + {success && {success}} + + {/* Avatar */} +
+ + {avatarSrc && } + {initials} + +
+ + + {user?.avatar_url && ( + + )} +
+
+ + + + {/* Form fields */} +
+
+ + setForm({ ...form, first_name: e.target.value })} /> +
+
+ + setForm({ ...form, last_name: e.target.value })} /> +
+
+
+ + setForm({ ...form, display_name: e.target.value })} placeholder="Wird automatisch aus Vor-/Nachname generiert, wenn leer" /> +
+
+
+ + setForm({ ...form, username: e.target.value })} /> +
+
+ + setForm({ ...form, email: e.target.value })} /> +
+
+ + +
+
+ ) +} +``` + +**Step 1b: SecurityTab component** (in the same file) + +```tsx +function SecurityTab() { + const { user } = useAuth() + const [saving, setSaving] = useState(false) + const [success, setSuccess] = useState('') + const [error, setError] = useState('') + const [form, setForm] = useState({ + old_password: '', + new_password: '', + confirm_password: '', + }) + + const handleChangePassword = async () => { + setError('') + setSuccess('') + if (form.new_password !== form.confirm_password) { + setError('Die Passwörter stimmen nicht überein.') + return + } + if (form.new_password.length < 8) { + setError('Das neue Passwort muss mindestens 8 Zeichen lang sein.') + return + } + setSaving(true) + try { + await authService.changePassword({ + old_password: form.old_password, + new_password: form.new_password, + }) + setSuccess('Passwort erfolgreich geändert.') + setForm({ old_password: '', new_password: '', confirm_password: '' }) + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Fehler beim Ändern des Passworts.') + } finally { + setSaving(false) + } + } + + return ( +
+ + + Passwort ändern + Verwenden Sie ein starkes Passwort mit mindestens 8 Zeichen. + + + {error && {error}} + {success && {success}} +
+ + setForm({ ...form, old_password: e.target.value })} /> +
+
+ + setForm({ ...form, new_password: e.target.value })} /> +
+
+ + setForm({ ...form, confirm_password: e.target.value })} /> +
+ +
+
+ + + + Sitzungsinformationen + + +

+ Letzter Login:{' '} + {user?.last_login + ? new Date(user.last_login).toLocaleString('de-DE') + : 'Keine Daten verfügbar'} +

+
+
+
+ ) +} +``` + +**Step 1c: MFATab component** (in the same file) + +```tsx +function MFATab() { + const { user, refreshUser } = useAuth() + const [step, setStep] = useState<'idle' | 'setup' | 'verify'>('idle') + const [secret, setSecret] = useState('') + const [qrUri, setQrUri] = useState('') + const [code, setCode] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [showDisableDialog, setShowDisableDialog] = useState(false) + + const handleStartSetup = async () => { + setLoading(true) + setError('') + try { + const result = await authService.setupMFA() + setSecret(result.secret) + setQrUri(result.qr_uri) + setStep('verify') + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Fehler beim MFA-Setup.') + } finally { + setLoading(false) + } + } + + const handleVerify = async () => { + setLoading(true) + setError('') + try { + await authService.verifyMFA({ secret, code }) + await refreshUser() + setSuccess('Zwei-Faktor-Authentifizierung erfolgreich aktiviert!') + setStep('idle') + setCode('') + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Ungültiger Code. Bitte erneut versuchen.') + } finally { + setLoading(false) + } + } + + const handleDisable = async () => { + setLoading(true) + setError('') + try { + await authService.disableMFA(password) + await refreshUser() + setSuccess('Zwei-Faktor-Authentifizierung deaktiviert.') + setShowDisableDialog(false) + setPassword('') + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Fehler beim Deaktivieren.') + } finally { + setLoading(false) + } + } + + return ( + + + + Zwei-Faktor-Authentifizierung + {user?.mfa_enabled && ( + + Aktiv + + )} + + + Schützen Sie Ihr Konto mit einem zusätzlichen Sicherheitsfaktor über eine Authenticator-App. + + + + {error && {error}} + {success && {success}} + + {!user?.mfa_enabled && step === 'idle' && ( +
+

+ Die Zwei-Faktor-Authentifizierung ist derzeit nicht aktiviert. Aktivieren Sie diese Funktion, + um Ihr Konto zusätzlich zu schützen. +

+ +
+ )} + + {step === 'verify' && ( +
+

+ Scannen Sie den folgenden QR-Code mit Ihrer Authenticator-App (z.B. Google Authenticator, Authy): +

+
+ QR Code für 2FA +
+

+ Manueller Schlüssel: {secret} +

+
+ + setCode(e.target.value)} + placeholder="6-stelliger Code" + maxLength={6} + /> +
+
+ + +
+
+ )} + + {user?.mfa_enabled && step === 'idle' && !showDisableDialog && ( +
+

+ Ihr Konto ist durch Zwei-Faktor-Authentifizierung geschützt. +

+ +
+ )} + + {showDisableDialog && ( +
+

+ Bitte bestätigen Sie mit Ihrem Passwort, um 2FA zu deaktivieren. +

+
+ + setPassword(e.target.value)} + /> +
+
+ + +
+
+ )} +
+
+ ) +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/pages/AccountPage.tsx +git commit -m "feat: add AccountPage with Profile, Security, and MFA tabs" +``` + +--- + +### Task 8: Frontend — Route, Sidebar & Header Integration + +**Files:** +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/components/layout/Sidebar.tsx` +- Modify: `frontend/src/components/layout/Header.tsx` + +**Step 1: Add route in App.tsx** + +Add import: + +```typescript +import { AccountPage } from '@/pages/AccountPage' +``` + +Add route inside the `AppLayout` route group (after `reports` route, before `admin/users`): + +```tsx +} /> +``` + +**Step 2: Add Sidebar entry** + +In `frontend/src/components/layout/Sidebar.tsx`: + +1. Add import: `UserCog` from `lucide-react` +2. Add a new `accountNavItems` array: + +```typescript +const accountNavItems: NavItem[] = [ + { label: 'Kontoverwaltung', to: '/account', icon: UserCog }, +] +``` + +3. In the JSX, add a new section after `` (line 82) and before the admin section: + +```tsx + + +``` + +**Step 3: Add Header dropdown link** + +In `frontend/src/components/layout/Header.tsx`: + +1. Add import: `UserCog` from `lucide-react`, `useNavigate` from `react-router-dom` +2. Before the "Abmelden" DropdownMenuItem, add: + +```tsx + navigate('/account')} className="cursor-pointer"> + + Konto verwalten + + +``` + +3. Add `const navigate = useNavigate()` inside the `Header` component. + +**Step 4: Update Header avatar to show uploaded image** + +In the Header's Avatar component, change: + +```tsx + + {initials} + +``` + +to: + +```tsx + + {user?.avatar_url && } + {initials} + +``` + +Add `AvatarImage` to the Avatar import. + +**Step 5: Commit** + +```bash +git add frontend/src/App.tsx frontend/src/components/layout/Sidebar.tsx frontend/src/components/layout/Header.tsx +git commit -m "feat: add /account route, sidebar entry and header link" +``` + +--- + +### Task 9: Build & Verify + +**Step 1: Build frontend** + +```bash +cd /home/frontend/dak_c2s/frontend +pnpm build +``` + +Expected: Build succeeds with no TypeScript errors. + +**Step 2: Test backend starts** + +```bash +cd /home/frontend/dak_c2s/backend +python -m uvicorn app.main:app --host 0.0.0.0 --port 8099 & +sleep 2 +curl -s http://localhost:8099/api/health +kill %1 +``` + +Expected: `{"status":"ok"}` + +**Step 3: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix: resolve build issues" +```