mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 17:13:42 +00:00
feat: add AccountPage with Profile, Security, and MFA tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
832f5c0a63
commit
45cadd07ce
1 changed files with 455 additions and 0 deletions
455
frontend/src/pages/AccountPage.tsx
Normal file
455
frontend/src/pages/AccountPage.tsx
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 ?? 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue