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

1136 lines
33 KiB
Markdown

# 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<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`:
```typescript
refreshUser: () => Promise<void>
```
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 (
<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)
```tsx
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)
```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 (
<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)
```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 (
<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**
```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
<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:
```typescript
const accountNavItems: NavItem[] = [
{ label: 'Kontoverwaltung', to: '/account', icon: UserCog },
]
```
3. In the JSX, add a new section after `</nav>` (line 82) and before the admin section:
```tsx
<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:
```tsx
<DropdownMenuItem onClick={() => navigate('/account')} className="cursor-pointer">
<UserCog className="mr-2 h-4 w-4" />
Konto verwalten
</DropdownMenuItem>
<DropdownMenuSeparator />
```
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
<Avatar size="sm">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
```
to:
```tsx
<Avatar size="sm">
{user?.avatar_url && <AvatarImage src={user.avatar_url} />}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
```
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"
```