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
+58
View File
@@ -846,3 +846,61 @@ Keine Commits in dieser Session.
- frontend/DEVLOG.md | 22 + - frontend/DEVLOG.md | 22 +
--- ---
## 2026-05-24 12:03 12:03 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### 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 +++++++++++++++++++--------
---
## 2026-05-24 12:04 12:08 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 981bde3 feat(kiosk): Migration 0021 Ed25519-Auth, Status-Enum, Heartbeat, IP-Whitelist
### Geänderte Dateien
- backend/app/models/company.py | 5 +
- backend/app/models/kiosk_device.py | 47 ++++++-
- backend/migrations/versions/0021_kiosk_security.py | 143 +++++++++++++++++++++
- .../migrations/versions/0022_sick_note_config.py | 2 +-
---
## 2026-05-24 12:09 12:11 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/models/company.py | 5 +
- backend/app/models/kiosk_device.py | 47 ++++++-
- backend/migrations/versions/0021_kiosk_security.py | 143 +++++++++++++++++++++
- .../migrations/versions/0022_sick_note_config.py | 2 +-
---
## 2026-05-24 12:13 12:13 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/models/company.py | 5 +
- backend/app/models/kiosk_device.py | 47 ++++++-
- backend/migrations/versions/0021_kiosk_security.py | 143 +++++++++++++++++++++
- .../migrations/versions/0022_sick_note_config.py | 2 +-
---
+233
View File
@@ -0,0 +1,233 @@
"""
Ed25519-basierte Request-Verifizierung für Kiosk-Geräte.
Jeder Kiosk-Request muss folgende HTTP-Header mitschicken:
X-Kiosk-Key-Id : UUID des Geräts (entspricht KioskDevice.id)
X-Kiosk-Timestamp : Unix-Zeit in Sekunden (max 30s Drift zum Server)
X-Kiosk-Nonce : Einmalige UUID (Replay-Schutz, 60s-Fenster via Redis)
X-Kiosk-Signature : Base64(Ed25519-Sig über "METHOD PATH TIMESTAMP NONCE sha256(BODY)")
Bei Fehler: 401 Unauthorized (oder 403 für IP-Whitelist-Verletzungen).
"""
import base64
import hashlib
import ipaddress
import logging
import time
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key, load_ssh_public_key
from fastapi import Depends, Header, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
logger = logging.getLogger(__name__)
# ── Nonce-Cache (Redis wenn verfügbar, sonst In-Memory-Fallback) ─────────────
_nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch)
_NONCE_TTL = 60 # Sekunden
def _cleanup_nonce_cache() -> None:
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen."""
now = time.time()
expired = [k for k, v in _nonce_cache.items() if v <= now]
for k in expired:
del _nonce_cache[k]
async def _check_and_set_nonce(nonce: str) -> bool:
"""
Prüft ob die Nonce schon gesehen wurde und speichert sie.
Gibt True zurück wenn die Nonce NEU ist (Request erlaubt).
Gibt False zurück wenn die Nonce bereits bekannt ist (Replay!).
"""
try:
import redis.asyncio as aioredis
r: Any = aioredis.from_url(settings.redis_url, decode_responses=True)
key = f"kiosk:nonce:{nonce}"
# SETNX: setzt nur wenn nicht vorhanden, gibt 1 zurück wenn gesetzt
result = await r.set(key, "1", ex=_NONCE_TTL, nx=True)
await r.aclose()
return result is not None # None = bereits vorhanden
except Exception as e:
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache: %s", e)
# Fallback: In-Memory
_cleanup_nonce_cache()
now = time.time()
if nonce in _nonce_cache:
return False
_nonce_cache[nonce] = now + _NONCE_TTL
return True
# ── Öffentlichen Schlüssel laden ─────────────────────────────────────────────
def _load_ed25519_public_key(public_key_str: str) -> Ed25519PublicKey:
"""
Lädt einen Ed25519-Public-Key aus PEM- oder OpenSSH-Format.
Wirft ValueError bei ungültigem Format.
"""
key_bytes = public_key_str.strip().encode()
# OpenSSH-Format (beginnt mit "ssh-ed25519 ...")
if key_bytes.startswith(b"ssh-ed25519"):
key = load_ssh_public_key(key_bytes)
if not isinstance(key, Ed25519PublicKey):
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
return key
# PEM-Format (beginnt mit "-----BEGIN ...")
if key_bytes.startswith(b"-----"):
key = load_pem_public_key(key_bytes)
if not isinstance(key, Ed25519PublicKey):
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
return key
raise ValueError("Unbekanntes Schlüsselformat. PEM oder OpenSSH erwartet.")
# ── IP-Whitelist prüfen ───────────────────────────────────────────────────────
def _check_ip_whitelist(client_ip: str, ip_whitelist: str) -> bool:
"""
Prüft ob client_ip in einer kommaseparierten CIDR-Liste enthalten ist.
Gibt True zurück wenn erlaubt, False wenn nicht.
"""
try:
client_addr = ipaddress.ip_address(client_ip)
except ValueError:
logger.warning("Ungültige Client-IP: %s", client_ip)
return False
for cidr in ip_whitelist.split(","):
cidr = cidr.strip()
if not cidr:
continue
try:
network = ipaddress.ip_network(cidr, strict=False)
if client_addr in network:
return True
except ValueError:
logger.warning("Ungültiger CIDR-Eintrag in ip_whitelist: %s", cidr)
continue
return False
# ── FastAPI Dependency ────────────────────────────────────────────────────────
async def verify_kiosk_request(
request: Request,
x_kiosk_key_id: str = Header(..., alias="X-Kiosk-Key-Id"),
x_kiosk_timestamp: str = Header(..., alias="X-Kiosk-Timestamp"),
x_kiosk_nonce: str = Header(..., alias="X-Kiosk-Nonce"),
x_kiosk_signature: str = Header(..., alias="X-Kiosk-Signature"),
db: AsyncSession = Depends(get_db),
) -> KioskDevice:
"""
FastAPI-Dependency: Verifiziert einen signierten Kiosk-Request.
Schritte:
1. Timestamp-Check (max 30s Drift)
2. Nonce-Check (Replay-Schutz)
3. Gerät laden + Status prüfen
4. IP-Whitelist (falls konfiguriert)
5. Ed25519-Signatur verifizieren
6. last_heartbeat_at aktualisieren
Gibt das verifizierte KioskDevice zurück.
"""
# 1. Timestamp-Check
try:
request_time = float(x_kiosk_timestamp)
except ValueError:
raise HTTPException(status_code=401, detail="Ungültiger Timestamp.")
drift = abs(time.time() - request_time)
if drift > 30:
raise HTTPException(
status_code=401,
detail=f"Timestamp liegt zu weit in der Vergangenheit oder Zukunft ({drift:.1f}s Abweichung, max 30s).",
)
# 2. Nonce-Check
# UUID-Format validieren (grobe Prüfung)
try:
UUID(x_kiosk_nonce)
except ValueError:
raise HTTPException(status_code=401, detail="Nonce muss eine gültige UUID sein.")
nonce_ok = await _check_and_set_nonce(x_kiosk_nonce)
if not nonce_ok:
raise HTTPException(status_code=401, detail="Replay-Angriff erkannt: Nonce bereits verwendet.")
# 3. Gerät laden
try:
device_uuid = UUID(x_kiosk_key_id)
except ValueError:
raise HTTPException(status_code=401, detail="X-Kiosk-Key-Id muss eine gültige UUID sein.")
device = await db.scalar(
select(KioskDevice).where(KioskDevice.id == device_uuid)
)
if device is None:
raise HTTPException(status_code=401, detail="Kiosk-Gerät nicht gefunden.")
if device.status != KioskDeviceStatus.APPROVED:
raise HTTPException(
status_code=401,
detail=f"Kiosk-Gerät ist nicht freigegeben (Status: {device.status.value}).",
)
if not device.public_key:
raise HTTPException(status_code=401, detail="Kein Public Key für dieses Gerät registriert.")
# 4. IP-Whitelist prüfen (optional)
if device.ip_whitelist:
client_ip = request.client.host if request.client else ""
if not client_ip:
raise HTTPException(status_code=403, detail="Client-IP nicht ermittelbar, IP-Whitelist aktiv.")
if not _check_ip_whitelist(client_ip, device.ip_whitelist):
raise HTTPException(
status_code=403,
detail=f"Client-IP {client_ip} ist nicht in der erlaubten IP-Whitelist.",
)
# 5. Signatur verifizieren
body = await request.body()
body_hash = hashlib.sha256(body).hexdigest()
message = f"{request.method} {request.url.path} {x_kiosk_timestamp} {x_kiosk_nonce} {body_hash}"
try:
signature_bytes = base64.b64decode(x_kiosk_signature)
except Exception:
raise HTTPException(status_code=401, detail="Signatur ist kein gültiges Base64.")
try:
pub_key = _load_ed25519_public_key(device.public_key)
except ValueError as e:
logger.error("Fehler beim Laden des Public Keys für Gerät %s: %s", device.id, e)
raise HTTPException(status_code=401, detail="Public Key des Geräts ist ungültig.")
try:
pub_key.verify(signature_bytes, message.encode())
except InvalidSignature:
raise HTTPException(status_code=401, detail="Ungültige Signatur.")
# 6. Heartbeat-Zeitstempel aktualisieren
device.last_heartbeat_at = datetime.now(timezone.utc)
await db.flush()
return device
+92 -35
View File
@@ -1,12 +1,23 @@
from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.core.dependencies import require_role from app.core.dependencies import require_role
from app.core.kiosk_security import verify_kiosk_request
from app.models.company import Company
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceCreated, KioskDeviceOut, KioskDeviceUpdate from app.schemas.kiosk import (
HeartbeatRequest,
HeartbeatResponse,
KioskApproveResponse,
KioskDeviceCreate,
KioskDeviceOut,
KioskDeviceUpdate,
)
from app.services.kiosk_service import kiosk_service from app.services.kiosk_service import kiosk_service
router = APIRouter(prefix="/kiosk", tags=["Kiosk"]) router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
@@ -18,26 +29,34 @@ _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
@router.get("/devices", response_model=list[KioskDeviceOut]) @router.get("/devices", response_model=list[KioskDeviceOut])
async def list_devices( async def list_devices(
status: str | None = Query(None, description="Filter: pending, approved oder revoked"),
current_user: User = require_role(*_admin_roles), current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Alle registrierten Kiosk-Geräte der Firma auflisten.""" """Alle registrierten Kiosk-Geräte der Firma auflisten (optional nach Status filtern)."""
return await kiosk_service.list_devices(current_user.company_id, db) status_filter: KioskDeviceStatus | None = None
if status is not None:
try:
status_filter = KioskDeviceStatus(status)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Ungültiger Status-Filter '{status}'. Erlaubt: pending, approved, revoked.",
)
devices = await kiosk_service.list_devices(current_user.company_id, db, status_filter)
return [KioskDeviceOut.model_validate(d) for d in devices]
@router.post("/devices", response_model=KioskDeviceCreated, status_code=201) @router.post("/devices", response_model=KioskDeviceOut, status_code=201)
async def create_device( async def create_device(
data: KioskDeviceCreate, data: KioskDeviceCreate,
current_user: User = require_role(*_admin_roles), current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben.""" """Neues Kiosk-Gerät mit Ed25519-Public-Key registrieren."""
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, db) device = await kiosk_service.create_device(current_user.company_id, data, db)
await db.commit() await db.commit()
return KioskDeviceCreated( return KioskDeviceOut.model_validate(device)
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@router.get("/devices/{device_id}", response_model=KioskDeviceOut) @router.get("/devices/{device_id}", response_model=KioskDeviceOut)
@@ -46,7 +65,9 @@ async def get_device(
current_user: User = require_role(*_admin_roles), current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return await kiosk_service.get_device(device_id, current_user.company_id, db) """Einzelnes Kiosk-Gerät abrufen."""
device = await kiosk_service.get_device(device_id, current_user.company_id, db)
return KioskDeviceOut.model_validate(device)
@router.patch("/devices/{device_id}", response_model=KioskDeviceOut) @router.patch("/devices/{device_id}", response_model=KioskDeviceOut)
@@ -56,44 +77,80 @@ async def update_device(
current_user: User = require_role(*_admin_roles), current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Name, Standort oder IP-Whitelist eines Kiosk-Geräts aktualisieren."""
device = await kiosk_service.update_device(device_id, current_user.company_id, data, db) device = await kiosk_service.update_device(device_id, current_user.company_id, data, db)
await db.commit() await db.commit()
return KioskDeviceOut.model_validate(device) return KioskDeviceOut.model_validate(device)
@router.post("/devices/{device_id}/rotate-token", response_model=KioskDeviceCreated)
async def rotate_token(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Token rotieren das alte Token wird sofort ungültig."""
device, raw_token = await kiosk_service.rotate_token(device_id, current_user.company_id, db)
await db.commit()
return KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@router.delete("/devices/{device_id}", status_code=204) @router.delete("/devices/{device_id}", status_code=204)
async def delete_device( async def delete_device(
device_id: UUID, device_id: UUID,
current_user: User = require_role(*_admin_roles), current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Kiosk-Gerät dauerhaft löschen."""
await kiosk_service.delete_device(device_id, current_user.company_id, db) await kiosk_service.delete_device(device_id, current_user.company_id, db)
await db.commit() await db.commit()
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ───────────────────────── @router.post("/devices/{device_id}/approve", response_model=KioskApproveResponse)
async def approve_device(
@router.get("/me", response_model=KioskDeviceOut) device_id: UUID,
async def kiosk_me( current_user: User = require_role(*_admin_roles),
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Kiosk-Gerät prüft seine eigene Identität / aktualisiert last_seen_at.""" """Kiosk-Gerät freigeben (Status: pending → approved)."""
device = await kiosk_service.authenticate_device(x_kiosk_token, db) device = await kiosk_service.approve_device(device_id, current_user.company_id, db)
await db.commit() await db.commit()
return KioskDeviceOut.model_validate(device) return KioskApproveResponse(
id=device.id,
status=device.status.value,
message=f"Gerät '{device.name}' wurde freigegeben.",
)
@router.post("/devices/{device_id}/revoke", response_model=KioskApproveResponse)
async def revoke_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Kiosk-Gerät sperren (Status → revoked). Erfordert Re-Enrollment."""
device = await kiosk_service.revoke_device(device_id, current_user.company_id, db)
await db.commit()
return KioskApproveResponse(
id=device.id,
status=device.status.value,
message=f"Gerät '{device.name}' wurde gesperrt. Re-Enrollment erforderlich.",
)
# ── Kiosk-Endpunkte (signierte Requests via Ed25519) ──────────────────────────
@router.post("/heartbeat", response_model=HeartbeatResponse)
async def heartbeat(
data: HeartbeatRequest,
device: KioskDevice = Depends(verify_kiosk_request),
db: AsyncSession = Depends(get_db),
):
"""
Heartbeat-Endpunkt für Kiosk-Geräte.
Muss mit Ed25519-Signatur-Headern aufgerufen werden:
X-Kiosk-Key-Id, X-Kiosk-Timestamp, X-Kiosk-Nonce, X-Kiosk-Signature
Gibt Server-Zeit und Konfiguration zurück.
"""
company = await db.get(Company, device.company_id)
if company is None:
raise HTTPException(status_code=500, detail="Firma nicht gefunden.")
await kiosk_service.process_heartbeat(device, data, company, db)
await db.commit()
return HeartbeatResponse(
server_time=datetime.now(timezone.utc).isoformat(),
heartbeat_interval_sec=company.kiosk_heartbeat_interval_sec,
device_status=device.status.value,
)
+63 -7
View File
@@ -1,12 +1,32 @@
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
def _calc_heartbeat_status(last_heartbeat_at: datetime | None) -> str:
"""Berechnet den Liveness-Status aus dem letzten Heartbeat-Zeitstempel."""
if last_heartbeat_at is None:
return "offline"
now = datetime.now(timezone.utc)
# last_heartbeat_at könnte timezone-naive sein (ältere DB-Einträge)
if last_heartbeat_at.tzinfo is None:
last_heartbeat_at = last_heartbeat_at.replace(tzinfo=timezone.utc)
delta = (now - last_heartbeat_at).total_seconds()
if delta <= 90:
return "online"
if delta <= 300: # 5 Minuten
return "stale"
return "offline"
# ── Admin-Schemas ─────────────────────────────────────────────────────────────
class KioskDeviceCreate(BaseModel): class KioskDeviceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
location: str | None = Field(None, max_length=255) location: str | None = Field(None, max_length=255)
public_key: str # Ed25519 PEM oder OpenSSH
ip_whitelist: str | None = None # CIDR-Liste kommasepariert, z.B. "10.0.0.0/24,192.168.1.0/24"
@field_validator("name") @field_validator("name")
@classmethod @classmethod
@@ -15,10 +35,18 @@ class KioskDeviceCreate(BaseModel):
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.") raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip() return v.strip()
@field_validator("public_key")
@classmethod
def public_key_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Public Key darf nicht leer sein.")
return v.strip()
class KioskDeviceUpdate(BaseModel): class KioskDeviceUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255) name: str | None = Field(None, min_length=1, max_length=255)
location: str | None = Field(None, max_length=255) location: str | None = Field(None, max_length=255)
ip_whitelist: str | None = None
@field_validator("name") @field_validator("name")
@classmethod @classmethod
@@ -28,7 +56,6 @@ class KioskDeviceUpdate(BaseModel):
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.") raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip() return v.strip()
return v return v
is_active: bool | None = None
class KioskDeviceOut(BaseModel): class KioskDeviceOut(BaseModel):
@@ -38,11 +65,40 @@ class KioskDeviceOut(BaseModel):
company_id: uuid.UUID company_id: uuid.UUID
name: str name: str
location: str | None location: str | None
is_active: bool status: str # pending / approved / revoked
last_seen_at: datetime | None public_key: str | None
key_algorithm: str
last_heartbeat_at: datetime | None
client_version: str | None
offline_queue_size: int
ip_whitelist: str | None
created_at: datetime created_at: datetime
heartbeat_status: str = "offline" # wird in model_validator gesetzt
@classmethod
def model_validate(cls, obj, *args, **kwargs): # type: ignore[override]
instance = super().model_validate(obj, *args, **kwargs)
instance.heartbeat_status = _calc_heartbeat_status(instance.last_heartbeat_at)
return instance
class KioskDeviceCreated(KioskDeviceOut): class KioskApproveResponse(BaseModel):
"""Wird nur einmalig bei Erstellung zurückgegeben enthält den Klartext-Token.""" id: uuid.UUID
token: str status: str
message: str
# ── Heartbeat-Schemas ─────────────────────────────────────────────────────────
class HeartbeatRequest(BaseModel):
uptime_seconds: int | None = None
current_user_id: uuid.UUID | None = None
browser_version: str | None = None
queued_offline_entries: int = 0
client_version: str | None = None
class HeartbeatResponse(BaseModel):
server_time: str # ISO-8601-Zeitstempel des Servers
heartbeat_interval_sec: int
device_status: str # pending / approved / revoked
+96 -44
View File
@@ -1,4 +1,3 @@
import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
@@ -6,37 +5,34 @@ from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_token from app.models.company import Company
from app.models.kiosk_device import KioskDevice from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate from app.schemas.kiosk import HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate
class KioskService: class KioskService:
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]: # ── Lesende Operationen ───────────────────────────────────────────────────
result = await db.scalars(
async def list_devices(
self,
company_id: UUID,
db: AsyncSession,
status_filter: KioskDeviceStatus | None = None,
) -> list[KioskDevice]:
query = (
select(KioskDevice) select(KioskDevice)
.where(KioskDevice.company_id == company_id) .where(KioskDevice.company_id == company_id)
.order_by(KioskDevice.created_at.desc()) .order_by(KioskDevice.created_at.desc())
) )
if status_filter is not None:
query = query.where(KioskDevice.status == status_filter)
result = await db.scalars(query)
return list(result.all()) return list(result.all())
async def create_device( async def get_device(
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> tuple[KioskDevice, str]: ) -> KioskDevice:
"""Gerät anlegen. Gibt (device, raw_token) zurück raw_token nur einmalig."""
raw_token = secrets.token_urlsafe(48)
device = KioskDevice(
company_id=company_id,
name=data.name,
location=data.location,
token_hash=hash_token(raw_token),
)
db.add(device)
await db.flush()
return device, raw_token
async def get_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> KioskDevice:
device = await db.scalar( device = await db.scalar(
select(KioskDevice).where( select(KioskDevice).where(
KioskDevice.id == device_id, KioskDevice.id == device_id,
@@ -47,8 +43,40 @@ class KioskService:
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.") raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
return device return device
# ── Schreibende Operationen ───────────────────────────────────────────────
async def create_device(
self,
company_id: UUID,
data: KioskDeviceCreate,
db: AsyncSession,
) -> KioskDevice:
"""
Gerät anlegen.
Status = pending wenn kiosk_require_approval, sonst approved.
"""
company = await db.get(Company, company_id)
require_approval = company.kiosk_require_approval if company else True
device = KioskDevice(
company_id=company_id,
name=data.name,
location=data.location,
public_key=data.public_key,
ip_whitelist=data.ip_whitelist,
key_algorithm="ed25519",
status=KioskDeviceStatus.PENDING if require_approval else KioskDeviceStatus.APPROVED,
)
db.add(device)
await db.flush()
return device
async def update_device( async def update_device(
self, device_id: UUID, company_id: UUID, data: KioskDeviceUpdate, db: AsyncSession self,
device_id: UUID,
company_id: UUID,
data: KioskDeviceUpdate,
db: AsyncSession,
) -> KioskDevice: ) -> KioskDevice:
device = await self.get_device(device_id, company_id, db) device = await self.get_device(device_id, company_id, db)
changes = data.model_dump(exclude_none=True) changes = data.model_dump(exclude_none=True)
@@ -56,32 +84,56 @@ class KioskService:
setattr(device, field, value) setattr(device, field, value)
return device return device
async def rotate_token( async def delete_device(
self, device_id: UUID, company_id: UUID, db: AsyncSession self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> tuple[KioskDevice, str]: ) -> None:
"""Token rotieren altes Token wird sofort ungültig."""
device = await self.get_device(device_id, company_id, db)
raw_token = secrets.token_urlsafe(48)
device.token_hash = hash_token(raw_token)
return device, raw_token
async def delete_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> None:
device = await self.get_device(device_id, company_id, db) device = await self.get_device(device_id, company_id, db)
await db.delete(device) await db.delete(device)
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice: # ── Status-Verwaltung ─────────────────────────────────────────────────────
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
token_hash = hash_token(raw_token) async def approve_device(
device = await db.scalar( self, device_id: UUID, company_id: UUID, db: AsyncSession
select(KioskDevice).where( ) -> KioskDevice:
KioskDevice.token_hash == token_hash, """Gerät freigeben: Status → approved."""
KioskDevice.is_active.is_(True), device = await self.get_device(device_id, company_id, db)
) device.status = KioskDeviceStatus.APPROVED
)
if device is None:
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
device.last_seen_at = datetime.now(timezone.utc)
return device return device
async def revoke_device(
self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> KioskDevice:
"""Gerät sperren: Status → revoked."""
device = await self.get_device(device_id, company_id, db)
device.status = KioskDeviceStatus.REVOKED
return device
# ── Heartbeat ─────────────────────────────────────────────────────────────
async def process_heartbeat(
self,
device: KioskDevice,
data: HeartbeatRequest,
company: Company,
db: AsyncSession,
) -> None:
"""
Heartbeat-Daten vom Kiosk-Gerät verarbeiten.
last_heartbeat_at wird bereits in verify_kiosk_request gesetzt
hier werden nur die zusätzlichen Felder aktualisiert.
"""
if data.client_version is not None:
device.client_version = data.client_version
device.offline_queue_size = data.queued_offline_entries
if data.current_user_id is not None and company.kiosk_track_current_user:
device.current_user_id = data.current_user_id
elif not company.kiosk_track_current_user:
# DSGVO-Opt-Out: aktuellen User nicht speichern
device.current_user_id = None
await db.flush()
kiosk_service = KioskService() kiosk_service = KioskService()
+529
View File
@@ -0,0 +1,529 @@
"""
TimeMaster CLI Kiosk-Geräteverwaltung
Verwendung (auf dem Server):
cd /opt/timemaster/backend
source venv/bin/activate
python cli.py kiosk list
python cli.py kiosk add --company "Acme GmbH" --name "Eingang" --pubkey ~/.ssh/kiosk.pub
"""
import asyncio
import base64
import hashlib
import ipaddress
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich import box
# ── Projekt-Imports ───────────────────────────────────────────────────────────
# Sicherstellen, dass das backend/-Verzeichnis im Python-Pfad ist,
# damit app.* importiert werden kann.
_HERE = Path(__file__).parent.resolve()
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
# .env im selben Verzeichnis laden, bevor pydantic-settings greift
_env_file = _HERE / ".env"
if _env_file.exists():
# Minimales manuelles Laden, damit DATABASE_URL vor dem Settings-Import steht
import os
for line in _env_file.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip('"').strip("'")
if key not in os.environ:
os.environ[key] = val
from sqlalchemy import select, text, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
from app.models.company import Company
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
# ── Typer Apps ────────────────────────────────────────────────────────────────
app = typer.Typer(
name="timemaster",
help="TimeMaster CLI Verwaltungstool für den Server",
no_args_is_help=True,
add_completion=False,
)
kiosk_app = typer.Typer(
help="Kiosk-Geräteverwaltung",
no_args_is_help=True,
)
app.add_typer(kiosk_app, name="kiosk")
console = Console()
err_console = Console(stderr=True)
# ── DB-Hilfsfunktionen ────────────────────────────────────────────────────────
def _make_engine():
"""Erstellt einen AsyncEngine auf Basis der aktuellen DATABASE_URL."""
db_url = settings.database_url
return create_async_engine(db_url, pool_pre_ping=True, pool_size=2, max_overflow=2)
async def _open_session() -> AsyncSession:
"""Gibt eine neue AsyncSession zurück mit deaktiviertem RLS."""
engine = _make_engine()
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
session = Session()
await session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
return session
def _run(coro):
"""Blockierendes asyncio.run() mit sauberem Fehler-Handling."""
try:
return asyncio.run(coro)
except Exception as exc:
err_console.print(f"[bold red]Fehler:[/bold red] {exc}")
raise typer.Exit(1)
# ── Public-Key-Fingerprint (SHA-256, wie ssh-keygen -l -E sha256) ─────────────
def _pubkey_fingerprint(pubkey_str: str) -> str:
"""
Berechnet SHA-256-Fingerprint eines OpenSSH-Public-Keys.
Format: SHA256:<base64> (ohne trailing =, wie ssh-keygen -l -E sha256)
Funktioniert für 'ssh-ed25519 AAAA...' und PEM-Blöcke.
"""
pubkey_str = pubkey_str.strip()
try:
if pubkey_str.startswith("-----BEGIN"):
# PEM → binär
lines = [l for l in pubkey_str.splitlines() if not l.startswith("-----")]
raw = base64.b64decode("".join(lines))
else:
# OpenSSH-Einzeiler: zweites Feld ist der Base64-Teil
parts = pubkey_str.split()
if len(parts) < 2:
raise ValueError("Ungültiges Public-Key-Format")
raw = base64.b64decode(parts[1])
digest = hashlib.sha256(raw).digest()
b64 = base64.b64encode(digest).decode().rstrip("=")
return f"SHA256:{b64}"
except Exception:
return "(Fingerprint nicht berechenbar)"
# ── Heartbeat-Status-Hilfsfunktion ────────────────────────────────────────────
def _heartbeat_label(last_hb: Optional[datetime]) -> str:
if last_hb is None:
return "[dim]nie[/dim]"
now = datetime.now(timezone.utc)
if last_hb.tzinfo is None:
last_hb = last_hb.replace(tzinfo=timezone.utc)
delta = (now - last_hb).total_seconds()
ts = last_hb.strftime("%d.%m.%Y %H:%M:%S")
if delta < 90:
return f"[green]online[/green] ({ts})"
elif delta < 300:
return f"[yellow]stale[/yellow] ({ts})"
else:
return f"[red]offline[/red] ({ts})"
def _status_icon(status: KioskDeviceStatus) -> str:
return {
KioskDeviceStatus.APPROVED: "[green]approved[/green]",
KioskDeviceStatus.PENDING: "[yellow]pending[/yellow]",
KioskDeviceStatus.REVOKED: "[red]revoked[/red]",
}.get(status, status.value)
# ── CIDR-Validierung ──────────────────────────────────────────────────────────
def _validate_ip_whitelist(ip_whitelist: Optional[str]) -> None:
"""Wirft typer.BadParameter bei ungültiger CIDR-Notation."""
if not ip_whitelist:
return
for cidr in ip_whitelist.split(","):
cidr = cidr.strip()
if not cidr:
continue
try:
ipaddress.ip_network(cidr, strict=False)
except ValueError:
raise typer.BadParameter(
f"Ungültige CIDR-Notation: '{cidr}'. "
"Beispiel: 10.0.0.0/24,192.168.1.0/24"
)
# ── Firma suchen ──────────────────────────────────────────────────────────────
async def _find_company(session: AsyncSession, company_name: str) -> Company:
"""
Sucht Firma case-insensitiv. Fehler wenn 0 oder >1 Treffer.
"""
result = await session.execute(
select(Company).where(Company.name.ilike(f"%{company_name}%"))
)
companies = result.scalars().all()
if not companies:
raise typer.BadParameter(
f"Keine Firma mit dem Namen '{company_name}' gefunden."
)
if len(companies) > 1:
names = ", ".join(c.name for c in companies)
raise typer.BadParameter(
f"Mehrere Firmen gefunden: {names}\n"
"Bitte den Namen genauer angeben."
)
return companies[0]
# ── Gerät suchen ──────────────────────────────────────────────────────────────
async def _find_device(session: AsyncSession, device_id: str) -> KioskDevice:
try:
dev_uuid = uuid.UUID(device_id)
except ValueError:
raise typer.BadParameter(f"Ungültige UUID: '{device_id}'")
result = await session.execute(
select(KioskDevice).where(KioskDevice.id == dev_uuid)
)
device = result.scalar_one_or_none()
if device is None:
raise typer.BadParameter(f"Kein Gerät mit ID '{device_id}' gefunden.")
return device
# ── Subcommand: kiosk add ─────────────────────────────────────────────────────
@kiosk_app.command("add")
def kiosk_add(
company: str = typer.Option(..., "--company", help="Firmenname (Teilübereinstimmung möglich)"),
name: str = typer.Option(..., "--name", help="Name des Kiosk-Geräts, z.B. 'Eingang Berlin'"),
location: Optional[str] = typer.Option(None, "--location", help="Standort-Beschreibung"),
pubkey: Optional[Path] = typer.Option(None, "--pubkey", help="Pfad zur Public-Key-Datei (OpenSSH oder PEM)"),
ip_whitelist: Optional[str] = typer.Option(None, "--ip-whitelist", help="CIDR-Liste, z.B. '10.0.0.0/24,192.168.1.0/24'"),
):
"""Neues Kiosk-Gerät registrieren (Status: pending)."""
# Validierungen vor DB-Zugriff
_validate_ip_whitelist(ip_whitelist)
pubkey_str: Optional[str] = None
if pubkey is not None:
if not pubkey.exists():
err_console.print(f"[bold red]Fehler:[/bold red] Public-Key-Datei nicht gefunden: {pubkey}")
raise typer.Exit(1)
pubkey_str = pubkey.read_text().strip()
if not pubkey_str:
err_console.print("[bold red]Fehler:[/bold red] Public-Key-Datei ist leer.")
raise typer.Exit(1)
async def _add():
session = await _open_session()
try:
firm = await _find_company(session, company)
device = KioskDevice(
company_id=firm.id,
name=name,
location=location,
status=KioskDeviceStatus.PENDING,
public_key=pubkey_str,
key_algorithm="ed25519",
ip_whitelist=ip_whitelist,
)
session.add(device)
await session.commit()
await session.refresh(device)
return device, firm
except Exception:
await session.rollback()
raise
finally:
await session.close()
device, firm = _run(_add())
console.print()
console.print(Panel(
f"[bold green]✓ Gerät erfolgreich angelegt[/bold green]\n\n"
f" [bold]Name:[/bold] {device.name}\n"
f" [bold]ID:[/bold] {device.id}\n"
f" [bold]Firma:[/bold] {firm.name}\n"
f" [bold]Standort:[/bold] {device.location or ''}\n"
f" [bold]Status:[/bold] {_status_icon(device.status)}\n"
+ (f" [bold]Fingerprint:[/bold] {_pubkey_fingerprint(device.public_key)}\n" if device.public_key else " [bold]Public Key:[/bold] [dim]nicht gesetzt[/dim]\n")
+ (f" [bold]IP-Whitelist:[/bold] {device.ip_whitelist}\n" if device.ip_whitelist else ""),
title="Kiosk-Gerät angelegt",
border_style="green",
))
console.print()
console.print("[yellow]Hinweis:[/yellow] Das Gerät wartet auf Admin-Freigabe im Web-Interface.")
if not pubkey_str:
console.print("[yellow]Hinweis:[/yellow] Kein Public Key gesetzt. Der Enrollment-Flow muss im Browser abgeschlossen werden.")
console.print()
# ── Subcommand: kiosk list ────────────────────────────────────────────────────
@kiosk_app.command("list")
def kiosk_list(
company: Optional[str] = typer.Option(None, "--company", help="Nur Geräte dieser Firma anzeigen"),
status: Optional[str] = typer.Option(None, "--status", help="Filter: pending|approved|revoked"),
):
"""Alle registrierten Kiosk-Geräte auflisten."""
# Status-Enum validieren
status_filter: Optional[KioskDeviceStatus] = None
if status is not None:
try:
status_filter = KioskDeviceStatus(status.lower())
except ValueError:
err_console.print(
f"[bold red]Fehler:[/bold red] Ungültiger Status '{status}'. "
"Erlaubt: pending, approved, revoked"
)
raise typer.Exit(1)
async def _list():
session = await _open_session()
try:
q = select(KioskDevice, Company).join(Company, KioskDevice.company_id == Company.id)
if status_filter is not None:
q = q.where(KioskDevice.status == status_filter)
if company:
q = q.where(Company.name.ilike(f"%{company}%"))
q = q.order_by(Company.name, KioskDevice.name)
result = await session.execute(q)
return result.all()
finally:
await session.close()
rows = _run(_list())
if not rows:
console.print("[dim]Keine Geräte gefunden.[/dim]")
return
table = Table(
show_header=True,
header_style="bold cyan",
box=box.ROUNDED,
show_lines=False,
)
table.add_column("ID", style="dim", min_width=8, max_width=36, no_wrap=True)
table.add_column("Firma", min_width=10)
table.add_column("Name", min_width=12)
table.add_column("Standort")
table.add_column("Status", min_width=10)
table.add_column("Heartbeat", min_width=14)
table.add_column("Key-Algo", justify="center")
for device, firm in rows:
# UUID kurz anzeigen (erste 8 Zeichen + ...)
short_id = str(device.id)[:8] + ""
table.add_row(
short_id,
firm.name,
device.name,
device.location or "",
_status_icon(device.status),
_heartbeat_label(device.last_heartbeat_at),
device.key_algorithm or "",
)
console.print()
console.print(table)
console.print(f" [dim]{len(rows)} Gerät(e)[/dim]")
console.print()
# ── Subcommand: kiosk approve ─────────────────────────────────────────────────
@kiosk_app.command("approve")
def kiosk_approve(
device_id: str = typer.Argument(..., help="UUID des Kiosk-Geräts"),
):
"""Kiosk-Gerät freigeben (Status: pending → approved)."""
async def _approve():
session = await _open_session()
try:
device = await _find_device(session, device_id)
if device.status == KioskDeviceStatus.APPROVED:
return device, "already_approved"
old_status = device.status
device.status = KioskDeviceStatus.APPROVED
await session.commit()
return device, old_status
except Exception:
await session.rollback()
raise
finally:
await session.close()
device, old_status = _run(_approve())
if old_status == "already_approved":
console.print(f"[yellow]Info:[/yellow] Gerät '{device.name}' ist bereits [green]approved[/green].")
else:
console.print(
f"[bold green]✓[/bold green] Gerät [bold]{device.name}[/bold] "
f"({str(device.id)[:8]}…) wurde [green]freigegeben[/green] "
f"(vorher: {old_status.value})."
)
# ── Subcommand: kiosk revoke ──────────────────────────────────────────────────
@kiosk_app.command("revoke")
def kiosk_revoke(
device_id: str = typer.Argument(..., help="UUID des Kiosk-Geräts"),
yes: bool = typer.Option(False, "--yes", "-y", help="Ohne Bestätigung sperren"),
):
"""Kiosk-Gerät sperren (Status → revoked)."""
async def _get():
session = await _open_session()
try:
return await _find_device(session, device_id), session
except Exception:
await session.close()
raise
async def _revoke():
session = await _open_session()
try:
device = await _find_device(session, device_id)
if device.status == KioskDeviceStatus.REVOKED:
return device, "already_revoked"
old_status = device.status
device.status = KioskDeviceStatus.REVOKED
await session.commit()
return device, old_status
except Exception:
await session.rollback()
raise
finally:
await session.close()
# Bestätigung einholen, wenn --yes nicht gesetzt
if not yes:
async def _peek():
session = await _open_session()
try:
return await _find_device(session, device_id)
finally:
await session.close()
device = _run(_peek())
confirm = typer.confirm(
f"Gerät '{device.name}' ({str(device.id)[:8]}…) wirklich sperren?"
)
if not confirm:
console.print("[dim]Abgebrochen.[/dim]")
raise typer.Exit(0)
device, old_status = _run(_revoke())
if old_status == "already_revoked":
console.print(f"[yellow]Info:[/yellow] Gerät '{device.name}' ist bereits [red]revoked[/red].")
else:
console.print(
f"[bold red]✓[/bold red] Gerät [bold]{device.name}[/bold] "
f"({str(device.id)[:8]}…) wurde [red]gesperrt[/red] "
f"(vorher: {old_status.value})."
)
# ── Subcommand: kiosk info ────────────────────────────────────────────────────
@kiosk_app.command("info")
def kiosk_info(
device_id: str = typer.Argument(..., help="UUID des Kiosk-Geräts"),
):
"""Detailinfo zu einem Kiosk-Gerät anzeigen."""
async def _info():
session = await _open_session()
try:
device = await _find_device(session, device_id)
# Firma nachladen
result = await session.execute(
select(Company).where(Company.id == device.company_id)
)
firm = result.scalar_one_or_none()
return device, firm
finally:
await session.close()
device, firm = _run(_info())
fingerprint = ""
pubkey_preview = ""
if device.public_key:
fingerprint = _pubkey_fingerprint(device.public_key)
# Ersten 60 Zeichen des Keys als Vorschau
key_stripped = device.public_key.strip()
pubkey_preview = (key_stripped[:60] + "") if len(key_stripped) > 60 else key_stripped
created_str = ""
if device.created_at:
ts = device.created_at
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
created_str = ts.strftime("%d.%m.%Y %H:%M:%S UTC")
enrollment_str = ""
if device.enrollment_expires_at:
ts = device.enrollment_expires_at
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
enrollment_str = ts.strftime("%d.%m.%Y %H:%M:%S UTC")
lines = [
f" [bold]ID:[/bold] {device.id}",
f" [bold]Firma:[/bold] {firm.name if firm else str(device.company_id)}",
f" [bold]Name:[/bold] {device.name}",
f" [bold]Standort:[/bold] {device.location or ''}",
f" [bold]Status:[/bold] {_status_icon(device.status)}",
f" [bold]Key-Algorithmus:[/bold] {device.key_algorithm or ''}",
f" [bold]Public Key:[/bold] {pubkey_preview}",
f" [bold]Key-Fingerprint:[/bold] {fingerprint}",
f" [bold]IP-Whitelist:[/bold] {device.ip_whitelist or ''}",
f" [bold]Heartbeat:[/bold] {_heartbeat_label(device.last_heartbeat_at)}",
f" [bold]Client-Version:[/bold] {device.client_version or ''}",
f" [bold]Offline-Queue:[/bold] {device.offline_queue_size}",
f" [bold]Aktueller User:[/bold] {device.current_user_id or ''}",
f" [bold]Enrollment läuft ab:[/bold] {enrollment_str}",
f" [bold]Angelegt am:[/bold] {created_str}",
]
console.print()
console.print(Panel(
"\n".join(lines),
title=f"Kiosk-Gerät: {device.name}",
border_style="cyan",
))
console.print()
# ── Entry Point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
app()
+2
View File
@@ -24,3 +24,5 @@ pytest-asyncio>=0.23.0
pytest-httpx>=0.30.0 pytest-httpx>=0.30.0
aiosqlite>=0.20.0 aiosqlite>=0.20.0
weasyprint>=61.0 weasyprint>=61.0
typer>=0.12.0
rich>=13.7.0
+16
View File
@@ -451,3 +451,19 @@ Keine Commits in dieser Session.
- backend/tests/test_rls.py | 190 ++++++++++++++++++ - 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 { useAuth } from '../context/AuthContext'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { api } from '../api/client'
interface NavItem { interface NavItem {
path: string path: string
@@ -8,6 +9,11 @@ interface NavItem {
roles?: string[] roles?: string[]
} }
interface KioskHealthDevice {
status: 'pending' | 'approved' | 'revoked'
heartbeat_status: 'online' | 'stale' | 'offline'
}
const MAIN_NAV: NavItem[] = [ const MAIN_NAV: NavItem[] = [
{ path: '/dashboard', label: 'Dashboard' }, { path: '/dashboard', label: 'Dashboard' },
{ path: '/time', label: 'Zeiterfassung' }, { path: '/time', label: 'Zeiterfassung' },
@@ -38,6 +44,62 @@ const ROLE_LABELS: Record<string, string> = {
EMPLOYEE: 'Mitarbeiter', 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 }: { export function Layout({ children, userRole, userName }: {
children: React.ReactNode children: React.ReactNode
userRole: string userRole: string
@@ -97,8 +159,11 @@ export function Layout({ children, userRole, userName }: {
))} ))}
</nav> </nav>
{/* Rechte Seite: Einstellungen + User + Abmelden */} {/* Rechte Seite: Health-Badge + Einstellungen + User + Abmelden */}
<div className='flex items-center gap-1 flex-shrink-0'> <div className='flex items-center gap-1.5 flex-shrink-0'>
{/* Kiosk Health-Badge */}
<KioskHealthBadge userRole={userRole} />
{/* Zahnrad-Dropdown */} {/* Zahnrad-Dropdown */}
{visibleSettings.length > 0 && ( {visibleSettings.length > 0 && (
+281 -137
View File
@@ -9,8 +9,14 @@ interface KioskDevice {
company_id: string company_id: string
name: string name: string
location: string | null location: string | null
is_active: boolean status: 'pending' | 'approved' | 'revoked'
last_seen_at: string | null 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 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' 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() { export function KioskDevicesPage() {
const [me, setMe] = useState<Me | null>(null) const [me, setMe] = useState<Me | null>(null)
const [devices, setDevices] = useState<KioskDevice[]>([]) const [devices, setDevices] = useState<KioskDevice[]>([])
const [loading, setLoading] = useState(true) 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 // Create modal
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [createName, setCreateName] = useState('') const [createName, setCreateName] = useState('')
const [createLocation, setCreateLocation] = useState('') const [createLocation, setCreateLocation] = useState('')
const [createPublicKey, setCreatePublicKey] = useState('')
const [createIpWhitelist, setCreateIpWhitelist] = useState('')
const [createLoading, setCreateLoading] = useState(false) const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState('') const [createError, setCreateError] = useState<string | null>(null)
// Token display modal (after create / rotate)
const [shownToken, setShownToken] = useState<{ deviceName: string; token: string } | null>(null)
const [tokenCopied, setTokenCopied] = useState(false)
// Edit modal // Edit modal
const [editDevice, setEditDevice] = useState<KioskDevice | null>(null) const [editDevice, setEditDevice] = useState<KioskDevice | null>(null)
const [editName, setEditName] = useState('') const [editName, setEditName] = useState('')
const [editLocation, setEditLocation] = useState('') const [editLocation, setEditLocation] = useState('')
const [editActive, setEditActive] = useState(true) const [editIpWhitelist, setEditIpWhitelist] = useState('')
const [editLoading, setEditLoading] = useState(false) const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState('') const [editError, setEditError] = useState<string | null>(null)
async function load() { async function load() {
try { try {
@@ -62,57 +106,54 @@ export function KioskDevicesPage() {
} }
} }
useEffect(() => { load() }, []) useEffect(() => {
load()
const interval = setInterval(load, 30000)
return () => clearInterval(interval)
}, [])
async function handleCreate() { async function handleCreate() {
setCreateError('') setCreateError(null)
if (!createName.trim()) { setCreateError('Name ist erforderlich.'); return } if (!createName.trim()) { setCreateError('Name ist erforderlich.'); return }
if (!createPublicKey.trim()) { setCreateError('Public Key ist erforderlich.'); return }
setCreateLoading(true) setCreateLoading(true)
try { try {
const result = await api.post<KioskDevice & { token: string }>('/kiosk/devices', { await api.post<KioskDevice>('/kiosk/devices', {
name: createName.trim(), name: createName.trim(),
location: createLocation.trim() || null, location: createLocation.trim() || null,
public_key: createPublicKey.trim(),
ip_whitelist: createIpWhitelist.trim() || null,
}) })
setShownToken({ deviceName: result.name, token: result.token })
setShowCreate(false) setShowCreate(false)
setCreateName('') setCreateName('')
setCreateLocation('') setCreateLocation('')
setCreatePublicKey('')
setCreateIpWhitelist('')
setActiveTab('pending')
load() load()
} catch (e: unknown) { } catch (e: unknown) {
setCreateError(e instanceof Error ? e.message : 'Fehler') setCreateError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
} finally { } finally {
setCreateLoading(false) setCreateLoading(false)
} }
} }
async function handleEdit() { async function handleApprove(device: KioskDevice) {
if (!editDevice) return
setEditError('')
if (!editName.trim()) { setEditError('Name ist erforderlich.'); return }
setEditLoading(true)
try { try {
await api.patch(`/kiosk/devices/${editDevice.id}`, { await api.post(`/kiosk/devices/${device.id}/approve`, {})
name: editName.trim(),
location: editLocation.trim() || null,
is_active: editActive,
})
setEditDevice(null)
load() load()
} catch (e: unknown) { } catch (e: unknown) {
setEditError(e instanceof Error ? e.message : 'Fehler') alert(e instanceof Error ? e.message : 'Fehler beim Freigeben')
} finally {
setEditLoading(false)
} }
} }
async function handleRotateToken(device: KioskDevice) { async function handleRevoke(device: KioskDevice) {
if (!confirm(`Token für${device.name}" wirklich rotieren? Das alte Token wird sofort ungültig.`)) return if (!confirm(`Gerät${device.name}" wirklich sperren?`)) return
try { try {
const result = await api.post<KioskDevice & { token: string }>(`/kiosk/devices/${device.id}/rotate-token`, {}) await api.post(`/kiosk/devices/${device.id}/revoke`, {})
setShownToken({ deviceName: result.name, token: result.token })
load() load()
} catch (e: unknown) { } 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}`) await api.del(`/kiosk/devices/${device.id}`)
load() load()
} catch (e: unknown) { } 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) setEditDevice(device)
setEditName(device.name) setEditName(device.name)
setEditLocation(device.location ?? '') setEditLocation(device.location ?? '')
setEditActive(device.is_active) setEditIpWhitelist(device.ip_whitelist ?? '')
setEditError('') setEditError(null)
}
function copyToken(token: string) {
navigator.clipboard.writeText(token).then(() => {
setTokenCopied(true)
setTimeout(() => setTokenCopied(false), 2000)
})
} }
function formatDate(iso: string | null) { function formatDate(iso: string | null) {
@@ -146,9 +200,21 @@ export function KioskDevicesPage() {
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) 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 (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> 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 ( return (
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}> <Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
<div className='space-y-6'> <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> <p className='text-sm text-gray-500 mt-0.5'>{devices.length} {devices.length === 1 ? 'Gerät' : 'Geräte'} registriert</p>
</div> </div>
<button <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' 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 + Gerät hinzufügen
</button> </button>
</div> </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'> <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. <span className='mr-1'>🔐</span>
Der Token wird nur einmalig bei der Erstellung angezeigt bitte sofort sichern. 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> </div>
{/* Tabelle */} {/* Tabs */}
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'> <div className='border-b border-gray-200'>
<div className='overflow-x-auto'> <nav className='flex gap-6' aria-label='Tabs'>
<table className='w-full text-sm'> <button
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'> onClick={() => setActiveTab('pending')}
<tr> className={`pb-3 text-sm font-medium transition-colors ${
{['Gerät', 'Standort', 'Status', 'Zuletzt gesehen', 'Angelegt', ''].map(h => ( activeTab === 'pending'
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th> ? 'border-b-2 border-blue-600 text-blue-600'
))} : 'text-gray-500 hover:text-gray-700'
</tr> }`}
</thead> >
<tbody className='divide-y divide-gray-100'> Wartet auf Freigabe
{devices.map(d => ( {pendingDevices.length > 0 && (
<tr key={d.id} className='hover:bg-gray-50 transition-colors'> <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'>
<td className='px-4 py-3 font-medium text-gray-800'>{d.name}</td> {pendingDevices.length}
<td className='px-4 py-3 text-gray-500'>{d.location ?? '—'}</td> </span>
<td className='px-4 py-3'> )}
<span className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${ </button>
d.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600' <button
}`}> onClick={() => setActiveTab('active')}
{d.is_active ? 'Aktiv' : 'Deaktiviert'} className={`pb-3 text-sm font-medium transition-colors ${
</span> activeTab === 'active'
</td> ? 'border-b-2 border-blue-600 text-blue-600'
<td className='px-4 py-3 text-gray-500'>{formatDate(d.last_seen_at)}</td> : 'text-gray-500 hover:text-gray-700'
<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'> Aktive Geräte
<button onClick={() => openEdit(d)} className='text-xs text-blue-600 hover:underline'>Bearbeiten</button> </button>
<button onClick={() => handleRotateToken(d)} className='text-xs text-yellow-600 hover:underline'>Token rotieren</button> </nav>
<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>
</div> </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> </div>
{/* Gerät erstellen Modal */} {/* Gerät erstellen Modal */}
{showCreate && ( {showCreate && (
<Modal title='Neues Kiosk-Gerät' onClose={() => setShowCreate(false)}> <Modal title='Neues Kiosk-Gerät' onClose={() => setShowCreate(false)}>
<div className='space-y-3'> <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'> <label className='block'>
<span className='text-xs font-medium text-gray-700'>Name *</span> <span className='text-xs font-medium text-gray-700'>Name *</span>
<input <input
@@ -243,6 +401,25 @@ export function KioskDevicesPage() {
placeholder='z.B. Erdgeschoss, Halle A' placeholder='z.B. Erdgeschoss, Halle A'
/> />
</label> </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>} {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'> <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={() => 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> </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 */} {/* Bearbeiten Modal */}
{editDevice && ( {editDevice && (
<Modal title={`Gerät bearbeiten ${editDevice.name}`} onClose={() => setEditDevice(null)}> <Modal title={`Gerät bearbeiten ${editDevice.name}`} onClose={() => setEditDevice(null)}>
@@ -308,14 +453,14 @@ export function KioskDevicesPage() {
placeholder='z.B. Erdgeschoss, Halle A' placeholder='z.B. Erdgeschoss, Halle A'
/> />
</label> </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 <input
type='checkbox' value={editIpWhitelist}
checked={editActive} onChange={e => setEditIpWhitelist(e.target.value)}
onChange={e => setEditActive(e.target.checked)} className={inputClass}
className='w-4 h-4 rounded border-gray-300 text-blue-600' placeholder='10.0.0.0/24,192.168.1.0/24'
/> />
<span className='text-sm text-gray-700'>Gerät aktiv</span>
</label> </label>
{editError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{editError}</p>} {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'> <div className='flex justify-end gap-2 pt-2'>
@@ -330,4 +475,3 @@ export function KioskDevicesPage() {
</Layout> </Layout>
) )
} }