# 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" ```