diff --git a/DEVLOG.md b/DEVLOG.md index 93c9dfd..1098f1b 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 +- + +--- diff --git a/backend/app/core/kiosk_security.py b/backend/app/core/kiosk_security.py new file mode 100644 index 0000000..05c4028 --- /dev/null +++ b/backend/app/core/kiosk_security.py @@ -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 diff --git a/backend/app/routers/kiosk.py b/backend/app/routers/kiosk.py index f57713b..f4bb87d 100644 --- a/backend/app/routers/kiosk.py +++ b/backend/app/routers/kiosk.py @@ -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, + ) diff --git a/backend/app/schemas/kiosk.py b/backend/app/schemas/kiosk.py index caf66bd..25dad62 100644 --- a/backend/app/schemas/kiosk.py +++ b/backend/app/schemas/kiosk.py @@ -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 diff --git a/backend/app/services/kiosk_service.py b/backend/app/services/kiosk_service.py index d793814..185d6bc 100644 --- a/backend/app/services/kiosk_service.py +++ b/backend/app/services/kiosk_service.py @@ -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() diff --git a/backend/cli.py b/backend/cli.py new file mode 100644 index 0000000..244bb3b --- /dev/null +++ b/backend/cli.py @@ -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: (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() diff --git a/backend/requirements.txt b/backend/requirements.txt index 698b038..3db0a0b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index f70e235..a424c0f 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -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 +++++++++++++++++++-------- + +--- diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 3e91cbf..9d80a73 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 = { 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('/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 ( + + ) +} + export function Layout({ children, userRole, userName }: { children: React.ReactNode userRole: string @@ -97,8 +159,11 @@ export function Layout({ children, userRole, userName }: { ))} - {/* Rechte Seite: Einstellungen + User + Abmelden */} -
+ {/* Rechte Seite: Health-Badge + Einstellungen + User + Abmelden */} +
+ + {/* Kiosk Health-Badge */} + {/* Zahnrad-Dropdown */} {visibleSettings.length > 0 && ( diff --git a/frontend/src/pages/KioskDevicesPage.tsx b/frontend/src/pages/KioskDevicesPage.tsx index 632412c..8c7fd7f 100644 --- a/frontend/src/pages/KioskDevicesPage.tsx +++ b/frontend/src/pages/KioskDevicesPage.tsx @@ -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 ( + + + Gesperrt + + ) + } + if (device.heartbeat_status === 'online') { + return ( + + + Online + + ) + } + if (device.heartbeat_status === 'stale') { + return ( + + + Veraltet + + ) + } + return ( + + + Offline + + ) +} + +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(null) const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState('') + const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState<'pending' | 'active'>('pending') // Create modal - const [showCreate, setShowCreate] = useState(false) - const [createName, setCreateName] = useState('') + const [showCreate, setShowCreate] = useState(false) + const [createName, setCreateName] = useState('') const [createLocation, setCreateLocation] = useState('') + const [createPublicKey, setCreatePublicKey] = useState('') + const [createIpWhitelist, setCreateIpWhitelist] = useState('') const [createLoading, setCreateLoading] = useState(false) - const [createError, setCreateError] = useState('') - - // Token display modal (after create / rotate) - const [shownToken, setShownToken] = useState<{ deviceName: string; token: string } | null>(null) - const [tokenCopied, setTokenCopied] = useState(false) + const [createError, setCreateError] = useState(null) // Edit modal - const [editDevice, setEditDevice] = useState(null) - const [editName, setEditName] = useState('') - const [editLocation, setEditLocation] = useState('') - const [editActive, setEditActive] = useState(true) - const [editLoading, setEditLoading] = useState(false) - const [editError, setEditError] = useState('') + const [editDevice, setEditDevice] = useState(null) + const [editName, setEditName] = useState('') + const [editLocation, setEditLocation] = useState('') + const [editIpWhitelist, setEditIpWhitelist] = useState('') + const [editLoading, setEditLoading] = useState(false) + const [editError, setEditError] = useState(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('/kiosk/devices', { + await api.post('/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(`/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
if (error) return

{error}

+ const pendingDevices = devices.filter(d => d.status === 'pending') + const activeDevices = devices.filter(d => d.status === 'approved' || d.status === 'revoked') + return (
@@ -160,70 +226,162 @@ export function KioskDevicesPage() {

{devices.length} {devices.length === 1 ? 'Gerät' : 'Geräte'} registriert

- {/* Info-Hinweis */} + {/* Info-Banner */}
- Kiosk-Geräte authentifizieren sich per X-Kiosk-Token Header. - 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.
- {/* Tabelle */} -
-
- - - - {['Gerät', 'Standort', 'Status', 'Zuletzt gesehen', 'Angelegt', ''].map(h => ( - - ))} - - - - {devices.map(d => ( - - - - - - - - - ))} - {devices.length === 0 && ( - - - - )} - -
{h}
{d.name}{d.location ?? '—'} - - {d.is_active ? 'Aktiv' : 'Deaktiviert'} - - {formatDate(d.last_seen_at)}{formatDate(d.created_at)} -
- - - -
-
- Noch keine Kiosk-Geräte angelegt. -
-
+ {/* Tabs */} +
+
+ + {/* Tab: Wartet auf Freigabe */} + {activeTab === 'pending' && ( +
+
+ + + + {['Name', 'Standort', 'Public Key', 'Angelegt', ''].map(h => ( + + ))} + + + + {pendingDevices.map(d => ( + + + + + + + + ))} + {pendingDevices.length === 0 && ( + + + + )} + +
{h}
{d.name}{d.location ?? '—'}{truncateKey(d.public_key)}{formatDate(d.created_at)} +
+ + +
+
+ Keine Geräte warten auf Freigabe. +
+
+
+ )} + + {/* Tab: Aktive Geräte */} + {activeTab === 'active' && ( +
+
+ + + + {['Name', 'Standort', 'Status', 'Letzter Heartbeat', 'Client-Version', 'Offline-Queue', ''].map(h => ( + + ))} + + + + {activeDevices.map(d => ( + + + + + + + + + + ))} + {activeDevices.length === 0 && ( + + + + )} + +
{h}
{d.name}{d.location ?? '—'} + + {formatDate(d.last_heartbeat_at)}{d.client_version ?? '—'} + {d.offline_queue_size > 0 + ? {d.offline_queue_size} + : '0' + } + +
+ + {d.status === 'approved' && ( + + )} + +
+
+ Noch keine aktiven Geräte vorhanden. +
+
+
+ )}
{/* Gerät erstellen Modal */} {showCreate && ( setShowCreate(false)}>
+
+ Das Gerät erscheint nach dem Anlegen im Tab „Wartet auf Freigabe". Ein Admin muss es freigeben. +
+