mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 20:43:41 +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