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>
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
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­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>
|
||
)
|
||
}
|