Files
timemaster/frontend/src/pages/ProfilePage.tsx
T
sysopsandClaude Sonnet 4.6 1fedd683e0 Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:03:27 +02:00

280 lines
13 KiB
TypeScript

import { useEffect, useState } from 'react'
import QRCode from 'qrcode'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
interface UserOut {
id: string; first_name: string; last_name: string; email: string; role: string; personnel_number: string | null
totp_enabled?: boolean
}
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin', COMPANY_ADMIN: 'Administrator',
HR: 'HR', MANAGER: 'Manager', EMPLOYEE: 'Mitarbeiter',
}
// ── TOTP-Setup-Schritte ───────────────────────────────────────────────────────
type TotpStep = 'idle' | 'setup' | 'confirm' | 'done'
function TotpSection({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) {
const [step, setStep] = useState<TotpStep>('idle')
const [qrDataUrl, setQrDataUrl] = useState('')
const [secret, setSecret] = useState('')
const [code, setCode] = useState('')
const [disablePw, setDisablePw] = useState('')
const [disableCode, setDisableCode] = useState('')
const [showDisable, setShowDisable] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const inp = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const startSetup = async () => {
setLoading(true); setError('')
try {
const res = await api.post<{ secret: string; otpauth_uri: string }>('/auth/totp/setup', {})
setSecret(res.secret)
// Secret in DB speichern (ohne Aktivierung)
await api.post('/auth/totp/setup/save', {})
const dataUrl = await QRCode.toDataURL(res.otpauth_uri, { width: 200, margin: 2 })
setQrDataUrl(dataUrl)
setStep('setup')
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setLoading(false) }
}
const confirmCode = async () => {
if (code.length !== 6) { setError('Code muss 6 Ziffern haben'); return }
setLoading(true); setError('')
try {
await api.post('/auth/totp/confirm', { code })
setStep('done')
setCode('')
onToggle()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Ungültiger Code') }
finally { setLoading(false) }
}
const disableTotp = async () => {
setLoading(true); setError('')
try {
await api.post('/auth/totp/disable', { password: disablePw, code: disableCode })
setShowDisable(false); setDisablePw(''); setDisableCode('')
onToggle()
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
finally { setLoading(false) }
}
// Aktiv-Zustand
if (enabled && step !== 'done') {
return (
<div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='w-2.5 h-2.5 rounded-full bg-green-500 inline-block' />
<span className='text-sm font-medium text-green-700'>Aktiv</span>
</div>
<button onClick={() => setShowDisable(!showDisable)}
className='text-sm text-red-500 hover:text-red-700 hover:underline'>
Deaktivieren
</button>
</div>
{showDisable && (
<div className='mt-4 space-y-3 border-t border-gray-100 pt-4'>
<p className='text-sm text-gray-600'>Zur Bestätigung Passwort und aktuellen TOTP-Code eingeben:</p>
<input type='password' placeholder='Aktuelles Passwort' value={disablePw}
onChange={e => setDisablePw(e.target.value)} className={inp} />
<input type='text' inputMode='numeric' placeholder='6-stelliger Code' maxLength={6}
value={disableCode} onChange={e => setDisableCode(e.target.value.replace(/\D/g, ''))} className={inp} />
{error && <p className='text-sm text-red-600'>{error}</p>}
<div className='flex gap-2'>
<button onClick={disableTotp} disabled={loading || !disablePw || disableCode.length < 6}
className='flex-1 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50'>
{loading ? 'Bitte warten…' : '2FA deaktivieren'}
</button>
<button onClick={() => { setShowDisable(false); setError('') }}
className='px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50'>
Abbrechen
</button>
</div>
</div>
)}
</div>
)
}
// Setup-Flow
if (step === 'idle') {
return (
<div>
<p className='text-sm text-gray-500 mb-4'>
Mit einem Einmalpasswort (TOTP) aus einer Authenticator-App wird dein Konto zusätzlich abgesichert.
Kompatibel mit Google Authenticator, Authy, und anderen TOTP-Apps.
</p>
<button onClick={startSetup} disabled={loading}
className='px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{loading ? 'Bitte warten…' : '2FA einrichten'}
</button>
{error && <p className='text-sm text-red-600 mt-2'>{error}</p>}
</div>
)
}
if (step === 'setup') {
return (
<div className='space-y-4'>
<p className='text-sm text-gray-600'>
<strong>Schritt 1:</strong> Scanne den QR-Code mit deiner Authenticator-App.
</p>
<div className='flex justify-center'>
{qrDataUrl && <img src={qrDataUrl} alt='TOTP QR-Code' className='rounded-lg border border-gray-200 p-2' />}
</div>
<details className='text-xs text-gray-400'>
<summary className='cursor-pointer hover:text-gray-600'>Secret manuell eingeben</summary>
<code className='block mt-2 bg-gray-50 rounded px-3 py-2 text-gray-700 font-mono break-all select-all'>
{secret}
</code>
</details>
<p className='text-sm text-gray-600'>
<strong>Schritt 2:</strong> Gib den 6-stelligen Code aus der App ein:
</p>
<input type='text' inputMode='numeric' placeholder='123456' maxLength={6}
value={code} onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
className={`${inp} text-center text-xl tracking-widest font-mono`} autoFocus />
{error && <p className='text-sm text-red-600'>{error}</p>}
<div className='flex gap-2'>
<button onClick={confirmCode} disabled={loading || code.length < 6}
className='flex-1 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50'>
{loading ? 'Prüfen…' : 'Bestätigen & aktivieren'}
</button>
<button onClick={() => { setStep('idle'); setCode(''); setError('') }}
className='px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-600 hover:bg-gray-50'>
Abbrechen
</button>
</div>
</div>
)
}
// done
return (
<div className='flex items-center gap-2 text-green-700'>
<svg className='w-5 h-5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
<span className='text-sm font-medium'>Zwei-Faktor-Authentifizierung erfolgreich aktiviert!</span>
</div>
)
}
// ── Hauptseite ────────────────────────────────────────────────────────────────
export function ProfilePage() {
const [me, setMe] = useState<UserOut | null>(null)
const [totpEnabled, setTotpEnabled] = useState(false)
const [currentPw, setCurrentPw] = useState('')
const [newPw, setNewPw] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadMe = () => {
api.get<UserOut>('/auth/me').then(u => {
setMe(u)
setTotpEnabled(u.totp_enabled ?? false)
}).catch(() => {})
}
useEffect(() => { loadMe() }, [])
async function changePassword(e: React.FormEvent) {
e.preventDefault()
if (newPw !== confirmPw) { setError('Passwörter stimmen nicht überein'); return }
if (newPw.length < 8) { setError('Mindestens 8 Zeichen'); return }
setSaving(true); setError(null); setSuccess(false)
try {
await api.post('/auth/change-password', { current_password: currentPw, new_password: newPw })
setSuccess(true)
setCurrentPw(''); setNewPw(''); setConfirmPw('')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Ändern des Passworts')
} finally {
setSaving(false)
}
}
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className='max-w-lg mx-auto space-y-6'>
<h1 className='text-2xl font-bold text-gray-900'>Mein Profil</h1>
{/* Profil-Info */}
{me && (
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<div className='flex items-center gap-4'>
<div className='w-14 h-14 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-bold text-xl'>
{me.first_name[0]}{me.last_name[0]}
</div>
<div>
<p className='text-lg font-semibold text-gray-900'>{me.first_name} {me.last_name}</p>
<p className='text-sm text-gray-500'>{me.email}</p>
<span className='inline-block mt-1 text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-700 font-medium'>
{ROLE_LABELS[me.role] ?? me.role}
</span>
</div>
</div>
{me.personnel_number && (
<div className='mt-4 pt-4 border-t border-gray-100 flex items-center justify-between'>
<span className='text-sm text-gray-500'>Personalnummer</span>
<span className='text-sm font-mono font-semibold text-gray-800'>{me.personnel_number}</span>
</div>
)}
</div>
)}
{/* Zwei-Faktor-Authentifizierung */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<div className='mb-4'>
<h2 className='font-semibold text-gray-800'>Zwei-Faktor-Authentifizierung</h2>
<p className='text-sm text-gray-400 mt-0.5'>TOTP · Google Authenticator, Authy, etc.</p>
</div>
<TotpSection enabled={totpEnabled} onToggle={loadMe} />
</div>
{/* Passwort ändern */}
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
<h2 className='font-semibold text-gray-700 mb-4'>Passwort ändern</h2>
<form onSubmit={changePassword} className='space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Aktuelles Passwort</label>
<input type='password' value={currentPw} onChange={e => setCurrentPw(e.target.value)}
required autoComplete='current-password'
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Neues Passwort</label>
<input type='password' value={newPw} onChange={e => setNewPw(e.target.value)}
required autoComplete='new-password'
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
<p className='mt-1 text-xs text-gray-400'>Mindestens 8 Zeichen, 1 Großbuchstabe, 1 Zahl</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Neues Passwort bestätigen</label>
<input type='password' value={confirmPw} onChange={e => setConfirmPw(e.target.value)}
required autoComplete='new-password'
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' />
</div>
{error && <p className='text-sm text-red-600'>{error}</p>}
{success && <p className='text-sm text-green-600 font-medium'>Passwort erfolgreich geändert</p>}
<button type='submit' disabled={saving || !currentPw || !newPw || !confirmPw}
className='w-full py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50'>
{saving ? 'Speichern…' : 'Passwort ändern'}
</button>
</form>
</div>
</div>
</Layout>
)
}