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>
This commit is contained in:
2026-05-24 12:13:46 +02:00
co-authored by Claude Sonnet 4.6
parent 981bde3dc1
commit 0f83d13c0c
10 changed files with 1438 additions and 226 deletions
+68 -3
View File
@@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useState, useRef, useEffect } from 'react'
import { api } from '../api/client'
interface NavItem {
path: string
@@ -8,6 +9,11 @@ interface NavItem {
roles?: string[]
}
interface KioskHealthDevice {
status: 'pending' | 'approved' | 'revoked'
heartbeat_status: 'online' | 'stale' | 'offline'
}
const MAIN_NAV: NavItem[] = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/time', label: 'Zeiterfassung' },
@@ -38,6 +44,62 @@ const ROLE_LABELS: Record<string, string> = {
EMPLOYEE: 'Mitarbeiter',
}
const KIOSK_HEALTH_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN']
function KioskHealthBadge({ userRole }: { userRole: string }) {
const navigate = useNavigate()
const [online, setOnline] = useState(0)
const [total, setTotal] = useState(0)
const [visible, setVisible] = useState(false)
async function fetchHealth() {
try {
const devices = await api.get<KioskHealthDevice[]>('/kiosk/devices')
const approved = devices.filter(d => d.status === 'approved')
const onlineCount = approved.filter(d => d.heartbeat_status === 'online').length
setTotal(approved.length)
setOnline(onlineCount)
setVisible(true)
} catch {
setVisible(false)
}
}
useEffect(() => {
if (!KIOSK_HEALTH_ROLES.includes(userRole)) return
fetchHealth()
const interval = setInterval(fetchHealth, 30000)
return () => clearInterval(interval)
}, [userRole])
if (!KIOSK_HEALTH_ROLES.includes(userRole) || !visible || total === 0) return null
let dotColor = 'bg-green-500'
let textColor = 'text-green-700'
let bgColor = 'bg-green-50 border-green-200'
if (online === 0) {
dotColor = 'bg-red-500'
textColor = 'text-red-700'
bgColor = 'bg-red-50 border-red-200'
} else if (online < total) {
dotColor = 'bg-yellow-400'
textColor = 'text-yellow-700'
bgColor = 'bg-yellow-50 border-yellow-200'
}
return (
<button
onClick={() => navigate('/settings/kiosk')}
title='Kiosk-Geräte-Status klicken zum Öffnen'
className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border font-medium transition-opacity hover:opacity-80 ${bgColor} ${textColor}`}
>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`} />
{online}/{total} Kiosks
</button>
)
}
export function Layout({ children, userRole, userName }: {
children: React.ReactNode
userRole: string
@@ -97,8 +159,11 @@ export function Layout({ children, userRole, userName }: {
))}
</nav>
{/* Rechte Seite: Einstellungen + User + Abmelden */}
<div className='flex items-center gap-1 flex-shrink-0'>
{/* Rechte Seite: Health-Badge + Einstellungen + User + Abmelden */}
<div className='flex items-center gap-1.5 flex-shrink-0'>
{/* Kiosk Health-Badge */}
<KioskHealthBadge userRole={userRole} />
{/* Zahnrad-Dropdown */}
{visibleSettings.length > 0 && (