Files
timemaster/frontend/src/pages/PublicStampPage.tsx
T
patrickandClaude Opus 4.8 707b73b0c5 feat: Statischer firmenweiter QR-Code für mobiles Ein-/Ausstempeln
Mitarbeiter scannen einen am Eingang ausgehängten QR-Code mit dem Privat-Handy
(/stamp?t=<token>), melden sich per Personalnummer + PIN an und stempeln ein/aus.

Eigener öffentlicher Endpunkt-Pfad, da der Kiosk-PIN-Login Ed25519-Geräte-
Signaturen verlangt, die ein Privat-Handy nicht hat.

Backend:
- Company.public_stamp_enabled (opt-in, default OFF) + rotierbares
  public_stamp_token_hash (SHA-256) + created_at; Migration 0033
- Router /time/public: company/auth/action (slowapi-Limits, AuditLog)
- kiosk_auth_service.login_pin_public() reused PIN-Lockout, keyed auf
  (public:company_id, personnel_number)
- public_stamp_session_service: 120s Redis-Kurz-Session
- Admin-Token-Endpunkte in companies.py (GET/rotate/DELETE)

Frontend:
- Public-Route /stamp (PublicStampPage)
- Stempel-PIN-Verwaltung in ProfilePage (reused POST /users/{id}/kiosk-pin)
- QR-Generierung/Druck/Toggle in CompanySettingsPage

Sicherheit: schwächer als Kiosk (keine Geräte-Signatur/Nonce/IP-Whitelist),
bewusster BYOD-Komfort-Tradeoff; Schutz über PIN + Lockout + opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:58:38 +02:00

266 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
const BASE = '/api/v1'
// Öffentliche Endpunkte: KEIN Bearer-Token, daher nicht der api-Client (der
// hängt Authorization an und triggert Token-Refresh). Schlankes fetch.
async function publicPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
const e = new Error(typeof err.detail === 'string' ? err.detail : res.statusText)
;(e as Error & { status?: number }).status = res.status
throw e
}
return res.json()
}
interface TimeEntry { id: string; start_time: string; end_time: string | null }
interface StampStatus { open: boolean; on_break: boolean; today: TimeEntry[] }
interface AuthResponse extends StampStatus { session_token: string; user_name: string; expires_in_seconds: number }
interface ActionResponse extends StampStatus { warnings: string[] }
function fmtTime(iso: string | null): string {
if (!iso) return ''
return iso.slice(0, 5)
}
export function PublicStampPage() {
const [params] = useSearchParams()
const token = params.get('t') ?? ''
const [companyName, setCompanyName] = useState<string | null>(null)
const [enabled, setEnabled] = useState(true)
const [loadingCompany, setLoadingCompany] = useState(true)
const [personnelNumber, setPersonnelNumber] = useState('')
const [pin, setPin] = useState('')
const [sessionToken, setSessionToken] = useState<string | null>(null)
const [userName, setUserName] = useState('')
const [status, setStatus] = useState<StampStatus | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [warnings, setWarnings] = useState<string[]>([])
const [info, setInfo] = useState<string | null>(null)
useEffect(() => {
if (!token) { setLoadingCompany(false); return }
fetch(`${BASE}/time/public/company?t=${encodeURIComponent(token)}`)
.then(r => r.ok ? r.json() : Promise.reject(new Error('ungültig')))
.then((c: { company_name: string; enabled: boolean }) => {
setCompanyName(c.company_name)
setEnabled(c.enabled)
})
.catch(() => setCompanyName(null))
.finally(() => setLoadingCompany(false))
}, [token])
const resetToLogin = useCallback((msg: string) => {
setSessionToken(null)
setStatus(null)
setPin('')
setInfo(msg)
}, [])
async function authenticate(e: React.FormEvent) {
e.preventDefault()
setBusy(true); setError(null); setWarnings([]); setInfo(null)
try {
const res = await publicPost<AuthResponse>('/time/public/auth', {
token, personnel_number: personnelNumber, pin,
})
setSessionToken(res.session_token)
setUserName(res.user_name)
setStatus({ open: res.open, on_break: res.on_break, today: res.today })
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen')
} finally {
setBusy(false)
}
}
async function doAction(action: 'in' | 'out' | 'break_start' | 'break_end') {
if (!sessionToken) return
setBusy(true); setError(null); setWarnings([])
try {
const res = await publicPost<ActionResponse>('/time/public/action', {
session_token: sessionToken, action,
})
setStatus({ open: res.open, on_break: res.on_break, today: res.today })
if (res.warnings.length) setWarnings(res.warnings)
} catch (err: unknown) {
const status = (err as Error & { status?: number }).status
if (status === 401) {
resetToLogin('Sitzung abgelaufen. Bitte erneut anmelden.')
} else {
setError(err instanceof Error ? err.message : 'Aktion fehlgeschlagen')
}
} finally {
setBusy(false)
}
}
// ── Render-Zustände ─────────────────────────────────────────────────────────
if (loadingCompany) {
return (
<Shell>
<div className='flex flex-col items-center gap-3 py-10'>
<div className='animate-spin rounded-full h-9 w-9 border-4 border-blue-500 border-t-transparent' />
<p className='text-sm text-gray-400'>Wird geladen</p>
</div>
</Shell>
)
}
if (!token || companyName === null) {
return (
<Shell>
<div className='bg-red-50 border border-red-200 rounded-xl px-4 py-5 text-center'>
<p className='text-3xl mb-2'>🚫</p>
<p className='font-semibold text-red-700'>QR-Code ungültig</p>
<p className='text-sm text-red-600 mt-1'>Dieser QR-Code ist nicht (mehr) gültig. Bitte an die Verwaltung wenden.</p>
</div>
</Shell>
)
}
if (!enabled) {
return (
<Shell company={companyName}>
<div className='bg-amber-50 border border-amber-200 rounded-xl px-4 py-5 text-center'>
<p className='text-3xl mb-2'>🔒</p>
<p className='font-semibold text-amber-800'>QR-Stempeln deaktiviert</p>
<p className='text-sm text-amber-700 mt-1'>Das mobile Stempeln per QR ist für dieses Unternehmen derzeit nicht aktiviert.</p>
</div>
</Shell>
)
}
// Angemeldet → Stempel-Ansicht
if (sessionToken && status) {
const isOpen = status.open
const onBreak = status.on_break
return (
<Shell company={companyName}>
<div className='space-y-4'>
<p className='text-center text-sm text-gray-500'>Angemeldet als</p>
<p className='text-center text-xl font-bold text-gray-900 -mt-3'>{userName}</p>
{warnings.length > 0 && (
<div className='bg-yellow-50 border border-yellow-200 rounded-xl px-4 py-3'>
<ul className='text-sm text-yellow-700 list-disc list-inside space-y-0.5'>
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{error && <div className='bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>{error}</div>}
<div className={`inline-flex w-full justify-center items-center gap-2 px-3 py-2 rounded-full text-sm font-semibold ${
onBreak ? 'bg-yellow-100 text-yellow-700' : isOpen ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
<span className={`w-2 h-2 rounded-full ${onBreak ? 'bg-yellow-400' : isOpen ? 'bg-green-500' : 'bg-gray-400'}`} />
{onBreak ? 'In Pause' : isOpen ? 'Eingestempelt' : 'Nicht eingestempelt'}
</div>
{!isOpen ? (
<button onClick={() => doAction('in')} disabled={busy}
className='w-full min-h-[80px] rounded-3xl bg-green-500 active:bg-green-700 text-white text-2xl font-bold shadow-md disabled:opacity-50'>
{busy ? '…' : 'EINSTEMPELN'}
</button>
) : onBreak ? (
<button onClick={() => doAction('break_end')} disabled={busy}
className='w-full min-h-[80px] rounded-3xl bg-yellow-400 active:bg-yellow-600 text-white text-2xl font-bold shadow-md disabled:opacity-50'>
{busy ? '…' : 'PAUSE BEENDEN'}
</button>
) : (
<>
<button onClick={() => doAction('out')} disabled={busy}
className='w-full min-h-[80px] rounded-3xl bg-red-500 active:bg-red-700 text-white text-2xl font-bold shadow-md disabled:opacity-50'>
{busy ? '…' : 'AUSSTEMPELN'}
</button>
<button onClick={() => doAction('break_start')} disabled={busy}
className='w-full min-h-[48px] rounded-xl border border-yellow-300 text-yellow-600 font-semibold active:bg-yellow-50 disabled:opacity-50'>
Pause starten
</button>
</>
)}
{status.today.length > 0 && (
<div className='bg-white rounded-xl border border-gray-200 px-4 py-3'>
<p className='text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2'>Heute</p>
<ul className='text-sm text-gray-700 space-y-1'>
{status.today.map(e => (
<li key={e.id} className='flex justify-between'>
<span>{fmtTime(e.start_time)} {fmtTime(e.end_time)}</span>
</li>
))}
</ul>
</div>
)}
<button onClick={() => resetToLogin('')}
className='w-full text-sm text-gray-400 underline pt-2'>
Fertig / Abmelden
</button>
</div>
</Shell>
)
}
// PIN-Anmeldung
return (
<Shell company={companyName}>
<form onSubmit={authenticate} className='space-y-4'>
{info && <div className='bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-700'>{info}</div>}
{error && <div className='bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700'>{error}</div>}
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Personalnummer</label>
<input
inputMode='numeric' autoComplete='off' value={personnelNumber}
onChange={e => setPersonnelNumber(e.target.value.replace(/\D/g, ''))}
className='w-full text-center text-2xl tracking-widest font-mono border border-gray-300 rounded-xl px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500'
required
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>PIN</label>
<input
type='password' inputMode='numeric' autoComplete='off' value={pin}
onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
className='w-full text-center text-2xl tracking-widest font-mono border border-gray-300 rounded-xl px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500'
required
/>
<p className='mt-1 text-xs text-gray-400'>PIN im Mitarbeiter-Portal unter Mein Profil setzen/ändern.</p>
</div>
<button type='submit' disabled={busy || !personnelNumber || pin.length < 4}
className='w-full min-h-[56px] rounded-2xl bg-blue-600 active:bg-blue-800 text-white text-lg font-bold shadow-md disabled:opacity-50'>
{busy ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
</Shell>
)
}
function Shell({ company, children }: { company?: string; children: React.ReactNode }) {
return (
<div className='min-h-screen bg-gray-50 flex flex-col items-center px-4 py-8'>
<div className='w-full max-w-sm'>
<div className='text-center mb-6'>
<p className='text-xs font-semibold text-blue-600 uppercase tracking-widest'>Zeiterfassung</p>
<h1 className='text-2xl font-bold text-gray-900 mt-1'>{company ?? 'Stempeln'}</h1>
</div>
{children}
</div>
</div>
)
}