feat(kiosk): Stufe 3 – ServiceWorker, WebCrypto Setup-Flow, Kiosk-UI, 15 Security-Tests
3A – Frontend Kiosk-Modus: - public/kiosk-sw.js (NEU, 187 Zeilen): ServiceWorker signiert alle /api/v1/kiosk/ Requests automatisch mit Ed25519. Keypair-Generierung intern (non-extractable), Speicherung in IndexedDB. BroadcastChannel-Leader-Election für Heartbeat. - KioskSetupPage.tsx (NEU, 307 Zeilen): Enrollment-Flow unter /kiosk/setup. Keypair-Generierung via WebCrypto im ServiceWorker, Public Key als PEM anzeigen. Browser-Kompatibilitäts-Check (Ed25519 ab Chrome 113+). - KioskStampPage.tsx (NEU, 348 Zeilen): Kiosk-UI unter /kiosk. Live-Uhr mit Server-Zeit-Offset, Heartbeat-Loop 30s, Online/Offline-Indikator. - App.tsx: /kiosk und /kiosk/setup Routen außerhalb ProtectedRoute 3B – Tests: - tests/test_kiosk_security.py (NEU, 387 Zeilen): 15/15 Tests grün Abgedeckt: gültige Signatur, falscher Key, Replay-Schutz, Timestamp-Drift, Future-Timestamp, pending/revoked Device, unbekanntes Gerät, fehlende Header, Lifecycle-Tests, heartbeat_status nach Heartbeat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -904,3 +904,23 @@ Keine Commits in dieser Session.
|
|||||||
- .../migrations/versions/0022_sick_note_config.py | 2 +-
|
- .../migrations/versions/0022_sick_note_config.py | 2 +-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-05-24 12:17 – 12:19 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 58 ++++
|
||||||
|
- backend/app/core/kiosk_security.py | 233 ++++++++++++++
|
||||||
|
- backend/app/routers/kiosk.py | 119 +++++--
|
||||||
|
- backend/app/schemas/kiosk.py | 70 ++++-
|
||||||
|
- backend/app/services/kiosk_service.py | 138 ++++++---
|
||||||
|
- backend/cli.py | 529 ++++++++++++++++++++++++++++++++
|
||||||
|
- backend/requirements.txt | 2 +
|
||||||
|
- frontend/DEVLOG.md | 16 +
|
||||||
|
- frontend/src/components/Layout.tsx | 71 ++++-
|
||||||
|
- frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++--------
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""
|
||||||
|
Ed25519-Kiosk-Sicherheitstests für TimeMaster.
|
||||||
|
|
||||||
|
Testet:
|
||||||
|
- Gültiger signierter Heartbeat
|
||||||
|
- Ungültige Signatur → 401
|
||||||
|
- Replay-Angriff (gleiche Nonce) → 401
|
||||||
|
- Timestamp-Drift > 30s → 401
|
||||||
|
- Pending-Gerät → 401
|
||||||
|
- Gerät-Lifecycle (anlegen, genehmigen, sperren)
|
||||||
|
- Fehlende Kiosk-Header → 422
|
||||||
|
- Geräteliste für Admin
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
DEVICES_URL = "/api/v1/kiosk/devices"
|
||||||
|
HEARTBEAT_URL = "/api/v1/kiosk/heartbeat"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hilffunktion ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_kiosk_headers(
|
||||||
|
device_id: str,
|
||||||
|
private_key: Ed25519PrivateKey,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: bytes = b"",
|
||||||
|
) -> dict:
|
||||||
|
"""Erzeugt gültige Kiosk-Signatur-Header für einen Request."""
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
nonce = str(uuid.uuid4())
|
||||||
|
body_hash = hashlib.sha256(body).hexdigest()
|
||||||
|
message = f"{method} {path} {timestamp} {nonce} {body_hash}".encode()
|
||||||
|
signature = private_key.sign(message)
|
||||||
|
sig_b64 = base64.b64encode(signature).decode()
|
||||||
|
return {
|
||||||
|
"X-Kiosk-Key-Id": device_id,
|
||||||
|
"X-Kiosk-Timestamp": timestamp,
|
||||||
|
"X-Kiosk-Nonce": nonce,
|
||||||
|
"X-Kiosk-Signature": sig_b64,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||||
|
async def kiosk_keypair():
|
||||||
|
"""Ed25519-Keypair für Tests."""
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
public_key_pem = (
|
||||||
|
private_key.public_key()
|
||||||
|
.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
|
||||||
|
.decode()
|
||||||
|
)
|
||||||
|
return private_key, public_key_pem
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||||
|
async def kiosk_admin_headers(registered_user):
|
||||||
|
"""Admin-Authorization-Header aus registered_user."""
|
||||||
|
return {"Authorization": f"Bearer {registered_user['tokens']['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||||
|
async def approved_kiosk_device(client: AsyncClient, kiosk_admin_headers, kiosk_keypair):
|
||||||
|
"""Registriert ein Kiosk-Gerät und genehmigt es direkt."""
|
||||||
|
_, public_key_pem = kiosk_keypair
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
DEVICES_URL,
|
||||||
|
json={
|
||||||
|
"name": "Test-Kiosk",
|
||||||
|
"location": "Testlabor",
|
||||||
|
"public_key": public_key_pem,
|
||||||
|
},
|
||||||
|
headers=kiosk_admin_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
device = resp.json()
|
||||||
|
|
||||||
|
approve_resp = await client.post(
|
||||||
|
f"{DEVICES_URL}/{device['id']}/approve",
|
||||||
|
headers=kiosk_admin_headers,
|
||||||
|
)
|
||||||
|
assert approve_resp.status_code == 200, approve_resp.text
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_heartbeat_valid(client: AsyncClient, approved_kiosk_device, kiosk_keypair):
|
||||||
|
"""Gültiger signierter Heartbeat → 200 mit server_time, heartbeat_interval_sec, device_status."""
|
||||||
|
private_key, _ = kiosk_keypair
|
||||||
|
device_id = approved_kiosk_device["id"]
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
HEARTBEAT_URL,
|
||||||
|
content=body,
|
||||||
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert "server_time" in data
|
||||||
|
assert "heartbeat_interval_sec" in data
|
||||||
|
assert data["device_status"] == "approved"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_invalid_signature(client: AsyncClient, approved_kiosk_device, kiosk_keypair):
|
||||||
|
"""Heartbeat mit falschem Signing-Key → 401."""
|
||||||
|
wrong_key = Ed25519PrivateKey.generate()
|
||||||
|
device_id = approved_kiosk_device["id"]
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
headers = make_kiosk_headers(device_id, wrong_key, "POST", HEARTBEAT_URL, body)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
HEARTBEAT_URL,
|
||||||
|
content=body,
|
||||||
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_replay(client: AsyncClient, approved_kiosk_device, kiosk_keypair):
|
||||||
|
"""Gleiche Nonce zweimal senden → zweiter Request 401 (Replay-Schutz)."""
|
||||||
|
private_key, _ = kiosk_keypair
|
||||||
|
device_id = approved_kiosk_device["id"]
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body)
|
||||||
|
merged = {**headers, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
# Erster Request muss durchgehen
|
||||||
|
r1 = await client.post(HEARTBEAT_URL, content=body, headers=merged)
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
|
||||||
|
# Zweiter Request mit identischen Headern (selbe Nonce) muss abgelehnt werden
|
||||||
|
r2 = await client.post(HEARTBEAT_URL, content=body, headers=merged)
|
||||||
|
assert r2.status_code == 401, r2.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_timestamp_drift(client: AsyncClient, approved_kiosk_device, kiosk_keypair):
|
||||||
|
"""Timestamp der mehr als 30 Sekunden zurückliegt → 401."""
|
||||||
|
private_key, _ = kiosk_keypair
|
||||||
|
device_id = approved_kiosk_device["id"]
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
|
||||||
|
old_timestamp = str(int(time.time()) - 60)
|
||||||
|
nonce = str(uuid.uuid4())
|
||||||
|
body_hash = hashlib.sha256(body).hexdigest()
|
||||||
|
message = f"POST {HEARTBEAT_URL} {old_timestamp} {nonce} {body_hash}".encode()
|
||||||
|
signature = private_key.sign(message)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Kiosk-Key-Id": device_id,
|
||||||
|
"X-Kiosk-Timestamp": old_timestamp,
|
||||||
|
"X-Kiosk-Nonce": nonce,
|
||||||
|
"X-Kiosk-Signature": base64.b64encode(signature).decode(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
resp = await client.post(HEARTBEAT_URL, content=body, headers=headers)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_pending_device(client: AsyncClient, kiosk_admin_headers, kiosk_keypair):
|
||||||
|
"""Heartbeat von einem Gerät das noch nicht genehmigt wurde → 401."""
|
||||||
|
private_key, public_key_pem = kiosk_keypair
|
||||||
|
|
||||||
|
# Neues Gerät anlegen, aber NICHT genehmigen
|
||||||
|
resp = await client.post(
|
||||||
|
DEVICES_URL,
|
||||||
|
json={"name": "Pending-Kiosk", "public_key": public_key_pem},
|
||||||
|
headers=kiosk_admin_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
device_id = resp.json()["id"]
|
||||||
|
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body)
|
||||||
|
r = await client.post(
|
||||||
|
HEARTBEAT_URL,
|
||||||
|
content=body,
|
||||||
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401, r.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_revoked_device(
|
||||||
|
client: AsyncClient, kiosk_admin_headers, kiosk_keypair
|
||||||
|
):
|
||||||
|
"""Heartbeat von einem gesperrten Gerät → 401."""
|
||||||
|
private_key, public_key_pem = kiosk_keypair
|
||||||
|
|
||||||
|
# Gerät anlegen, genehmigen und dann sperren
|
||||||
|
resp = await client.post(
|
||||||
|
DEVICES_URL,
|
||||||
|
json={"name": "Revoked-Kiosk", "public_key": public_key_pem},
|
||||||
|
headers=kiosk_admin_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
device_id = resp.json()["id"]
|
||||||
|
|
||||||
|
await client.post(f"{DEVICES_URL}/{device_id}/approve", headers=kiosk_admin_headers)
|
||||||
|
revoke_resp = await client.post(
|
||||||
|
f"{DEVICES_URL}/{device_id}/revoke", headers=kiosk_admin_headers
|
||||||
|
)
|
||||||
|
assert revoke_resp.status_code == 200, revoke_resp.text
|
||||||
|
assert revoke_resp.json()["status"] == "revoked"
|
||||||
|
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body)
|
||||||
|
r = await client.post(
|
||||||
|
HEARTBEAT_URL,
|
||||||
|
content=body,
|
||||||
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401, r.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kiosk_device_lifecycle(client: AsyncClient, kiosk_admin_headers, kiosk_keypair):
|
||||||
|
"""Gerät anlegen (pending) → genehmigen (approved) → sperren (revoked)."""
|
||||||
|
_, public_key_pem = kiosk_keypair
|
||||||
|
|
||||||
|
# Anlegen
|
||||||
|
r = await client.post(
|
||||||
|
DEVICES_URL,
|
||||||
|
json={"name": "Lifecycle-Test", "public_key": public_key_pem},
|
||||||
|
headers=kiosk_admin_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
dev = r.json()
|
||||||
|
dev_id = dev["id"]
|
||||||
|
assert dev["status"] == "pending"
|
||||||
|
|
||||||
|
# Genehmigen
|
||||||
|
r = await client.post(f"{DEVICES_URL}/{dev_id}/approve", headers=kiosk_admin_headers)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["status"] == "approved"
|
||||||
|
|
||||||
|
# Sperren
|
||||||
|
r = await client.post(f"{DEVICES_URL}/{dev_id}/revoke", headers=kiosk_admin_headers)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["status"] == "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_missing_headers(client: AsyncClient):
|
||||||
|
"""Heartbeat ohne Kiosk-Header → 422 (FastAPI Validierungsfehler)."""
|
||||||
|
resp = await client.post(
|
||||||
|
HEARTBEAT_URL,
|
||||||
|
json={"queued_offline_entries": 0},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kiosk_devices_list(
|
||||||
|
client: AsyncClient, kiosk_admin_headers, approved_kiosk_device
|
||||||
|
):
|
||||||
|
"""Admin kann Geräteliste abrufen und approved_kiosk_device ist enthalten."""
|
||||||
|
resp = await client.get(DEVICES_URL, headers=kiosk_admin_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
devices = resp.json()
|
||||||
|
assert isinstance(devices, list)
|
||||||
|
assert len(devices) > 0
|
||||||
|
ids = [d["id"] for d in devices]
|
||||||
|
assert approved_kiosk_device["id"] in ids
|
||||||
|
# heartbeat_status muss in jedem Eintrag vorhanden sein
|
||||||
|
assert "heartbeat_status" in devices[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kiosk_devices_list_no_auth(client: AsyncClient):
|
||||||
|
"""Geräteliste ohne Auth-Header → 401 oder 403."""
|
||||||
|
resp = await client.get(DEVICES_URL)
|
||||||
|
assert resp.status_code in (401, 403), resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kiosk_device_create_invalid_key(client: AsyncClient, kiosk_admin_headers):
|
||||||
|
"""
|
||||||
|
Ungültiger Public Key beim Anlegen.
|
||||||
|
|
||||||
|
Das Backend validiert den Public Key erst bei der ersten signierten Anfrage
|
||||||
|
(lazy validation in verify_kiosk_request), nicht schon beim Anlegen.
|
||||||
|
Deshalb wird das Gerät mit Status 'pending' angelegt (201).
|
||||||
|
Ein Heartbeat mit diesem Gerät würde dann 401 liefern.
|
||||||
|
"""
|
||||||
|
resp = await client.post(
|
||||||
|
DEVICES_URL,
|
||||||
|
json={"name": "Bad-Key-Kiosk", "public_key": "kein-valid-key"},
|
||||||
|
headers=kiosk_admin_headers,
|
||||||
|
)
|
||||||
|
# Backend akzeptiert den Key beim Anlegen (lazy validation) → 201
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_unknown_device(client: AsyncClient, kiosk_keypair):
|
||||||
|
"""Heartbeat mit unbekannter Geräte-ID → 401."""
|
||||||
|
private_key, _ = kiosk_keypair
|
||||||
|
fake_device_id = str(uuid.uuid4())
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
headers = make_kiosk_headers(fake_device_id, private_key, "POST", HEARTBEAT_URL, body)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
HEARTBEAT_URL,
|
||||||
|
content=body,
|
||||||
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_invalid_key_id_format(client: AsyncClient, kiosk_keypair):
|
||||||
|
"""X-Kiosk-Key-Id ist keine gültige UUID → 401."""
|
||||||
|
private_key, _ = kiosk_keypair
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
nonce = str(uuid.uuid4())
|
||||||
|
body_hash = hashlib.sha256(body).hexdigest()
|
||||||
|
message = f"POST {HEARTBEAT_URL} {timestamp} {nonce} {body_hash}".encode()
|
||||||
|
signature = private_key.sign(message)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Kiosk-Key-Id": "nicht-eine-uuid",
|
||||||
|
"X-Kiosk-Timestamp": timestamp,
|
||||||
|
"X-Kiosk-Nonce": nonce,
|
||||||
|
"X-Kiosk-Signature": base64.b64encode(signature).decode(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
resp = await client.post(HEARTBEAT_URL, content=body, headers=headers)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_future_timestamp(client: AsyncClient, approved_kiosk_device, kiosk_keypair):
|
||||||
|
"""Timestamp weit in der Zukunft (>30s) → 401."""
|
||||||
|
private_key, _ = kiosk_keypair
|
||||||
|
device_id = approved_kiosk_device["id"]
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
|
||||||
|
future_timestamp = str(int(time.time()) + 120)
|
||||||
|
nonce = str(uuid.uuid4())
|
||||||
|
body_hash = hashlib.sha256(body).hexdigest()
|
||||||
|
message = f"POST {HEARTBEAT_URL} {future_timestamp} {nonce} {body_hash}".encode()
|
||||||
|
signature = private_key.sign(message)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Kiosk-Key-Id": device_id,
|
||||||
|
"X-Kiosk-Timestamp": future_timestamp,
|
||||||
|
"X-Kiosk-Nonce": nonce,
|
||||||
|
"X-Kiosk-Signature": base64.b64encode(signature).decode(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
resp = await client.post(HEARTBEAT_URL, content=body, headers=headers)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_updates_heartbeat_status(
|
||||||
|
client: AsyncClient, approved_kiosk_device, kiosk_keypair, kiosk_admin_headers
|
||||||
|
):
|
||||||
|
"""Nach einem erfolgreichen Heartbeat hat das Gerät heartbeat_status 'online'."""
|
||||||
|
private_key, _ = kiosk_keypair
|
||||||
|
device_id = approved_kiosk_device["id"]
|
||||||
|
body = b'{"queued_offline_entries": 0}'
|
||||||
|
headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body)
|
||||||
|
|
||||||
|
hb_resp = await client.post(
|
||||||
|
HEARTBEAT_URL,
|
||||||
|
content=body,
|
||||||
|
headers={**headers, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert hb_resp.status_code == 200, hb_resp.text
|
||||||
|
|
||||||
|
# Gerät abrufen und heartbeat_status prüfen
|
||||||
|
dev_resp = await client.get(
|
||||||
|
f"{DEVICES_URL}/{device_id}", headers=kiosk_admin_headers
|
||||||
|
)
|
||||||
|
assert dev_resp.status_code == 200, dev_resp.text
|
||||||
|
assert dev_resp.json()["heartbeat_status"] == "online"
|
||||||
@@ -467,3 +467,23 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/TimeTrackingPage.tsx | 521 +++++++++++++++++++--------
|
- frontend/src/pages/TimeTrackingPage.tsx | 521 +++++++++++++++++++--------
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-05-24 12:13 – 12:14 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 0f83d13 feat(kiosk): Stufe 2 – Ed25519-Auth, CLI-Tool, neue KioskDevicesPage
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 58 ++++
|
||||||
|
- backend/app/core/kiosk_security.py | 233 ++++++++++++++
|
||||||
|
- backend/app/routers/kiosk.py | 119 +++++--
|
||||||
|
- backend/app/schemas/kiosk.py | 70 ++++-
|
||||||
|
- backend/app/services/kiosk_service.py | 138 ++++++---
|
||||||
|
- backend/cli.py | 529 ++++++++++++++++++++++++++++++++
|
||||||
|
- backend/requirements.txt | 2 +
|
||||||
|
- frontend/DEVLOG.md | 16 +
|
||||||
|
- frontend/src/components/Layout.tsx | 71 ++++-
|
||||||
|
- frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++--------
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// TimeMaster Kiosk ServiceWorker
|
||||||
|
// Signs all /api/v1/kiosk/ requests with Ed25519 credentials stored in IndexedDB.
|
||||||
|
|
||||||
|
const DB_NAME = 'kiosk-credentials';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_NAME = 'credentials';
|
||||||
|
const CRED_KEY = 'active';
|
||||||
|
const CLIENT_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fetch interception ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (!url.pathname.startsWith('/api/v1/kiosk/')) return;
|
||||||
|
event.respondWith(signAndFetch(event.request));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function signAndFetch(request) {
|
||||||
|
const creds = await getCredentials();
|
||||||
|
if (!creds) {
|
||||||
|
// No keypair stored yet – pass through unsigned (e.g. during setup)
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read body bytes for hashing (clone before consuming)
|
||||||
|
let bodyBytes = new Uint8Array(0);
|
||||||
|
if (request.body) {
|
||||||
|
bodyBytes = new Uint8Array(await request.clone().arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA-256 of body as lowercase hex
|
||||||
|
const bodyHashBuffer = await crypto.subtle.digest('SHA-256', bodyBytes);
|
||||||
|
const bodyHash = Array.from(new Uint8Array(bodyHashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
const nonce = crypto.randomUUID();
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
|
||||||
|
// Signature message: "METHOD PATH TIMESTAMP NONCE BODYHASH"
|
||||||
|
const message = `${method} ${path} ${timestamp} ${nonce} ${bodyHash}`;
|
||||||
|
const messageBytes = new TextEncoder().encode(message);
|
||||||
|
|
||||||
|
const signatureBuffer = await crypto.subtle.sign(
|
||||||
|
'Ed25519',
|
||||||
|
creds.privateKey,
|
||||||
|
messageBytes
|
||||||
|
);
|
||||||
|
const signature = btoa(
|
||||||
|
String.fromCharCode(...new Uint8Array(signatureBuffer))
|
||||||
|
);
|
||||||
|
|
||||||
|
const newHeaders = new Headers(request.headers);
|
||||||
|
newHeaders.set('X-Kiosk-Key-Id', creds.deviceId);
|
||||||
|
newHeaders.set('X-Kiosk-Timestamp', timestamp);
|
||||||
|
newHeaders.set('X-Kiosk-Nonce', nonce);
|
||||||
|
newHeaders.set('X-Kiosk-Signature', signature);
|
||||||
|
newHeaders.set('X-Kiosk-Client-Version', CLIENT_VERSION);
|
||||||
|
|
||||||
|
const newRequest = new Request(request, { headers: newHeaders });
|
||||||
|
return fetch(newRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message handler ────────────────────────────────────────────────────────
|
||||||
|
// Handles messages from the page via MessageChannel.
|
||||||
|
|
||||||
|
self.addEventListener('message', async (event) => {
|
||||||
|
const { type, deviceId, port } = event.data;
|
||||||
|
const replyPort = port || (event.ports && event.ports[0]);
|
||||||
|
|
||||||
|
if (type === 'GENERATE_KEYPAIR') {
|
||||||
|
try {
|
||||||
|
// Generate non-extractable Ed25519 keypair
|
||||||
|
const keyPair = await crypto.subtle.generateKey(
|
||||||
|
'Ed25519',
|
||||||
|
false, // non-extractable – private key stays inside SW
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export public key in SPKI format → PEM
|
||||||
|
const spkiBuffer = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||||
|
const publicKeyPem = spkiToPem(spkiBuffer);
|
||||||
|
|
||||||
|
// Persist in IndexedDB
|
||||||
|
await saveCredentials(deviceId, keyPair.privateKey);
|
||||||
|
|
||||||
|
if (replyPort) {
|
||||||
|
replyPort.postMessage({ success: true, publicKeyPem });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (replyPort) {
|
||||||
|
replyPort.postMessage({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'CHECK_CREDENTIALS') {
|
||||||
|
const creds = await getCredentials();
|
||||||
|
if (replyPort) {
|
||||||
|
replyPort.postMessage({ hasCredentials: !!creds, deviceId: creds?.deviceId ?? null });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'CLEAR_CREDENTIALS') {
|
||||||
|
await clearCredentials();
|
||||||
|
if (replyPort) {
|
||||||
|
replyPort.postMessage({ success: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── IndexedDB helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openDb() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
req.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCredentials() {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
const req = store.get(CRED_KEY);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
if (!req.result) { resolve(null); return; }
|
||||||
|
resolve({ deviceId: req.result.deviceId, privateKey: req.result.privateKey });
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCredentials(deviceId, privateKey) {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
const req = store.put({ key: CRED_KEY, deviceId, privateKey });
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCredentials() {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
const req = store.delete(CRED_KEY);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function spkiToPem(spkiBuffer) {
|
||||||
|
const b64 = btoa(String.fromCharCode(...new Uint8Array(spkiBuffer)));
|
||||||
|
const lines = b64.match(/.{1,64}/g).join('\n');
|
||||||
|
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----`;
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import { CompanySettingsPage } from './pages/CompanySettingsPage'
|
|||||||
import { ProfilePage } from './pages/ProfilePage'
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
import { KioskDevicesPage } from './pages/KioskDevicesPage'
|
import { KioskDevicesPage } from './pages/KioskDevicesPage'
|
||||||
import { AuditLogPage } from './pages/AuditLogPage'
|
import { AuditLogPage } from './pages/AuditLogPage'
|
||||||
|
import { KioskSetupPage } from './pages/KioskSetupPage'
|
||||||
|
import { KioskStampPage } from './pages/KioskStampPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -32,6 +34,8 @@ export default function App() {
|
|||||||
<Route path='/register' element={<RegisterPage />} />
|
<Route path='/register' element={<RegisterPage />} />
|
||||||
<Route path='/forgot-password' element={<ForgotPasswordPage />} />
|
<Route path='/forgot-password' element={<ForgotPasswordPage />} />
|
||||||
<Route path='/auth/reset-password' element={<ResetPasswordPage />} />
|
<Route path='/auth/reset-password' element={<ResetPasswordPage />} />
|
||||||
|
<Route path='/kiosk/setup' element={<KioskSetupPage />} />
|
||||||
|
<Route path='/kiosk' element={<KioskStampPage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path='/dashboard' element={<DashboardPage />} />
|
<Route path='/dashboard' element={<DashboardPage />} />
|
||||||
<Route path='/time' element={<TimeTrackingPage />} />
|
<Route path='/time' element={<TimeTrackingPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { useSearchParams, Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
type SetupStep = 'idle' | 'registering-sw' | 'generating' | 'done' | 'error'
|
||||||
|
|
||||||
|
function isBrowserSupported(): boolean {
|
||||||
|
try {
|
||||||
|
// Ed25519 requires SubtleCrypto and the algorithm support
|
||||||
|
// We can only really verify at runtime, but check for the basics here
|
||||||
|
return (
|
||||||
|
typeof crypto !== 'undefined' &&
|
||||||
|
typeof crypto.subtle !== 'undefined' &&
|
||||||
|
typeof crypto.randomUUID === 'function' &&
|
||||||
|
'serviceWorker' in navigator
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessageToSW(
|
||||||
|
reg: ServiceWorkerRegistration,
|
||||||
|
message: object
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
channel.port1.onmessage = (e) => resolve(e.data as Record<string, unknown>)
|
||||||
|
channel.port1.onmessageerror = () => reject(new Error('MessageChannel-Fehler'))
|
||||||
|
const sw = reg.active
|
||||||
|
if (!sw) { reject(new Error('ServiceWorker nicht aktiv')); return }
|
||||||
|
sw.postMessage({ ...message, port: channel.port2 }, [channel.port2])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KioskSetupPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const deviceIdParam = searchParams.get('device_id') ?? ''
|
||||||
|
|
||||||
|
const [deviceId, setDeviceId] = useState(deviceIdParam)
|
||||||
|
const [step, setStep] = useState<SetupStep>('idle')
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||||
|
const [publicKeyPem, setPublicKeyPem] = useState<string | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [existingDeviceId, setExistingDeviceId] = useState<string | null>(null)
|
||||||
|
const swRegRef = useRef<ServiceWorkerRegistration | null>(null)
|
||||||
|
|
||||||
|
const supported = isBrowserSupported()
|
||||||
|
|
||||||
|
// On mount: register SW and check for existing credentials
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' })
|
||||||
|
// Wait until the SW is active
|
||||||
|
if (!reg.active) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const sw = reg.installing || reg.waiting
|
||||||
|
if (!sw) { resolve(); return }
|
||||||
|
sw.addEventListener('statechange', function handler() {
|
||||||
|
if (sw.state === 'activated') {
|
||||||
|
sw.removeEventListener('statechange', handler)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Re-fetch after activation
|
||||||
|
const updated = await navigator.serviceWorker.ready
|
||||||
|
swRegRef.current = updated
|
||||||
|
} else {
|
||||||
|
swRegRef.current = reg
|
||||||
|
}
|
||||||
|
// Check if credentials already exist
|
||||||
|
const ready = await navigator.serviceWorker.ready
|
||||||
|
swRegRef.current = ready
|
||||||
|
const result = await sendMessageToSW(ready, { type: 'CHECK_CREDENTIALS' })
|
||||||
|
if (result.hasCredentials) {
|
||||||
|
setExistingDeviceId(result.deviceId as string)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SW registration failure is non-fatal for the setup UI
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [supported])
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
setErrorMsg(null)
|
||||||
|
setCopied(false)
|
||||||
|
setPublicKeyPem(null)
|
||||||
|
|
||||||
|
if (!deviceId.trim()) {
|
||||||
|
setErrorMsg('Bitte eine Geraete-ID eingeben (UUID des Kiosk-Geraets).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('registering-sw')
|
||||||
|
try {
|
||||||
|
if (!swRegRef.current) {
|
||||||
|
const reg = await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' })
|
||||||
|
const ready = await navigator.serviceWorker.ready
|
||||||
|
swRegRef.current = reg.active ? reg : ready
|
||||||
|
}
|
||||||
|
const ready = await navigator.serviceWorker.ready
|
||||||
|
swRegRef.current = ready
|
||||||
|
|
||||||
|
setStep('generating')
|
||||||
|
const result = await sendMessageToSW(ready, {
|
||||||
|
type: 'GENERATE_KEYPAIR',
|
||||||
|
deviceId: deviceId.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error((result.error as string) ?? 'Unbekannter Fehler im ServiceWorker')
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublicKeyPem(result.publicKeyPem as string)
|
||||||
|
setExistingDeviceId(deviceId.trim())
|
||||||
|
setStep('done')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
// Ed25519 not supported
|
||||||
|
if (msg.includes('Ed25519') || msg.includes('NotSupportedError') || msg.includes('algorithm')) {
|
||||||
|
setErrorMsg(
|
||||||
|
'Dieser Browser unterstuetzt kein Ed25519. Bitte Chrome 113+, Firefox 130+ oder Safari 16.4+ verwenden.'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setErrorMsg(msg)
|
||||||
|
}
|
||||||
|
setStep('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
if (!swRegRef.current) return
|
||||||
|
try {
|
||||||
|
await sendMessageToSW(swRegRef.current, { type: 'CLEAR_CREDENTIALS' })
|
||||||
|
setExistingDeviceId(null)
|
||||||
|
setPublicKeyPem(null)
|
||||||
|
setStep('idle')
|
||||||
|
setDeviceId(deviceIdParam)
|
||||||
|
setErrorMsg(null)
|
||||||
|
setCopied(false)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : 'Fehler beim Zuruecksetzen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
if (!publicKeyPem) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(publicKeyPem)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
// fallback: select textarea
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGenerating = step === 'registering-sw' || step === 'generating'
|
||||||
|
|
||||||
|
if (!supported) {
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-gray-900 text-white flex items-center justify-center p-6'>
|
||||||
|
<div className='max-w-md w-full bg-gray-800 rounded-2xl p-8 text-center'>
|
||||||
|
<div className='text-4xl mb-4'>⚠️</div>
|
||||||
|
<h1 className='text-xl font-bold mb-2'>Browser nicht unterstuetzt</h1>
|
||||||
|
<p className='text-gray-400 text-sm'>
|
||||||
|
Dieser Browser unterstuetzt kein Ed25519 oder ServiceWorker.
|
||||||
|
Bitte Chrome 113+, Firefox 130+ oder Safari 16.4+ verwenden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-gray-900 text-white flex items-center justify-center p-6'>
|
||||||
|
<div className='max-w-lg w-full space-y-6'>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className='text-center'>
|
||||||
|
<div className='text-5xl mb-3'>🔐</div>
|
||||||
|
<h1 className='text-2xl font-bold'>Kiosk einrichten</h1>
|
||||||
|
<p className='text-gray-400 text-sm mt-1'>
|
||||||
|
Generiert ein Ed25519-Schluessel­paar fuer dieses Geraet.
|
||||||
|
Der private Schluessel verlässt den Browser nie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing credentials warning */}
|
||||||
|
{existingDeviceId && step !== 'done' && (
|
||||||
|
<div className='bg-yellow-900/50 border border-yellow-700 rounded-xl px-4 py-3 text-sm text-yellow-300'>
|
||||||
|
<span className='font-semibold'>Achtung:</span> Dieses Geraet hat bereits gespeicherte Credentials
|
||||||
|
fuer Geraet <code className='bg-yellow-900 rounded px-1'>{existingDeviceId}</code>.
|
||||||
|
Ein Neugenerieren ueberschreibt den vorhandenen Schluessel.{' '}
|
||||||
|
<button onClick={handleReset} className='underline hover:text-yellow-100 ml-1'>
|
||||||
|
Zuruecksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main card */}
|
||||||
|
<div className='bg-gray-800 rounded-2xl p-6 space-y-4'>
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-sm font-medium text-gray-300 block mb-1'>Geraete-ID (UUID)</span>
|
||||||
|
<input
|
||||||
|
value={deviceId}
|
||||||
|
onChange={e => setDeviceId(e.target.value)}
|
||||||
|
placeholder='z.B. 550e8400-e29b-41d4-a716-446655440000'
|
||||||
|
disabled={isGenerating || step === 'done'}
|
||||||
|
className='w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-sm text-white
|
||||||
|
placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
disabled:opacity-50'
|
||||||
|
/>
|
||||||
|
<p className='text-xs text-gray-500 mt-1'>
|
||||||
|
Die UUID des Geraets aus der Admin-Oberflaeche (Einstellungen → Kiosk-Geraete).
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className='bg-red-900/50 border border-red-700 rounded-lg px-3 py-2 text-sm text-red-300'>
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step !== 'done' && (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating || !deviceId.trim()}
|
||||||
|
className='w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
rounded-xl font-semibold text-sm transition-colors'
|
||||||
|
>
|
||||||
|
{step === 'registering-sw' && 'ServiceWorker wird registriert…'}
|
||||||
|
{step === 'generating' && 'Schluesselpaar wird generiert…'}
|
||||||
|
{(step === 'idle' || step === 'error') && 'Schluesselpaar generieren'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success state */}
|
||||||
|
{step === 'done' && publicKeyPem && (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='flex items-center gap-2 text-green-400 text-sm font-medium'>
|
||||||
|
<span className='text-lg'>✓</span>
|
||||||
|
Schluesselpaar generiert und gespeichert.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center justify-between mb-1'>
|
||||||
|
<span className='text-xs font-medium text-gray-400 uppercase tracking-wide'>
|
||||||
|
Public Key (PEM-Format)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className='text-xs text-blue-400 hover:text-blue-300 transition-colors'
|
||||||
|
>
|
||||||
|
{copied ? '✓ Kopiert!' : 'Kopieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className='bg-gray-900 rounded-lg p-3 text-xs text-green-300 font-mono
|
||||||
|
overflow-x-auto whitespace-pre-wrap break-all border border-gray-700'>
|
||||||
|
{publicKeyPem}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='bg-blue-900/40 border border-blue-700 rounded-lg px-3 py-3 text-xs text-blue-300 space-y-1'>
|
||||||
|
<p className='font-semibold text-blue-200'>Naechste Schritte:</p>
|
||||||
|
<ol className='list-decimal ml-4 space-y-1'>
|
||||||
|
<li>Public Key oben kopieren und an den Admin schicken (E-Mail reicht – kein Geheimnis).</li>
|
||||||
|
<li>
|
||||||
|
Admin registriert das Geraet auf dem Server:{' '}
|
||||||
|
<code className='bg-blue-950 rounded px-1'>timemaster kiosk add --pubkey ...</code>
|
||||||
|
</li>
|
||||||
|
<li>Admin gibt das Geraet in der Web-Oberflaeche frei.</li>
|
||||||
|
<li>Kiosk-Modus starten.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-3'>
|
||||||
|
<Link
|
||||||
|
to='/kiosk'
|
||||||
|
className='flex-1 text-center py-2.5 bg-green-600 hover:bg-green-700 rounded-xl
|
||||||
|
font-semibold text-sm transition-colors'
|
||||||
|
>
|
||||||
|
Zum Kiosk →
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className='px-4 py-2.5 border border-gray-600 text-gray-300 hover:bg-gray-700
|
||||||
|
rounded-xl text-sm transition-colors'
|
||||||
|
>
|
||||||
|
Zuruecksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back link */}
|
||||||
|
{step !== 'done' && (
|
||||||
|
<p className='text-center text-xs text-gray-600'>
|
||||||
|
<Link to='/login' className='hover:text-gray-400 underline'>Zurueck zum Login</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface HeartbeatResponse {
|
||||||
|
server_time?: string
|
||||||
|
server_timestamp?: number
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastChannel name – only one tab sends heartbeats
|
||||||
|
const HEARTBEAT_CHANNEL = 'kiosk-heartbeat'
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||||
|
const CLIENT_VERSION = '1.0.0'
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessageToSW(
|
||||||
|
reg: ServiceWorkerRegistration,
|
||||||
|
message: object
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
channel.port1.onmessage = (e) => resolve(e.data as Record<string, unknown>)
|
||||||
|
channel.port1.onmessageerror = () => reject(new Error('MessageChannel-Fehler'))
|
||||||
|
const sw = reg.active
|
||||||
|
if (!sw) { reject(new Error('ServiceWorker nicht aktiv')); return }
|
||||||
|
sw.postMessage({ ...message, port: channel.port2 }, [channel.port2])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KioskStampPage() {
|
||||||
|
const [displayTime, setDisplayTime] = useState(new Date())
|
||||||
|
const [serverTimeOffset, setServerTimeOffset] = useState(0) // ms offset from server
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
|
const [heartbeatStatus, setHeartbeatStatus] = useState<'connected' | 'error' | 'pending'>('pending')
|
||||||
|
const [heartbeatError, setHeartbeatError] = useState<string | null>(null)
|
||||||
|
const [hasCredentials, setHasCredentials] = useState<boolean | null>(null)
|
||||||
|
const [deviceId, setDeviceId] = useState<string | null>(null)
|
||||||
|
const [isLeaderTab, setIsLeaderTab] = useState(false)
|
||||||
|
|
||||||
|
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const clockIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const broadcastRef = useRef<BroadcastChannel | null>(null)
|
||||||
|
const swRegRef = useRef<ServiceWorkerRegistration | null>(null)
|
||||||
|
const startTimeRef = useRef<number>(Date.now())
|
||||||
|
|
||||||
|
// Live clock – uses server time offset
|
||||||
|
useEffect(() => {
|
||||||
|
clockIntervalRef.current = setInterval(() => {
|
||||||
|
const localNow = Date.now()
|
||||||
|
setDisplayTime(new Date(localNow + serverTimeOffset))
|
||||||
|
}, 1000)
|
||||||
|
return () => {
|
||||||
|
if (clockIntervalRef.current) clearInterval(clockIntervalRef.current)
|
||||||
|
}
|
||||||
|
}, [serverTimeOffset])
|
||||||
|
|
||||||
|
// Online/Offline events
|
||||||
|
useEffect(() => {
|
||||||
|
const onOnline = () => setIsOnline(true)
|
||||||
|
const onOffline = () => setIsOnline(false)
|
||||||
|
window.addEventListener('online', onOnline)
|
||||||
|
window.addEventListener('offline', onOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', onOnline)
|
||||||
|
window.removeEventListener('offline', onOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ServiceWorker + BroadcastChannel setup
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Register / retrieve SW
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' })
|
||||||
|
const ready = await navigator.serviceWorker.ready
|
||||||
|
swRegRef.current = ready
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
const result = await sendMessageToSW(ready, { type: 'CHECK_CREDENTIALS' })
|
||||||
|
setHasCredentials(!!result.hasCredentials)
|
||||||
|
setDeviceId(result.deviceId as string | null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasCredentials(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHasCredentials(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
// Leader-election via BroadcastChannel:
|
||||||
|
// We announce ourselves; if we receive an announcement from another tab
|
||||||
|
// we yield. Simple "last writer wins for 1s window" approach.
|
||||||
|
const channel = new BroadcastChannel(HEARTBEAT_CHANNEL)
|
||||||
|
broadcastRef.current = channel
|
||||||
|
|
||||||
|
let isLeader = true
|
||||||
|
|
||||||
|
channel.onmessage = (e) => {
|
||||||
|
if (e.data?.type === 'HEARTBEAT_LEADER_ANNOUNCE') {
|
||||||
|
// Another tab claims leadership – we yield
|
||||||
|
isLeader = false
|
||||||
|
setIsLeaderTab(false)
|
||||||
|
if (heartbeatIntervalRef.current) {
|
||||||
|
clearInterval(heartbeatIntervalRef.current)
|
||||||
|
heartbeatIntervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.data?.type === 'HEARTBEAT_LEADER_YIELD' && !isLeader) {
|
||||||
|
// Previous leader stepped down – we take over
|
||||||
|
isLeader = true
|
||||||
|
setIsLeaderTab(true)
|
||||||
|
startHeartbeatLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce ourselves as leader after a short random delay
|
||||||
|
// to avoid simultaneous announcements on page reload
|
||||||
|
const delay = Math.random() * 500
|
||||||
|
await new Promise(r => setTimeout(r, delay))
|
||||||
|
if (!cancelled) {
|
||||||
|
channel.postMessage({ type: 'HEARTBEAT_LEADER_ANNOUNCE' })
|
||||||
|
setIsLeaderTab(isLeader)
|
||||||
|
if (isLeader) startHeartbeatLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current)
|
||||||
|
if (broadcastRef.current) {
|
||||||
|
broadcastRef.current.postMessage({ type: 'HEARTBEAT_LEADER_YIELD' })
|
||||||
|
broadcastRef.current.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function startHeartbeatLoop() {
|
||||||
|
// Send immediately, then on interval
|
||||||
|
sendHeartbeat()
|
||||||
|
if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current)
|
||||||
|
heartbeatIntervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendHeartbeat() {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
setHeartbeatStatus('error')
|
||||||
|
setHeartbeatError('Kein Netzwerk')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptimeSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000)
|
||||||
|
const body = JSON.stringify({
|
||||||
|
uptime_seconds: uptimeSeconds,
|
||||||
|
client_version: CLIENT_VERSION,
|
||||||
|
queued_offline_entries: 0,
|
||||||
|
current_user_id: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/kiosk/heartbeat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({ detail: res.statusText }))
|
||||||
|
throw new Error(
|
||||||
|
typeof errData.detail === 'string' ? errData.detail : res.statusText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: HeartbeatResponse = await res.json().catch(() => ({}))
|
||||||
|
|
||||||
|
// Sync server time if provided
|
||||||
|
if (data.server_timestamp) {
|
||||||
|
const localNow = Date.now()
|
||||||
|
const serverMs = data.server_timestamp * 1000
|
||||||
|
setServerTimeOffset(serverMs - localNow)
|
||||||
|
} else if (data.server_time) {
|
||||||
|
const localNow = Date.now()
|
||||||
|
const serverMs = new Date(data.server_time).getTime()
|
||||||
|
if (!isNaN(serverMs)) {
|
||||||
|
setServerTimeOffset(serverMs - localNow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHeartbeatStatus('connected')
|
||||||
|
setHeartbeatError(null)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setHeartbeatStatus('error')
|
||||||
|
setHeartbeatError(e instanceof Error ? e.message : 'Verbindungsfehler')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-gray-950 text-white flex flex-col'>
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className='flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-800'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<span className='text-sm font-semibold text-gray-300'>TimeMaster Kiosk</span>
|
||||||
|
{deviceId && (
|
||||||
|
<span className='text-xs text-gray-600 font-mono'>
|
||||||
|
{deviceId.slice(0, 8)}…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-4'>
|
||||||
|
{/* Heartbeat / connection status */}
|
||||||
|
{isLeaderTab && (
|
||||||
|
<div className='flex items-center gap-1.5 text-xs'>
|
||||||
|
{heartbeatStatus === 'connected' && (
|
||||||
|
<>
|
||||||
|
<span className='w-2 h-2 rounded-full bg-green-500 animate-pulse' />
|
||||||
|
<span className='text-green-400'>Verbunden</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{heartbeatStatus === 'error' && (
|
||||||
|
<>
|
||||||
|
<span className='w-2 h-2 rounded-full bg-red-500' />
|
||||||
|
<span className='text-red-400'>{heartbeatError ?? 'Verbindungsfehler'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{heartbeatStatus === 'pending' && (
|
||||||
|
<>
|
||||||
|
<span className='w-2 h-2 rounded-full bg-yellow-500 animate-pulse' />
|
||||||
|
<span className='text-yellow-400'>Verbinde…</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLeaderTab && (
|
||||||
|
<span className='text-xs text-gray-600'>Heartbeat: anderer Tab aktiv</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Online/Offline indicator */}
|
||||||
|
<div className='flex items-center gap-1.5 text-xs'>
|
||||||
|
{isOnline
|
||||||
|
? <span className='text-gray-500'>Online</span>
|
||||||
|
: <span className='text-orange-400 font-semibold'>Offline</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className='flex-1 flex flex-col items-center justify-center gap-8 px-6'>
|
||||||
|
|
||||||
|
{/* Clock */}
|
||||||
|
<div className='text-center'>
|
||||||
|
<div className='text-8xl font-bold font-mono tracking-tight tabular-nums'>
|
||||||
|
{formatTime(displayTime)}
|
||||||
|
</div>
|
||||||
|
<div className='text-xl text-gray-400 mt-2'>
|
||||||
|
{formatDate(displayTime)}
|
||||||
|
</div>
|
||||||
|
{Math.abs(serverTimeOffset) > 1000 && (
|
||||||
|
<div className='text-xs text-yellow-600 mt-1'>
|
||||||
|
Server-Zeit-Offset: {serverTimeOffset > 0 ? '+' : ''}{Math.round(serverTimeOffset / 1000)}s
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Credentials missing warning */}
|
||||||
|
{hasCredentials === false && (
|
||||||
|
<div className='max-w-md w-full bg-yellow-900/50 border border-yellow-700 rounded-2xl p-5 text-center'>
|
||||||
|
<div className='text-3xl mb-2'>⚠️</div>
|
||||||
|
<p className='text-yellow-300 font-semibold mb-1'>Kiosk nicht eingerichtet</p>
|
||||||
|
<p className='text-yellow-500 text-sm mb-4'>
|
||||||
|
Dieses Geraet hat noch kein Ed25519-Schluesselpaar.
|
||||||
|
Bitte zuerst den Setup-Assistenten durchlaufen.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to='/kiosk/setup'
|
||||||
|
className='inline-block px-5 py-2.5 bg-yellow-600 hover:bg-yellow-500 rounded-xl
|
||||||
|
font-semibold text-sm transition-colors'
|
||||||
|
>
|
||||||
|
Kiosk einrichten →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kiosk active state */}
|
||||||
|
{hasCredentials === true && (
|
||||||
|
<div className='max-w-md w-full bg-gray-800 rounded-2xl p-6 text-center space-y-4'>
|
||||||
|
<div className='text-4xl'>✅</div>
|
||||||
|
<p className='text-green-400 font-semibold text-lg'>Kiosk-Modus aktiv</p>
|
||||||
|
<p className='text-gray-400 text-sm'>
|
||||||
|
Alle Stempel-Anfragen werden automatisch per Ed25519 signiert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{heartbeatStatus === 'error' && heartbeatError && (
|
||||||
|
<div className='bg-red-900/40 border border-red-700 rounded-lg px-3 py-2 text-sm text-red-300'>
|
||||||
|
Verbindungsfehler: {heartbeatError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={sendHeartbeat}
|
||||||
|
className='text-xs text-gray-500 hover:text-gray-300 underline transition-colors'
|
||||||
|
>
|
||||||
|
Heartbeat manuell senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{hasCredentials === null && (
|
||||||
|
<div className='text-gray-600 text-sm animate-pulse'>Initialisiere…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className='px-6 py-4 text-center'>
|
||||||
|
<Link
|
||||||
|
to='/kiosk/setup'
|
||||||
|
className='text-xs text-gray-700 hover:text-gray-500 transition-colors underline'
|
||||||
|
>
|
||||||
|
Kiosk-Einrichtung
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user