feat(kiosk): Stufe 3 – ServiceWorker, WebCrypto Setup-Flow, Kiosk-UI, 15 Security-Tests
3A – Frontend Kiosk-Modus: - public/kiosk-sw.js (NEU, 187 Zeilen): ServiceWorker signiert alle /api/v1/kiosk/ Requests automatisch mit Ed25519. Keypair-Generierung intern (non-extractable), Speicherung in IndexedDB. BroadcastChannel-Leader-Election für Heartbeat. - KioskSetupPage.tsx (NEU, 307 Zeilen): Enrollment-Flow unter /kiosk/setup. Keypair-Generierung via WebCrypto im ServiceWorker, Public Key als PEM anzeigen. Browser-Kompatibilitäts-Check (Ed25519 ab Chrome 113+). - KioskStampPage.tsx (NEU, 348 Zeilen): Kiosk-UI unter /kiosk. Live-Uhr mit Server-Zeit-Offset, Heartbeat-Loop 30s, Online/Offline-Indikator. - App.tsx: /kiosk und /kiosk/setup Routen außerhalb ProtectedRoute 3B – Tests: - tests/test_kiosk_security.py (NEU, 387 Zeilen): 15/15 Tests grün Abgedeckt: gültige Signatur, falscher Key, Replay-Schutz, Timestamp-Drift, Future-Timestamp, pending/revoked Device, unbekanntes Gerät, fehlende Header, Lifecycle-Tests, heartbeat_status nach Heartbeat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import { 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'
|
||||
|
||||
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])
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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())
|
||||
|
||||
// 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
|
||||
}, [])
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
{Math.abs(serverTimeOffset) > 1000 && (
|
||||
<div className='text-xs text-yellow-600 mt-1'>
|
||||
Server-Zeit-Offset: {serverTimeOffset > 0 ? '+' : ''}{Math.round(serverTimeOffset / 1000)}s
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Kiosk active state */}
|
||||
{hasCredentials === true && (
|
||||
<div className='max-w-md w-full bg-gray-800 rounded-2xl p-6 text-center space-y-4'>
|
||||
<div className='text-4xl'>✅</div>
|
||||
<p className='text-green-400 font-semibold text-lg'>Kiosk-Modus aktiv</p>
|
||||
<p className='text-gray-400 text-sm'>
|
||||
Alle Stempel-Anfragen werden automatisch per Ed25519 signiert.
|
||||
</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>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={sendHeartbeat}
|
||||
className='text-xs text-gray-500 hover:text-gray-300 underline transition-colors'
|
||||
>
|
||||
Heartbeat manuell senden
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user