diff --git a/CHANGELOG.md b/CHANGELOG.md index 908f277..5941174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,62 @@ The two builds must ship **together**. Note the `docker-compose.yml` container --- +### Client portal *(new — read-only client-facing view)* + +A scoped, read-only portal at **`/portal/*`** where a client sees only *their* +locations, live. Built inside Terra-View (no new service), reusing the cached +SLMM feed; every route resolves the client through one swappable +`get_current_client` gate, so the interim magic/open-link auth can be replaced +(M4) without touching routes or templates. Strictly read-only — no device control. + +#### Added + +- **Per-client scoping + interim auth.** New `Client`, `ClientAccessToken`, and a + `Project.client_id` FK. A signed (HMAC) session cookie carries the access-token + id, re-validated against the DB each request (revoke kills live sessions, with + server-side expiry). Entry via a magic link (`/portal/enter/{token}`) or a + dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**). +- **Live location view.** KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate + instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to + the client's unit (a scrubbed bridge to the SLMM fan-out feed). The stream + **auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle + cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular. +- **Locations overview.** Live status map (level-colored dots, dark/light CARTO + tiles) + a status rollup (live/offline counts, "loudest now"). Leq is the + headline metric. +- **Alerts (config → surface → 24/7).** Threshold-rule config on the SLM detail + page (proxying SLMM's alert CRUD); breach **history + ack** internally and a + read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel + in the portal; enabling a rule pins that device's monitor on so alerts evaluate + round-the-clock. +- **Operator sharing tools.** A **"View client portal"** preview button and a + **"Copy client link"** modal (mint / list / revoke magic links) on the project + page, plus a `backend/portal_admin.py` CLI. +- **Field-instrument design.** Distinctive themed portal — Hanken Grotesk UI + + IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a + **light/dark toggle** (light default, persisted, no-flash). + +#### Security + +- All scoping enforced server-side (404-not-403, no existence leak); client + endpoints return **scrubbed** projections (no device-health/internal ids); WS + frames whitelisted; operator-set strings HTML-escaped before injection (XSS). + Pre-merge code review hardened cookie expiry, open-links default, and the slug + collision. Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is + tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog". + +#### Upgrade Notes + +- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py` + (adds `projects.client_id`; the `clients` / `client_access_tokens` tables + auto-create). +- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies), + and keep **`PORTAL_OPEN_LINKS=false`** there. +- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator + + cooldown + keepalive coupling) — same build pairing as above. + +--- + ## [0.13.3] - 2026-06-05 Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work. diff --git a/backend/main.py b/backend/main.py index 54792ad..799d943 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 @@ -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, PORTAL_OPEN_LINKS + +@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) @@ -390,10 +409,84 @@ 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, }) +@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.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/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 diff --git a/backend/portal_admin.py b/backend/portal_admin.py new file mode 100644 index 0000000..2d8b346 --- /dev/null +++ b/backend/portal_admin.py @@ -0,0 +1,169 @@ +#!/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 + +# 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 + +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() diff --git a/backend/portal_auth.py b/backend/portal_auth.py new file mode 100644 index 0000000..d233e2f --- /dev/null +++ b/backend/portal_auth.py @@ -0,0 +1,183 @@ +""" +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 uuid +import base64 +import hashlib +import logging +import secrets +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 + +# Plain, no-token portal links (/portal/open/{project_id}). These are an +# UNAUTHENTICATED, proxy-reachable session-minting path (and a linked project's +# open link grants the *whole* client's scope), so they default OFF and must be +# explicitly enabled — set PORTAL_OPEN_LINKS=true only in a dev/prototype env. +PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "false").lower() in ("1", "true", "yes") +if PORTAL_OPEN_LINKS: + logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. " + "Keep this OFF in any internet-facing / production deployment.") + + +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())) + if not isinstance(data, dict): + return None + # Server-side expiry: a leaked cookie isn't valid forever (max_age is only a + # browser hint). iat is set by make_session_cookie. + iat = data.get("iat") + if not isinstance(iat, (int, float)) or (time.time() - iat) > COOKIE_MAX_AGE: + return None + return data.get("tid") + except Exception: + return None + + +# -- the dependency every portal route uses --------------------------------- + +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: + return None + tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first() + if not tok: + 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 + + +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 + + +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() + if client is None: + slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects + 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 + 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, + token_hash=hash_token(secrets.token_urlsafe(32)), + label="preview") + db.add(tok) + db.commit() + return tok.id diff --git a/backend/routers/portal.py b/backend/routers/portal.py new file mode 100644 index 0000000..8c58614 --- /dev/null +++ b/backend/routers/portal.py @@ -0,0 +1,358 @@ +""" +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 os +import json +import asyncio +import logging +from datetime import datetime + +import httpx +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, SessionLocal +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, +) + +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. +_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 + + +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.""" + 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("/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) + 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), + 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": _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") +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": []} + raw = (r.json() or {}).get("readings", []) + fields = ("timestamp", "lp", "leq", "lmax", "ln1", "ln2") # whitelist, like the other endpoints + return {"status": "ok", "readings": [{k: x.get(k) for k in fields} for x in raw]} + + +# 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")} + + +# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/ +# hysteresis internals). +_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s", + "schedule_start", "schedule_end", "schedule_days") + + +@router.get("/api/location/{location_id}/thresholds") +async def portal_location_thresholds(location_id: str, + client: Client = Depends(get_current_client), + db: Session = Depends(get_db)): + """The active alert limits for a location the client owns (enabled rules only), + so the client can see what they're being alerted on. Read-only, scrubbed.""" + resolve_client_location(client, location_id, db) + unit_id = active_unit_for_location(location_id, db) + if not unit_id: + return {"status": "ok", "rules": []} + try: + async with httpx.AsyncClient(timeout=5.0) as hc: + r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules") + except Exception: + return {"status": "ok", "rules": []} + if r.status_code != 200: + return {"status": "ok", "rules": []} + raw = (r.json() or {}).get("rules", []) + rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")] + return {"status": "ok", "rules": rules} + + +# -- live stream (fan-out feed, scoped + scrubbed) --------------------------- + +def _scrub_frame(raw: 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. Returns None for a non-JSON frame + so the caller drops it rather than forwarding anything unscrubbed.""" + try: + d = json.loads(raw) + except Exception: + return None + 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: + frame = _scrub_frame(message) + if frame is not None: + await websocket.send_text(frame) + + 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/docs/CLIENT_PORTAL.md b/docs/CLIENT_PORTAL.md new file mode 100644 index 0000000..b654d72 --- /dev/null +++ b/docs/CLIENT_PORTAL.md @@ -0,0 +1,208 @@ +# 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, 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 + 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. + +## 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 + 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. + +## Security hardening backlog ("Fest 2026") + +The to-do for the dedicated hardening pass, roughly highest-impact first. Until +then the portal runs on security-by-obscurity (open port + interim links) — fine +for a not-in-use demo, not for real clients. + +**Exposure (the big one):** port 8001 serves the *entire operator app* (roster, +projects, `/admin/*`, device config, the SLMM proxy) with **zero auth**, so an +open port exposes far more than the read-only portal. +- [ ] Reverse proxy (NPM/Caddy/Nginx) in front, exposing **only `/portal/*`** to + the internet; keep the operator app reachable on the LAN only. +- [ ] TLS everywhere (Let's Encrypt). Then set portal cookies `Secure`. +- [ ] Don't port-forward the raw app; if a quick gate is wanted before M4, an + auth proxy (Authelia / Authentik) can front the portal without writing auth. + +**Config musts:** +- [ ] Set a real `SECRET_KEY` env (signs session cookies; default is public). +- [ ] `PORTAL_OPEN_LINKS=false` in any internet-facing env (it defaults off now). + +**M4 — real auth** (replaces the interim token behind `get_current_client`): +- [ ] Magic-link email and/or accounts; proper sessions + password reset. +- [ ] Authenticate the **operator** app too (it currently has none). +- [ ] Gate the operator-only endpoints that are presently unauthenticated: + `/projects/{id}/portal-preview`, `/projects/{id}/portal-link*`, + `/portal/open/*`. + +**Smaller items from the pre-merge code review:** +- [ ] Keepalive isn't auto-turned-off when the last alert rule on a unit is + deleted (intentional "never auto-off"; revisit if it wastes cellular). +- [ ] Consider rate-limiting the scoped portal endpoints once public. diff --git a/templates/portal/access_required.html b/templates/portal/access_required.html new file mode 100644 index 0000000..95591d0 --- /dev/null +++ b/templates/portal/access_required.html @@ -0,0 +1,19 @@ +{% 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..c58f0be --- /dev/null +++ b/templates/portal/base.html @@ -0,0 +1,196 @@ + + + + + + {% block title %}Monitoring{% endblock %} · TMI + + + + + + + + + + + + + + + {% block head %}{% endblock %} + + +
+ +
+ +
+ {% block content %}{% endblock %} +
+ +
+ + Read-only monitoring view · data provided as-is for informational purposes +
+ + + {% block scripts %}{% endblock %} + + diff --git a/templates/portal/location.html b/templates/portal/location.html new file mode 100644 index 0000000..377117f --- /dev/null +++ b/templates/portal/location.html @@ -0,0 +1,335 @@ +{% 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 %} + + + +
+
Leq · average
+
+ -- + dB +
+ +
+
+
Lp · instant
+
--dB
+
+
+
Lmax · peak
+
--dB
+
+
+
L1
+
--dB
+
+
+
L10
+
--dB
+
+
+
+ + +
+
Live trace · last 2h
+
+ + +
+
+ + + + + +
+
Alert history
+
+
+{% endif %} +{% endblock %} + +{% block scripts %} +{% if has_device %} + +{% endif %} +{% endblock %} diff --git a/templates/portal/overview.html b/templates/portal/overview.html new file mode 100644 index 0000000..941063d --- /dev/null +++ b/templates/portal/overview.html @@ -0,0 +1,192 @@ +{% extends "portal/base.html" %} +{% block title %}Your locations{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+
Live monitoring
+

Your locations

+

Real-time sound levels across your active monitoring sites.

+
+ +{% if locations %} + + + + + + +{% else %} +
No active monitoring locations yet.
+{% endif %} +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/templates/projects/detail.html b/templates/projects/detail.html index ba971f3..572493a 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -4,7 +4,7 @@ {% block content %} -
+ @@ -2074,5 +2096,125 @@ document.addEventListener('DOMContentLoaded', function() { } }); + + + + + + {% endblock %} diff --git a/templates/slm_detail.html b/templates/slm_detail.html index 6a17ea8..3aa8fdf 100644 --- a/templates/slm_detail.html +++ b/templates/slm_detail.html @@ -112,4 +112,267 @@
+ + +
+
+
+

Alerts + +

+

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

+
+ +
+ +
+ + + + + +
+
+

History

+ +
+
+
+
+ + {% endblock %}