Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.2 KiB
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
- Read-only. No device control (start/stop/config), no roster editing, no internal pages. A client can look, never touch.
- Strictly scoped. A client only ever sees data for their projects. Every
portal endpoint verifies ownership server-side — never trust a
unit_id/location_idfrom the request. - Cache-first, no device contention. Portal live data comes from SLMM's
cache (the same cached
/status+/historythe 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. - 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)
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
# 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 newSECRET_KEYenv) → redirects to/portal.last_used_atupdated. get_current_clientreads + 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+ClientAccessTokenmodels;Project.client_idmigration.SECRET_KEYenv + signed-cookie session helper.get_current_clientdependency +/portal/enter/{token}+ logout.- Scoping helper
resolve_client_location. /portaloverview +/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
/adminpage 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_clientwith 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)
- Run the migration on the prod DB —
migrate_add_client_portal.pyaddsprojects.client_id(the new tables auto-create viacreate_all). Skipping it 500s anything that touchesProject.client_id. This is the silent killer.docker compose exec web-app python3 backend/migrate_add_client_portal.py - Set a real
SECRET_KEYin 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. - SLMM_BASE_URL — prod base compose already points at
:8100(correct; the:9100mismatch is a dev-only override quirk). For full live data (L1/L10 + chart backfill) prod SLMM must be on thedevbuild 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. - 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). - 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_KEYmust be a real secret in prod (env, not committed).- Cookies:
HttpOnly,SameSite=Lax,Secureonce behind TLS. - Tokens stored hashed; raw shown once. Revocation is immediate.