Files
terra-view/docs/CLIENT_PORTAL.md
T
serversdown a64c9ced65 docs: client portal design + milestone plan (M1 live view → M4 full auth)
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 <noreply@anthropic.com>
2026-06-10 21:29:14 +00:00

6.6 KiB
Raw Blame History

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(). M1M3 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)

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

def get_current_client(request, db) -> Client:   # every /portal route depends on this
    # M1M3: read signed `portal_client` cookie -> load Client
    # M4: same signature, backed by real sessions (magic-link / password)

Interim "magic URL" flow (M1M3):

  • 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.