0f83d13c0c
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>
478 lines
19 KiB
TypeScript
478 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|