feat(frontend): Bearer-Kiosk-Gerät + QR-Code-Einrichtung im Portal
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Python Dependency Audit (push) Canceled after 0s

KioskDevicesPage: Modus-Umschalter im Create-Modal (Ed25519 PC-Terminal vs.
Bearer/VPN-Tablet). Bearer-Modus ruft neuen POST /kiosk/devices/bearer auf,
zeigt Token danach einmalig als QR-Code (JSON {url, token}, scanbar von der
Kiosk-App) + Klartext mit Kopier-Button. Kein Public-Key-Feld im Bearer-Modus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 13:23:56 +02:00
1 parent d80fc59f17
commit 62a19d17e8
1 file changed
+101 -17
+101 -17
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import QRCode from 'qrcode'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
@@ -76,6 +77,7 @@ export function KioskDevicesPage() {
// Create modal
const [showCreate, setShowCreate] = useState(false)
const [createMode, setCreateMode] = useState<'ed25519' | 'bearer'>('ed25519')
const [createName, setCreateName] = useState('')
const [createLocation, setCreateLocation] = useState('')
const [createPublicKey, setCreatePublicKey] = useState('')
@@ -83,6 +85,16 @@ export function KioskDevicesPage() {
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
// Bearer-Token-Ergebnis (nur einmal sichtbar, danach nicht mehr abrufbar)
const [bearerResult, setBearerResult] = useState<{ token: string; deviceName: string } | null>(null)
const [bearerQrDataUrl, setBearerQrDataUrl] = useState('')
useEffect(() => {
if (!bearerResult) { setBearerQrDataUrl(''); return }
const payload = JSON.stringify({ url: `${window.location.origin}/api/v1`, token: bearerResult.token })
QRCode.toDataURL(payload, { width: 260, margin: 2 }).then(setBearerQrDataUrl)
}, [bearerResult])
// Edit modal
const [editDevice, setEditDevice] = useState<KioskDevice | null>(null)
const [editName, setEditName] = useState('')
@@ -115,20 +127,30 @@ export function KioskDevicesPage() {
async function handleCreate() {
setCreateError(null)
if (!createName.trim()) { setCreateError('Name ist erforderlich.'); return }
if (!createPublicKey.trim()) { setCreateError('Public Key ist erforderlich.'); return }
if (createMode === 'ed25519' && !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,
})
if (createMode === 'bearer') {
const res = await api.post<{ device: KioskDevice; token: string }>('/kiosk/devices/bearer', {
name: createName.trim(),
location: createLocation.trim() || null,
ip_whitelist: createIpWhitelist.trim() || null,
})
setBearerResult({ token: res.token, deviceName: res.device.name })
} else {
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('')
setCreateMode('ed25519')
setActiveTab('pending')
load()
} catch (e: unknown) {
@@ -382,6 +404,27 @@ export function KioskDevicesPage() {
<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>
<div className='flex rounded-lg border border-gray-300 overflow-hidden text-sm'>
<button
onClick={() => setCreateMode('ed25519')}
className={`flex-1 px-3 py-2 font-medium ${createMode === 'ed25519' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
>
PC-Terminal (Ed25519)
</button>
<button
onClick={() => setCreateMode('bearer')}
className={`flex-1 px-3 py-2 font-medium ${createMode === 'bearer' ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
>
Android-Tablet (Bearer/VPN)
</button>
</div>
{createMode === 'bearer' && (
<div className='bg-indigo-50 border border-indigo-200 rounded-lg px-3 py-2 text-xs text-indigo-800'>
Kein Public Key nötig – nach dem Anlegen bekommst du ein Token
+ QR-Code, den die Kiosk-App beim Einrichten scannen kann.
Setzt WireGuard-VPN mit fester Geräte-IP voraus.
</div>
)}
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Name *</span>
<input
@@ -401,16 +444,18 @@ 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>
{createMode === 'ed25519' && (
<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
@@ -431,6 +476,45 @@ export function KioskDevicesPage() {
</Modal>
)}
{/* Bearer-Token-Ergebnis (nur einmal sichtbar) */}
{bearerResult && (
<Modal title='Kiosk-Gerät angelegt' onClose={() => setBearerResult(null)}>
<div className='space-y-4'>
<div className='bg-yellow-50 border border-yellow-200 rounded-lg px-3 py-2 text-xs text-yellow-800'>
Token wird nur hier angezeigt – danach nicht mehr abrufbar. Beim
erneuten Öffnen dieses Dialogs (z.B. Seiten-Reload) ist er weg.
</div>
<p className='text-sm text-gray-700'>
Gerät <strong>{bearerResult.deviceName}</strong> – QR-Code in der
Kiosk-App unter „Ersteinrichtung" scannen, oder Server-URL +
Token manuell eintragen.
</p>
{bearerQrDataUrl && (
<div className='flex justify-center'>
<img src={bearerQrDataUrl} alt='QR-Code für Kiosk-Einrichtung' className='rounded-lg border border-gray-200' />
</div>
)}
<div>
<span className='text-xs font-medium text-gray-700'>Token (manuell)</span>
<div className='flex gap-2 mt-1'>
<input readOnly value={bearerResult.token} className={inputClass + ' font-mono text-xs'} />
<button
onClick={() => navigator.clipboard.writeText(bearerResult.token)}
className='px-3 py-2 text-xs font-medium text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 whitespace-nowrap'
>
Kopieren
</button>
</div>
</div>
<div className='flex justify-end pt-2'>
<button onClick={() => setBearerResult(null)} className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700'>
Fertig
</button>
</div>
</div>
</Modal>
)}
{/* Bearbeiten Modal */}
{editDevice && (
<Modal title={`Gerät bearbeiten – ${editDevice.name}`} onClose={() => setEditDevice(null)}>