From e44791fd300c1ada1ded5774ce706feebb8fcfd4 Mon Sep 17 00:00:00 2001 From: Oliver G Date: Sun, 15 Feb 2026 15:31:24 +0100 Subject: [PATCH] feat: add protected admin area with bootstrap and data management --- README.md | 5 + backend/db.py | 9 + backend/server.py | 459 ++++++++++++++++++++++++++++++++++++++++++++++ src/app.js | 311 +++++++++++++++++++++++++++++++ src/index.html | 97 ++++++++++ src/styles.css | 43 ++++- 6 files changed, 923 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b060f4d..9e3c585 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Implementiert: - API: `GET /spot/score`, `POST /spot/signal` - Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche - API: `GET /geocode/search` (Nominatim-Proxy), `GET /map/tile/{z}/{x}/{y}.png` (OSM-Tile-Proxy) +- Admin-Bereich (Setup/Login, geschuetzt per User/Passwort + Session-Token) +- Admin-API fuer Uebersicht und Event-Verwaltung (`/admin/*`) - Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash - Quick Decision UI mit Signal-Buttons - Offline-First im Frontend: Score-Cache + Signal-Queue @@ -77,6 +79,9 @@ Ortssuche: Tile-Proxy: - `curl -I "http://127.0.0.1:8787/map/tile/12/2149/1387.png"` +Admin-Setup (nur beim ersten Start): +- `curl -X POST "http://127.0.0.1:8787/admin/bootstrap" -H "Content-Type: application/json" -d '{"username":"admin","password":""}'` + ## DSGVO-Hinweise im MVP - Kein Login diff --git a/backend/db.py b/backend/db.py index ac2f19d..09c9b4e 100644 --- a/backend/db.py +++ b/backend/db.py @@ -98,6 +98,15 @@ def init_db() -> None: notes TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS admin_user ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_signal_spot_timestamp ON community_signal (spot_id, timestamp); diff --git a/backend/server.py b/backend/server.py index 7f8a1a3..a300fc6 100644 --- a/backend/server.py +++ b/backend/server.py @@ -3,6 +3,7 @@ import hashlib import hmac import json import os +import secrets import uuid from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -36,10 +37,14 @@ NOMINATIM_USER_AGENT = os.environ.get( "StaySense/0.1 (staysense.vanityontour.de)", ) OSM_TILE_BASE_URL = os.environ.get("STAYSENSE_OSM_TILE_BASE_URL", "https://tile.openstreetmap.org") +ADMIN_SESSION_HOURS = int(os.environ.get("STAYSENSE_ADMIN_SESSION_HOURS", "12")) +ADMIN_PBKDF2_ITERATIONS = int(os.environ.get("STAYSENSE_ADMIN_PBKDF2_ITERATIONS", "390000")) +ADMIN_MANUAL_SOURCE = "admin_manual" FALLBACK_POLICE_POINTS = [(51.2507, 6.9751), (51.2965, 6.8494), (51.3398, 7.0438)] FALLBACK_FIRE_POINTS = [(51.2518, 6.9800), (51.2937, 6.8568), (51.3314, 7.0540)] FALLBACK_HOSPITAL_POINTS = [(51.2556, 6.9723), (51.2891, 6.8457), (51.3321, 7.0403)] +ADMIN_SESSIONS: dict[str, dict] = {} def utc_now() -> dt.datetime: @@ -88,6 +93,14 @@ def read_json(handler: BaseHTTPRequestHandler) -> dict: return json.loads(body.decode("utf-8")) +def require_fields(handler: BaseHTTPRequestHandler, body: dict, fields: list[str]) -> bool: + missing = [field for field in fields if not body.get(field)] + if missing: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "missing_fields", "fields": missing}) + return False + return True + + def hashed_device(device_token: str) -> str: return hmac.new( key=SERVER_SALT.encode("utf-8"), @@ -96,6 +109,121 @@ def hashed_device(device_token: str) -> str: ).hexdigest() +def pbkdf2_hash(password: str, salt_hex: str) -> str: + dk = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + bytes.fromhex(salt_hex), + ADMIN_PBKDF2_ITERATIONS, + dklen=32, + ) + return dk.hex() + + +def admin_exists() -> bool: + with get_conn() as conn: + row = conn.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone() + return bool(row) + + +def get_admin_user() -> dict | None: + with get_conn() as conn: + row = conn.execute("SELECT id, username, password_hash, password_salt FROM admin_user WHERE id = 1").fetchone() + return dict(row) if row else None + + +def validate_admin_password(password: str) -> bool: + if not isinstance(password, str): + return False + return len(password) >= 10 + + +def create_admin_user(username: str, password: str) -> tuple[bool, str]: + if not isinstance(username, str) or len(username.strip()) < 3: + return False, "invalid_username" + if not validate_admin_password(password): + return False, "invalid_password" + if admin_exists(): + return False, "already_initialized" + + now_iso = to_iso(utc_now()) + username_clean = username.strip() + salt_hex = secrets.token_hex(16) + pw_hash = pbkdf2_hash(password, salt_hex) + with get_conn() as conn: + conn.execute( + """ + INSERT INTO admin_user (id, username, password_hash, password_salt, created_at, updated_at) + VALUES (1, ?, ?, ?, ?, ?) + """, + (username_clean, pw_hash, salt_hex, now_iso, now_iso), + ) + return True, "created" + + +def create_admin_session(username: str) -> dict: + token = secrets.token_urlsafe(32) + expires_at = utc_now() + dt.timedelta(hours=ADMIN_SESSION_HOURS) + ADMIN_SESSIONS[token] = { + "username": username, + "expires_at": expires_at, + } + return {"token": token, "expires_at": to_iso(expires_at), "session_hours": ADMIN_SESSION_HOURS} + + +def cleanup_admin_sessions() -> None: + now = utc_now() + expired = [token for token, item in ADMIN_SESSIONS.items() if item["expires_at"] <= now] + for token in expired: + ADMIN_SESSIONS.pop(token, None) + + +def admin_auth(handler: BaseHTTPRequestHandler) -> tuple[bool, str]: + cleanup_admin_sessions() + auth_header = handler.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return False, "missing_token" + token = auth_header.replace("Bearer ", "", 1).strip() + session = ADMIN_SESSIONS.get(token) + if not session: + return False, "invalid_token" + if session["expires_at"] <= utc_now(): + ADMIN_SESSIONS.pop(token, None) + return False, "expired_token" + return True, session["username"] + + +def parse_admin_event(body: dict) -> tuple[dict | None, str | None]: + try: + event_type = str(body.get("event_type", "")).strip() + lat = float(body.get("lat")) + lon = float(body.get("lon")) + risk_modifier = int(body.get("risk_modifier")) + start_datetime = to_iso(parse_iso8601(body.get("start_datetime"))) + end_datetime = to_iso(parse_iso8601(body.get("end_datetime"))) + source = str(body.get("source", ADMIN_MANUAL_SOURCE)).strip() or ADMIN_MANUAL_SOURCE + except Exception: + return None, "invalid_payload" + + if event_type not in {"market", "waste", "event", "construction"}: + return None, "invalid_event_type" + if not (47.0 <= lat <= 55.5 and 5.0 <= lon <= 16.0): + return None, "lat_lon_out_of_bounds" + if start_datetime >= end_datetime: + return None, "invalid_time_window" + if risk_modifier < -50 or risk_modifier > 50: + return None, "invalid_risk_modifier" + return { + "event_type": event_type, + "lat": lat, + "lon": lon, + "risk_modifier": risk_modifier, + "start_datetime": start_datetime, + "end_datetime": end_datetime, + "source": source, + }, None + + def fetch_points(sql: str, params: tuple) -> list[tuple[float, float]]: with get_conn() as conn: rows = conn.execute(sql, params).fetchall() @@ -599,6 +727,293 @@ def handle_tile_proxy(handler: BaseHTTPRequestHandler, path: str) -> None: binary_response(handler, HTTPStatus.OK, payload, "image/png", cache_control="public, max-age=43200") +def handle_admin_bootstrap_status(handler: BaseHTTPRequestHandler) -> None: + json_response( + handler, + HTTPStatus.OK, + { + "initialized": admin_exists(), + "session_hours": ADMIN_SESSION_HOURS, + "password_policy": {"min_length": 10}, + }, + ) + + +def handle_admin_bootstrap(handler: BaseHTTPRequestHandler) -> None: + try: + body = read_json(handler) + except Exception: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"}) + return + if not require_fields(handler, body, ["username", "password"]): + return + + ok, status = create_admin_user(body["username"], body["password"]) + if not ok: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": status}) + return + session = create_admin_session(body["username"].strip()) + json_response(handler, HTTPStatus.CREATED, {"created": True, "session": session}) + + +def handle_admin_login(handler: BaseHTTPRequestHandler) -> None: + try: + body = read_json(handler) + except Exception: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"}) + return + if not require_fields(handler, body, ["username", "password"]): + return + if not admin_exists(): + json_response(handler, HTTPStatus.PRECONDITION_FAILED, {"error": "admin_not_initialized"}) + return + + admin = get_admin_user() + if not admin: + json_response(handler, HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "admin_lookup_failed"}) + return + if body["username"].strip() != admin["username"]: + json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": "invalid_credentials"}) + return + + expected = pbkdf2_hash(body["password"], admin["password_salt"]) + if not hmac.compare_digest(expected, admin["password_hash"]): + json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": "invalid_credentials"}) + return + + session = create_admin_session(admin["username"]) + json_response(handler, HTTPStatus.OK, {"login": "ok", "session": session}) + + +def handle_admin_logout(handler: BaseHTTPRequestHandler) -> None: + auth_header = handler.headers.get("Authorization", "") + token = auth_header.replace("Bearer ", "", 1).strip() if auth_header.startswith("Bearer ") else "" + if token: + ADMIN_SESSIONS.pop(token, None) + json_response(handler, HTTPStatus.OK, {"logout": "ok"}) + + +def handle_admin_overview(handler: BaseHTTPRequestHandler) -> None: + ok, username = admin_auth(handler) + if not ok: + json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username}) + return + + with get_conn() as conn: + counts = { + "spots": conn.execute("SELECT COUNT(*) AS c FROM spot").fetchone()["c"], + "signals": conn.execute("SELECT COUNT(*) AS c FROM community_signal").fetchone()["c"], + "events": conn.execute("SELECT COUNT(*) AS c FROM open_data_event").fetchone()["c"], + "data_sources": conn.execute("SELECT COUNT(*) AS c FROM data_source_state").fetchone()["c"], + } + latest_signals = [dict(r) for r in conn.execute( + """ + SELECT spot_id, signal_type, timestamp + FROM community_signal + ORDER BY timestamp DESC + LIMIT 20 + """ + ).fetchall()] + latest_events = [dict(r) for r in conn.execute( + """ + SELECT id, event_type, lat, lon, start_datetime, end_datetime, risk_modifier, source, imported_at + FROM open_data_event + ORDER BY start_datetime DESC + LIMIT 20 + """ + ).fetchall()] + sources = [dict(r) for r in conn.execute( + """ + SELECT source_name, imported_at, record_count, notes + FROM data_source_state + ORDER BY imported_at DESC + LIMIT 20 + """ + ).fetchall()] + json_response( + handler, + HTTPStatus.OK, + { + "admin_user": username, + "counts": counts, + "latest_signals": latest_signals, + "latest_events": latest_events, + "data_sources": sources, + }, + ) + + +def handle_admin_events_list(handler: BaseHTTPRequestHandler, query: dict[str, list[str]]) -> None: + ok, username = admin_auth(handler) + if not ok: + json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username}) + return + try: + limit = int((query.get("limit") or ["100"])[0]) + except Exception: + limit = 100 + limit = max(1, min(limit, 500)) + with get_conn() as conn: + events = [dict(r) for r in conn.execute( + """ + SELECT id, event_type, lat, lon, start_datetime, end_datetime, risk_modifier, source, imported_at + FROM open_data_event + ORDER BY start_datetime DESC + LIMIT ? + """, + (limit,), + ).fetchall()] + json_response(handler, HTTPStatus.OK, {"admin_user": username, "events": events}) + + +def handle_admin_events_create(handler: BaseHTTPRequestHandler) -> None: + ok, username = admin_auth(handler) + if not ok: + json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username}) + return + try: + body = read_json(handler) + except Exception: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"}) + return + event, error = parse_admin_event(body) + if error: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": error}) + return + + now_iso = to_iso(utc_now()) + event_id = str(uuid.uuid4()) + with get_conn() as conn: + conn.execute( + """ + INSERT INTO open_data_event ( + id, event_type, lat, lon, start_datetime, end_datetime, risk_modifier, source, imported_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + event["event_type"], + event["lat"], + event["lon"], + event["start_datetime"], + event["end_datetime"], + event["risk_modifier"], + event["source"], + now_iso, + ), + ) + count = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (event["source"],)).fetchone()["c"] + conn.execute( + """ + INSERT INTO data_source_state (source_name, imported_at, record_count, notes) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_name) DO UPDATE SET + imported_at = excluded.imported_at, + record_count = excluded.record_count, + notes = excluded.notes + """, + (event["source"], now_iso, count, f"manual update by {username}"), + ) + json_response(handler, HTTPStatus.CREATED, {"created": True, "id": event_id}) + + +def handle_admin_events_update(handler: BaseHTTPRequestHandler, event_id: str) -> None: + ok, username = admin_auth(handler) + if not ok: + json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username}) + return + try: + body = read_json(handler) + except Exception: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"}) + return + event, error = parse_admin_event(body) + if error: + json_response(handler, HTTPStatus.BAD_REQUEST, {"error": error}) + return + + now_iso = to_iso(utc_now()) + with get_conn() as conn: + existing = conn.execute("SELECT source FROM open_data_event WHERE id = ?", (event_id,)).fetchone() + if not existing: + json_response(handler, HTTPStatus.NOT_FOUND, {"error": "event_not_found"}) + return + conn.execute( + """ + UPDATE open_data_event + SET event_type = ?, lat = ?, lon = ?, start_datetime = ?, end_datetime = ?, risk_modifier = ?, source = ?, imported_at = ? + WHERE id = ? + """, + ( + event["event_type"], + event["lat"], + event["lon"], + event["start_datetime"], + event["end_datetime"], + event["risk_modifier"], + event["source"], + now_iso, + event_id, + ), + ) + count_new = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (event["source"],)).fetchone()["c"] + conn.execute( + """ + INSERT INTO data_source_state (source_name, imported_at, record_count, notes) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_name) DO UPDATE SET + imported_at = excluded.imported_at, + record_count = excluded.record_count, + notes = excluded.notes + """, + (event["source"], now_iso, count_new, f"manual update by {username}"), + ) + old_source = existing["source"] + if old_source != event["source"]: + count_old = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (old_source,)).fetchone()["c"] + if count_old == 0: + conn.execute("DELETE FROM data_source_state WHERE source_name = ?", (old_source,)) + else: + conn.execute( + """ + UPDATE data_source_state + SET imported_at = ?, record_count = ?, notes = ? + WHERE source_name = ? + """, + (now_iso, count_old, f"manual update by {username}", old_source), + ) + json_response(handler, HTTPStatus.OK, {"updated": True, "id": event_id}) + + +def handle_admin_events_delete(handler: BaseHTTPRequestHandler, event_id: str) -> None: + ok, username = admin_auth(handler) + if not ok: + json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username}) + return + + now_iso = to_iso(utc_now()) + with get_conn() as conn: + existing = conn.execute("SELECT source FROM open_data_event WHERE id = ?", (event_id,)).fetchone() + if not existing: + json_response(handler, HTTPStatus.NOT_FOUND, {"error": "event_not_found"}) + return + source_name = existing["source"] + conn.execute("DELETE FROM open_data_event WHERE id = ?", (event_id,)) + count = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (source_name,)).fetchone()["c"] + if count == 0: + conn.execute("DELETE FROM data_source_state WHERE source_name = ?", (source_name,)) + else: + conn.execute( + """ + UPDATE data_source_state + SET imported_at = ?, record_count = ?, notes = ? + WHERE source_name = ? + """, + (now_iso, count, f"manual delete by {username}", source_name), + ) + json_response(handler, HTTPStatus.OK, {"deleted": True, "id": event_id}) + + class StaySenseHandler(BaseHTTPRequestHandler): def log_message(self, format: str, *args) -> None: return @@ -620,6 +1035,16 @@ class StaySenseHandler(BaseHTTPRequestHandler): if parsed.path.startswith("/map/tile/"): handle_tile_proxy(self, parsed.path) return + if parsed.path == "/admin/bootstrap/status": + handle_admin_bootstrap_status(self) + return + if parsed.path == "/admin/overview": + handle_admin_overview(self) + return + if parsed.path == "/admin/events": + query = parse_qs(parsed.query) + handle_admin_events_list(self, query) + return json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"}) def do_POST(self) -> None: @@ -627,6 +1052,40 @@ class StaySenseHandler(BaseHTTPRequestHandler): if parsed.path == "/spot/signal": handle_signal(self) return + if parsed.path == "/admin/bootstrap": + handle_admin_bootstrap(self) + return + if parsed.path == "/admin/login": + handle_admin_login(self) + return + if parsed.path == "/admin/logout": + handle_admin_logout(self) + return + if parsed.path == "/admin/events": + handle_admin_events_create(self) + return + json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"}) + + def do_PUT(self) -> None: + parsed = urlparse(self.path) + if parsed.path.startswith("/admin/events/"): + event_id = parsed.path.replace("/admin/events/", "", 1).strip() + if not event_id: + json_response(self, HTTPStatus.BAD_REQUEST, {"error": "event_id_required"}) + return + handle_admin_events_update(self, event_id) + return + json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"}) + + def do_DELETE(self) -> None: + parsed = urlparse(self.path) + if parsed.path.startswith("/admin/events/"): + event_id = parsed.path.replace("/admin/events/", "", 1).strip() + if not event_id: + json_response(self, HTTPStatus.BAD_REQUEST, {"error": "event_id_required"}) + return + handle_admin_events_delete(self, event_id) + return json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"}) diff --git a/src/app.js b/src/app.js index 427288e..80bc08e 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,7 @@ const DEVICE_TOKEN_KEY = "staysense.device_token.v1"; const SETTINGS_KEY = "staysense.settings.v1"; const SCORE_CACHE_KEY = "staysense.score_cache.v1"; const SIGNAL_QUEUE_KEY = "staysense.signal_queue.v1"; +const ADMIN_TOKEN_KEY = "staysense.admin_token.v1"; const MAX_CACHE_ITEMS = 50; const latEl = document.getElementById("lat"); @@ -31,6 +32,32 @@ const queueStatusEl = document.getElementById("queue-status"); const signalsEnabledEl = document.getElementById("signals-enabled"); const legalOutputEl = document.getElementById("legal-output"); +const adminSetupEl = document.getElementById("admin-setup"); +const adminLoginEl = document.getElementById("admin-login"); +const adminContentEl = document.getElementById("admin-content"); +const adminStatusEl = document.getElementById("admin-status"); +const adminCountsEl = document.getElementById("admin-counts"); +const adminEventsEl = document.getElementById("admin-events"); +const adminSignalsEl = document.getElementById("admin-signals"); +const adminSourcesEl = document.getElementById("admin-sources"); +const adminSetupUserEl = document.getElementById("admin-setup-user"); +const adminSetupPassEl = document.getElementById("admin-setup-pass"); +const adminSetupSubmitEl = document.getElementById("admin-setup-submit"); +const adminLoginUserEl = document.getElementById("admin-login-user"); +const adminLoginPassEl = document.getElementById("admin-login-pass"); +const adminLoginSubmitEl = document.getElementById("admin-login-submit"); +const adminLogoutEl = document.getElementById("admin-logout"); +const adminRefreshEl = document.getElementById("admin-refresh"); +const adminEventCreateEl = document.getElementById("admin-event-create"); +const adminEventDeleteEl = document.getElementById("admin-event-delete"); +const adminEventIdEl = document.getElementById("admin-event-id"); +const adminEventTypeEl = document.getElementById("admin-event-type"); +const adminEventRiskEl = document.getElementById("admin-event-risk"); +const adminEventLatEl = document.getElementById("admin-event-lat"); +const adminEventLonEl = document.getElementById("admin-event-lon"); +const adminEventStartEl = document.getElementById("admin-event-start"); +const adminEventEndEl = document.getElementById("admin-event-end"); +const adminEventSourceEl = document.getElementById("admin-event-source"); let currentSpot = null; let scoreCache = loadJSON(SCORE_CACHE_KEY, []); @@ -43,6 +70,7 @@ let map = null; let mapMarker = null; let searchResults = []; let selectedSearchIndex = -1; +let adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || ""; const deviceToken = ensureDeviceToken(); initialize(); @@ -90,6 +118,14 @@ function initialize() { legalOutputEl.textContent = "MVP-Hinweis: Impressum im Produktionsbetrieb verpflichtend mit Anbieterkennzeichnung."; }); + adminSetupSubmitEl.addEventListener("click", adminBootstrap); + adminLoginSubmitEl.addEventListener("click", adminLogin); + adminLogoutEl.addEventListener("click", adminLogout); + adminRefreshEl.addEventListener("click", loadAdminOverview); + adminEventCreateEl.addEventListener("click", saveAdminEvent); + adminEventDeleteEl.addEventListener("click", deleteAdminEvent); + adminEventsEl.addEventListener("click", onAdminEventListClick); + // Pilotwert für Mettmann, falls noch keine Eingabe. if (!latEl.value && !lonEl.value) { latEl.value = "51.2500"; @@ -102,6 +138,7 @@ function initialize() { renderQueueStatus(); checkApiHealth(); setInterval(checkApiHealth, 30000); + loadAdminBootstrapStatus(); } function ensureDeviceToken() { @@ -145,17 +182,30 @@ async function checkApiHealth() { if (!response.ok) { throw new Error("health_failed"); } + const payload = await response.json(); apiOnline = true; lastHealthLatencyMs = Math.round(performance.now() - started); lastHealthCheckAt = new Date().toISOString(); + renderDataStatusFromHealth(payload && payload.health ? payload.health : null); } catch { apiOnline = false; lastHealthLatencyMs = null; lastHealthCheckAt = new Date().toISOString(); + dataStatusEl.textContent = "Datenstand: aktuell nicht abrufbar (API offline)"; } renderNetworkStatus(); } +function renderDataStatusFromHealth(health) { + if (!health || !health.has_data) { + dataStatusEl.textContent = "Datenstand: keine Quellenmetadaten"; + return; + } + const freshness = `freshest ${health.freshest_age_hours}h, stalest ${health.stalest_age_hours}h`; + const stale = health.stale_sources && health.stale_sources.length ? `, stale: ${health.stale_sources.join(", ")}` : ""; + dataStatusEl.textContent = `Datenstand: ${freshness}${stale}`; +} + function renderQueueStatus() { queueStatusEl.textContent = `Warteschlange: ${signalQueue.length} ausstehend`; } @@ -282,6 +332,267 @@ async function searchLocation() { } } +async function loadAdminBootstrapStatus() { + adminStatusEl.textContent = "Admin-Status wird geladen ..."; + try { + const response = await fetch(`${API_BASE}/admin/bootstrap/status`, { cache: "no-store" }); + if (!response.ok) { + throw new Error("bootstrap_status_failed"); + } + const payload = await response.json(); + renderAdminMode(payload.initialized); + if (!payload.initialized) { + adminStatusEl.textContent = "Erst-Setup erforderlich: Bitte initialen Admin anlegen."; + return; + } + adminStatusEl.textContent = adminToken + ? "Admin bereit. Session wird geprüft ..." + : "Admin bereit. Bitte anmelden."; + if (adminToken) { + await loadAdminOverview(); + } + } catch { + renderAdminMode(false); + adminStatusEl.textContent = "Admin-Status konnte nicht geladen werden."; + } +} + +function renderAdminMode(initialized) { + adminSetupEl.classList.toggle("hidden", initialized); + adminLoginEl.classList.toggle("hidden", !initialized); + if (!initialized) { + adminContentEl.classList.add("hidden"); + } +} + +function adminHeaders() { + return adminToken ? { Authorization: `Bearer ${adminToken}` } : {}; +} + +async function adminBootstrap() { + const username = adminSetupUserEl.value.trim(); + const password = adminSetupPassEl.value; + if (username.length < 3 || password.length < 10) { + adminStatusEl.textContent = "Setup fehlgeschlagen: User mind. 3 Zeichen, Passwort mind. 10 Zeichen."; + return; + } + adminSetupSubmitEl.disabled = true; + try { + const response = await fetch(`${API_BASE}/admin/bootstrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || "setup_failed"); + } + adminToken = payload.session.token; + localStorage.setItem(ADMIN_TOKEN_KEY, adminToken); + adminSetupPassEl.value = ""; + adminLoginUserEl.value = username; + adminStatusEl.textContent = "Admin wurde angelegt und eingeloggt."; + renderAdminMode(true); + await loadAdminOverview(); + } catch (error) { + adminStatusEl.textContent = `Setup fehlgeschlagen: ${String(error.message || "unbekannter Fehler")}`; + } finally { + adminSetupSubmitEl.disabled = false; + } +} + +async function adminLogin() { + const username = adminLoginUserEl.value.trim(); + const password = adminLoginPassEl.value; + if (!username || !password) { + adminStatusEl.textContent = "Bitte User und Passwort eingeben."; + return; + } + adminLoginSubmitEl.disabled = true; + try { + const response = await fetch(`${API_BASE}/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || "login_failed"); + } + adminToken = payload.session.token; + localStorage.setItem(ADMIN_TOKEN_KEY, adminToken); + adminLoginPassEl.value = ""; + adminStatusEl.textContent = "Admin Login erfolgreich."; + await loadAdminOverview(); + } catch (error) { + adminStatusEl.textContent = `Login fehlgeschlagen: ${String(error.message || "unbekannter Fehler")}`; + } finally { + adminLoginSubmitEl.disabled = false; + } +} + +async function adminLogout() { + try { + await fetch(`${API_BASE}/admin/logout`, { method: "POST", headers: adminHeaders() }); + } catch { + // ignore + } + adminToken = ""; + localStorage.removeItem(ADMIN_TOKEN_KEY); + adminContentEl.classList.add("hidden"); + adminStatusEl.textContent = "Abgemeldet."; +} + +function renderAdminList(target, rows, mapper) { + target.innerHTML = ""; + if (!rows || !rows.length) { + target.textContent = "Keine Daten."; + return; + } + rows.forEach((row) => { + const div = document.createElement("div"); + div.className = "admin-list-item"; + if (row && row.id) { + div.dataset.eventId = row.id; + div.title = "Klick setzt Event-ID"; + } + div.textContent = mapper(row); + target.appendChild(div); + }); +} + +async function loadAdminOverview() { + if (!adminToken) { + adminStatusEl.textContent = "Nicht eingeloggt."; + adminContentEl.classList.add("hidden"); + return; + } + try { + const response = await fetch(`${API_BASE}/admin/overview`, { headers: adminHeaders() }); + const payload = await response.json().catch(() => ({})); + if (response.status === 401) { + adminToken = ""; + localStorage.removeItem(ADMIN_TOKEN_KEY); + adminContentEl.classList.add("hidden"); + adminStatusEl.textContent = "Session abgelaufen. Bitte erneut anmelden."; + return; + } + if (!response.ok) { + throw new Error(payload.error || "admin_overview_failed"); + } + adminContentEl.classList.remove("hidden"); + adminStatusEl.textContent = `Eingeloggt als ${payload.admin_user}.`; + adminCountsEl.textContent = `Spots: ${payload.counts.spots} | Signale: ${payload.counts.signals} | Events: ${payload.counts.events} | Quellen: ${payload.counts.data_sources}`; + renderAdminList( + adminEventsEl, + payload.latest_events, + (row) => + `${row.id} | ${row.event_type} | ${Number(row.lat).toFixed(5)}, ${Number(row.lon).toFixed(5)} | ${row.start_datetime} -> ${row.end_datetime} | risk ${row.risk_modifier} | ${row.source}` + ); + renderAdminList( + adminSignalsEl, + payload.latest_signals, + (row) => `${row.timestamp} | ${row.signal_type} | spot ${row.spot_id}` + ); + renderAdminList( + adminSourcesEl, + payload.data_sources, + (row) => `${row.source_name} | ${row.record_count} records | ${row.imported_at} | ${row.notes}` + ); + } catch (error) { + adminStatusEl.textContent = `Admin-Daten konnten nicht geladen werden: ${String(error.message || "unbekannter Fehler")}`; + } +} + +function buildAdminEventPayload() { + return { + event_type: adminEventTypeEl.value, + risk_modifier: Number(adminEventRiskEl.value), + lat: Number(adminEventLatEl.value), + lon: Number(adminEventLonEl.value), + start_datetime: adminEventStartEl.value.trim(), + end_datetime: adminEventEndEl.value.trim(), + source: adminEventSourceEl.value.trim() || "admin_manual", + }; +} + +async function saveAdminEvent() { + if (!adminToken) { + adminStatusEl.textContent = "Bitte zuerst anmelden."; + return; + } + const eventId = adminEventIdEl.value.trim(); + const payload = buildAdminEventPayload(); + if (!Number.isFinite(payload.lat) || !Number.isFinite(payload.lon)) { + adminStatusEl.textContent = "Ungültige Event-Koordinaten."; + return; + } + const method = eventId ? "PUT" : "POST"; + const url = eventId ? `${API_BASE}/admin/events/${encodeURIComponent(eventId)}` : `${API_BASE}/admin/events`; + adminEventCreateEl.disabled = true; + try { + const response = await fetch(url, { + method, + headers: { ...adminHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.error || "event_save_failed"); + } + adminStatusEl.textContent = eventId ? `Event aktualisiert: ${eventId}` : `Event angelegt: ${body.id}`; + if (!eventId && body.id) { + adminEventIdEl.value = body.id; + } + await loadAdminOverview(); + } catch (error) { + adminStatusEl.textContent = `Event konnte nicht gespeichert werden: ${String(error.message || "unbekannter Fehler")}`; + } finally { + adminEventCreateEl.disabled = false; + } +} + +async function deleteAdminEvent() { + if (!adminToken) { + adminStatusEl.textContent = "Bitte zuerst anmelden."; + return; + } + const eventId = adminEventIdEl.value.trim(); + if (!eventId) { + adminStatusEl.textContent = "Bitte Event-ID zum Löschen eingeben."; + return; + } + adminEventDeleteEl.disabled = true; + try { + const response = await fetch(`${API_BASE}/admin/events/${encodeURIComponent(eventId)}`, { + method: "DELETE", + headers: adminHeaders(), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.error || "event_delete_failed"); + } + adminStatusEl.textContent = `Event gelöscht: ${eventId}`; + adminEventIdEl.value = ""; + await loadAdminOverview(); + } catch (error) { + adminStatusEl.textContent = `Event konnte nicht gelöscht werden: ${String(error.message || "unbekannter Fehler")}`; + } finally { + adminEventDeleteEl.disabled = false; + } +} + +function onAdminEventListClick(event) { + const row = event.target.closest(".admin-list-item"); + if (!row) { + return; + } + if (row.dataset.eventId) { + adminEventIdEl.value = row.dataset.eventId; + adminStatusEl.textContent = `Event-ID übernommen: ${row.dataset.eventId}`; + } +} + function renderSearchResults() { searchResultsEl.innerHTML = ""; searchResults.forEach((result, index) => { diff --git a/src/index.html b/src/index.html index 5f4182a..e51662e 100644 --- a/src/index.html +++ b/src/index.html @@ -75,6 +75,103 @@ Warteschlange: 0 ausstehend +
+

Admin

+

Geschützter Bereich für Datenansicht und Event-Verwaltung.

+ + + + + + Admin-Status wird geladen ... + + +
+

Settings