From 4932b8809f451494029df7ceaa8af7ecc2c52ffb Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 27 Aug 2026 13:37:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(kiosk):=20PC-Terminal-Frontend=20f=C3=BCr?= =?UTF-8?q?=20NFC-Login=20+=20Stempeln?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 d1d8069/K-3-Fix) - Nach Login: Stempel-Aktionen je nach aktuellem Status (Ein/Aus/Pause starten/beenden) über die neuen /kiosk/stamp/*-Endpunkte (2464c74) - 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 Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD --- frontend/src/pages/KioskStampPage.tsx | 279 ++++++++++++++++++++++++-- 1 file changed, 262 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/KioskStampPage.tsx b/frontend/src/pages/KioskStampPage.tsx index 52c5648..c8b1189 100644 --- a/frontend/src/pages/KioskStampPage.tsx +++ b/frontend/src/pages/KioskStampPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' interface HeartbeatResponse { @@ -12,6 +12,19 @@ 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' }) } @@ -39,6 +52,19 @@ async function sendMessageToSW( }) } +async function kioskFetch(path: string, body: object): Promise { + 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 @@ -49,11 +75,44 @@ export function KioskStampPage() { const [deviceId, setDeviceId] = useState(null) const [isLeaderTab, setIsLeaderTab] = useState(false) + // ── Login/Stamp-Flow ──────────────────────────────────────────────────── + const [step, setStep] = useState('idle') + const [nfcUid, setNfcUid] = useState(null) + const [pin, setPin] = useState('') + const [sessionToken, setSessionToken] = useState(null) + const [userName, setUserName] = useState(null) + const [stampStatus, setStampStatus] = useState(null) + const [flowError, setFlowError] = useState(null) + const [busy, setBusy] = useState(false) + const [doneMessage, setDoneMessage] = useState(null) + const heartbeatIntervalRef = useRef | null>(null) const clockIntervalRef = useRef | null>(null) const broadcastRef = useRef(null) const swRegRef = useRef(null) const startTimeRef = useRef(Date.now()) + const resetTimeoutRef = useRef | 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(() => { @@ -156,6 +215,39 @@ export function KioskStampPage() { // 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() @@ -215,6 +307,56 @@ export function KioskStampPage() { } } + 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( + '/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 = { + 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 (
@@ -278,11 +420,6 @@ export function KioskStampPage() {
{formatDate(displayTime)}
- {Math.abs(serverTimeOffset) > 1000 && ( -
- Server-Zeit-Offset: {serverTimeOffset > 0 ? '+' : ''}{Math.round(serverTimeOffset / 1000)}s -
- )}
{/* Credentials missing warning */} @@ -304,30 +441,138 @@ export function KioskStampPage() { )} - {/* Kiosk active state */} - {hasCredentials === true && ( -
-
-

Kiosk-Modus aktiv

-

- Alle Stempel-Anfragen werden automatisch per Ed25519 signiert. + {/* ── Login/Stempel-Flow ─────────────────────────────────────────── */} + {hasCredentials === true && step === 'idle' && ( +

+
💳
+

Bitte Karte auflegen

+

+ Kein Kartenleser-Fokus nötig – Eingabe wird automatisch erkannt.

- {heartbeatStatus === 'error' && heartbeatError && (
Verbindungsfehler: {heartbeatError}
)} +
+ )} + {hasCredentials === true && step === 'pin' && ( +
+
🔑
+

PIN eingeben

+
+ {'•'.repeat(pin.length)} +
+ {flowError && ( +
+ {flowError} +
+ )} +
+ {['1','2','3','4','5','6','7','8','9','','0','⌫'].map((k, i) => ( + k === '' ?
: ( + + ) + ))} +
+
+ + +
+
+ )} + + {hasCredentials === true && step === 'action' && stampStatus && ( +
+

Angemeldet als

+

{userName}

+ {flowError && ( +
+ {flowError} +
+ )} +
+ {!stampStatus.stamped_in && ( + + )} + {stampStatus.stamped_in && !stampStatus.on_break && ( + <> + + + + )} + {stampStatus.stamped_in && stampStatus.on_break && ( + + )} +
)} + {hasCredentials === true && step === 'done' && ( +
+
+

{doneMessage}

+ {flowError && ( +

{flowError}

+ )} +

Zurück zur Startseite in wenigen Sekunden…

+
+ )} + {/* Loading state */} {hasCredentials === null && (
Initialisiere…