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:
@@ -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 && (
|
||||
|
||||
@@ -9,8 +9,14 @@ interface KioskDevice {
|
||||
company_id: string
|
||||
name: string
|
||||
location: string | null
|
||||
is_active: boolean
|
||||
last_seen_at: 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
|
||||
}
|
||||
|
||||
@@ -22,30 +28,68 @@ interface Me {
|
||||
|
||||
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('')
|
||||
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 [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('')
|
||||
|
||||
// Token display modal (after create / rotate)
|
||||
const [shownToken, setShownToken] = useState<{ deviceName: string; token: string } | null>(null)
|
||||
const [tokenCopied, setTokenCopied] = 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 [editActive, setEditActive] = useState(true)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
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 {
|
||||
@@ -62,57 +106,54 @@ export function KioskDevicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
const interval = setInterval(load, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
async function handleCreate() {
|
||||
setCreateError('')
|
||||
setCreateError(null)
|
||||
if (!createName.trim()) { setCreateError('Name ist erforderlich.'); return }
|
||||
if (!createPublicKey.trim()) { setCreateError('Public Key ist erforderlich.'); return }
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const result = await api.post<KioskDevice & { token: string }>('/kiosk/devices', {
|
||||
await api.post<KioskDevice>('/kiosk/devices', {
|
||||
name: createName.trim(),
|
||||
location: createLocation.trim() || null,
|
||||
public_key: createPublicKey.trim(),
|
||||
ip_whitelist: createIpWhitelist.trim() || null,
|
||||
})
|
||||
setShownToken({ deviceName: result.name, token: result.token })
|
||||
setShowCreate(false)
|
||||
setCreateName('')
|
||||
setCreateLocation('')
|
||||
setCreatePublicKey('')
|
||||
setCreateIpWhitelist('')
|
||||
setActiveTab('pending')
|
||||
load()
|
||||
} catch (e: unknown) {
|
||||
setCreateError(e instanceof Error ? e.message : 'Fehler')
|
||||
setCreateError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
if (!editDevice) return
|
||||
setEditError('')
|
||||
if (!editName.trim()) { setEditError('Name ist erforderlich.'); return }
|
||||
setEditLoading(true)
|
||||
async function handleApprove(device: KioskDevice) {
|
||||
try {
|
||||
await api.patch(`/kiosk/devices/${editDevice.id}`, {
|
||||
name: editName.trim(),
|
||||
location: editLocation.trim() || null,
|
||||
is_active: editActive,
|
||||
})
|
||||
setEditDevice(null)
|
||||
await api.post(`/kiosk/devices/${device.id}/approve`, {})
|
||||
load()
|
||||
} catch (e: unknown) {
|
||||
setEditError(e instanceof Error ? e.message : 'Fehler')
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
alert(e instanceof Error ? e.message : 'Fehler beim Freigeben')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotateToken(device: KioskDevice) {
|
||||
if (!confirm(`Token für „${device.name}" wirklich rotieren? Das alte Token wird sofort ungültig.`)) return
|
||||
async function handleRevoke(device: KioskDevice) {
|
||||
if (!confirm(`Gerät „${device.name}" wirklich sperren?`)) return
|
||||
try {
|
||||
const result = await api.post<KioskDevice & { token: string }>(`/kiosk/devices/${device.id}/rotate-token`, {})
|
||||
setShownToken({ deviceName: result.name, token: result.token })
|
||||
await api.post(`/kiosk/devices/${device.id}/revoke`, {})
|
||||
load()
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
alert(e instanceof Error ? e.message : 'Fehler beim Sperren')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +163,27 @@ export function KioskDevicesPage() {
|
||||
await api.del(`/kiosk/devices/${device.id}`)
|
||||
load()
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,15 +191,8 @@ export function KioskDevicesPage() {
|
||||
setEditDevice(device)
|
||||
setEditName(device.name)
|
||||
setEditLocation(device.location ?? '')
|
||||
setEditActive(device.is_active)
|
||||
setEditError('')
|
||||
}
|
||||
|
||||
function copyToken(token: string) {
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
setTokenCopied(true)
|
||||
setTimeout(() => setTokenCopied(false), 2000)
|
||||
})
|
||||
setEditIpWhitelist(device.ip_whitelist ?? '')
|
||||
setEditError(null)
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
@@ -146,9 +200,21 @@ export function KioskDevicesPage() {
|
||||
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'>
|
||||
@@ -160,70 +226,162 @@ export function KioskDevicesPage() {
|
||||
<p className='text-sm text-gray-500 mt-0.5'>{devices.length} {devices.length === 1 ? 'Gerät' : 'Geräte'} registriert</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowCreate(true); setCreateName(''); setCreateLocation(''); setCreateError('') }}
|
||||
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-Hinweis */}
|
||||
{/* Info-Banner */}
|
||||
<div className='bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800'>
|
||||
Kiosk-Geräte authentifizieren sich per <code className='bg-blue-100 px-1 rounded text-xs font-mono'>X-Kiosk-Token</code> Header.
|
||||
Der Token wird nur einmalig bei der Erstellung angezeigt – bitte sofort sichern.
|
||||
<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>
|
||||
|
||||
{/* Tabelle */}
|
||||
<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>
|
||||
{['Gerät', 'Standort', 'Status', 'Zuletzt gesehen', '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'>
|
||||
{devices.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'>
|
||||
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
d.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
{d.is_active ? 'Aktiv' : 'Deaktiviert'}
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-4 py-3 text-gray-500'>{formatDate(d.last_seen_at)}</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={() => openEdit(d)} className='text-xs text-blue-600 hover:underline'>Bearbeiten</button>
|
||||
<button onClick={() => handleRotateToken(d)} className='text-xs text-yellow-600 hover:underline'>Token rotieren</button>
|
||||
<button onClick={() => handleDelete(d)} className='text-xs text-red-500 hover:underline'>Löschen</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className='px-4 py-12 text-center text-gray-400'>
|
||||
Noch keine Kiosk-Geräte angelegt.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</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
|
||||
@@ -243,6 +401,25 @@ export function KioskDevicesPage() {
|
||||
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>
|
||||
@@ -254,38 +431,6 @@ export function KioskDevicesPage() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Token-Anzeige Modal */}
|
||||
{shownToken && (
|
||||
<Modal title='Gerät-Token – jetzt sichern!' onClose={() => { setShownToken(null); setTokenCopied(false) }}>
|
||||
<div className='space-y-4'>
|
||||
<p className='text-sm text-gray-600'>
|
||||
Token für <strong>{shownToken.deviceName}</strong>. Dieser Token wird <strong>nur einmalig</strong> angezeigt und kann danach nicht mehr abgerufen werden.
|
||||
</p>
|
||||
<div className='bg-gray-900 rounded-lg px-4 py-3 font-mono text-xs text-green-400 break-all select-all'>
|
||||
{shownToken.token}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToken(shownToken.token)}
|
||||
className={`w-full py-2 text-sm font-semibold rounded-lg transition-colors ${
|
||||
tokenCopied
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tokenCopied ? 'Kopiert!' : 'In Zwischenablage kopieren'}
|
||||
</button>
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
onClick={() => { setShownToken(null); setTokenCopied(false) }}
|
||||
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700'
|
||||
>
|
||||
Token gesichert – Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Bearbeiten Modal */}
|
||||
{editDevice && (
|
||||
<Modal title={`Gerät bearbeiten – ${editDevice.name}`} onClose={() => setEditDevice(null)}>
|
||||
@@ -308,14 +453,14 @@ export function KioskDevicesPage() {
|
||||
placeholder='z.B. Erdgeschoss, Halle A'
|
||||
/>
|
||||
</label>
|
||||
<label className='flex items-center gap-2 cursor-pointer'>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>IP-Whitelist (optional)</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={editActive}
|
||||
onChange={e => setEditActive(e.target.checked)}
|
||||
className='w-4 h-4 rounded border-gray-300 text-blue-600'
|
||||
value={editIpWhitelist}
|
||||
onChange={e => setEditIpWhitelist(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder='10.0.0.0/24,192.168.1.0/24'
|
||||
/>
|
||||
<span className='text-sm text-gray-700'>Gerät aktiv</span>
|
||||
</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'>
|
||||
@@ -330,4 +475,3 @@ export function KioskDevicesPage() {
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user