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>
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user