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
parent 981bde3dc1
commit 0f83d13c0c
10 changed files with 1438 additions and 226 deletions
+16
View File
@@ -451,3 +451,19 @@ Keine Commits in dieser Session.
- backend/tests/test_rls.py | 190 ++++++++++++++++++
---
## 2026-05-24 11:59 12:01 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 62ef6c2 feat: Live-Stempel-Uhr, Break-UI, Balance-Widget, Approval-Queue + PDF-Export (WeasyPrint)
### Geänderte Dateien
- DEVLOG.md | 128 +++++++
- backend/app/routers/absence.py | 159 +++++++++
- backend/app/routers/absence_service.py | 615 ++++++++++++++++++++++++++++++++
- backend/requirements.txt | 1 +
- backend/tests/test_reports.py | 44 +++
- frontend/src/pages/TimeTrackingPage.tsx | 521 +++++++++++++++++++--------
---
+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 && (
+281 -137
View File
@@ -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>
)
}