Files
timemaster/frontend/src/pages/KioskDevicesPage.tsx
T
patrick 0f83d13c0c feat(kiosk): Stufe 2 – Ed25519-Auth, CLI-Tool, neue KioskDevicesPage
2A – Backend Ed25519-Verifizierung:
- app/core/kiosk_security.py (NEU): verify_kiosk_request() Dependency
  - Timestamp-Check (30s Drift), Nonce-Cache (Redis/In-Memory), IP-Whitelist
  - Ed25519-Signatur über METHOD+PATH+TIMESTAMP+NONCE+sha256(BODY)
  - PEM + OpenSSH Key-Format unterstützt
- app/routers/kiosk.py: approve/revoke Endpunkte, POST /heartbeat (Ed25519-signiert)
- app/services/kiosk_service.py: token-basierte Methoden entfernt, approve/revoke/heartbeat
- app/schemas/kiosk.py: KioskDeviceOut mit heartbeat_status, HeartbeatRequest/Response

2B – CLI-Tool:
- cli.py (NEU, 529 Zeilen): Typer-CLI mit kiosk add/list/approve/revoke/info
  - Public-Key-Fingerprint (SHA256), Rich-Tabellen, CIDR-Validierung
  - Direkter DB-Zugriff mit RLS-Bypass

2C – Frontend:
- KioskDevicesPage.tsx: Zwei-Tab-Layout (Wartet/Aktiv), Status-Ampel,
  Auto-Refresh 30s, Ed25519-Workflow (kein Token mehr)
- Layout.tsx: KioskHealthBadge (online/total, 30s Refresh, nur COMPANY_ADMIN)

requirements.txt: typer>=0.12.0, rich>=13.7.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:13:46 +02:00

478 lines
19 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 } 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 (
<span className='flex items-center text-xs text-gray-500 font-medium'>
<span className='w-2 h-2 rounded-full inline-block mr-1.5 bg-gray-400' />
Gesperrt
</span>
)
}
if (device.heartbeat_status === 'online') {
return (
<span className='flex items-center text-xs text-green-700 font-medium'>
<span className='w-2 h-2 rounded-full inline-block mr-1.5 bg-green-500' />
Online
</span>
)
}
if (device.heartbeat_status === 'stale') {
return (
<span className='flex items-center text-xs text-yellow-700 font-medium'>
<span className='w-2 h-2 rounded-full inline-block mr-1.5 bg-yellow-400' />
Veraltet
</span>
)
}
return (
<span className='flex items-center text-xs text-red-600 font-medium'>
<span className='w-2 h-2 rounded-full inline-block mr-1.5 bg-red-500' />
Offline
</span>
)
}
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<Me | null>(null)
const [devices, setDevices] = useState<KioskDevice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<string | null>(null)
// Edit modal
const [editDevice, setEditDevice] = useState<KioskDevice | null>(null)
const [editName, setEditName] = useState('')
const [editLocation, setEditLocation] = useState('')
const [editIpWhitelist, setEditIpWhitelist] = useState('')
const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState<string | null>(null)
async function load() {
try {
const [meData, devicesData] = await Promise.all([
api.get<Me>('/auth/me'),
api.get<KioskDevice[]>('/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<KioskDevice>('/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 <div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
if (error) return <div className='min-h-screen bg-gray-50 flex items-center justify-center'><p className='text-red-600'>{error}</p></div>
const pendingDevices = devices.filter(d => d.status === 'pending')
const activeDevices = devices.filter(d => d.status === 'approved' || d.status === 'revoked')
return (
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
<div className='space-y-6'>
{/* Header */}
<div className='flex items-center justify-between'>
<div>
<h1 className='text-xl font-bold text-gray-800'>Kiosk-Geräte</h1>
<p className='text-sm text-gray-500 mt-0.5'>{devices.length} {devices.length === 1 ? 'Gerät' : 'Geräte'} registriert</p>
</div>
<button
onClick={openCreate}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Gerät hinzufügen
</button>
</div>
{/* Info-Banner */}
<div className='bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800'>
<span className='mr-1'>🔐</span>
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.
</div>
{/* Tabs */}
<div className='border-b border-gray-200'>
<nav className='flex gap-6' aria-label='Tabs'>
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 text-sm font-medium transition-colors ${
activeTab === 'pending'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Wartet auf Freigabe
{pendingDevices.length > 0 && (
<span className='ml-2 inline-flex items-center justify-center w-5 h-5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold'>
{pendingDevices.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('active')}
className={`pb-3 text-sm font-medium transition-colors ${
activeTab === 'active'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Aktive Geräte
</button>
</nav>
</div>
{/* Tab: Wartet auf Freigabe */}
{activeTab === 'pending' && (
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Name', 'Standort', 'Public Key', 'Angelegt', ''].map(h => (
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{pendingDevices.map(d => (
<tr key={d.id} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-3 font-medium text-gray-800'>{d.name}</td>
<td className='px-4 py-3 text-gray-500'>{d.location ?? '—'}</td>
<td className='px-4 py-3 text-gray-400 font-mono text-xs'>{truncateKey(d.public_key)}</td>
<td className='px-4 py-3 text-gray-500'>{formatDate(d.created_at)}</td>
<td className='px-4 py-3'>
<div className='flex gap-3 justify-end'>
<button
onClick={() => handleApprove(d)}
className='text-xs text-green-700 font-medium hover:underline'
>
Freigeben
</button>
<button
onClick={() => handleDelete(d)}
className='text-xs text-red-500 hover:underline'
>
Ablehnen
</button>
</div>
</td>
</tr>
))}
{pendingDevices.length === 0 && (
<tr>
<td colSpan={5} className='px-4 py-12 text-center text-gray-400'>
Keine Geräte warten auf Freigabe.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab: Aktive Geräte */}
{activeTab === 'active' && (
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'>
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Name', 'Standort', 'Status', 'Letzter Heartbeat', 'Client-Version', 'Offline-Queue', ''].map(h => (
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{activeDevices.map(d => (
<tr key={d.id} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-3 font-medium text-gray-800'>{d.name}</td>
<td className='px-4 py-3 text-gray-500'>{d.location ?? '—'}</td>
<td className='px-4 py-3'>
<HeartbeatDot device={d} />
</td>
<td className='px-4 py-3 text-gray-500'>{formatDate(d.last_heartbeat_at)}</td>
<td className='px-4 py-3 text-gray-500'>{d.client_version ?? '—'}</td>
<td className='px-4 py-3 text-gray-500'>
{d.offline_queue_size > 0
? <span className='text-orange-600 font-medium'>{d.offline_queue_size}</span>
: '0'
}
</td>
<td className='px-4 py-3'>
<div className='flex gap-3 justify-end'>
<button onClick={() => openEdit(d)} className='text-xs text-blue-600 hover:underline'>Bearbeiten</button>
{d.status === 'approved' && (
<button onClick={() => handleRevoke(d)} className='text-xs text-yellow-600 hover:underline'>Sperren</button>
)}
<button onClick={() => handleDelete(d)} className='text-xs text-red-500 hover:underline'>Löschen</button>
</div>
</td>
</tr>
))}
{activeDevices.length === 0 && (
<tr>
<td colSpan={7} className='px-4 py-12 text-center text-gray-400'>
Noch keine aktiven Geräte vorhanden.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Gerät erstellen Modal */}
{showCreate && (
<Modal title='Neues Kiosk-Gerät' onClose={() => setShowCreate(false)}>
<div className='space-y-3'>
<div className='bg-blue-50 border border-blue-200 rounded-lg px-3 py-2 text-xs text-blue-800'>
Das Gerät erscheint nach dem Anlegen im Tab Wartet auf Freigabe". Ein Admin muss es freigeben.
</div>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Name *</span>
<input
value={createName}
onChange={e => setCreateName(e.target.value)}
className={inputClass}
placeholder='z.B. Eingang Büro'
autoFocus
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Standort</span>
<input
value={createLocation}
onChange={e => setCreateLocation(e.target.value)}
className={inputClass}
placeholder='z.B. Erdgeschoss, Halle A'
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Public Key *</span>
<textarea
value={createPublicKey}
onChange={e => setCreatePublicKey(e.target.value)}
rows={4}
className={inputClass + ' resize-none font-mono text-xs'}
placeholder='ssh-ed25519 AAAA...'
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>IP-Whitelist (optional)</span>
<input
value={createIpWhitelist}
onChange={e => setCreateIpWhitelist(e.target.value)}
className={inputClass}
placeholder='10.0.0.0/24,192.168.1.0/24'
/>
</label>
{createError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{createError}</p>}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setShowCreate(false)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Abbrechen</button>
<button onClick={handleCreate} disabled={createLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{createLoading ? 'Erstelle…' : 'Erstellen'}
</button>
</div>
</div>
</Modal>
)}
{/* Bearbeiten Modal */}
{editDevice && (
<Modal title={`Gerät bearbeiten ${editDevice.name}`} onClose={() => setEditDevice(null)}>
<div className='space-y-3'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Name *</span>
<input
value={editName}
onChange={e => setEditName(e.target.value)}
className={inputClass}
autoFocus
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Standort</span>
<input
value={editLocation}
onChange={e => setEditLocation(e.target.value)}
className={inputClass}
placeholder='z.B. Erdgeschoss, Halle A'
/>
</label>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>IP-Whitelist (optional)</span>
<input
value={editIpWhitelist}
onChange={e => setEditIpWhitelist(e.target.value)}
className={inputClass}
placeholder='10.0.0.0/24,192.168.1.0/24'
/>
</label>
{editError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{editError}</p>}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setEditDevice(null)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Abbrechen</button>
<button onClick={handleEdit} disabled={editLoading} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50'>
{editLoading ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</Modal>
)}
</Layout>
)
}