docs: client portal design + milestone plan (M1 live view → M4 full auth) #61
@@ -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.
|
||||
Reference in New Issue
Block a user