dak.c2s/docs/plans/2026-02-26-kontoverwaltung-implementation.md
CCS Admin e0b2d1e01d docs: add Kontoverwaltung design and implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:12:13 +00:00

33 KiB

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):

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

cd /home/frontend/dak_c2s/backend
alembic revision --autogenerate -m "add profile fields to users"

Step 3: Run migration

cd /home/frontend/dak_c2s/backend
alembic upgrade head

Expected: 4 new nullable columns in users table.

Step 4: Commit

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:

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:

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:

class MFADisableRequest(BaseModel):
    """Password confirmation required to disable MFA."""

    password: str

Step 4: Commit

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:

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:

@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

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

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):

# --- 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:

import os
import time
from fastapi import UploadFile, File
from app.config import get_settings

Then add endpoints:

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

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:

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:

@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:

@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

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):

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:

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:

export async function updateProfile(data: ProfileUpdatePayload): Promise<User> {
  const response = await api.put<User>('/auth/profile', data)
  return response.data
}

export async function uploadAvatar(file: File): Promise<User> {
  const formData = new FormData()
  formData.append('file', file)
  const response = await api.post<User>('/auth/avatar', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
  })
  return response.data
}

export async function deleteAvatar(): Promise<User> {
  const response = await api.delete<User>('/auth/avatar')
  return response.data
}

export async function changePassword(data: ChangePasswordPayload): Promise<void> {
  await api.post('/auth/change-password', data)
}

export async function setupMFA(): Promise<MFASetupResponse> {
  const response = await api.post<MFASetupResponse>('/auth/mfa/setup')
  return response.data
}

export async function verifyMFA(data: MFAVerifyPayload): Promise<void> {
  await api.post('/auth/mfa/verify', data)
}

export async function disableMFA(password: string): Promise<void> {
  await api.delete('/auth/mfa', { data: { password } })
}

Step 4: Add refreshUser to AuthContext

In frontend/src/context/AuthContext.tsx, add to AuthContextType:

refreshUser: () => Promise<void>

Add the function:

const refreshUserFn = async () => {
  const updatedUser = await authService.getMe()
  setUser(updatedUser)
}

Include in provider value: refreshUser: refreshUserFn

Step 5: Commit

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:

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 (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold tracking-tight">Kontoverwaltung</h1>
        <p className="text-muted-foreground">
          Verwalten Sie Ihr Profil, Ihre Sicherheitseinstellungen und die Zwei-Faktor-Authentifizierung.
        </p>
      </div>

      <Tabs defaultValue="profile" className="space-y-6">
        <TabsList>
          <TabsTrigger value="profile" className="gap-2">
            <UserCog className="h-4 w-4" />
            Profil
          </TabsTrigger>
          <TabsTrigger value="security" className="gap-2">
            <Shield className="h-4 w-4" />
            Sicherheit
          </TabsTrigger>
          <TabsTrigger value="mfa" className="gap-2">
            <Smartphone className="h-4 w-4" />
            Zwei-Faktor
          </TabsTrigger>
        </TabsList>

        <TabsContent value="profile">
          <ProfileTab />
        </TabsContent>
        <TabsContent value="security">
          <SecurityTab />
        </TabsContent>
        <TabsContent value="mfa">
          <MFATab />
        </TabsContent>
      </Tabs>
    </div>
  )
}

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)

function ProfileTab() {
  const { user, refreshUser } = useAuth()
  const [saving, setSaving] = useState(false)
  const [success, setSuccess] = useState('')
  const [error, setError] = useState('')
  const fileInputRef = useRef<HTMLInputElement>(null)
  const [avatarUploading, setAvatarUploading] = useState(false)

  const [form, setForm] = useState<ProfileUpdatePayload>({
    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<HTMLInputElement>) => {
    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 (
    <Card>
      <CardHeader>
        <CardTitle>Profildaten</CardTitle>
        <CardDescription>Aktualisieren Sie Ihre persönlichen Informationen.</CardDescription>
      </CardHeader>
      <CardContent className="space-y-6">
        {error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
        {success && <Alert><AlertDescription>{success}</AlertDescription></Alert>}

        {/* Avatar */}
        <div className="flex items-center gap-4">
          <Avatar size="lg">
            {avatarSrc && <AvatarImage src={avatarSrc} />}
            <AvatarFallback>{initials}</AvatarFallback>
          </Avatar>
          <div className="flex gap-2">
            <input
              ref={fileInputRef}
              type="file"
              accept="image/jpeg,image/png"
              className="hidden"
              onChange={handleAvatarUpload}
            />
            <Button
              variant="outline"
              size="sm"
              onClick={() => fileInputRef.current?.click()}
              disabled={avatarUploading}
            >
              {avatarUploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
              Bild hochladen
            </Button>
            {user?.avatar_url && (
              <Button variant="outline" size="sm" onClick={handleAvatarDelete} disabled={avatarUploading}>
                <Trash2 className="mr-2 h-4 w-4" />
                Entfernen
              </Button>
            )}
          </div>
        </div>

        <Separator />

        {/* Form fields */}
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="space-y-2">
            <Label htmlFor="first_name">Vorname</Label>
            <Input id="first_name" value={form.first_name} onChange={(e) => setForm({ ...form, first_name: e.target.value })} />
          </div>
          <div className="space-y-2">
            <Label htmlFor="last_name">Nachname</Label>
            <Input id="last_name" value={form.last_name} onChange={(e) => setForm({ ...form, last_name: e.target.value })} />
          </div>
        </div>
        <div className="space-y-2">
          <Label htmlFor="display_name">Anzeigename</Label>
          <Input id="display_name" value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} placeholder="Wird automatisch aus Vor-/Nachname generiert, wenn leer" />
        </div>
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="space-y-2">
            <Label htmlFor="username">Benutzername</Label>
            <Input id="username" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
          </div>
          <div className="space-y-2">
            <Label htmlFor="email">E-Mail</Label>
            <Input id="email" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
          </div>
        </div>

        <Button onClick={handleSave} disabled={saving}>
          {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
          Speichern
        </Button>
      </CardContent>
    </Card>
  )
}

Step 1b: SecurityTab component (in the same file)

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 (
    <div className="space-y-6">
      <Card>
        <CardHeader>
          <CardTitle>Passwort ändern</CardTitle>
          <CardDescription>Verwenden Sie ein starkes Passwort mit mindestens 8 Zeichen.</CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          {error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
          {success && <Alert><AlertDescription>{success}</AlertDescription></Alert>}
          <div className="space-y-2">
            <Label htmlFor="old_password">Aktuelles Passwort</Label>
            <Input id="old_password" type="password" value={form.old_password} onChange={(e) => setForm({ ...form, old_password: e.target.value })} />
          </div>
          <div className="space-y-2">
            <Label htmlFor="new_password">Neues Passwort</Label>
            <Input id="new_password" type="password" value={form.new_password} onChange={(e) => setForm({ ...form, new_password: e.target.value })} />
          </div>
          <div className="space-y-2">
            <Label htmlFor="confirm_password">Neues Passwort bestätigen</Label>
            <Input id="confirm_password" type="password" value={form.confirm_password} onChange={(e) => setForm({ ...form, confirm_password: e.target.value })} />
          </div>
          <Button onClick={handleChangePassword} disabled={saving}>
            {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Shield className="mr-2 h-4 w-4" />}
            Passwort ändern
          </Button>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>Sitzungsinformationen</CardTitle>
        </CardHeader>
        <CardContent>
          <p className="text-sm text-muted-foreground">
            Letzter Login:{' '}
            {user?.last_login
              ? new Date(user.last_login).toLocaleString('de-DE')
              : 'Keine Daten verfügbar'}
          </p>
        </CardContent>
      </Card>
    </div>
  )
}

Step 1c: MFATab component (in the same file)

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 (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center gap-2">
          Zwei-Faktor-Authentifizierung
          {user?.mfa_enabled && (
            <span className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-200">
              Aktiv
            </span>
          )}
        </CardTitle>
        <CardDescription>
          Schützen Sie Ihr Konto mit einem zusätzlichen Sicherheitsfaktor über eine Authenticator-App.
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        {error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
        {success && <Alert><AlertDescription>{success}</AlertDescription></Alert>}

        {!user?.mfa_enabled && step === 'idle' && (
          <div className="space-y-3">
            <p className="text-sm text-muted-foreground">
              Die Zwei-Faktor-Authentifizierung ist derzeit nicht aktiviert. Aktivieren Sie diese Funktion,
              um Ihr Konto zusätzlich zu schützen.
            </p>
            <Button onClick={handleStartSetup} disabled={loading}>
              {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Smartphone className="mr-2 h-4 w-4" />}
              2FA aktivieren
            </Button>
          </div>
        )}

        {step === 'verify' && (
          <div className="space-y-4">
            <p className="text-sm">
              Scannen Sie den folgenden QR-Code mit Ihrer Authenticator-App (z.B. Google Authenticator, Authy):
            </p>
            <div className="flex justify-center rounded-lg border bg-white p-4">
              <img
                src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUri)}`}
                alt="QR Code für 2FA"
                className="h-48 w-48"
              />
            </div>
            <p className="text-xs text-muted-foreground text-center">
              Manueller Schlüssel: <code className="rounded bg-muted px-1">{secret}</code>
            </p>
            <div className="space-y-2">
              <Label htmlFor="mfa_code">Bestätigungscode</Label>
              <Input
                id="mfa_code"
                value={code}
                onChange={(e) => setCode(e.target.value)}
                placeholder="6-stelliger Code"
                maxLength={6}
              />
            </div>
            <div className="flex gap-2">
              <Button onClick={handleVerify} disabled={loading || code.length !== 6}>
                {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
                Bestätigen
              </Button>
              <Button variant="outline" onClick={() => { setStep('idle'); setError('') }}>
                Abbrechen
              </Button>
            </div>
          </div>
        )}

        {user?.mfa_enabled && step === 'idle' && !showDisableDialog && (
          <div className="space-y-3">
            <p className="text-sm text-muted-foreground">
              Ihr Konto ist durch Zwei-Faktor-Authentifizierung geschützt.
            </p>
            <Button variant="destructive" onClick={() => setShowDisableDialog(true)}>
              2FA deaktivieren
            </Button>
          </div>
        )}

        {showDisableDialog && (
          <div className="space-y-4 rounded-lg border border-destructive/50 p-4">
            <p className="text-sm font-medium text-destructive">
              Bitte bestätigen Sie mit Ihrem Passwort, um 2FA zu deaktivieren.
            </p>
            <div className="space-y-2">
              <Label htmlFor="disable_password">Passwort</Label>
              <Input
                id="disable_password"
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
              />
            </div>
            <div className="flex gap-2">
              <Button variant="destructive" onClick={handleDisable} disabled={loading || !password}>
                {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
                Deaktivieren
              </Button>
              <Button variant="outline" onClick={() => { setShowDisableDialog(false); setPassword(''); setError('') }}>
                Abbrechen
              </Button>
            </div>
          </div>
        )}
      </CardContent>
    </Card>
  )
}

Step 2: Commit

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:

import { AccountPage } from '@/pages/AccountPage'

Add route inside the AppLayout route group (after reports route, before admin/users):

<Route path="account" element={<AccountPage />} />

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:
const accountNavItems: NavItem[] = [
  { label: 'Kontoverwaltung', to: '/account', icon: UserCog },
]
  1. In the JSX, add a new section after </nav> (line 82) and before the admin section:
<Separator />
<nav className="flex flex-col gap-1">
  {accountNavItems.map((item) => (
    <NavItemLink key={item.to} item={item} />
  ))}
</nav>

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:
<DropdownMenuItem onClick={() => navigate('/account')} className="cursor-pointer">
  <UserCog className="mr-2 h-4 w-4" />
  Konto verwalten
</DropdownMenuItem>
<DropdownMenuSeparator />
  1. Add const navigate = useNavigate() inside the Header component.

Step 4: Update Header avatar to show uploaded image

In the Header's Avatar component, change:

<Avatar size="sm">
  <AvatarFallback>{initials}</AvatarFallback>
</Avatar>

to:

<Avatar size="sm">
  {user?.avatar_url && <AvatarImage src={user.avatar_url} />}
  <AvatarFallback>{initials}</AvatarFallback>
</Avatar>

Add AvatarImage to the Avatar import.

Step 5: Commit

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

cd /home/frontend/dak_c2s/frontend
pnpm build

Expected: Build succeeds with no TypeScript errors.

Step 2: Test backend starts

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

git add -A
git commit -m "fix: resolve build issues"