import { useEffect, useState } from 'react' import { api } from '../api/client' import { Spinner } from '../components/Spinner' import { Layout } from '../components/Layout' import { Modal } from '../components/Modal' interface KioskDevice { id: string company_id: string name: string location: string | null status: 'pending' | 'approved' | 'revoked' public_key: string | null key_algorithm: string last_heartbeat_at: string | null client_version: string | null offline_queue_size: number ip_whitelist: string | null heartbeat_status: 'online' | 'stale' | 'offline' created_at: string } interface Me { first_name: string last_name: string role: string } const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent' function HeartbeatDot({ device }: { device: KioskDevice }) { if (device.status === 'revoked') { return ( Gesperrt ) } if (device.heartbeat_status === 'online') { return ( Online ) } if (device.heartbeat_status === 'stale') { return ( Veraltet ) } return ( Offline ) } function truncateKey(key: string | null): string { if (!key) return '—' const trimmed = key.trim() return trimmed.length > 40 ? trimmed.slice(0, 40) + '...' : trimmed } export function KioskDevicesPage() { const [me, setMe] = useState(null) const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [activeTab, setActiveTab] = useState<'pending' | 'active'>('pending') // Create modal const [showCreate, setShowCreate] = useState(false) const [createName, setCreateName] = useState('') const [createLocation, setCreateLocation] = useState('') const [createPublicKey, setCreatePublicKey] = useState('') const [createIpWhitelist, setCreateIpWhitelist] = useState('') const [createLoading, setCreateLoading] = useState(false) const [createError, setCreateError] = useState(null) // Edit modal const [editDevice, setEditDevice] = useState(null) const [editName, setEditName] = useState('') const [editLocation, setEditLocation] = useState('') const [editIpWhitelist, setEditIpWhitelist] = useState('') const [editLoading, setEditLoading] = useState(false) const [editError, setEditError] = useState(null) async function load() { try { const [meData, devicesData] = await Promise.all([ api.get('/auth/me'), api.get('/kiosk/devices'), ]) setMe(meData) setDevices(devicesData) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') } finally { setLoading(false) } } useEffect(() => { load() const interval = setInterval(load, 30000) return () => clearInterval(interval) }, []) async function handleCreate() { setCreateError(null) if (!createName.trim()) { setCreateError('Name ist erforderlich.'); return } if (!createPublicKey.trim()) { setCreateError('Public Key ist erforderlich.'); return } setCreateLoading(true) try { await api.post('/kiosk/devices', { name: createName.trim(), location: createLocation.trim() || null, public_key: createPublicKey.trim(), ip_whitelist: createIpWhitelist.trim() || null, }) setShowCreate(false) setCreateName('') setCreateLocation('') setCreatePublicKey('') setCreateIpWhitelist('') setActiveTab('pending') load() } catch (e: unknown) { setCreateError(e instanceof Error ? e.message : 'Fehler beim Erstellen') } finally { setCreateLoading(false) } } async function handleApprove(device: KioskDevice) { try { await api.post(`/kiosk/devices/${device.id}/approve`, {}) load() } catch (e: unknown) { alert(e instanceof Error ? e.message : 'Fehler beim Freigeben') } } async function handleRevoke(device: KioskDevice) { if (!confirm(`Gerät „${device.name}" wirklich sperren?`)) return try { await api.post(`/kiosk/devices/${device.id}/revoke`, {}) load() } catch (e: unknown) { alert(e instanceof Error ? e.message : 'Fehler beim Sperren') } } async function handleDelete(device: KioskDevice) { if (!confirm(`Gerät „${device.name}" wirklich löschen?`)) return try { await api.del(`/kiosk/devices/${device.id}`) load() } catch (e: unknown) { alert(e instanceof Error ? e.message : 'Fehler beim Löschen') } } async function handleEdit() { if (!editDevice) return setEditError(null) if (!editName.trim()) { setEditError('Name ist erforderlich.'); return } setEditLoading(true) try { await api.patch(`/kiosk/devices/${editDevice.id}`, { name: editName.trim(), location: editLocation.trim() || null, ip_whitelist: editIpWhitelist.trim() || null, }) setEditDevice(null) load() } catch (e: unknown) { setEditError(e instanceof Error ? e.message : 'Fehler beim Speichern') } finally { setEditLoading(false) } } function openEdit(device: KioskDevice) { setEditDevice(device) setEditName(device.name) setEditLocation(device.location ?? '') setEditIpWhitelist(device.ip_whitelist ?? '') setEditError(null) } function formatDate(iso: string | null) { if (!iso) return '—' return new Date(iso).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) } function openCreate() { setCreateName('') setCreateLocation('') setCreatePublicKey('') setCreateIpWhitelist('') setCreateError(null) setShowCreate(true) } if (loading) return
if (error) return

{error}

const pendingDevices = devices.filter(d => d.status === 'pending') const activeDevices = devices.filter(d => d.status === 'approved' || d.status === 'revoked') return (
{/* Header */}

Kiosk-Geräte

{devices.length} {devices.length === 1 ? 'Gerät' : 'Geräte'} registriert

{/* Info-Banner */}
🔐 Kiosk-Geräte authentifizieren sich per Ed25519-Signatur. Jedes Gerät benötigt ein eigenes Ed25519-Schlüsselpaar. Der Public Key wird bei der Registrierung hinterlegt.
{/* Tabs */}
{/* Tab: Wartet auf Freigabe */} {activeTab === 'pending' && (
{['Name', 'Standort', 'Public Key', 'Angelegt', ''].map(h => ( ))} {pendingDevices.map(d => ( ))} {pendingDevices.length === 0 && ( )}
{h}
{d.name} {d.location ?? '—'} {truncateKey(d.public_key)} {formatDate(d.created_at)}
Keine Geräte warten auf Freigabe.
)} {/* Tab: Aktive Geräte */} {activeTab === 'active' && (
{['Name', 'Standort', 'Status', 'Letzter Heartbeat', 'Client-Version', 'Offline-Queue', ''].map(h => ( ))} {activeDevices.map(d => ( ))} {activeDevices.length === 0 && ( )}
{h}
{d.name} {d.location ?? '—'} {formatDate(d.last_heartbeat_at)} {d.client_version ?? '—'} {d.offline_queue_size > 0 ? {d.offline_queue_size} : '0' }
{d.status === 'approved' && ( )}
Noch keine aktiven Geräte vorhanden.
)}
{/* Gerät erstellen Modal */} {showCreate && ( setShowCreate(false)}>
Das Gerät erscheint nach dem Anlegen im Tab „Wartet auf Freigabe". Ein Admin muss es freigeben.