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>
280 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|