From a64c9ced65a4d34fbfe102847d5b89eff6d25cfa Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:29:14 +0000 Subject: [PATCH 01/27] =?UTF-8?q?docs:=20client=20portal=20design=20+=20mi?= =?UTF-8?q?lestone=20plan=20(M1=20live=20view=20=E2=86=92=20M4=20full=20au?= =?UTF-8?q?th)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only, client-scoped portal inside Terra-View (/portal/*), reusing cached SLMM reads. Data chain Client -> Project.client_id -> MonitoringLocation -> active UnitAssignment -> unit_id -> SLMM cache. Auth is a swappable get_current_client gate; M1-M3 ride an interim signed "magic URL", M4 replaces the backing. Milestones: M1 live view, M2 dashboard+alerts, M3 reports, M4 auth. Co-Authored-By: Claude Opus 4.8 --- docs/CLIENT_PORTAL.md | 142 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/CLIENT_PORTAL.md diff --git a/docs/CLIENT_PORTAL.md b/docs/CLIENT_PORTAL.md new file mode 100644 index 0000000..b74a94d --- /dev/null +++ b/docs/CLIENT_PORTAL.md @@ -0,0 +1,142 @@ +# Client Portal — Design & Build Plan + +**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x + +A client-facing, **read-only**, **scoped** view into a client's own monitoring +data. The first internet-facing-with-real-clients surface in the system. Built +*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM +reads and Terra-View's report generation — Terra-View stays the UI/business layer; +SLMM stays the device layer. + +## Principles + +1. **Read-only.** No device control (start/stop/config), no roster editing, no + internal pages. A client can look, never touch. +2. **Strictly scoped.** A client only ever sees data for *their* projects. Every + portal endpoint verifies ownership server-side — never trust a `unit_id` / + `location_id` from the request. +3. **Cache-first, no device contention.** Portal live data comes from SLMM's + cache (the same cached `/status` + `/history` the internal dashboard uses). + No device-hitting calls from the portal — a client can't make us hammer the + NL-43. Freshness depends on **keepalive being on** for the client's units. +4. **Auth is a swappable gate.** Every route depends on one resolver, + `get_current_client()`. M1–M3 ride on an interim signed "magic URL"; M4 + replaces the resolver's backing without touching routes or templates. + +## The data chain (how a client maps to live data) + +``` +Client.id + └─ Project (client_id == Client.id, status != deleted) + └─ MonitoringLocation (project_id, location_type == "sound", removed_at IS NULL) + └─ UnitAssignment (location_id, status == "active", device_type == "slm", + assigned_until IS NULL or future) + └─ unit_id == RosterUnit.id == SLMM unit_id + └─ SLMM cached /status + /history (read-only) +``` + +So the portal shows a client their **locations**, each surfacing the live sound +level from whatever SLM is currently assigned there. + +## Data model (new) + +```python +class Client(Base): # the customer org + id, name, slug (unique, URL-safe), contact_email (nullable, for M4), + active (bool), created_at + +class ClientAccessToken(Base): # the interim "magic URL" gate + id, client_id, token_hash (sha256 — raw shown once on creation), + label, created_at, last_used_at, revoked_at (nullable) +``` + +Plus a migration adding **`Project.client_id`** (nullable FK → `clients.id`). +The existing free-text `Project.client_name` stays for display/back-compat; +`client_id` is the authoritative link. + +## Auth — the swappable gate + +```python +def get_current_client(request, db) -> Client: # every /portal route depends on this + # M1–M3: read signed `portal_client` cookie -> load Client + # M4: same signature, backed by real sessions (magic-link / password) +``` + +**Interim "magic URL" flow (M1–M3):** +- Operator creates a `Client` + an access token → gets a one-time-display URL: + `https://…/portal/enter/{token}`. +- Client clicks it → token is hashed, looked up (must be un-revoked) → + sets a **signed session cookie** (`portal_client`, HMAC via a new `SECRET_KEY` + env) → redirects to `/portal`. `last_used_at` updated. +- `get_current_client` reads + verifies the cookie thereafter. No valid cookie → + "link invalid / expired" page. +- Revoke = set `revoked_at`; the link (and any cookie minted from it) stops working. + +Unguessable + revocable + per-person, no email infra or passwords yet — and M4 +slots in behind the same `get_current_client` with zero route/template churn. + +## Routes (`/portal/*`) + +| Route | Purpose | +|-------|---------| +| `GET /portal/enter/{token}` | validate token → set cookie → redirect to `/portal` | +| `GET /portal` | client's locations overview (status tiles + map) | +| `GET /portal/location/{id}` | read-only live panel for that location's SLM | +| `GET /portal/api/location/{id}/live` | **scoped** cached `/status` for the location's unit | +| `GET /portal/api/location/{id}/history` | **scoped** cached trail for the chart | +| `GET /portal/logout` | clear cookie | + +**Scoping helper** (used by every data route): +`resolve_client_location(client, location_id, db) -> (location, unit_id)` — raises +403 if the location isn't in one of the client's projects. The portal never calls +the open `/api/slmm/{unit}/*` endpoints with a client-supplied id. + +## Templates (`templates/portal/`) + +- `portal/base.html` — minimal client-branded shell (no internal sidebar/nav). +- `portal/overview.html` — location tiles (live cards mini) + a locations map. +- `portal/location.html` — the read-only live panel: cards (Lp/Leq/Lmax/L1/L10), + L1/L10 chart, measuring + freshness badge. Reuses the cache-populate JS from the + internal panel, **stripped** of start/stop, config, and the device-hitting + refresh (cache + 15s auto-poll only). + +--- + +## Milestones + +### M1 — Live view only *(current)* +Interim magic-URL gate; a client sees their locations and per-location read-only +live data, all from cache. +- [ ] `Client` + `ClientAccessToken` models; `Project.client_id` migration. +- [ ] `SECRET_KEY` env + signed-cookie session helper. +- [ ] `get_current_client` dependency + `/portal/enter/{token}` + logout. +- [ ] Scoping helper `resolve_client_location`. +- [ ] `/portal` overview + `/portal/location/{id}` (read-only live panel). +- [ ] Scoped `/portal/api/location/{id}/live` + `/history`. +- [ ] Portal templates (base, overview, location). +- [ ] Minimal admin: create client + mint/revoke access link (small `/admin` + page or a script for now). + +### M2 — Dashboard + alerts +- Richer client dashboard (multi-location at-a-glance, map, status rollup). +- Surface each location's **threshold-alert status** (read-only) + an event/inbox + view. Leans on the SLMM alert engine + dispatch. + +### M3 — Reports +- Client-facing list + download of the daily baseline-comparison reports. +- Depends on the FTP report pipeline (`feat/ftp-report-pipeline`) landing and + being wired into the portal's scoped routes. + +### M4 — Full auth system +- Replace the interim token behind `get_current_client` with a real auth design: + magic-link (passwordless email) and/or accounts, proper sessions, password + reset, and likely auth for the *internal* app too. Reverse-proxy + TLS posture. + +## Security notes + +- Portal is auth-gated from day one (even the interim gate) — never wide-open like + the internal app. +- All scoping enforced server-side; client-supplied ids are always re-checked. +- `SECRET_KEY` must be a real secret in prod (env, not committed). +- Cookies: `HttpOnly`, `SameSite=Lax`, `Secure` once behind TLS. +- Tokens stored hashed; raw shown once. Revocation is immediate. -- 2.52.0 From 80a8470b5504532532064f9273d1e4e84fdbc77f Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:32:09 +0000 Subject: [PATCH 02/27] =?UTF-8?q?feat(portal):=20M1=20data=20model=20?= =?UTF-8?q?=E2=80=94=20Client,=20ClientAccessToken,=20Project.client=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client (customer org), ClientAccessToken (interim hashed magic-URL gate), and an authoritative Project.client_id FK (client_name kept for display). New tables auto-create via create_all; migrate_add_client_portal.py adds projects.client_id. Co-Authored-By: Claude Opus 4.8 --- backend/migrate_add_client_portal.py | 56 ++++++++++++++++++++++++++++ backend/models.py | 35 +++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 backend/migrate_add_client_portal.py diff --git a/backend/migrate_add_client_portal.py b/backend/migrate_add_client_portal.py new file mode 100644 index 0000000..bc66f72 --- /dev/null +++ b/backend/migrate_add_client_portal.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Database migration: Client Portal (M1). + +Adds the authoritative client link to projects: + - projects.client_id (TEXT, nullable) -> clients.id + +The `clients` and `client_access_tokens` tables are created automatically by +SQLAlchemy `create_all` at app startup (they're brand-new tables), so this +migration only handles the column that create_all won't add to an existing +`projects` table. + +Run once per database: + docker exec terra-view-terra-view-1 python3 backend/migrate_add_client_portal.py +""" + +import sqlite3 +from pathlib import Path + + +def migrate(): + possible_paths = [ + Path("data/seismo_fleet.db"), + Path("data/sfm.db"), + Path("data/seismo.db"), + ] + db_path = next((p for p in possible_paths if p.exists()), None) + if db_path is None: + print(f"Database not found in any of: {[str(p) for p in possible_paths]}") + print("A fresh DB created via models.py will include projects.client_id automatically.") + return + + print(f"Using database: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute("PRAGMA table_info(projects)") + existing = {row[1] for row in cursor.fetchall()} + + if "client_id" not in existing: + try: + cursor.execute("ALTER TABLE projects ADD COLUMN client_id TEXT") + print("✓ Added column: projects.client_id (TEXT)") + except sqlite3.OperationalError as e: + print(f"✗ Failed to add projects.client_id: {e}") + else: + print("○ Column already exists: projects.client_id") + + conn.commit() + conn.close() + print("\n✓ Client-portal migration complete.") + print(" Note: `clients` + `client_access_tokens` tables auto-create on app startup.") + + +if __name__ == "__main__": + migrate() diff --git a/backend/models.py b/backend/models.py index aae3039..8c5d5e9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -192,6 +192,7 @@ class Project(Base): # Project metadata client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick") + client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display) site_address = Column(String, nullable=True) site_coordinates = Column(String, nullable=True) # "lat,lon" start_date = Column(Date, nullable=True) @@ -704,3 +705,37 @@ class PendingDeployment(Base): created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +# ============================================================================ +# CLIENT PORTAL — read-only, scoped client access (see docs/CLIENT_PORTAL.md) +# ============================================================================ + +class Client(Base): + """A portal client (customer org). Owns one or more Projects via + Project.client_id; their portal surfaces only those projects' locations. + Read-only — clients never control devices.""" + __tablename__ = "clients" + + id = Column(String, primary_key=True, index=True) # UUID + name = Column(String, nullable=False) # display name, e.g. "PJ Dick" + slug = Column(String, nullable=False, unique=True, index=True) # URL-safe handle + contact_email = Column(String, nullable=True) # for M4 magic-link + active = Column(Boolean, default=True) # False = portal access off + created_at = Column(DateTime, default=datetime.utcnow) + + +class ClientAccessToken(Base): + """Interim 'magic URL' gate (M1-M3). The raw secret lives in the link and is + shown once on creation; only its sha256 is stored here. Revoke by setting + revoked_at. In M4 this is replaced behind get_current_client() without + touching routes/templates.""" + __tablename__ = "client_access_tokens" + + id = Column(String, primary_key=True, index=True) # UUID + client_id = Column(String, nullable=False, index=True) # FK -> clients.id + token_hash = Column(String, nullable=False, index=True) # sha256 hex of the secret + label = Column(String, nullable=True) # e.g. "Dave's link" + created_at = Column(DateTime, default=datetime.utcnow) + last_used_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) # set = link no longer works -- 2.52.0 From 6c048a9c30cd307b28602236620e8536b52c3464 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:36:09 +0000 Subject: [PATCH 03/27] =?UTF-8?q?feat(portal):=20M1=20auth=20gate=20?= =?UTF-8?q?=E2=80=94=20signed=20magic-URL=20session=20+=20get=5Fcurrent=5F?= =?UTF-8?q?client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend/portal_auth.py: stdlib HMAC-signed session cookie carrying the access- token id (re-validated against the DB each request, so revoke kills live sessions), hash_token, resolve_token, and the get_current_client dependency (raises PortalAuthError). SECRET_KEY env (insecure dev default + warning). routers/portal.py: /portal/enter/{token} mints the cookie -> /portal; /logout; /access; /portal home stub. main.py registers the router + a PortalAuthError handler (HTML access page for pages, 401 JSON for /portal/api/*). Portal shell templates (base, access_required, overview stub), branded dark. Verified: cookie round-trip + tamper/garbage rejection, token resolution (valid/bad), get_current_client (valid/no-cookie/revoked) — 8/8 against a temp DB. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 19 +++++ backend/portal_auth.py | 114 ++++++++++++++++++++++++++ backend/routers/portal.py | 67 +++++++++++++++ templates/portal/access_required.html | 20 +++++ templates/portal/base.html | 44 ++++++++++ templates/portal/overview.html | 22 +++++ 6 files changed, 286 insertions(+) create mode 100644 backend/portal_auth.py create mode 100644 backend/routers/portal.py create mode 100644 templates/portal/access_required.html create mode 100644 templates/portal/base.html create mode 100644 templates/portal/overview.html diff --git a/backend/main.py b/backend/main.py index 54792ad..2f025fd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -66,6 +66,21 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static") # Use shared templates configuration with timezone filters from backend.templates_config import templates +# Client-portal auth: an unauthenticated portal request renders the access page +# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every +# portal route can simply Depends(get_current_client). +from backend.portal_auth import PortalAuthError + +@app.exception_handler(PortalAuthError) +async def portal_auth_handler(request: Request, exc: PortalAuthError): + if request.url.path.startswith("/portal/api"): + return JSONResponse(status_code=401, content={"detail": "Not authenticated"}) + return templates.TemplateResponse( + "portal/access_required.html", + {"request": request, "reason": "required"}, + status_code=401, + ) + # Add custom context processor to inject environment variable into all templates @app.middleware("http") async def add_environment_to_context(request: Request, call_next): @@ -97,6 +112,10 @@ app.include_router(slmm.router) app.include_router(slm_ui.router) app.include_router(slm_dashboard.router) app.include_router(seismo_dashboard.router) + +# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md +from backend.routers import portal +app.include_router(portal.router) app.include_router(sfm.router) app.include_router(modem_dashboard.router) diff --git a/backend/portal_auth.py b/backend/portal_auth.py new file mode 100644 index 0000000..02933b0 --- /dev/null +++ b/backend/portal_auth.py @@ -0,0 +1,114 @@ +""" +Client-portal auth — the swappable gate (see docs/CLIENT_PORTAL.md). + +M1-M3 ride on an interim signed "magic URL": an unguessable token in the link +mints a signed session cookie. Every portal route depends on get_current_client(); +M4 replaces the backing (magic-link / accounts) without touching routes/templates. + +The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated +against the DB on every request, so revoking a link (revoked_at) kills its live +sessions on the next request — not just future clicks. + +No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY. +""" + +import os +import hmac +import json +import time +import base64 +import hashlib +import logging +from datetime import datetime + +from fastapi import Request, Depends +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models import Client, ClientAccessToken + +logger = logging.getLogger(__name__) + +# Signing secret for portal session cookies. MUST be set to a real secret in prod +# (env). The insecure default only exists so dev/test boots without config. +SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me") +if SECRET_KEY == "dev-insecure-change-me": + logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.") + +COOKIE_NAME = "portal_session" +COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days + + +class PortalAuthError(Exception): + """Raised by get_current_client when there's no valid portal session. + Handled centrally in main.py: HTML routes get the access-required page, + /portal/api/* routes get a 401 JSON.""" + + +# -- token + cookie primitives ---------------------------------------------- + +def hash_token(raw: str) -> str: + """sha256 hex of a raw access-token secret (what we store + look up by).""" + return hashlib.sha256(raw.encode()).hexdigest() + + +def _sign(body: str) -> str: + return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest() + + +def make_session_cookie(token_id: str) -> str: + body = base64.urlsafe_b64encode( + json.dumps({"tid": token_id, "iat": int(time.time())}).encode() + ).decode() + return f"{body}.{_sign(body)}" + + +def _read_session_cookie(value: str): + """Return the token id from a signed cookie, or None if missing/tampered.""" + try: + body, sig = value.rsplit(".", 1) + except (ValueError, AttributeError): + return None + if not hmac.compare_digest(sig, _sign(body)): + return None + try: + data = json.loads(base64.urlsafe_b64decode(body.encode())) + except Exception: + return None + return data.get("tid") + + +# -- the dependency every portal route uses --------------------------------- + +def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client: + """Resolve the authenticated client, or raise PortalAuthError. + + Re-validates the access token on every request so a revoked link / disabled + client drops the session immediately.""" + cookie = request.cookies.get(COOKIE_NAME) + token_id = _read_session_cookie(cookie) if cookie else None + if not token_id: + raise PortalAuthError() + tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first() + if not tok: + raise PortalAuthError() + client = db.query(Client).filter_by(id=tok.client_id, active=True).first() + if not client: + raise PortalAuthError() + return client + + +def resolve_token(raw_token: str, db: Session): + """Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on + success, or (None, None). Also stamps last_used_at.""" + tok = db.query(ClientAccessToken).filter_by( + token_hash=hash_token(raw_token), revoked_at=None + ).first() + if not tok: + return None, None + client = db.query(Client).filter_by(id=tok.client_id, active=True).first() + if not client: + return None, None + tok.last_used_at = datetime.utcnow() + db.commit() + return tok, client diff --git a/backend/routers/portal.py b/backend/routers/portal.py new file mode 100644 index 0000000..bc96006 --- /dev/null +++ b/backend/routers/portal.py @@ -0,0 +1,67 @@ +""" +Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md). + +M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed +session cookie, then sees their locations (overview) and per-location read-only +live data sourced from SLMM's cache. Every data route re-checks ownership. +""" + +import logging + +from fastapi import APIRouter, Request, Depends +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models import Client +from backend.templates_config import templates +from backend.portal_auth import ( + get_current_client, make_session_cookie, resolve_token, + COOKIE_NAME, COOKIE_MAX_AGE, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/portal", tags=["portal"]) + + +@router.get("/enter/{token}") +def portal_enter(token: str, request: Request, db: Session = Depends(get_db)): + """Magic-URL entry: validate the token, mint a session cookie, land on /portal.""" + tok, client = resolve_token(token, db) + if not client: + return templates.TemplateResponse( + "portal/access_required.html", + {"request": request, "reason": "invalid"}, + status_code=403, + ) + resp = RedirectResponse(url="/portal", status_code=303) + resp.set_cookie( + COOKIE_NAME, make_session_cookie(tok.id), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", + ) + logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}") + return resp + + +@router.get("/logout") +def portal_logout(): + resp = RedirectResponse(url="/portal/access", status_code=303) + resp.delete_cookie(COOKIE_NAME) + return resp + + +@router.get("/access") +def portal_access(request: Request): + """Landing for an unauthenticated visitor (no valid link).""" + return templates.TemplateResponse( + "portal/access_required.html", {"request": request, "reason": "required"} + ) + + +@router.get("") +def portal_home(request: Request, client: Client = Depends(get_current_client)): + """Client overview. (M1 task 4 fills in the scoped location list + map.)""" + return templates.TemplateResponse( + "portal/overview.html", + {"request": request, "client": client, "locations": []}, + ) diff --git a/templates/portal/access_required.html b/templates/portal/access_required.html new file mode 100644 index 0000000..b6d7423 --- /dev/null +++ b/templates/portal/access_required.html @@ -0,0 +1,20 @@ +{% extends "portal/base.html" %} +{% block title %}Access{% endblock %} +{% block content %} +
+
+ + + +
+ {% if reason == "invalid" %} +

This link isn't valid

+

The access link is expired or has been revoked. + Please contact TMI for a new link.

+ {% else %} +

Access link required

+

Open the monitoring link TMI sent you to view your locations.

+ {% endif %} +
+{% endblock %} diff --git a/templates/portal/base.html b/templates/portal/base.html new file mode 100644 index 0000000..25daa37 --- /dev/null +++ b/templates/portal/base.html @@ -0,0 +1,44 @@ + + + + + + {% block title %}Monitoring{% endblock %} · TMI + + + + + + {% block head %}{% endblock %} + + +
+ +
+ +
+ {% block content %}{% endblock %} +
+ +
+ Read-only monitoring view. Data is provided as-is for informational purposes. +
+ {% block scripts %}{% endblock %} + + diff --git a/templates/portal/overview.html b/templates/portal/overview.html new file mode 100644 index 0000000..fefc731 --- /dev/null +++ b/templates/portal/overview.html @@ -0,0 +1,22 @@ +{% extends "portal/base.html" %} +{% block title %}Your locations{% endblock %} +{% block content %} +

Your monitoring locations

+

Live sound levels for your active locations.

+ +{# M1 task 4 fleshes this out into location tiles + a map. #} +{% if locations %} +
+ {% for loc in locations %} + +
{{ loc.name }}
+
{{ loc.address or loc.project_name }}
+
+ {% endfor %} +
+{% else %} +
+ No active monitoring locations yet. +
+{% endif %} +{% endblock %} -- 2.52.0 From 9f402100579c948f7d1e4794372aa55daf2a93ae Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:38:16 +0000 Subject: [PATCH 04/27] feat(portal): M1 scoping gate + scoped cache endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_client_location() enforces ownership (sound location in one of the client's active projects) and 404s everything else — same response for missing and not-yours, so location existence never leaks. active_unit_for_location() resolves the currently-assigned SLM. Scoped GET /portal/api/location/{id}/live and /history: gate -> resolve unit -> read SLMM cache (never the device). /live returns a SCRUBBED projection (sound metrics + run state only; no battery/SD/raw_payload). Both degrade gracefully when there's no device or SLMM is down. Verified: ownership gate (owns / other-client / vibration / deleted-project / removed / nonexistent) + active-vs-completed unit resolution — 8/8 on a temp DB. Co-Authored-By: Claude Opus 4.8 --- backend/routers/portal.py | 90 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/backend/routers/portal.py b/backend/routers/portal.py index bc96006..1cc01c1 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -6,14 +6,18 @@ session cookie, then sees their locations (overview) and per-location read-only live data sourced from SLMM's cache. Every data route re-checks ownership. """ +import os import logging +from datetime import datetime -from fastapi import APIRouter, Request, Depends +import httpx +from fastapi import APIRouter, Request, Depends, HTTPException from fastapi.responses import RedirectResponse +from sqlalchemy import or_ from sqlalchemy.orm import Session from backend.database import get_db -from backend.models import Client +from backend.models import Client, MonitoringLocation, Project, UnitAssignment from backend.templates_config import templates from backend.portal_auth import ( get_current_client, make_session_cookie, resolve_token, @@ -23,6 +27,44 @@ from backend.portal_auth import ( logger = logging.getLogger(__name__) router = APIRouter(prefix="/portal", tags=["portal"]) +SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") + +# Whitelist of fields the portal exposes to a client — sound metrics + run state +# only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed. +_PORTAL_LIVE_FIELDS = ("measurement_state", "last_seen", "measurement_start_time", + "lp", "leq", "lmax", "lpeak", "ln1", "ln2") + + +# -- scoping (every data route gates through these) -------------------------- + +def _client_project_ids(client: Client, db: Session) -> list: + return [r[0] for r in db.query(Project.id).filter( + Project.client_id == client.id, Project.status != "deleted").all()] + + +def resolve_client_location(client: Client, location_id: str, db: Session) -> MonitoringLocation: + """Ownership gate: location must be a sound location in one of the client's + active projects. Raises 404 (not 403) for both 'missing' and 'not yours' so + we never leak whether a location exists.""" + loc = db.query(MonitoringLocation).filter_by(id=location_id, removed_at=None).first() + if (not loc or loc.location_type != "sound" + or loc.project_id not in _client_project_ids(client, db)): + raise HTTPException(status_code=404, detail="Location not found") + return loc + + +def active_unit_for_location(location_id: str, db: Session): + """The SLM unit currently assigned to this location, or None.""" + now = datetime.utcnow() + asg = (db.query(UnitAssignment) + .filter(UnitAssignment.location_id == location_id, + UnitAssignment.status == "active", + UnitAssignment.device_type == "slm", + or_(UnitAssignment.assigned_until.is_(None), + UnitAssignment.assigned_until > now)) + .order_by(UnitAssignment.assigned_at.desc()).first()) + return asg.unit_id if asg else None + @router.get("/enter/{token}") def portal_enter(token: str, request: Request, db: Session = Depends(get_db)): @@ -65,3 +107,47 @@ def portal_home(request: Request, client: Client = Depends(get_current_client)): "portal/overview.html", {"request": request, "client": client, "locations": []}, ) + + +# -- scoped data (cache reads only — never hits the device) ------------------ + +@router.get("/api/location/{location_id}/live") +async def portal_location_live(location_id: str, + client: Client = Depends(get_current_client), + db: Session = Depends(get_db)): + """Scrubbed cached live reading for a location the client owns.""" + resolve_client_location(client, location_id, db) + unit_id = active_unit_for_location(location_id, db) + if not unit_id: + return {"status": "ok", "data": None, "reason": "no_device"} + try: + async with httpx.AsyncClient(timeout=5.0) as hc: + r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status") + except Exception: + return {"status": "ok", "data": None, "reason": "unreachable"} + if r.status_code != 200: + return {"status": "ok", "data": None, "reason": "no_data"} + full = (r.json() or {}).get("data", {}) or {} + return {"status": "ok", "data": {k: full.get(k) for k in _PORTAL_LIVE_FIELDS}} + + +@router.get("/api/location/{location_id}/history") +async def portal_location_history(location_id: str, hours: float = 2.0, + client: Client = Depends(get_current_client), + db: Session = Depends(get_db)): + """Cached chart trail for a location the client owns. (Trail rows are already + just timestamp + lp/leq/lmax/ln1/ln2 — safe to pass through.)""" + resolve_client_location(client, location_id, db) + unit_id = active_unit_for_location(location_id, db) + if not unit_id: + return {"status": "ok", "readings": []} + hours = max(0.1, min(hours, 48.0)) + try: + async with httpx.AsyncClient(timeout=5.0) as hc: + r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/history", + params={"hours": hours}) + except Exception: + return {"status": "ok", "readings": []} + if r.status_code != 200: + return {"status": "ok", "readings": []} + return {"status": "ok", "readings": (r.json() or {}).get("readings", [])} -- 2.52.0 From d3e221b6b12fa03dbe7c5d1aa481e50101cb4a44 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:41:13 +0000 Subject: [PATCH 05/27] =?UTF-8?q?feat(portal):=20M1=20pages=20=E2=80=94=20?= =?UTF-8?q?locations=20overview=20+=20read-only=20live=20location=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /portal overview: client's active sound locations as live tiles (current Lp + Live/Stopped badge + "updated Xm ago", polled from the scoped cache every 15s) plus a Leaflet map of locations with coordinates. /portal/location/{id}: 404-gated read-only live panel — Lp/Leq/Lmax/L1/L10 cards + a 4-line Chart.js trace (backfilled from /history) + measuring/freshness badge. Cache-only, 15s poll, no device controls, no refresh-from-device. _client_locations() feeds the overview. Verified: portal.py compiles; both inline scripts balance; all four portal templates parse in Jinja2. Co-Authored-By: Claude Opus 4.8 --- backend/routers/portal.py | 40 +++++++++- templates/portal/location.html | 138 +++++++++++++++++++++++++++++++++ templates/portal/overview.html | 87 +++++++++++++++++++-- 3 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 templates/portal/location.html diff --git a/backend/routers/portal.py b/backend/routers/portal.py index 1cc01c1..1382b45 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -66,6 +66,26 @@ def active_unit_for_location(location_id: str, db: Session): return asg.unit_id if asg else None +def _client_locations(client: Client, db: Session) -> list: + """The client's active sound locations (for the overview tiles + map).""" + pids = _client_project_ids(client, db) + if not pids: + return [] + projs = {p.id: p.name for p in + db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()} + locs = (db.query(MonitoringLocation) + .filter(MonitoringLocation.project_id.in_(pids), + MonitoringLocation.location_type == "sound", + MonitoringLocation.removed_at.is_(None)) + .order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()) + return [{ + "id": loc.id, "name": loc.name, + "address": loc.address, "coordinates": loc.coordinates, + "project_name": projs.get(loc.project_id), + "has_device": active_unit_for_location(loc.id, db) is not None, + } for loc in locs] + + @router.get("/enter/{token}") def portal_enter(token: str, request: Request, db: Session = Depends(get_db)): """Magic-URL entry: validate the token, mint a session cookie, land on /portal.""" @@ -101,14 +121,28 @@ def portal_access(request: Request): @router.get("") -def portal_home(request: Request, client: Client = Depends(get_current_client)): - """Client overview. (M1 task 4 fills in the scoped location list + map.)""" +def portal_home(request: Request, client: Client = Depends(get_current_client), + db: Session = Depends(get_db)): + """Client overview — their active sound locations with live tiles + a map.""" return templates.TemplateResponse( "portal/overview.html", - {"request": request, "client": client, "locations": []}, + {"request": request, "client": client, + "locations": _client_locations(client, db)}, ) +@router.get("/location/{location_id}") +def portal_location(location_id: str, request: Request, + client: Client = Depends(get_current_client), + db: Session = Depends(get_db)): + """Read-only live view for one of the client's locations (404 if not owned).""" + loc = resolve_client_location(client, location_id, db) + return templates.TemplateResponse("portal/location.html", { + "request": request, "client": client, "location": loc, + "has_device": active_unit_for_location(location_id, db) is not None, + }) + + # -- scoped data (cache reads only — never hits the device) ------------------ @router.get("/api/location/{location_id}/live") diff --git a/templates/portal/location.html b/templates/portal/location.html new file mode 100644 index 0000000..4bb107b --- /dev/null +++ b/templates/portal/location.html @@ -0,0 +1,138 @@ +{% extends "portal/base.html" %} +{% block title %}{{ location.name }}{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +← All locations +

{{ location.name }}

+
+ + +
+ +{% if not has_device %} +
+ No device is currently assigned to this location. +
+{% else %} +
+
+
Lp (Instant)
+
--
dB
+
+
+
Leq (Average)
+
--
dB
+
+
+
Lmax (Max)
+
--
dB
+
+
+
L1
+
--
dB
+
+
+
L10
+
--
dB
+
+
+ +
+ +
+{% endif %} +{% endblock %} + +{% block scripts %} +{% if has_device %} + +{% endif %} +{% endblock %} diff --git a/templates/portal/overview.html b/templates/portal/overview.html index fefc731..a5b0994 100644 --- a/templates/portal/overview.html +++ b/templates/portal/overview.html @@ -1,16 +1,29 @@ {% extends "portal/base.html" %} {% block title %}Your locations{% endblock %} +{% block head %} + +{% endblock %} {% block content %}

Your monitoring locations

-

Live sound levels for your active locations.

+

Live sound levels for your active locations. Read-only.

-{# M1 task 4 fleshes this out into location tiles + a map. #} {% if locations %} + + @@ -20,3 +33,67 @@ {% endif %} {% endblock %} + +{% block scripts %} + + +{% endblock %} -- 2.52.0 From 26b4b1e7e402348ae5dfb528c4e1c53c1ae38d7b Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:43:28 +0000 Subject: [PATCH 06/27] =?UTF-8?q?feat(portal):=20M1=20admin=20CLI=20?= =?UTF-8?q?=E2=80=94=20create=20client,=20link=20projects,=20mint/revoke?= =?UTF-8?q?=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend/portal_admin.py (run in-container): create-client, link-project (by id/ number/name -> sets Project.client_id), mint-link (prints the full magic URL once, stores only the hash), list, revoke. PORTAL_BASE_URL controls the printed link base. Co-Authored-By: Claude Opus 4.8 --- backend/portal_admin.py | 165 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 backend/portal_admin.py diff --git a/backend/portal_admin.py b/backend/portal_admin.py new file mode 100644 index 0000000..c69e4db --- /dev/null +++ b/backend/portal_admin.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Client-portal admin CLI (M1). Operator tooling — run inside the terra-view +container against the live DB. The raw magic-link token is shown ONCE on mint; +only its hash is stored. + + # create a client + python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com] + + # attach a project to a client (sets Project.client_id) — by id, number, or name + python3 backend/portal_admin.py link-project --slug myler --project-id + python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23 + python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall" + + # mint a magic access link (FULL URL PRINTED ONCE — copy it now) + python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"] + + # list clients, their projects, and active links + python3 backend/portal_admin.py list + + # revoke a link (stops the link AND any live session it minted) + python3 backend/portal_admin.py revoke --token-id + +The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001). +""" + +import os +import sys +import uuid +import secrets +import argparse +from datetime import datetime + +from backend.database import SessionLocal +from backend.models import Client, ClientAccessToken, Project +from backend.portal_auth import hash_token + +PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/") + + +def _get_client(db, slug): + c = db.query(Client).filter_by(slug=slug).first() + if not c: + sys.exit(f"No client with slug '{slug}'. Create it first.") + return c + + +def create_client(args): + db = SessionLocal() + try: + if db.query(Client).filter_by(slug=args.slug).first(): + sys.exit(f"A client with slug '{args.slug}' already exists.") + c = Client(id=str(uuid.uuid4()), name=args.name, slug=args.slug, + contact_email=args.email, active=True) + db.add(c) + db.commit() + print(f"✓ Created client '{c.name}' (slug={c.slug}, id={c.id})") + print(" Next: link-project, then mint-link.") + finally: + db.close() + + +def link_project(args): + db = SessionLocal() + try: + c = _get_client(db, args.slug) + q = db.query(Project) + if args.project_id: + p = q.filter_by(id=args.project_id).first() + elif args.project_number: + p = q.filter_by(project_number=args.project_number).first() + elif args.project_name: + p = q.filter_by(name=args.project_name).first() + else: + sys.exit("Specify --project-id, --project-number, or --project-name.") + if not p: + sys.exit("Project not found.") + p.client_id = c.id + db.commit() + print(f"✓ Linked project '{p.name}' (id={p.id}) -> client '{c.name}'") + finally: + db.close() + + +def mint_link(args): + db = SessionLocal() + try: + c = _get_client(db, args.slug) + raw = secrets.token_urlsafe(32) + tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id, + token_hash=hash_token(raw), label=args.label) + db.add(tok) + db.commit() + print(f"✓ Minted access link for '{c.name}'" + f"{f' ({args.label})' if args.label else ''} — token id {tok.id}") + print("\n COPY THIS NOW (shown only once):\n") + print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n") + finally: + db.close() + + +def revoke(args): + db = SessionLocal() + try: + tok = db.query(ClientAccessToken).filter_by(id=args.token_id).first() + if not tok: + sys.exit("No token with that id.") + if tok.revoked_at: + print("○ Already revoked.") + return + tok.revoked_at = datetime.utcnow() + db.commit() + print(f"✓ Revoked token {tok.id} — the link and any live sessions it minted are dead.") + finally: + db.close() + + +def list_all(args): + db = SessionLocal() + try: + clients = db.query(Client).order_by(Client.name).all() + if not clients: + print("No clients yet.") + return + for c in clients: + state = "" if c.active else " [INACTIVE]" + print(f"\n● {c.name} (slug={c.slug}){state}") + projs = db.query(Project).filter_by(client_id=c.id).all() + print(" projects: " + (", ".join(p.name for p in projs) or "(none linked)")) + toks = db.query(ClientAccessToken).filter_by(client_id=c.id).all() + if not toks: + print(" links: (none — run mint-link)") + for t in toks: + status = "revoked" if t.revoked_at else "active" + last = t.last_used_at.strftime("%Y-%m-%d %H:%M") if t.last_used_at else "never used" + print(f" link {t.id} [{status}] {t.label or ''} (last: {last})") + print() + finally: + db.close() + + +def main(): + ap = argparse.ArgumentParser(description="Client-portal admin (M1)") + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("create-client"); p.add_argument("--name", required=True) + p.add_argument("--slug", required=True); p.add_argument("--email"); p.set_defaults(fn=create_client) + + p = sub.add_parser("link-project"); p.add_argument("--slug", required=True) + p.add_argument("--project-id"); p.add_argument("--project-number"); p.add_argument("--project-name") + p.set_defaults(fn=link_project) + + p = sub.add_parser("mint-link"); p.add_argument("--slug", required=True) + p.add_argument("--label"); p.set_defaults(fn=mint_link) + + p = sub.add_parser("revoke"); p.add_argument("--token-id", required=True); p.set_defaults(fn=revoke) + + p = sub.add_parser("list"); p.set_defaults(fn=list_all) + + args = ap.parse_args() + args.fn(args) + + +if __name__ == "__main__": + main() -- 2.52.0 From 1cf80ea7ea6a6d02a26d3b34a03a28542908db5d Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 01:16:30 +0000 Subject: [PATCH 07/27] fix(portal): portal_admin.py runnable as a script, not just -m `python3 backend/portal_admin.py` set sys.path[0] to backend/, hiding the `backend` package and breaking `from backend.database import ...`. Insert the project root on sys.path so the documented script invocation works. Co-Authored-By: Claude Opus 4.8 --- backend/portal_admin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/portal_admin.py b/backend/portal_admin.py index c69e4db..2d8b346 100644 --- a/backend/portal_admin.py +++ b/backend/portal_admin.py @@ -31,6 +31,10 @@ import secrets import argparse from datetime import datetime +# Allow `python3 backend/portal_admin.py ...` (which puts backend/ on sys.path[0], +# hiding the `backend` package) in addition to `python3 -m backend.portal_admin`. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from backend.database import SessionLocal from backend.models import Client, ClientAccessToken, Project from backend.portal_auth import hash_token -- 2.52.0 From 2031681d0f2b7b4c7454fefb875bbf2ad47548af Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 02:01:58 +0000 Subject: [PATCH 08/27] docs(portal): add "Going to prod" checklist (migration, SECRET_KEY, exposure) Co-Authored-By: Claude Opus 4.8 --- docs/CLIENT_PORTAL.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/CLIENT_PORTAL.md b/docs/CLIENT_PORTAL.md index b74a94d..0fc2e88 100644 --- a/docs/CLIENT_PORTAL.md +++ b/docs/CLIENT_PORTAL.md @@ -132,6 +132,32 @@ live data, all from cache. magic-link (passwordless email) and/or accounts, proper sessions, password reset, and likely auth for the *internal* app too. Reverse-proxy + TLS posture. +## Going to prod (M1) + +1. **Run the migration on the prod DB** — `migrate_add_client_portal.py` adds + `projects.client_id` (the new tables auto-create via `create_all`). Skipping it + 500s anything that touches `Project.client_id`. This is the silent killer. + ```bash + docker compose exec web-app python3 backend/migrate_add_client_portal.py + ``` +2. **Set a real `SECRET_KEY`** in the prod env (compose). The portal signs session + cookies with it; the insecure dev default (it logs a warning at boot) is + forgeable. Non-negotiable for an internet-facing portal. +3. **SLMM_BASE_URL** — prod base compose already points at `:8100` (correct; the + `:9100` mismatch is a dev-only override quirk). For full live data (L1/L10 + + chart backfill) prod SLMM must be on the `dev` build with its migrations + (`migrate_add_ln_percentiles`, `migrate_add_monitor_enabled`) and **keepalive on** + for the client's units — otherwise the portal degrades gracefully (cards show + `--`, chart empty), it just isn't fully populated. +4. **Seed real clients** with the CLI (`backend/portal_admin.py`): `create-client` + → `link-project` (a real sound project with an active SLM assignment) → + `mint-link` → send the client the printed URL (shown once). +5. **Exposure** — portal routes are auth-gated, but port 8001 still serves the + whole *internal* app with no auth. Before real clients are on it, the portal + should sit behind the reverse proxy with only `/portal/*` exposed (or the app + restricted). This is the point where the parked reverse-proxy/TLS work becomes + load-bearing. + ## Security notes - Portal is auth-gated from day one (even the interim gate) — never wide-open like -- 2.52.0 From 3fc20e104a8ee6cb61caa41c9120549e86bfbfbf Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 02:18:06 +0000 Subject: [PATCH 09/27] feat(portal): one-click "View client portal" preview from the project page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "View client portal" button on the project detail page that opens the client portal scoped to that project — no CLI. GET /projects/{id}/portal-preview auto-provisions a client + access token for the project (provision_preview_session) and seals a portal session cookie, then redirects to /portal. - Reuses the project's linked client if it has one; otherwise creates/reuses a per-project 'preview-' client. Only sets project.client_id when unset, so it never clobbers a real client link. Idempotent — repeat clicks reuse the same client/token. - Lives under /projects (not /portal), so a future public proxy exposing only /portal/* won't expose this operator shortcut. Verified: provisioning (unlinked creates+links, idempotent, linked-no-clobber) 7/7. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 21 +++++++++++++++++++- backend/portal_auth.py | 35 ++++++++++++++++++++++++++++++++++ templates/projects/detail.html | 13 ++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index 2f025fd..5305c37 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse, FileResponse, JSONResponse +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse from fastapi.exceptions import RequestValidationError from sqlalchemy.orm import Session from typing import List, Dict, Optional @@ -413,6 +413,25 @@ async def project_detail_page(request: Request, project_id: str): }) +@app.get("/projects/{project_id}/portal-preview") +async def project_portal_preview(project_id: str, db: Session = Depends(get_db)): + """Operator testing shortcut: log into the client portal scoped to this project + (auto-provisioning a client/link if needed), no CLI. Lives under /projects (not + /portal), so a public proxy that exposes only /portal/* won't expose this.""" + from backend.models import Project + from backend.portal_auth import ( + provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, + ) + project = db.query(Project).filter_by(id=project_id).first() + if not project: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + token_id = provision_preview_session(project, db) + resp = RedirectResponse(url="/portal", status_code=303) + resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") + return resp + + @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) async def nrl_detail_page( request: Request, diff --git a/backend/portal_auth.py b/backend/portal_auth.py index 02933b0..da869a2 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -16,9 +16,11 @@ import os import hmac import json import time +import uuid import base64 import hashlib import logging +import secrets from datetime import datetime from fastapi import Request, Depends @@ -112,3 +114,36 @@ def resolve_token(raw_token: str, db: Session): tok.last_used_at = datetime.utcnow() db.commit() return tok, client + + +def provision_preview_session(project, db) -> str: + """Testing convenience (operator-side): ensure a Client + access token exist for + a project so an operator can preview the client portal without the CLI. Returns + the token id to seal into a session cookie. + + Reuses the project's linked client if it has one; otherwise creates/uses a + per-project 'preview-' client. Only sets project.client_id when it's unset, + so previewing never clobbers a real client link. The token's raw secret is + discarded (preview rides the cookie, not a magic link).""" + client = None + if project.client_id: + client = db.query(Client).filter_by(id=project.client_id, active=True).first() + if client is None: + slug = f"preview-{str(project.id)[:8]}" + client = db.query(Client).filter_by(slug=slug).first() + if client is None: + client = Client(id=str(uuid.uuid4()), + name=(project.client_name or project.name or "Preview"), + slug=slug, active=True) + db.add(client) + db.flush() + if not project.client_id: + project.client_id = client.id + tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first() + if tok is None: + tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, + token_hash=hash_token(secrets.token_urlsafe(32)), + label="preview") + db.add(tok) + db.commit() + return tok.id diff --git a/templates/projects/detail.html b/templates/projects/detail.html index ba971f3..1542c2f 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -4,7 +4,7 @@ {% block content %} -
+ -- 2.52.0 From 0103917870f83e83d605f923a87f4a3c07a20d5e Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 03:16:32 +0000 Subject: [PATCH 10/27] feat(portal): live ~1Hz WS stream with auto-close (visibility + idle cap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portal location view is now genuinely live, not a 15s poll. Scoped WS endpoint /portal/api/location/{id}/stream: authenticates via the session cookie, enforces ownership (resolve_client_location), then bridges the unit's shared SLMM /monitor fan-out feed to the browser — a viewer is just one more subscriber, no extra device connection. Frames are scrubbed to the portal whitelist (drops unit_id, raw_payload, counter, lmin) before reaching the client. location.html: cache prefill for instant first paint, then upgrades to the live socket (cards tick ~1Hz, chart scrolls). Auto-close so an abandoned tab can't pin the device at 1Hz polling (~8x cellular data): - closes when the tab is hidden, reopens when visible (Page Visibility) — the main guard; - hard 15-min cap -> "Live paused — click to resume" overlay. Refactor: client_from_cookie() extracted from get_current_client so the WS handler (no Request-based Depends) can auth the same way. Verified: scrub drops internal fields / keeps metrics + heartbeat (7/7), auth refactor (3/3), portal compiles, location.html JS balances + parses. Co-Authored-By: Claude Opus 4.8 --- backend/portal_auth.py | 26 ++++++---- backend/routers/portal.py | 93 ++++++++++++++++++++++++++++++++-- templates/portal/location.html | 82 ++++++++++++++++++++++++++++-- 3 files changed, 183 insertions(+), 18 deletions(-) diff --git a/backend/portal_auth.py b/backend/portal_auth.py index da869a2..ac52ae7 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -82,20 +82,24 @@ def _read_session_cookie(value: str): # -- the dependency every portal route uses --------------------------------- -def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client: - """Resolve the authenticated client, or raise PortalAuthError. - - Re-validates the access token on every request so a revoked link / disabled - client drops the session immediately.""" - cookie = request.cookies.get(COOKIE_NAME) - token_id = _read_session_cookie(cookie) if cookie else None +def client_from_cookie(cookie_value, db: Session): + """Resolve a Client from a raw session-cookie value, or None. Re-validates the + access token against the DB each call, so a revoked link / disabled client + drops immediately. Shared by the HTTP dependency and the WebSocket handler + (which can't use Request-based Depends).""" + token_id = _read_session_cookie(cookie_value) if cookie_value else None if not token_id: - raise PortalAuthError() + return None tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first() if not tok: - raise PortalAuthError() - client = db.query(Client).filter_by(id=tok.client_id, active=True).first() - if not client: + return None + return db.query(Client).filter_by(id=tok.client_id, active=True).first() + + +def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client: + """Resolve the authenticated client, or raise PortalAuthError.""" + client = client_from_cookie(request.cookies.get(COOKIE_NAME), db) + if client is None: raise PortalAuthError() return client diff --git a/backend/routers/portal.py b/backend/routers/portal.py index 1382b45..abbea7b 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -7,20 +7,23 @@ live data sourced from SLMM's cache. Every data route re-checks ownership. """ import os +import json +import asyncio import logging from datetime import datetime import httpx -from fastapi import APIRouter, Request, Depends, HTTPException +import websockets +from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket from fastapi.responses import RedirectResponse from sqlalchemy import or_ from sqlalchemy.orm import Session -from backend.database import get_db +from backend.database import get_db, SessionLocal from backend.models import Client, MonitoringLocation, Project, UnitAssignment from backend.templates_config import templates from backend.portal_auth import ( - get_current_client, make_session_cookie, resolve_token, + get_current_client, client_from_cookie, make_session_cookie, resolve_token, COOKIE_NAME, COOKIE_MAX_AGE, ) @@ -28,6 +31,7 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/portal", tags=["portal"]) SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") +SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://") # Whitelist of fields the portal exposes to a client — sound metrics + run state # only. Internal device health (battery/power/SD/raw_payload) is NOT disclosed. @@ -185,3 +189,86 @@ async def portal_location_history(location_id: str, hours: float = 2.0, if r.status_code != 200: return {"status": "ok", "readings": []} return {"status": "ok", "readings": (r.json() or {}).get("readings", [])} + + +# -- live stream (fan-out feed, scoped + scrubbed) --------------------------- + +def _scrub_frame(raw: str) -> str: + """Project a monitor frame down to the portal whitelist. Drops internal fields + (unit_id, raw_payload, lmin) before it reaches a client; passes control fields + (feed_status, heartbeat) + timestamp through.""" + try: + d = json.loads(raw) + except Exception: + return raw + out = {k: d.get(k) for k in _PORTAL_LIVE_FIELDS if k in d} + if "timestamp" in d: + out["timestamp"] = d["timestamp"] + for ctrl in ("feed_status", "heartbeat"): + if ctrl in d: + out[ctrl] = d[ctrl] + return json.dumps(out) + + +@router.websocket("/api/location/{location_id}/stream") +async def portal_location_stream(websocket: WebSocket, location_id: str): + """Live ~1Hz feed for a location the client owns. Auths via the session cookie, + enforces ownership, then bridges the unit's shared SLMM /monitor fan-out feed + to the browser (scrubbed). A viewer is just one more subscriber to the one + device feed — no extra device connection.""" + await websocket.accept() + + # Auth + ownership on a short-lived session, then release it for the long bridge. + db = SessionLocal() + try: + client = client_from_cookie(websocket.cookies.get(COOKIE_NAME), db) + if client is None: + await websocket.close(code=1008) # policy violation (not authenticated) + return + try: + resolve_client_location(client, location_id, db) + except HTTPException: + await websocket.close(code=1008) + return + unit_id = active_unit_for_location(location_id, db) + finally: + db.close() + + if not unit_id: + try: + await websocket.send_json({"feed_status": "no_device"}) + finally: + await websocket.close(code=1000) + return + + target = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/monitor" + backend_ws = None + try: + backend_ws = await websockets.connect(target) + + async def forward_to_client(): + async for message in backend_ws: + await websocket.send_text(_scrub_frame(message)) + + async def watch_client(): + while True: + await websocket.receive_text() + + tasks = [asyncio.ensure_future(forward_to_client()), + asyncio.ensure_future(watch_client())] + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for t in pending: + t.cancel() + for t in tasks: + try: + await t + except (asyncio.CancelledError, Exception): + pass + except Exception as e: + logger.warning(f"[PORTAL] stream {location_id}: {e}") + finally: + if backend_ws: + try: + await backend_ws.close() + except Exception: + pass diff --git a/templates/portal/location.html b/templates/portal/location.html index 4bb107b..1a3148c 100644 --- a/templates/portal/location.html +++ b/templates/portal/location.html @@ -39,8 +39,14 @@
-
+
+
{% endif %} {% endblock %} @@ -129,10 +135,78 @@ async function backfill() { } catch (e) { /* leave chart empty */ } } +// ---- live stream (upgrades the cache prefill to a real ~1Hz feed) -------- +let ws = null, hardCap = null, paused = false; +const IDLE_CAP_MS = 15 * 60 * 1000; // auto-close after 15 min so an abandoned + // tab doesn't pin the device at 1Hz polling + +function pushPoint(d) { + cd.t.push(new Date().toLocaleTimeString()); + cd.lp.push(numOrNull(d.lp)); cd.leq.push(numOrNull(d.leq)); + cd.ln1.push(numOrNull(d.ln1)); cd.ln2.push(numOrNull(d.ln2)); + if (cd.t.length > 600) { cd.t.shift(); cd.lp.shift(); cd.leq.shift(); cd.ln1.shift(); cd.ln2.shift(); } + chart.data.labels = cd.t; + chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq; + chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2; + chart.update('none'); +} + +function openStream() { + if (paused || ws) return; + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${proto}//${location.host}/portal/api/location/${encodeURIComponent(LOC_ID)}/stream`); + ws.onmessage = (e) => { + let d; try { d = JSON.parse(e.data); } catch (_) { return; } + if (d.feed_status === 'no_device') { + setBadge(null, null); + document.getElementById('p-fresh').textContent = 'No device assigned'; + return; + } + if (d.heartbeat) return; + if (d.feed_status === 'unreachable') { + document.getElementById('p-fresh').innerHTML = 'device unreachable'; + return; + } + setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax); + setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2); + const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure'; + setBadge(measuring, d.timestamp || new Date().toISOString()); + pushPoint(d); + }; + ws.onclose = () => { ws = null; }; + ws.onerror = () => {}; + clearTimeout(hardCap); + hardCap = setTimeout(() => { paused = true; closeStream(); showPaused(true); }, IDLE_CAP_MS); +} + +function closeStream() { + clearTimeout(hardCap); + if (ws) { try { ws.close(); } catch (_) {} ws = null; } +} + +function showPaused(on) { + const el = document.getElementById('p-paused'); + if (el) el.classList.toggle('hidden', !on); +} +function resumeStream() { + paused = false; showPaused(false); + prefill(); // refresh cards instantly on resume + openStream(); +} + +// Stop streaming when the tab is hidden (client switched away / locked phone) and +// resume when it's visible again — the main cost guard, so the device relaxes back +// to its idle poll rate the moment nobody is actually looking. +document.addEventListener('visibilitychange', () => { + if (document.hidden) closeStream(); + else if (!paused) openStream(); +}); +window.addEventListener('beforeunload', closeStream); + initChart(); -prefill(); -backfill(); -setInterval(prefill, 15000); // cache poll — read-only, no device contention +prefill(); // instant first paint from cache +backfill(); // seed the chart trail +openStream(); // then upgrade to the live feed {% endif %} {% endblock %} -- 2.52.0 From b971d1906805e6cb24af5b9109db8b746043dac3 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 03:33:42 +0000 Subject: [PATCH 11/27] feat(portal): tile headline is Leq, not Lp; note live-map for M2 Lp (instantaneous) twitches every reading and makes a poor at-a-glance headline; Leq (energy-average) is the stable, standard sound-monitoring/compliance metric. Overview tiles now lead with Leq. Design doc: live project map (status-colored pins + current-reading popups) recorded as an M2 item; headline-metric rationale noted. Co-Authored-By: Claude Opus 4.8 --- docs/CLIENT_PORTAL.md | 11 ++++++++++- templates/portal/overview.html | 10 +++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/CLIENT_PORTAL.md b/docs/CLIENT_PORTAL.md index 0fc2e88..c489567 100644 --- a/docs/CLIENT_PORTAL.md +++ b/docs/CLIENT_PORTAL.md @@ -118,10 +118,19 @@ live data, all from cache. page or a script for now). ### M2 — Dashboard + alerts -- Richer client dashboard (multi-location at-a-glance, map, status rollup). +- Richer client dashboard (multi-location at-a-glance, status rollup). +- **Live project map** — upgrade the overview's basic location pins into a real + project map: pins colored by measuring/level, popups showing each location's + current reading, centered/zoomed to the project. (M1 ships the plain pin map; + this makes it a live status map.) - Surface each location's **threshold-alert status** (read-only) + an event/inbox view. Leans on the SLMM alert engine + dispatch. +### Notes carried from M1 +- Tile headline metric is **Leq** (energy-average, the sound-monitoring compliance + metric) — chosen over the twitchy instantaneous Lp. If clients ever want a + different headline (e.g. Lmax for peaks), make it a per-deployment setting. + ### M3 — Reports - Client-facing list + download of the daily baseline-comparison reports. - Depends on the FTP report pipeline (`feat/ftp-report-pipeline`) landing and diff --git a/templates/portal/overview.html b/templates/portal/overview.html index a5b0994..57539be 100644 --- a/templates/portal/overview.html +++ b/templates/portal/overview.html @@ -20,8 +20,8 @@
{{ loc.address or loc.project_name or '' }}
- -- - dB Lp + -- + dB Leq
 
@@ -51,7 +51,7 @@ function fmtAgo(iso) { async function loadTile(loc) { const el = document.querySelector(`.loc-tile[data-loc="${loc.id}"]`); if (!el) return; - const lp = el.querySelector('.loc-lp'), badge = el.querySelector('.loc-badge'), + const leq = el.querySelector('.loc-leq'), badge = el.querySelector('.loc-badge'), fresh = el.querySelector('.loc-fresh'); try { const j = await (await fetch(`/portal/api/location/${encodeURIComponent(loc.id)}/live`)).json(); @@ -60,10 +60,10 @@ async function loadTile(loc) { if (!d) { badge.textContent = j.reason === 'no_device' ? 'No device' : 'Offline'; badge.className = 'loc-badge shrink-0 px-2 py-0.5 text-xs rounded-full bg-slate-700 text-gray-300'; - lp.textContent = '--'; fresh.innerHTML = ' '; + leq.textContent = '--'; fresh.innerHTML = ' '; return; } - lp.textContent = (d.lp == null || d.lp === '') ? '--' : d.lp; + leq.textContent = (d.leq == null || d.leq === '') ? '--' : d.leq; const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure'; badge.textContent = measuring ? '● Live' : 'Stopped'; badge.className = 'loc-badge shrink-0 px-2 py-0.5 text-xs rounded-full ' + -- 2.52.0 From 5455d3a9315563d538480accd10d22c54057fa75 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 03:40:17 +0000 Subject: [PATCH 12/27] style(portal): overview map uses dot markers, matching the internal project map Swap Leaflet's default teardrop pins for L.circleMarker (radius 8, seismo-orange fill, white border) + a name tooltip, same as partials/projects/location_map.html. Also disables scroll-wheel zoom to match. Co-Authored-By: Claude Opus 4.8 --- templates/portal/overview.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/templates/portal/overview.html b/templates/portal/overview.html index 57539be..acd82f6 100644 --- a/templates/portal/overview.html +++ b/templates/portal/overview.html @@ -81,14 +81,18 @@ const withCoords = LOCATIONS.filter(l => l.coordinates); if (withCoords.length) { const mapEl = document.getElementById('loc-map'); mapEl.classList.remove('hidden'); - const map = L.map('loc-map'); + const map = L.map('loc-map', { scrollWheelZoom: false }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(map); const pts = []; withCoords.forEach(l => { const [la, lo] = (l.coordinates || '').split(',').map(Number); if (!isNaN(la) && !isNaN(lo)) { - L.marker([la, lo]).addTo(map).bindPopup(l.name); + // Same dot style as the internal project map (circleMarker, not a pin). + L.circleMarker([la, lo], { + radius: 8, fillColor: '#f48b1c', color: '#fff', + weight: 2, opacity: 1, fillOpacity: 0.9, + }).addTo(map).bindTooltip(l.name, { direction: 'top', offset: [0, -6] }); pts.push([la, lo]); } }); -- 2.52.0 From b908f394ed8f937d735689f4132e692806baf76b Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 06:05:09 +0000 Subject: [PATCH 13/27] =?UTF-8?q?feat(portal):=20M2a=20=E2=80=94=20live=20?= =?UTF-8?q?status=20map=20+=20status=20rollup=20on=20the=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the existing per-location /live fetch (no backend change): - Map dots recolor live by current level (green/amber/red bands, grey when not measuring/offline) and the tooltip shows the live Leq. Bands are placeholders until M2 alert thresholds drive the color. - Status rollup header: total locations, # live vs offline, and a "Loudest now" Leq callout. Aggregated each 15s refresh. Refactored the refresh into refreshAll() (Promise.all over loadTile -> updateRollup); loadTile now also feeds liveState + recolors the matching map dot. Co-Authored-By: Claude Opus 4.8 --- templates/portal/overview.html | 124 +++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 21 deletions(-) diff --git a/templates/portal/overview.html b/templates/portal/overview.html index acd82f6..4f01d3d 100644 --- a/templates/portal/overview.html +++ b/templates/portal/overview.html @@ -8,6 +8,22 @@

Live sound levels for your active locations. Read-only.

{% if locations %} + + +
@@ -38,6 +54,20 @@ {% endblock %} -- 2.52.0 From 2da9493cb58ef4478dc9a9197e112caece75c65f Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 17:11:34 +0000 Subject: [PATCH 14/27] =?UTF-8?q?feat(portal):=20"Copy=20client=20link"=20?= =?UTF-8?q?=E2=80=94=20generate/copy/revoke=20shareable=20links=20from=20t?= =?UTF-8?q?he=20project=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No-CLI way to get a real shareable magic link (/portal/enter/) for a project's client. Project page gets a "Copy client link" button next to the preview; opens a modal that lists active links (with revoke), generates a fresh one, and copies it to the clipboard. Backend (operator, internal /projects/*): - POST /projects/{id}/portal-link -> mint a fresh token, return the full URL (built from request.base_url so it uses the operator's host). - GET /projects/{id}/portal-links -> list active links (label/created/last-used). - POST /projects/{id}/portal-link/{tid}/revoke -> revoke one (scoped to the project's client). Refactor: split ensure_project_client() + mint_link_token() out of provision_preview_session() so minting a shareable link and the preview cookie share one provisioning path. Verified: ensure/mint persistence across commits + sessions, minted link resolves, token stored hashed, second mint = distinct active link (4/4); compiles; share script balances; detail.html parses. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 54 ++++++++++++++ backend/portal_auth.py | 32 ++++++--- templates/projects/detail.html | 128 ++++++++++++++++++++++++++++++--- 3 files changed, 195 insertions(+), 19 deletions(-) diff --git a/backend/main.py b/backend/main.py index 5305c37..cad523a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -432,6 +432,60 @@ async def project_portal_preview(project_id: str, db: Session = Depends(get_db)) return resp +@app.post("/projects/{project_id}/portal-link") +async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)): + """Mint a fresh shareable client link for this project's client. Returns the + full /portal/enter/ URL (shown once). Operator-only (internal app).""" + from backend.models import Project + from backend.portal_auth import ensure_project_client, mint_link_token + project = db.query(Project).filter_by(id=project_id).first() + if not project: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + client = ensure_project_client(project, db) + raw = mint_link_token(client, db, label="shared link") + url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}" + return {"url": url, "client_name": client.name} + + +@app.get("/projects/{project_id}/portal-links") +async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)): + """List active (non-revoked) shareable links for this project's client.""" + from backend.models import Project, ClientAccessToken, Client + project = db.query(Project).filter_by(id=project_id).first() + if not project or not project.client_id: + return {"client_name": None, "links": []} + client = db.query(Client).filter_by(id=project.client_id).first() + toks = (db.query(ClientAccessToken) + .filter_by(client_id=project.client_id, revoked_at=None) + .order_by(ClientAccessToken.created_at.desc()).all()) + return { + "client_name": client.name if client else None, + "links": [{ + "id": t.id, "label": t.label, + "created_at": t.created_at.isoformat() if t.created_at else None, + "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None, + } for t in toks], + } + + +@app.post("/projects/{project_id}/portal-link/{token_id}/revoke") +async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)): + """Revoke one shareable link (scoped to this project's client). Kills the link + and any live session minted from it on the next request.""" + from datetime import datetime as _dt + from backend.models import Project, ClientAccessToken + project = db.query(Project).filter_by(id=project_id).first() + if not project or not project.client_id: + return JSONResponse(status_code=404, content={"detail": "Not found"}) + tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first() + if not tok: + return JSONResponse(status_code=404, content={"detail": "Link not found"}) + if not tok.revoked_at: + tok.revoked_at = _dt.utcnow() + db.commit() + return {"ok": True} + + @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) async def nrl_detail_page( request: Request, diff --git a/backend/portal_auth.py b/backend/portal_auth.py index ac52ae7..cb4dc8c 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -120,15 +120,10 @@ def resolve_token(raw_token: str, db: Session): return tok, client -def provision_preview_session(project, db) -> str: - """Testing convenience (operator-side): ensure a Client + access token exist for - a project so an operator can preview the client portal without the CLI. Returns - the token id to seal into a session cookie. - - Reuses the project's linked client if it has one; otherwise creates/uses a - per-project 'preview-' client. Only sets project.client_id when it's unset, - so previewing never clobbers a real client link. The token's raw secret is - discarded (preview rides the cookie, not a magic link).""" +def ensure_project_client(project, db) -> Client: + """Find or create the Client for a project. Reuses the project's linked client + if it has one; otherwise creates/uses a per-project 'preview-' client and + sets project.client_id (only when unset, so it never clobbers a real link).""" client = None if project.client_id: client = db.query(Client).filter_by(id=project.client_id, active=True).first() @@ -143,6 +138,25 @@ def provision_preview_session(project, db) -> str: db.flush() if not project.client_id: project.client_id = client.id + return client + + +def mint_link_token(client, db, label=None) -> str: + """Mint a fresh access token for a client and return the RAW secret (caller + builds the /portal/enter/ URL and shows it once). Only the hash is stored.""" + raw = secrets.token_urlsafe(32) + db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, + token_hash=hash_token(raw), label=label)) + db.commit() + return raw + + +def provision_preview_session(project, db) -> str: + """Operator preview shortcut: ensure a Client + access token exist for a project + and return a token id to seal into a session cookie (no shared link). Reuses an + existing token so repeat previews don't accumulate clutter; the raw secret is + discarded (preview rides the cookie).""" + client = ensure_project_client(project, db) tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first() if tok is None: tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 1542c2f..94675ed 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -18,16 +18,27 @@ Project - - - - - - - View client portal - + +
+ + + + + + + View client portal + +
@@ -2085,5 +2096,102 @@ document.addEventListener('DOMContentLoaded', function() { } }); + + + + + + {% endblock %} -- 2.52.0 From bececafe78e92be3f6adb93ff17a9e8551bf6f9a Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 17:26:37 +0000 Subject: [PATCH 15/27] feat(portal): plain no-token "open" links for dev feedback (PORTAL_OPEN_LINKS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a frictionless shareable link so anyone can open a project's client portal during dev without minting/copying a magic token. GET /portal/open/{project_id} (gated by PORTAL_OPEN_LINKS) provisions the client session and lands on /portal; lives under /portal so it works through a proxy exposing only /portal/*. The project page's "Copy client link" modal now leads with this Quick share link (amber, host taken from window.location.origin so it always matches the host you copied it from — no more LAN-vs-public foot-gun). The token-based generate/list/ revoke stays below for the eventual secure path. PORTAL_OPEN_LINKS defaults ON for the prototype (whole app is open anyway) and logs a warning; set =false before real clients. The get_current_client seam is untouched, so M4 auth still layers in front of the same routes regardless. Verified: compiles, share script balances, detail.html parses, flag default on / =false off. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 5 +++-- backend/portal_auth.py | 9 +++++++++ backend/routers/portal.py | 23 +++++++++++++++++++++++ templates/projects/detail.html | 23 +++++++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index cad523a..799d943 100644 --- a/backend/main.py +++ b/backend/main.py @@ -69,7 +69,7 @@ from backend.templates_config import templates # Client-portal auth: an unauthenticated portal request renders the access page # (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every # portal route can simply Depends(get_current_client). -from backend.portal_auth import PortalAuthError +from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS @app.exception_handler(PortalAuthError) async def portal_auth_handler(request: Request, exc: PortalAuthError): @@ -409,7 +409,8 @@ async def project_detail_page(request: Request, project_id: str): """Project detail dashboard""" return templates.TemplateResponse("projects/detail.html", { "request": request, - "project_id": project_id + "project_id": project_id, + "portal_open_links": PORTAL_OPEN_LINKS, }) diff --git a/backend/portal_auth.py b/backend/portal_auth.py index cb4dc8c..72537cc 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -40,6 +40,15 @@ if SECRET_KEY == "dev-insecure-change-me": COOKIE_NAME = "portal_session" COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days +# Dev convenience: plain, no-token portal links (/portal/open/{project_id}) so +# anyone can open a client portal for feedback without minting a magic link. +# Defaults ON for the current prototype (the whole app is open anyway); set +# PORTAL_OPEN_LINKS=false before real clients are on the portal. +PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "true").lower() in ("1", "true", "yes") +if PORTAL_OPEN_LINKS: + logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. " + "Set PORTAL_OPEN_LINKS=false before real clients.") + class PortalAuthError(Exception): """Raised by get_current_client when there's no valid portal session. diff --git a/backend/routers/portal.py b/backend/routers/portal.py index abbea7b..6af574e 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -24,6 +24,7 @@ from backend.models import Client, MonitoringLocation, Project, UnitAssignment from backend.templates_config import templates from backend.portal_auth import ( get_current_client, client_from_cookie, make_session_cookie, resolve_token, + provision_preview_session, PORTAL_OPEN_LINKS, COOKIE_NAME, COOKIE_MAX_AGE, ) @@ -109,6 +110,28 @@ def portal_enter(token: str, request: Request, db: Session = Depends(get_db)): return resp +@router.get("/open/{project_id}") +def portal_open(project_id: str, request: Request, db: Session = Depends(get_db)): + """Dev-only plain shareable link: open a project's client portal with no token + (gated by PORTAL_OPEN_LINKS). Lets anyone with the URL view it for feedback — + sets the session cookie and lands on /portal. Lives under /portal so it works + through a reverse proxy that exposes only /portal/*.""" + if not PORTAL_OPEN_LINKS: + return templates.TemplateResponse( + "portal/access_required.html", {"request": request, "reason": "required"}, + status_code=404) + project = db.query(Project).filter_by(id=project_id).first() + if not project: + return templates.TemplateResponse( + "portal/access_required.html", {"request": request, "reason": "invalid"}, + status_code=404) + token_id = provision_preview_session(project, db) + resp = RedirectResponse(url="/portal", status_code=303) + resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") + return resp + + @router.get("/logout") def portal_logout(): resp = RedirectResponse(url="/portal/access", status_code=303) diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 94675ed..572493a 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -2112,6 +2112,19 @@ document.addEventListener('DOMContentLoaded', function() { Anyone with a link can view this project's client portal (read-only). Links are revocable.

+ {% if portal_open_links %} + +
+ +
+ + +
+

For feedback during development. Disable PORTAL_OPEN_LINKS before real clients.

+
+ {% endif %} + + + +
+
+
+

Alerts

+

Threshold rules evaluated on this device's live feed.

+
+ +
+ +
+ + + +
+ + {% endblock %} -- 2.52.0 From 0914cf0a75326488c63d62939fcc4c80be7a547a Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 19:02:56 +0000 Subject: [PATCH 17/27] =?UTF-8?q?feat(portal):=20M2b-2=20=E2=80=94=20surfa?= =?UTF-8?q?ce=20alert=20state=20+=20breach=20history=20(internal=20+=20por?= =?UTF-8?q?tal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal (SLM detail page): live alarm-state badge in the Alerts header (● N active / ✓ all clear), a History list of fired events (onset → clear, peak dB, ack status) with an Ack button, refreshed every 20s. Reads the existing SLMM /alerts/events + /ack via the proxy. Portal (client, read-only, scoped): new GET /portal/api/location/{id}/events — ownership-gated, returns a scrubbed projection (rule_name/metric/threshold/onset/ peak/clear/status only; no internal ids or ack-by) plus an `active` count. The location page shows a red "Currently above threshold" banner when active and a read-only breach history, polled every 20s. No ack on the client side. Verified: portal.py compiles; both scripts balance; both templates parse. Co-Authored-By: Claude Opus 4.8 --- backend/routers/portal.py | 28 ++++++++++++ templates/portal/location.html | 48 +++++++++++++++++++++ templates/slm_detail.html | 79 +++++++++++++++++++++++++++++++++- 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/backend/routers/portal.py b/backend/routers/portal.py index 6af574e..6f86108 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -214,6 +214,34 @@ async def portal_location_history(location_id: str, hours: float = 2.0, return {"status": "ok", "readings": (r.json() or {}).get("readings", [])} +# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by). +_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at", + "onset_value", "peak_value", "clear_at", "status") + + +@router.get("/api/location/{location_id}/events") +async def portal_location_events(location_id: str, limit: int = 20, + client: Client = Depends(get_current_client), + db: Session = Depends(get_db)): + """Scrubbed breach history for a location the client owns (read-only).""" + resolve_client_location(client, location_id, db) + unit_id = active_unit_for_location(location_id, db) + if not unit_id: + return {"status": "ok", "events": []} + limit = max(1, min(limit, 100)) + try: + async with httpx.AsyncClient(timeout=5.0) as hc: + r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events", + params={"limit": limit}) + except Exception: + return {"status": "ok", "events": []} + if r.status_code != 200: + return {"status": "ok", "events": []} + raw = (r.json() or {}).get("events", []) + events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw] + return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")} + + # -- live stream (fan-out feed, scoped + scrubbed) --------------------------- def _scrub_frame(raw: str) -> str: diff --git a/templates/portal/location.html b/templates/portal/location.html index 1a3148c..7ebe04e 100644 --- a/templates/portal/location.html +++ b/templates/portal/location.html @@ -16,6 +16,13 @@ No device is currently assigned to this location. {% else %} +
Lp (Instant)
@@ -48,6 +55,12 @@
+ + +
+

Alert history

+
+
{% endif %} {% endblock %} @@ -203,10 +216,45 @@ document.addEventListener('visibilitychange', () => { }); window.addEventListener('beforeunload', closeStream); +// ---- alert history + current-alarm banner (read-only) -------------------- +const EV_METRIC = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' }; +function fmtAlertTime(iso) { return iso ? new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString() : ''; } + +async function loadEvents() { + try { + const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json(); + const events = j.events || []; + const banner = document.getElementById('p-alarm-banner'); + if (j.active) { + banner.classList.remove('hidden'); + document.getElementById('p-alarm-text').textContent = + j.active > 1 ? `${j.active} alerts currently active` : 'Currently above threshold.'; + } else banner.classList.add('hidden'); + const list = document.getElementById('p-events'); + if (!events.length) { list.innerHTML = '
No alerts have fired.
'; return; } + list.innerHTML = ''; + for (const e of events) { + const m = EV_METRIC[e.metric] || e.metric; + const active = e.status === 'active'; + const when = active ? `since ${fmtAlertTime(e.onset_at)}` + : `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`; + const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : ''; + const row = document.createElement('div'); + row.className = 'px-3 py-2 rounded-lg border text-sm ' + + (active ? 'border-red-700/60 bg-red-900/20 text-red-200' : 'border-slate-700 bg-slate-800/40 text-gray-300'); + row.innerHTML = `
${e.rule_name || 'Alert'} · ${m} ${e.threshold_db} dB
+
${when}${peak}
`; + list.appendChild(row); + } + } catch (e) { /* leave history as-is */ } +} + initChart(); prefill(); // instant first paint from cache backfill(); // seed the chart trail openStream(); // then upgrade to the live feed +loadEvents(); +setInterval(loadEvents, 20000); {% endif %} {% endblock %} diff --git a/templates/slm_detail.html b/templates/slm_detail.html index def5ed4..abeccbe 100644 --- a/templates/slm_detail.html +++ b/templates/slm_detail.html @@ -117,7 +117,9 @@
-

Alerts

+

Alerts + +

Threshold rules evaluated on this device's live feed.

+ + +
+
+

History

+ +
+
+
{% endblock %} -- 2.52.0 From fa7dc39e5ef698728cf7d3ea951a71272765086a Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 19:36:16 +0000 Subject: [PATCH 18/27] =?UTF-8?q?feat(portal):=20M2b-3=20note=20=E2=80=94?= =?UTF-8?q?=20enabled=20alerts=20keep=20the=20device=20monitored=2024/7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI note on the SLM alerts card reflecting the SLMM keepalive coupling. Co-Authored-By: Claude Opus 4.8 --- templates/slm_detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/slm_detail.html b/templates/slm_detail.html index abeccbe..3aa8fdf 100644 --- a/templates/slm_detail.html +++ b/templates/slm_detail.html @@ -120,7 +120,7 @@

Alerts

-

Threshold rules evaluated on this device's live feed.

+

Threshold rules evaluated on this device's live feed. An enabled alert keeps the device monitored 24/7.

-- 2.52.0 From 4839d14a22e916711107d9a7c928ef58c0b8b81f Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 19:41:48 +0000 Subject: [PATCH 19/27] =?UTF-8?q?style(portal):=20field-instrument=20redes?= =?UTF-8?q?ign=20=E2=80=94=20shell=20+=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refined dark "field instrument" aesthetic for the client-facing portal: - Type: Hanken Grotesk UI + IBM Plex Mono for readings (dB values feel like real instrumentation). Tabular numerals. - Atmosphere: deep navy-black base with a navy/burgundy aurora and a faint fixed instrument grid; sticky blurred header with an animated signal-bars mark. - Panel system (.panel/.panel-hover): translucent, hairline-lit, depth + hover lift. Pulsing live dot; staggered load reveal. - Overview: mono Leq hero on each tile (colored by level when live), pill badges with the pulsing dot, rollup pills, dark CARTO map tiles, level-colored dots. All live-data JS hook IDs preserved (verified). No backend change. Co-Authored-By: Claude Opus 4.8 --- templates/portal/base.html | 126 ++++++++++++++++++++++++++++----- templates/portal/overview.html | 108 ++++++++++++++-------------- 2 files changed, 163 insertions(+), 71 deletions(-) diff --git a/templates/portal/base.html b/templates/portal/base.html index 25daa37..e771c96 100644 --- a/templates/portal/base.html +++ b/templates/portal/base.html @@ -1,43 +1,135 @@ - + {% block title %}Monitoring{% endblock %} · TMI + + + + - + + + {% block head %}{% endblock %} - -
-
- - - TMI Monitoring{% if client %} · - {{ client.name }}{% endif %} + +
+
-
+
{% block content %}{% endblock %}
-
- Read-only monitoring view. Data is provided as-is for informational purposes. +
+ + Read-only monitoring view · data provided as-is for informational purposes
{% block scripts %}{% endblock %} diff --git a/templates/portal/overview.html b/templates/portal/overview.html index 4f01d3d..a4760fb 100644 --- a/templates/portal/overview.html +++ b/templates/portal/overview.html @@ -4,49 +4,55 @@ {% endblock %} {% block content %} -

Your monitoring locations

-

Live sound levels for your active locations. Read-only.

+
+
Live monitoring
+

Your locations

+

Real-time sound levels across your active monitoring sites.

+
{% if locations %} -
-