feat: add protected admin area with bootstrap and data management

This commit is contained in:
Oliver 2026-02-15 15:31:24 +01:00
parent 628d73afd6
commit e44791fd30
No known key found for this signature in database
6 changed files with 923 additions and 1 deletions

View file

@ -10,6 +10,8 @@ Implementiert:
- API: `GET /spot/score`, `POST /spot/signal` - API: `GET /spot/score`, `POST /spot/signal`
- Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche - Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche
- API: `GET /geocode/search` (Nominatim-Proxy), `GET /map/tile/{z}/{x}/{y}.png` (OSM-Tile-Proxy) - 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 - Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash
- Quick Decision UI mit Signal-Buttons - Quick Decision UI mit Signal-Buttons
- Offline-First im Frontend: Score-Cache + Signal-Queue - Offline-First im Frontend: Score-Cache + Signal-Queue
@ -77,6 +79,9 @@ Ortssuche:
Tile-Proxy: Tile-Proxy:
- `curl -I "http://127.0.0.1:8787/map/tile/12/2149/1387.png"` - `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":"<starkes-passwort>"}'`
## DSGVO-Hinweise im MVP ## DSGVO-Hinweise im MVP
- Kein Login - Kein Login

View file

@ -98,6 +98,15 @@ def init_db() -> None:
notes TEXT NOT NULL 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 CREATE INDEX IF NOT EXISTS idx_signal_spot_timestamp
ON community_signal (spot_id, timestamp); ON community_signal (spot_id, timestamp);

View file

@ -3,6 +3,7 @@ import hashlib
import hmac import hmac
import json import json
import os import os
import secrets
import uuid import uuid
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -36,10 +37,14 @@ NOMINATIM_USER_AGENT = os.environ.get(
"StaySense/0.1 (staysense.vanityontour.de)", "StaySense/0.1 (staysense.vanityontour.de)",
) )
OSM_TILE_BASE_URL = os.environ.get("STAYSENSE_OSM_TILE_BASE_URL", "https://tile.openstreetmap.org") 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_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_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)] 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: def utc_now() -> dt.datetime:
@ -88,6 +93,14 @@ def read_json(handler: BaseHTTPRequestHandler) -> dict:
return json.loads(body.decode("utf-8")) 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: def hashed_device(device_token: str) -> str:
return hmac.new( return hmac.new(
key=SERVER_SALT.encode("utf-8"), key=SERVER_SALT.encode("utf-8"),
@ -96,6 +109,121 @@ def hashed_device(device_token: str) -> str:
).hexdigest() ).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]]: def fetch_points(sql: str, params: tuple) -> list[tuple[float, float]]:
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute(sql, params).fetchall() 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") 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): class StaySenseHandler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args) -> None: def log_message(self, format: str, *args) -> None:
return return
@ -620,6 +1035,16 @@ class StaySenseHandler(BaseHTTPRequestHandler):
if parsed.path.startswith("/map/tile/"): if parsed.path.startswith("/map/tile/"):
handle_tile_proxy(self, parsed.path) handle_tile_proxy(self, parsed.path)
return 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"}) json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"})
def do_POST(self) -> None: def do_POST(self) -> None:
@ -627,6 +1052,40 @@ class StaySenseHandler(BaseHTTPRequestHandler):
if parsed.path == "/spot/signal": if parsed.path == "/spot/signal":
handle_signal(self) handle_signal(self)
return 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"}) json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"})

View file

@ -8,6 +8,7 @@ const DEVICE_TOKEN_KEY = "staysense.device_token.v1";
const SETTINGS_KEY = "staysense.settings.v1"; const SETTINGS_KEY = "staysense.settings.v1";
const SCORE_CACHE_KEY = "staysense.score_cache.v1"; const SCORE_CACHE_KEY = "staysense.score_cache.v1";
const SIGNAL_QUEUE_KEY = "staysense.signal_queue.v1"; const SIGNAL_QUEUE_KEY = "staysense.signal_queue.v1";
const ADMIN_TOKEN_KEY = "staysense.admin_token.v1";
const MAX_CACHE_ITEMS = 50; const MAX_CACHE_ITEMS = 50;
const latEl = document.getElementById("lat"); const latEl = document.getElementById("lat");
@ -31,6 +32,32 @@ const queueStatusEl = document.getElementById("queue-status");
const signalsEnabledEl = document.getElementById("signals-enabled"); const signalsEnabledEl = document.getElementById("signals-enabled");
const legalOutputEl = document.getElementById("legal-output"); 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 currentSpot = null;
let scoreCache = loadJSON(SCORE_CACHE_KEY, []); let scoreCache = loadJSON(SCORE_CACHE_KEY, []);
@ -43,6 +70,7 @@ let map = null;
let mapMarker = null; let mapMarker = null;
let searchResults = []; let searchResults = [];
let selectedSearchIndex = -1; let selectedSearchIndex = -1;
let adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || "";
const deviceToken = ensureDeviceToken(); const deviceToken = ensureDeviceToken();
initialize(); initialize();
@ -90,6 +118,14 @@ function initialize() {
legalOutputEl.textContent = "MVP-Hinweis: Impressum im Produktionsbetrieb verpflichtend mit Anbieterkennzeichnung."; 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. // Pilotwert für Mettmann, falls noch keine Eingabe.
if (!latEl.value && !lonEl.value) { if (!latEl.value && !lonEl.value) {
latEl.value = "51.2500"; latEl.value = "51.2500";
@ -102,6 +138,7 @@ function initialize() {
renderQueueStatus(); renderQueueStatus();
checkApiHealth(); checkApiHealth();
setInterval(checkApiHealth, 30000); setInterval(checkApiHealth, 30000);
loadAdminBootstrapStatus();
} }
function ensureDeviceToken() { function ensureDeviceToken() {
@ -145,17 +182,30 @@ async function checkApiHealth() {
if (!response.ok) { if (!response.ok) {
throw new Error("health_failed"); throw new Error("health_failed");
} }
const payload = await response.json();
apiOnline = true; apiOnline = true;
lastHealthLatencyMs = Math.round(performance.now() - started); lastHealthLatencyMs = Math.round(performance.now() - started);
lastHealthCheckAt = new Date().toISOString(); lastHealthCheckAt = new Date().toISOString();
renderDataStatusFromHealth(payload && payload.health ? payload.health : null);
} catch { } catch {
apiOnline = false; apiOnline = false;
lastHealthLatencyMs = null; lastHealthLatencyMs = null;
lastHealthCheckAt = new Date().toISOString(); lastHealthCheckAt = new Date().toISOString();
dataStatusEl.textContent = "Datenstand: aktuell nicht abrufbar (API offline)";
} }
renderNetworkStatus(); 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() { function renderQueueStatus() {
queueStatusEl.textContent = `Warteschlange: ${signalQueue.length} ausstehend`; 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() { function renderSearchResults() {
searchResultsEl.innerHTML = ""; searchResultsEl.innerHTML = "";
searchResults.forEach((result, index) => { searchResults.forEach((result, index) => {

View file

@ -75,6 +75,103 @@
<small id="queue-status">Warteschlange: 0 ausstehend</small> <small id="queue-status">Warteschlange: 0 ausstehend</small>
</section> </section>
<section class="panel admin-panel">
<h2>Admin</h2>
<p class="small">Geschützter Bereich für Datenansicht und Event-Verwaltung.</p>
<div id="admin-setup" class="admin-block hidden">
<h3>Erst-Setup</h3>
<label>
Admin User
<input id="admin-setup-user" type="text" placeholder="admin" />
</label>
<label>
Admin Passwort
<input id="admin-setup-pass" type="password" placeholder="mind. 10 Zeichen" />
</label>
<button id="admin-setup-submit" class="btn">Admin anlegen</button>
</div>
<div id="admin-login" class="admin-block hidden">
<h3>Login</h3>
<label>
User
<input id="admin-login-user" type="text" placeholder="admin" />
</label>
<label>
Passwort
<input id="admin-login-pass" type="password" />
</label>
<div class="button-row">
<button id="admin-login-submit" class="btn">Anmelden</button>
<button id="admin-logout" class="btn secondary">Abmelden</button>
</div>
</div>
<small id="admin-status">Admin-Status wird geladen ...</small>
<div id="admin-content" class="admin-block hidden">
<h3>Übersicht</h3>
<div id="admin-counts" class="admin-counts">-</div>
<h3>Event-Verwaltung</h3>
<label>
Event-ID (leer = neu)
<input id="admin-event-id" type="text" placeholder="UUID für Update/Delete" />
</label>
<div class="field-grid">
<label>
Typ
<select id="admin-event-type">
<option value="event">event</option>
<option value="market">market</option>
<option value="waste">waste</option>
<option value="construction">construction</option>
</select>
</label>
<label>
Risk Modifier
<input id="admin-event-risk" type="number" value="-10" min="-50" max="50" />
</label>
<label>
Latitude
<input id="admin-event-lat" type="number" step="0.000001" placeholder="51.250000" />
</label>
<label>
Longitude
<input id="admin-event-lon" type="number" step="0.000001" placeholder="6.973000" />
</label>
<label>
Start (ISO)
<input id="admin-event-start" type="text" placeholder="2026-02-15T20:00:00Z" />
</label>
<label>
Ende (ISO)
<input id="admin-event-end" type="text" placeholder="2026-02-16T06:00:00Z" />
</label>
<label>
Source
<input id="admin-event-source" type="text" value="admin_manual" />
</label>
</div>
<div class="button-row">
<button id="admin-event-create" class="btn">Event speichern</button>
<button id="admin-event-delete" class="btn secondary">Event löschen</button>
<button id="admin-refresh" class="btn secondary">Daten neu laden</button>
</div>
<h3>Letzte Events</h3>
<div id="admin-events" class="admin-list"></div>
<h3>Letzte Signale</h3>
<div id="admin-signals" class="admin-list"></div>
<h3>Datenquellen</h3>
<div id="admin-sources" class="admin-list"></div>
</div>
</section>
<section class="panel settings-panel"> <section class="panel settings-panel">
<h2>Settings</h2> <h2>Settings</h2>
<label class="toggle"> <label class="toggle">

View file

@ -108,11 +108,13 @@ label {
} }
input, input,
select,
button { button {
font: inherit; font: inherit;
} }
input { input,
select {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 10px;
padding: 9px; padding: 9px;
@ -281,6 +283,45 @@ input {
min-height: 42px; min-height: 42px;
} }
.hidden {
display: none;
}
.admin-block {
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
margin-top: 10px;
}
.admin-counts {
color: var(--ink);
background: #f7fafc;
border: 1px solid var(--line);
border-radius: 10px;
padding: 8px;
}
.admin-list {
border: 1px solid var(--line);
border-radius: 10px;
max-height: 180px;
overflow: auto;
padding: 8px;
background: #fff;
color: var(--muted);
font-size: 0.88rem;
}
.admin-list-item {
padding: 6px 0;
border-bottom: 1px solid var(--line);
}
.admin-list-item:last-child {
border-bottom: 0;
}
@media (min-width: 920px) { @media (min-width: 920px) {
.grid { .grid {
grid-template-columns: 1fr 1.2fr 0.9fr; grid-template-columns: 1fr 1.2fr 0.9fr;