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