mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 21:53:41 +00:00
1136 lines
33 KiB
Markdown
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"
|
|
```
|