Files
timemaster/frontend/src/pages/KioskSetupPage.tsx
T
patrickandClaude Sonnet 4.6 b547cdda33 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>
2026-05-24 12:23:03 +02:00

308 lines
12 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 { useEffect, useState, useRef } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
type SetupStep = 'idle' | 'registering-sw' | 'generating' | 'done' | 'error'
function isBrowserSupported(): boolean {
try {
// Ed25519 requires SubtleCrypto and the algorithm support
// We can only really verify at runtime, but check for the basics here
return (
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined' &&
typeof crypto.randomUUID === 'function' &&
'serviceWorker' in navigator
)
} catch {
return false
}
}
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 KioskSetupPage() {
const [searchParams] = useSearchParams()
const deviceIdParam = searchParams.get('device_id') ?? ''
const [deviceId, setDeviceId] = useState(deviceIdParam)
const [step, setStep] = useState<SetupStep>('idle')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [publicKeyPem, setPublicKeyPem] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [existingDeviceId, setExistingDeviceId] = useState<string | null>(null)
const swRegRef = useRef<ServiceWorkerRegistration | null>(null)
const supported = isBrowserSupported()
// On mount: register SW and check for existing credentials
useEffect(() => {
if (!supported) return
;(async () => {
try {
const reg = await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' })
// Wait until the SW is active
if (!reg.active) {
await new Promise<void>((resolve) => {
const sw = reg.installing || reg.waiting
if (!sw) { resolve(); return }
sw.addEventListener('statechange', function handler() {
if (sw.state === 'activated') {
sw.removeEventListener('statechange', handler)
resolve()
}
})
})
// Re-fetch after activation
const updated = await navigator.serviceWorker.ready
swRegRef.current = updated
} else {
swRegRef.current = reg
}
// Check if credentials already exist
const ready = await navigator.serviceWorker.ready
swRegRef.current = ready
const result = await sendMessageToSW(ready, { type: 'CHECK_CREDENTIALS' })
if (result.hasCredentials) {
setExistingDeviceId(result.deviceId as string)
}
} catch {
// SW registration failure is non-fatal for the setup UI
}
})()
}, [supported])
async function handleGenerate() {
setErrorMsg(null)
setCopied(false)
setPublicKeyPem(null)
if (!deviceId.trim()) {
setErrorMsg('Bitte eine Geraete-ID eingeben (UUID des Kiosk-Geraets).')
return
}
setStep('registering-sw')
try {
if (!swRegRef.current) {
const reg = await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' })
const ready = await navigator.serviceWorker.ready
swRegRef.current = reg.active ? reg : ready
}
const ready = await navigator.serviceWorker.ready
swRegRef.current = ready
setStep('generating')
const result = await sendMessageToSW(ready, {
type: 'GENERATE_KEYPAIR',
deviceId: deviceId.trim(),
})
if (!result.success) {
throw new Error((result.error as string) ?? 'Unbekannter Fehler im ServiceWorker')
}
setPublicKeyPem(result.publicKeyPem as string)
setExistingDeviceId(deviceId.trim())
setStep('done')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
// Ed25519 not supported
if (msg.includes('Ed25519') || msg.includes('NotSupportedError') || msg.includes('algorithm')) {
setErrorMsg(
'Dieser Browser unterstuetzt kein Ed25519. Bitte Chrome 113+, Firefox 130+ oder Safari 16.4+ verwenden.'
)
} else {
setErrorMsg(msg)
}
setStep('error')
}
}
async function handleReset() {
if (!swRegRef.current) return
try {
await sendMessageToSW(swRegRef.current, { type: 'CLEAR_CREDENTIALS' })
setExistingDeviceId(null)
setPublicKeyPem(null)
setStep('idle')
setDeviceId(deviceIdParam)
setErrorMsg(null)
setCopied(false)
} catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : 'Fehler beim Zuruecksetzen')
}
}
async function handleCopy() {
if (!publicKeyPem) return
try {
await navigator.clipboard.writeText(publicKeyPem)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// fallback: select textarea
}
}
const isGenerating = step === 'registering-sw' || step === 'generating'
if (!supported) {
return (
<div className='min-h-screen bg-gray-900 text-white flex items-center justify-center p-6'>
<div className='max-w-md w-full bg-gray-800 rounded-2xl p-8 text-center'>
<div className='text-4xl mb-4'>⚠️</div>
<h1 className='text-xl font-bold mb-2'>Browser nicht unterstuetzt</h1>
<p className='text-gray-400 text-sm'>
Dieser Browser unterstuetzt kein Ed25519 oder ServiceWorker.
Bitte Chrome 113+, Firefox 130+ oder Safari 16.4+ verwenden.
</p>
</div>
</div>
)
}
return (
<div className='min-h-screen bg-gray-900 text-white flex items-center justify-center p-6'>
<div className='max-w-lg w-full space-y-6'>
{/* Header */}
<div className='text-center'>
<div className='text-5xl mb-3'>🔐</div>
<h1 className='text-2xl font-bold'>Kiosk einrichten</h1>
<p className='text-gray-400 text-sm mt-1'>
Generiert ein Ed25519-Schluessel&shy;paar fuer dieses Geraet.
Der private Schluessel verlässt den Browser nie.
</p>
</div>
{/* Existing credentials warning */}
{existingDeviceId && step !== 'done' && (
<div className='bg-yellow-900/50 border border-yellow-700 rounded-xl px-4 py-3 text-sm text-yellow-300'>
<span className='font-semibold'>Achtung:</span> Dieses Geraet hat bereits gespeicherte Credentials
fuer Geraet <code className='bg-yellow-900 rounded px-1'>{existingDeviceId}</code>.
Ein Neugenerieren ueberschreibt den vorhandenen Schluessel.{' '}
<button onClick={handleReset} className='underline hover:text-yellow-100 ml-1'>
Zuruecksetzen
</button>
</div>
)}
{/* Main card */}
<div className='bg-gray-800 rounded-2xl p-6 space-y-4'>
<label className='block'>
<span className='text-sm font-medium text-gray-300 block mb-1'>Geraete-ID (UUID)</span>
<input
value={deviceId}
onChange={e => setDeviceId(e.target.value)}
placeholder='z.B. 550e8400-e29b-41d4-a716-446655440000'
disabled={isGenerating || step === 'done'}
className='w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-sm text-white
placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:opacity-50'
/>
<p className='text-xs text-gray-500 mt-1'>
Die UUID des Geraets aus der Admin-Oberflaeche (Einstellungen Kiosk-Geraete).
</p>
</label>
{errorMsg && (
<div className='bg-red-900/50 border border-red-700 rounded-lg px-3 py-2 text-sm text-red-300'>
{errorMsg}
</div>
)}
{step !== 'done' && (
<button
onClick={handleGenerate}
disabled={isGenerating || !deviceId.trim()}
className='w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
rounded-xl font-semibold text-sm transition-colors'
>
{step === 'registering-sw' && 'ServiceWorker wird registriert…'}
{step === 'generating' && 'Schluesselpaar wird generiert…'}
{(step === 'idle' || step === 'error') && 'Schluesselpaar generieren'}
</button>
)}
{/* Success state */}
{step === 'done' && publicKeyPem && (
<div className='space-y-4'>
<div className='flex items-center gap-2 text-green-400 text-sm font-medium'>
<span className='text-lg'></span>
Schluesselpaar generiert und gespeichert.
</div>
<div>
<div className='flex items-center justify-between mb-1'>
<span className='text-xs font-medium text-gray-400 uppercase tracking-wide'>
Public Key (PEM-Format)
</span>
<button
onClick={handleCopy}
className='text-xs text-blue-400 hover:text-blue-300 transition-colors'
>
{copied ? '✓ Kopiert!' : 'Kopieren'}
</button>
</div>
<pre className='bg-gray-900 rounded-lg p-3 text-xs text-green-300 font-mono
overflow-x-auto whitespace-pre-wrap break-all border border-gray-700'>
{publicKeyPem}
</pre>
</div>
<div className='bg-blue-900/40 border border-blue-700 rounded-lg px-3 py-3 text-xs text-blue-300 space-y-1'>
<p className='font-semibold text-blue-200'>Naechste Schritte:</p>
<ol className='list-decimal ml-4 space-y-1'>
<li>Public Key oben kopieren und an den Admin schicken (E-Mail reicht kein Geheimnis).</li>
<li>
Admin registriert das Geraet auf dem Server:{' '}
<code className='bg-blue-950 rounded px-1'>timemaster kiosk add --pubkey ...</code>
</li>
<li>Admin gibt das Geraet in der Web-Oberflaeche frei.</li>
<li>Kiosk-Modus starten.</li>
</ol>
</div>
<div className='flex gap-3'>
<Link
to='/kiosk'
className='flex-1 text-center py-2.5 bg-green-600 hover:bg-green-700 rounded-xl
font-semibold text-sm transition-colors'
>
Zum Kiosk
</Link>
<button
onClick={handleReset}
className='px-4 py-2.5 border border-gray-600 text-gray-300 hover:bg-gray-700
rounded-xl text-sm transition-colors'
>
Zuruecksetzen
</button>
</div>
</div>
)}
</div>
{/* Back link */}
{step !== 'done' && (
<p className='text-center text-xs text-gray-600'>
<Link to='/login' className='hover:text-gray-400 underline'>Zurueck zum Login</Link>
</p>
)}
</div>
</div>
)
}