Files
timemaster/frontend/src/pages/KioskStampPage.tsx
T
patrickandClaude Sonnet 5 81d58f720e
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
feat(kiosk): PC-Terminal-Frontend für NFC-Login + Stempeln
Bisher zeigte KioskStampPage nur einen Status ("Kiosk-Modus aktiv"), es gab
keine Möglichkeit sich einzuloggen oder tatsächlich zu stempeln. Jetzt:

- NFC-Erfassung ohne Fokus-Anforderung: globaler keydown-Listener erkennt den
  Tastatur-Emulations-Output günstiger USB-HID-RFID-Reader (Zeichen-Burst
  <300ms + Enter) und unterscheidet ihn von echter Tastatureingabe
- PIN-Pad nach Kartenerkennung (Pflicht-Zweitfaktor, siehe 3650da8/K-3-Fix)
- Nach Login: Stempel-Aktionen je nach aktuellem Status (Ein/Aus/Pause
  starten/beenden) über die neuen /kiosk/stamp/*-Endpunkte (2ce363c)
- Automatischer Reset zur Kartenauflege-Ansicht nach jeder Aktion

Alle Requests laufen weiter transparent durch den bestehenden Ed25519-
signierenden ServiceWorker (kiosk-sw.js, kein Adapter nötig).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
2026-08-27 13:37:06 +02:00

594 lines
21 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, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
interface HeartbeatResponse {
server_time?: string
server_timestamp?: number
status?: string
}
// BroadcastChannel name only one tab sends heartbeats
const HEARTBEAT_CHANNEL = 'kiosk-heartbeat'
const HEARTBEAT_INTERVAL_MS = 30_000
const CLIENT_VERSION = '1.0.0'
// NFC keyboard-wedge readers "type" the card UID very fast, ending with Enter.
// A human never types 10 digits in under this window, so a burst of digit
// keys arriving within NFC_BURST_MS is treated as a card tap, not typing.
const NFC_BURST_MS = 300
const NFC_MIN_LENGTH = 4
type Step = 'idle' | 'pin' | 'action' | 'done'
interface StampStatus {
stamped_in: boolean
on_break: boolean
}
function formatTime(date: Date): string {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
async function sendMessageToSW(
reg: ServiceWorkerRegistration,
message: object
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (e) => resolve(e.data as Record<string, unknown>)
channel.port1.onmessageerror = () => reject(new Error('MessageChannel-Fehler'))
const sw = reg.active
if (!sw) { reject(new Error('ServiceWorker nicht aktiv')); return }
sw.postMessage({ ...message, port: channel.port2 }, [channel.port2])
})
}
async function kioskFetch<T>(path: string, body: object): Promise<T> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(typeof data.detail === 'string' ? data.detail : `Fehler ${res.status}`)
}
return data as T
}
export function KioskStampPage() {
const [displayTime, setDisplayTime] = useState(new Date())
const [serverTimeOffset, setServerTimeOffset] = useState(0) // ms offset from server
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [heartbeatStatus, setHeartbeatStatus] = useState<'connected' | 'error' | 'pending'>('pending')
const [heartbeatError, setHeartbeatError] = useState<string | null>(null)
const [hasCredentials, setHasCredentials] = useState<boolean | null>(null)
const [deviceId, setDeviceId] = useState<string | null>(null)
const [isLeaderTab, setIsLeaderTab] = useState(false)
// ── Login/Stamp-Flow ────────────────────────────────────────────────────
const [step, setStep] = useState<Step>('idle')
const [nfcUid, setNfcUid] = useState<string | null>(null)
const [pin, setPin] = useState('')
const [sessionToken, setSessionToken] = useState<string | null>(null)
const [userName, setUserName] = useState<string | null>(null)
const [stampStatus, setStampStatus] = useState<StampStatus | null>(null)
const [flowError, setFlowError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [doneMessage, setDoneMessage] = useState<string | null>(null)
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const clockIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const broadcastRef = useRef<BroadcastChannel | null>(null)
const swRegRef = useRef<ServiceWorkerRegistration | null>(null)
const startTimeRef = useRef<number>(Date.now())
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// NFC keyboard-wedge capture buffer
const nfcBufferRef = useRef('')
const nfcLastKeyAtRef = useRef(0)
const resetFlow = useCallback(() => {
setStep('idle')
setNfcUid(null)
setPin('')
setSessionToken(null)
setUserName(null)
setStampStatus(null)
setFlowError(null)
setDoneMessage(null)
nfcBufferRef.current = ''
}, [])
function scheduleAutoReset(ms = 4000) {
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current)
resetTimeoutRef.current = setTimeout(resetFlow, ms)
}
// Live clock uses server time offset
useEffect(() => {
clockIntervalRef.current = setInterval(() => {
const localNow = Date.now()
setDisplayTime(new Date(localNow + serverTimeOffset))
}, 1000)
return () => {
if (clockIntervalRef.current) clearInterval(clockIntervalRef.current)
}
}, [serverTimeOffset])
// Online/Offline events
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
// ServiceWorker + BroadcastChannel setup
useEffect(() => {
let cancelled = false
async function init() {
// Register / retrieve SW
if ('serviceWorker' in navigator) {
try {
await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' })
const ready = await navigator.serviceWorker.ready
swRegRef.current = ready
if (!cancelled) {
const result = await sendMessageToSW(ready, { type: 'CHECK_CREDENTIALS' })
setHasCredentials(!!result.hasCredentials)
setDeviceId(result.deviceId as string | null)
}
} catch {
if (!cancelled) {
setHasCredentials(false)
}
}
} else {
setHasCredentials(false)
}
if (cancelled) return
// Leader-election via BroadcastChannel:
// We announce ourselves; if we receive an announcement from another tab
// we yield. Simple "last writer wins for 1s window" approach.
const channel = new BroadcastChannel(HEARTBEAT_CHANNEL)
broadcastRef.current = channel
let isLeader = true
channel.onmessage = (e) => {
if (e.data?.type === 'HEARTBEAT_LEADER_ANNOUNCE') {
// Another tab claims leadership we yield
isLeader = false
setIsLeaderTab(false)
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current)
heartbeatIntervalRef.current = null
}
}
if (e.data?.type === 'HEARTBEAT_LEADER_YIELD' && !isLeader) {
// Previous leader stepped down we take over
isLeader = true
setIsLeaderTab(true)
startHeartbeatLoop()
}
}
// Announce ourselves as leader after a short random delay
// to avoid simultaneous announcements on page reload
const delay = Math.random() * 500
await new Promise(r => setTimeout(r, delay))
if (!cancelled) {
channel.postMessage({ type: 'HEARTBEAT_LEADER_ANNOUNCE' })
setIsLeaderTab(isLeader)
if (isLeader) startHeartbeatLoop()
}
}
init()
return () => {
cancelled = true
if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current)
if (broadcastRef.current) {
broadcastRef.current.postMessage({ type: 'HEARTBEAT_LEADER_YIELD' })
broadcastRef.current.close()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// NFC keyboard-wedge listener: only active on the idle screen. Cheap USB-HID
// RFID readers "type" the UID as fast keystrokes ending with Enter - a human
// typing on a real keyboard can't hit 4+ digits within NFC_BURST_MS.
useEffect(() => {
if (step !== 'idle' || hasCredentials !== true) return
function onKeyDown(e: KeyboardEvent) {
const now = Date.now()
if (now - nfcLastKeyAtRef.current > NFC_BURST_MS) {
nfcBufferRef.current = ''
}
nfcLastKeyAtRef.current = now
if (e.key === 'Enter') {
const uid = nfcBufferRef.current
nfcBufferRef.current = ''
if (uid.length >= NFC_MIN_LENGTH) {
setNfcUid(uid)
setStep('pin')
setPin('')
setFlowError(null)
}
return
}
if (/^[0-9A-Za-z]$/.test(e.key)) {
nfcBufferRef.current += e.key
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [step, hasCredentials])
function startHeartbeatLoop() {
// Send immediately, then on interval
sendHeartbeat()
if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current)
heartbeatIntervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS)
}
async function sendHeartbeat() {
if (!navigator.onLine) {
setHeartbeatStatus('error')
setHeartbeatError('Kein Netzwerk')
return
}
const uptimeSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000)
const body = JSON.stringify({
uptime_seconds: uptimeSeconds,
client_version: CLIENT_VERSION,
queued_offline_entries: 0,
current_user_id: null,
})
try {
const res = await fetch('/api/v1/kiosk/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(
typeof errData.detail === 'string' ? errData.detail : res.statusText
)
}
const data: HeartbeatResponse = await res.json().catch(() => ({}))
// Sync server time if provided
if (data.server_timestamp) {
const localNow = Date.now()
const serverMs = data.server_timestamp * 1000
setServerTimeOffset(serverMs - localNow)
} else if (data.server_time) {
const localNow = Date.now()
const serverMs = new Date(data.server_time).getTime()
if (!isNaN(serverMs)) {
setServerTimeOffset(serverMs - localNow)
}
}
setHeartbeatStatus('connected')
setHeartbeatError(null)
} catch (e: unknown) {
setHeartbeatStatus('error')
setHeartbeatError(e instanceof Error ? e.message : 'Verbindungsfehler')
}
}
async function submitPin() {
if (!nfcUid || pin.length < 4) return
setBusy(true)
setFlowError(null)
try {
const login = await kioskFetch<{ session_token: string; user_name: string }>(
'/api/v1/kiosk/auth/nfc', { nfc_uid: nfcUid, pin }
)
setSessionToken(login.session_token)
setUserName(login.user_name)
const status = await kioskFetch<StampStatus>(
'/api/v1/kiosk/stamp/status', { session_token: login.session_token }
)
setStampStatus(status)
setStep('action')
} catch (e: unknown) {
setFlowError(e instanceof Error ? e.message : 'Anmeldung fehlgeschlagen')
setPin('')
} finally {
setBusy(false)
}
}
async function performAction(action: 'in' | 'out' | 'break-start' | 'break-end') {
if (!sessionToken) return
setBusy(true)
setFlowError(null)
try {
const result = await kioskFetch<{ status: string; warnings?: string[] }>(
`/api/v1/kiosk/stamp/${action}`, { session_token: sessionToken }
)
const labels: Record<string, string> = {
stamped_in: 'Eingestempelt ✓',
stamped_out: 'Ausgestempelt ✓',
break_started: 'Pause gestartet ✓',
break_ended: 'Pause beendet ✓',
}
setDoneMessage(labels[result.status] ?? 'Erledigt ✓')
if (result.warnings?.length) {
setFlowError(result.warnings.join(' · '))
}
setStep('done')
scheduleAutoReset()
} catch (e: unknown) {
setFlowError(e instanceof Error ? e.message : 'Aktion fehlgeschlagen')
} finally {
setBusy(false)
}
}
return (
<div className='min-h-screen bg-gray-950 text-white flex flex-col'>
{/* Status bar */}
<div className='flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-800'>
<div className='flex items-center gap-3'>
<span className='text-sm font-semibold text-gray-300'>TimeMaster Kiosk</span>
{deviceId && (
<span className='text-xs text-gray-600 font-mono'>
{deviceId.slice(0, 8)}
</span>
)}
</div>
<div className='flex items-center gap-4'>
{/* Heartbeat / connection status */}
{isLeaderTab && (
<div className='flex items-center gap-1.5 text-xs'>
{heartbeatStatus === 'connected' && (
<>
<span className='w-2 h-2 rounded-full bg-green-500 animate-pulse' />
<span className='text-green-400'>Verbunden</span>
</>
)}
{heartbeatStatus === 'error' && (
<>
<span className='w-2 h-2 rounded-full bg-red-500' />
<span className='text-red-400'>{heartbeatError ?? 'Verbindungsfehler'}</span>
</>
)}
{heartbeatStatus === 'pending' && (
<>
<span className='w-2 h-2 rounded-full bg-yellow-500 animate-pulse' />
<span className='text-yellow-400'>Verbinde</span>
</>
)}
</div>
)}
{!isLeaderTab && (
<span className='text-xs text-gray-600'>Heartbeat: anderer Tab aktiv</span>
)}
{/* Online/Offline indicator */}
<div className='flex items-center gap-1.5 text-xs'>
{isOnline
? <span className='text-gray-500'>Online</span>
: <span className='text-orange-400 font-semibold'>Offline</span>
}
</div>
</div>
</div>
{/* Main content */}
<div className='flex-1 flex flex-col items-center justify-center gap-8 px-6'>
{/* Clock */}
<div className='text-center'>
<div className='text-8xl font-bold font-mono tracking-tight tabular-nums'>
{formatTime(displayTime)}
</div>
<div className='text-xl text-gray-400 mt-2'>
{formatDate(displayTime)}
</div>
</div>
{/* Credentials missing warning */}
{hasCredentials === false && (
<div className='max-w-md w-full bg-yellow-900/50 border border-yellow-700 rounded-2xl p-5 text-center'>
<div className='text-3xl mb-2'>⚠️</div>
<p className='text-yellow-300 font-semibold mb-1'>Kiosk nicht eingerichtet</p>
<p className='text-yellow-500 text-sm mb-4'>
Dieses Geraet hat noch kein Ed25519-Schluesselpaar.
Bitte zuerst den Setup-Assistenten durchlaufen.
</p>
<Link
to='/kiosk/setup'
className='inline-block px-5 py-2.5 bg-yellow-600 hover:bg-yellow-500 rounded-xl
font-semibold text-sm transition-colors'
>
Kiosk einrichten
</Link>
</div>
)}
{/* ── Login/Stempel-Flow ─────────────────────────────────────────── */}
{hasCredentials === true && step === 'idle' && (
<div className='max-w-md w-full bg-gray-800 rounded-2xl p-8 text-center space-y-3'>
<div className='text-5xl'>💳</div>
<p className='text-gray-200 font-semibold text-lg'>Bitte Karte auflegen</p>
<p className='text-gray-500 text-sm'>
Kein Kartenleser-Fokus nötig Eingabe wird automatisch erkannt.
</p>
{heartbeatStatus === 'error' && heartbeatError && (
<div className='bg-red-900/40 border border-red-700 rounded-lg px-3 py-2 text-sm text-red-300'>
Verbindungsfehler: {heartbeatError}
</div>
)}
</div>
)}
{hasCredentials === true && step === 'pin' && (
<div className='max-w-xs w-full bg-gray-800 rounded-2xl p-6 text-center space-y-4'>
<div className='text-4xl'>🔑</div>
<p className='text-gray-200 font-semibold'>PIN eingeben</p>
<div className='text-2xl font-mono tracking-[0.5em] h-8'>
{'•'.repeat(pin.length)}
</div>
{flowError && (
<div className='bg-red-900/40 border border-red-700 rounded-lg px-3 py-2 text-sm text-red-300'>
{flowError}
</div>
)}
<div className='grid grid-cols-3 gap-2'>
{['1','2','3','4','5','6','7','8','9','','0','⌫'].map((k, i) => (
k === '' ? <div key={i} /> : (
<button
key={i}
disabled={busy}
onClick={() => {
if (k === '⌫') { setPin(p => p.slice(0, -1)); return }
setPin(p => (p.length < 6 ? p + k : p))
}}
className='py-3 rounded-xl bg-gray-700 hover:bg-gray-600 text-lg font-semibold
disabled:opacity-50 transition-colors'
>
{k}
</button>
)
))}
</div>
<div className='flex gap-2 pt-2'>
<button
onClick={resetFlow}
className='flex-1 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-sm'
>
Abbrechen
</button>
<button
onClick={submitPin}
disabled={pin.length < 4 || busy}
className='flex-1 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-sm font-semibold
disabled:opacity-50'
>
{busy ? '…' : 'Bestätigen'}
</button>
</div>
</div>
)}
{hasCredentials === true && step === 'action' && stampStatus && (
<div className='max-w-sm w-full bg-gray-800 rounded-2xl p-6 text-center space-y-4'>
<p className='text-gray-400 text-sm'>Angemeldet als</p>
<p className='text-gray-100 font-semibold text-xl'>{userName}</p>
{flowError && (
<div className='bg-red-900/40 border border-red-700 rounded-lg px-3 py-2 text-sm text-red-300'>
{flowError}
</div>
)}
<div className='space-y-2 pt-2'>
{!stampStatus.stamped_in && (
<button
onClick={() => performAction('in')}
disabled={busy}
className='w-full py-4 rounded-xl bg-green-600 hover:bg-green-500 font-semibold text-lg disabled:opacity-50'
>
Einstempeln
</button>
)}
{stampStatus.stamped_in && !stampStatus.on_break && (
<>
<button
onClick={() => performAction('out')}
disabled={busy}
className='w-full py-4 rounded-xl bg-red-600 hover:bg-red-500 font-semibold text-lg disabled:opacity-50'
>
Ausstempeln
</button>
<button
onClick={() => performAction('break-start')}
disabled={busy}
className='w-full py-3 rounded-xl bg-gray-700 hover:bg-gray-600 font-medium disabled:opacity-50'
>
Pause starten
</button>
</>
)}
{stampStatus.stamped_in && stampStatus.on_break && (
<button
onClick={() => performAction('break-end')}
disabled={busy}
className='w-full py-4 rounded-xl bg-blue-600 hover:bg-blue-500 font-semibold text-lg disabled:opacity-50'
>
Pause beenden
</button>
)}
</div>
<button
onClick={resetFlow}
className='text-xs text-gray-500 hover:text-gray-300 underline pt-2'
>
Abbrechen
</button>
</div>
)}
{hasCredentials === true && step === 'done' && (
<div className='max-w-sm w-full bg-gray-800 rounded-2xl p-8 text-center space-y-3'>
<div className='text-5xl'></div>
<p className='text-green-400 font-semibold text-xl'>{doneMessage}</p>
{flowError && (
<p className='text-yellow-400 text-sm'>{flowError}</p>
)}
<p className='text-gray-600 text-xs'>Zurück zur Startseite in wenigen Sekunden</p>
</div>
)}
{/* Loading state */}
{hasCredentials === null && (
<div className='text-gray-600 text-sm animate-pulse'>Initialisiere</div>
)}
</div>
{/* Footer */}
<div className='px-6 py-4 text-center'>
<Link
to='/kiosk/setup'
className='text-xs text-gray-700 hover:text-gray-500 transition-colors underline'
>
Kiosk-Einrichtung
</Link>
</div>
</div>
)
}