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:
@@ -846,3 +846,61 @@ Keine Commits in dieser Session.
|
||||
- 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 +-
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
@@ -1,12 +1,23 @@
|
||||
from datetime import datetime, timezone
|
||||
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 app.core.database import get_db
|
||||
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.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
|
||||
|
||||
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])
|
||||
async def list_devices(
|
||||
status: str | None = Query(None, description="Filter: pending, approved oder revoked"),
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Alle registrierten Kiosk-Geräte der Firma auflisten."""
|
||||
return await kiosk_service.list_devices(current_user.company_id, db)
|
||||
"""Alle registrierten Kiosk-Geräte der Firma auflisten (optional nach Status filtern)."""
|
||||
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(
|
||||
data: KioskDeviceCreate,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben."""
|
||||
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, db)
|
||||
"""Neues Kiosk-Gerät mit Ed25519-Public-Key registrieren."""
|
||||
device = await kiosk_service.create_device(current_user.company_id, data, db)
|
||||
await db.commit()
|
||||
return KioskDeviceCreated(
|
||||
**KioskDeviceOut.model_validate(device).model_dump(),
|
||||
token=raw_token,
|
||||
)
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
|
||||
@@ -46,7 +65,9 @@ async def get_device(
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
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)
|
||||
@@ -56,44 +77,80 @@ async def update_device(
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
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)
|
||||
await db.commit()
|
||||
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)
|
||||
async def delete_device(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Kiosk-Gerät dauerhaft löschen."""
|
||||
await kiosk_service.delete_device(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ─────────────────────────
|
||||
|
||||
@router.get("/me", response_model=KioskDeviceOut)
|
||||
async def kiosk_me(
|
||||
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
|
||||
@router.post("/devices/{device_id}/approve", response_model=KioskApproveResponse)
|
||||
async def approve_device(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Kiosk-Gerät prüft seine eigene Identität / aktualisiert last_seen_at."""
|
||||
device = await kiosk_service.authenticate_device(x_kiosk_token, db)
|
||||
"""Kiosk-Gerät freigeben (Status: pending → approved)."""
|
||||
device = await kiosk_service.approve_device(device_id, current_user.company_id, db)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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):
|
||||
name: str = Field(..., min_length=1, 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")
|
||||
@classmethod
|
||||
@@ -15,10 +35,18 @@ class KioskDeviceCreate(BaseModel):
|
||||
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
|
||||
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):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
location: str | None = Field(None, max_length=255)
|
||||
ip_whitelist: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -28,7 +56,6 @@ class KioskDeviceUpdate(BaseModel):
|
||||
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
|
||||
return v.strip()
|
||||
return v
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class KioskDeviceOut(BaseModel):
|
||||
@@ -38,11 +65,40 @@ class KioskDeviceOut(BaseModel):
|
||||
company_id: uuid.UUID
|
||||
name: str
|
||||
location: str | None
|
||||
is_active: bool
|
||||
last_seen_at: datetime | None
|
||||
status: str # pending / approved / revoked
|
||||
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
|
||||
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):
|
||||
"""Wird nur einmalig bei Erstellung zurückgegeben – enthält den Klartext-Token."""
|
||||
token: str
|
||||
class KioskApproveResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
@@ -6,37 +5,34 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_token
|
||||
from app.models.kiosk_device import KioskDevice
|
||||
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate
|
||||
from app.models.company import Company
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
from app.schemas.kiosk import HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate
|
||||
|
||||
|
||||
class KioskService:
|
||||
|
||||
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]:
|
||||
result = await db.scalars(
|
||||
# ── Lesende Operationen ───────────────────────────────────────────────────
|
||||
|
||||
async def list_devices(
|
||||
self,
|
||||
company_id: UUID,
|
||||
db: AsyncSession,
|
||||
status_filter: KioskDeviceStatus | None = None,
|
||||
) -> list[KioskDevice]:
|
||||
query = (
|
||||
select(KioskDevice)
|
||||
.where(KioskDevice.company_id == company_id)
|
||||
.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())
|
||||
|
||||
async def create_device(
|
||||
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession
|
||||
) -> tuple[KioskDevice, str]:
|
||||
"""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:
|
||||
async def get_device(
|
||||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> KioskDevice:
|
||||
device = await db.scalar(
|
||||
select(KioskDevice).where(
|
||||
KioskDevice.id == device_id,
|
||||
@@ -47,8 +43,40 @@ class KioskService:
|
||||
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
|
||||
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(
|
||||
self, device_id: UUID, company_id: UUID, data: KioskDeviceUpdate, db: AsyncSession
|
||||
self,
|
||||
device_id: UUID,
|
||||
company_id: UUID,
|
||||
data: KioskDeviceUpdate,
|
||||
db: AsyncSession,
|
||||
) -> KioskDevice:
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
changes = data.model_dump(exclude_none=True)
|
||||
@@ -56,32 +84,56 @@ class KioskService:
|
||||
setattr(device, field, value)
|
||||
return device
|
||||
|
||||
async def rotate_token(
|
||||
async def delete_device(
|
||||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> tuple[KioskDevice, str]:
|
||||
"""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:
|
||||
) -> None:
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
await db.delete(device)
|
||||
|
||||
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice:
|
||||
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
|
||||
token_hash = hash_token(raw_token)
|
||||
device = await db.scalar(
|
||||
select(KioskDevice).where(
|
||||
KioskDevice.token_hash == token_hash,
|
||||
KioskDevice.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
|
||||
device.last_seen_at = datetime.now(timezone.utc)
|
||||
# ── Status-Verwaltung ─────────────────────────────────────────────────────
|
||||
|
||||
async def approve_device(
|
||||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> KioskDevice:
|
||||
"""Gerät freigeben: Status → approved."""
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
device.status = KioskDeviceStatus.APPROVED
|
||||
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()
|
||||
|
||||
+529
@@ -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()
|
||||
@@ -24,3 +24,5 @@ pytest-asyncio>=0.23.0
|
||||
pytest-httpx>=0.30.0
|
||||
aiosqlite>=0.20.0
|
||||
weasyprint>=61.0
|
||||
typer>=0.12.0
|
||||
rich>=13.7.0
|
||||
|
||||
@@ -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 +++++++++++++++++++--------
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
@@ -8,6 +9,11 @@ interface NavItem {
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
interface KioskHealthDevice {
|
||||
status: 'pending' | 'approved' | 'revoked'
|
||||
heartbeat_status: 'online' | 'stale' | 'offline'
|
||||
}
|
||||
|
||||
const MAIN_NAV: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/time', label: 'Zeiterfassung' },
|
||||
@@ -38,6 +44,62 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
EMPLOYEE: 'Mitarbeiter',
|
||||
}
|
||||
|
||||
const KIOSK_HEALTH_ROLES = ['COMPANY_ADMIN', 'SUPER_ADMIN']
|
||||
|
||||
function KioskHealthBadge({ userRole }: { userRole: string }) {
|
||||
const navigate = useNavigate()
|
||||
const [online, setOnline] = useState(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
async function fetchHealth() {
|
||||
try {
|
||||
const devices = await api.get<KioskHealthDevice[]>('/kiosk/devices')
|
||||
const approved = devices.filter(d => d.status === 'approved')
|
||||
const onlineCount = approved.filter(d => d.heartbeat_status === 'online').length
|
||||
setTotal(approved.length)
|
||||
setOnline(onlineCount)
|
||||
setVisible(true)
|
||||
} catch {
|
||||
setVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!KIOSK_HEALTH_ROLES.includes(userRole)) return
|
||||
fetchHealth()
|
||||
const interval = setInterval(fetchHealth, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [userRole])
|
||||
|
||||
if (!KIOSK_HEALTH_ROLES.includes(userRole) || !visible || total === 0) return null
|
||||
|
||||
let dotColor = 'bg-green-500'
|
||||
let textColor = 'text-green-700'
|
||||
let bgColor = 'bg-green-50 border-green-200'
|
||||
|
||||
if (online === 0) {
|
||||
dotColor = 'bg-red-500'
|
||||
textColor = 'text-red-700'
|
||||
bgColor = 'bg-red-50 border-red-200'
|
||||
} else if (online < total) {
|
||||
dotColor = 'bg-yellow-400'
|
||||
textColor = 'text-yellow-700'
|
||||
bgColor = 'bg-yellow-50 border-yellow-200'
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate('/settings/kiosk')}
|
||||
title='Kiosk-Geräte-Status – klicken zum Öffnen'
|
||||
className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border font-medium transition-opacity hover:opacity-80 ${bgColor} ${textColor}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`} />
|
||||
{online}/{total} Kiosks
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout({ children, userRole, userName }: {
|
||||
children: React.ReactNode
|
||||
userRole: string
|
||||
@@ -97,8 +159,11 @@ export function Layout({ children, userRole, userName }: {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Rechte Seite: Einstellungen + User + Abmelden */}
|
||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||
{/* Rechte Seite: Health-Badge + Einstellungen + User + Abmelden */}
|
||||
<div className='flex items-center gap-1.5 flex-shrink-0'>
|
||||
|
||||
{/* Kiosk Health-Badge */}
|
||||
<KioskHealthBadge userRole={userRole} />
|
||||
|
||||
{/* Zahnrad-Dropdown */}
|
||||
{visibleSettings.length > 0 && (
|
||||
|
||||
@@ -9,8 +9,14 @@ interface KioskDevice {
|
||||
company_id: string
|
||||
name: string
|
||||
location: string | null
|
||||
is_active: boolean
|
||||
last_seen_at: string | null
|
||||
status: 'pending' | 'approved' | 'revoked'
|
||||
public_key: string | null
|
||||
key_algorithm: string
|
||||
last_heartbeat_at: string | null
|
||||
client_version: string | null
|
||||
offline_queue_size: number
|
||||
ip_whitelist: string | null
|
||||
heartbeat_status: 'online' | 'stale' | 'offline'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -22,30 +28,68 @@ interface Me {
|
||||
|
||||
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent'
|
||||
|
||||
function HeartbeatDot({ device }: { device: KioskDevice }) {
|
||||
if (device.status === 'revoked') {
|
||||
return (
|
||||
<span className='flex items-center text-xs text-gray-500 font-medium'>
|
||||
<span className='w-2 h-2 rounded-full inline-block mr-1.5 bg-gray-400' />
|
||||
Gesperrt
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (device.heartbeat_status === 'online') {
|
||||
return (
|
||||
<span className='flex items-center text-xs text-green-700 font-medium'>
|
||||
<span className='w-2 h-2 rounded-full inline-block mr-1.5 bg-green-500' />
|
||||
Online
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (device.heartbeat_status === 'stale') {
|
||||
return (
|
||||
<span className='flex items-center text-xs text-yellow-700 font-medium'>
|
||||
<span className='w-2 h-2 rounded-full inline-block mr-1.5 bg-yellow-400' />
|
||||
Veraltet
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className='flex items-center text-xs text-red-600 font-medium'>
|
||||
<span className='w-2 h-2 rounded-full inline-block mr-1.5 bg-red-500' />
|
||||
Offline
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function truncateKey(key: string | null): string {
|
||||
if (!key) return '—'
|
||||
const trimmed = key.trim()
|
||||
return trimmed.length > 40 ? trimmed.slice(0, 40) + '...' : trimmed
|
||||
}
|
||||
|
||||
export function KioskDevicesPage() {
|
||||
const [me, setMe] = useState<Me | null>(null)
|
||||
const [devices, setDevices] = useState<KioskDevice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'active'>('pending')
|
||||
|
||||
// Create modal
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [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 [editIpWhitelist, setEditIpWhitelist] = useState('')
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
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,57 +226,93 @@ 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 */}
|
||||
{/* 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>
|
||||
{['Gerät', 'Standort', 'Status', 'Zuletzt gesehen', 'Angelegt', ''].map(h => (
|
||||
{['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'>
|
||||
{devices.map(d => (
|
||||
{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'>
|
||||
<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-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={() => 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>
|
||||
<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>
|
||||
))}
|
||||
{devices.length === 0 && (
|
||||
{pendingDevices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className='px-4 py-12 text-center text-gray-400'>
|
||||
Noch keine Kiosk-Geräte angelegt.
|
||||
<td colSpan={5} className='px-4 py-12 text-center text-gray-400'>
|
||||
Keine Geräte warten auf Freigabe.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -218,12 +320,68 @@ export function KioskDevicesPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Aktive Geräte */}
|
||||
{activeTab === 'active' && (
|
||||
<div className='bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden'>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
|
||||
<tr>
|
||||
{['Name', 'Standort', 'Status', 'Letzter Heartbeat', 'Client-Version', 'Offline-Queue', ''].map(h => (
|
||||
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-gray-100'>
|
||||
{activeDevices.map(d => (
|
||||
<tr key={d.id} className='hover:bg-gray-50 transition-colors'>
|
||||
<td className='px-4 py-3 font-medium text-gray-800'>{d.name}</td>
|
||||
<td className='px-4 py-3 text-gray-500'>{d.location ?? '—'}</td>
|
||||
<td className='px-4 py-3'>
|
||||
<HeartbeatDot device={d} />
|
||||
</td>
|
||||
<td className='px-4 py-3 text-gray-500'>{formatDate(d.last_heartbeat_at)}</td>
|
||||
<td className='px-4 py-3 text-gray-500'>{d.client_version ?? '—'}</td>
|
||||
<td className='px-4 py-3 text-gray-500'>
|
||||
{d.offline_queue_size > 0
|
||||
? <span className='text-orange-600 font-medium'>{d.offline_queue_size}</span>
|
||||
: '0'
|
||||
}
|
||||
</td>
|
||||
<td className='px-4 py-3'>
|
||||
<div className='flex gap-3 justify-end'>
|
||||
<button onClick={() => openEdit(d)} className='text-xs text-blue-600 hover:underline'>Bearbeiten</button>
|
||||
{d.status === 'approved' && (
|
||||
<button onClick={() => handleRevoke(d)} className='text-xs text-yellow-600 hover:underline'>Sperren</button>
|
||||
)}
|
||||
<button onClick={() => handleDelete(d)} className='text-xs text-red-500 hover:underline'>Löschen</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{activeDevices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className='px-4 py-12 text-center text-gray-400'>
|
||||
Noch keine aktiven Geräte vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gerät erstellen Modal */}
|
||||
{showCreate && (
|
||||
<Modal title='Neues Kiosk-Gerät' onClose={() => setShowCreate(false)}>
|
||||
<div className='space-y-3'>
|
||||
<div className='bg-blue-50 border border-blue-200 rounded-lg px-3 py-2 text-xs text-blue-800'>
|
||||
Das Gerät erscheint nach dem Anlegen im Tab „Wartet auf Freigabe". Ein Admin muss es freigeben.
|
||||
</div>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Name *</span>
|
||||
<input
|
||||
@@ -243,6 +401,25 @@ export function KioskDevicesPage() {
|
||||
placeholder='z.B. Erdgeschoss, Halle A'
|
||||
/>
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>Public Key *</span>
|
||||
<textarea
|
||||
value={createPublicKey}
|
||||
onChange={e => setCreatePublicKey(e.target.value)}
|
||||
rows={4}
|
||||
className={inputClass + ' resize-none font-mono text-xs'}
|
||||
placeholder='ssh-ed25519 AAAA...'
|
||||
/>
|
||||
</label>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>IP-Whitelist (optional)</span>
|
||||
<input
|
||||
value={createIpWhitelist}
|
||||
onChange={e => setCreateIpWhitelist(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder='10.0.0.0/24,192.168.1.0/24'
|
||||
/>
|
||||
</label>
|
||||
{createError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{createError}</p>}
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
<button onClick={() => setShowCreate(false)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Abbrechen</button>
|
||||
@@ -254,38 +431,6 @@ export function KioskDevicesPage() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Token-Anzeige Modal */}
|
||||
{shownToken && (
|
||||
<Modal title='Gerät-Token – jetzt sichern!' onClose={() => { setShownToken(null); setTokenCopied(false) }}>
|
||||
<div className='space-y-4'>
|
||||
<p className='text-sm text-gray-600'>
|
||||
Token für <strong>{shownToken.deviceName}</strong>. Dieser Token wird <strong>nur einmalig</strong> angezeigt und kann danach nicht mehr abgerufen werden.
|
||||
</p>
|
||||
<div className='bg-gray-900 rounded-lg px-4 py-3 font-mono text-xs text-green-400 break-all select-all'>
|
||||
{shownToken.token}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToken(shownToken.token)}
|
||||
className={`w-full py-2 text-sm font-semibold rounded-lg transition-colors ${
|
||||
tokenCopied
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tokenCopied ? 'Kopiert!' : 'In Zwischenablage kopieren'}
|
||||
</button>
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
onClick={() => { setShownToken(null); setTokenCopied(false) }}
|
||||
className='px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700'
|
||||
>
|
||||
Token gesichert – Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Bearbeiten Modal */}
|
||||
{editDevice && (
|
||||
<Modal title={`Gerät bearbeiten – ${editDevice.name}`} onClose={() => setEditDevice(null)}>
|
||||
@@ -308,14 +453,14 @@ export function KioskDevicesPage() {
|
||||
placeholder='z.B. Erdgeschoss, Halle A'
|
||||
/>
|
||||
</label>
|
||||
<label className='flex items-center gap-2 cursor-pointer'>
|
||||
<label className='block'>
|
||||
<span className='text-xs font-medium text-gray-700'>IP-Whitelist (optional)</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={editActive}
|
||||
onChange={e => setEditActive(e.target.checked)}
|
||||
className='w-4 h-4 rounded border-gray-300 text-blue-600'
|
||||
value={editIpWhitelist}
|
||||
onChange={e => setEditIpWhitelist(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder='10.0.0.0/24,192.168.1.0/24'
|
||||
/>
|
||||
<span className='text-sm text-gray-700'>Gerät aktiv</span>
|
||||
</label>
|
||||
{editError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{editError}</p>}
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
@@ -330,4 +475,3 @@ export function KioskDevicesPage() {
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user