docs: client portal design + milestone plan (M1 live view → M4 full auth) #61

Merged
serversdown merged 27 commits from feat/client-portal into dev 2026-06-11 23:21:53 -04:00
Owner
No description provided.
serversdown added 27 commits 2026-06-11 23:21:32 -04:00
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>
Client (customer org), ClientAccessToken (interim hashed magic-URL gate), and an
authoritative Project.client_id FK (client_name kept for display). New tables
auto-create via create_all; migrate_add_client_portal.py adds projects.client_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
backend/portal_auth.py: stdlib HMAC-signed session cookie carrying the access-
token id (re-validated against the DB each request, so revoke kills live
sessions), hash_token, resolve_token, and the get_current_client dependency
(raises PortalAuthError). SECRET_KEY env (insecure dev default + warning).

routers/portal.py: /portal/enter/{token} mints the cookie -> /portal; /logout;
/access; /portal home stub. main.py registers the router + a PortalAuthError
handler (HTML access page for pages, 401 JSON for /portal/api/*).

Portal shell templates (base, access_required, overview stub), branded dark.

Verified: cookie round-trip + tamper/garbage rejection, token resolution
(valid/bad), get_current_client (valid/no-cookie/revoked) — 8/8 against a temp DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolve_client_location() enforces ownership (sound location in one of the
client's active projects) and 404s everything else — same response for missing
and not-yours, so location existence never leaks. active_unit_for_location()
resolves the currently-assigned SLM.

Scoped GET /portal/api/location/{id}/live and /history: gate -> resolve unit ->
read SLMM cache (never the device). /live returns a SCRUBBED projection (sound
metrics + run state only; no battery/SD/raw_payload). Both degrade gracefully
when there's no device or SLMM is down.

Verified: ownership gate (owns / other-client / vibration / deleted-project /
removed / nonexistent) + active-vs-completed unit resolution — 8/8 on a temp DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/portal overview: client's active sound locations as live tiles (current Lp +
Live/Stopped badge + "updated Xm ago", polled from the scoped cache every 15s)
plus a Leaflet map of locations with coordinates. /portal/location/{id}: 404-gated
read-only live panel — Lp/Leq/Lmax/L1/L10 cards + a 4-line Chart.js trace
(backfilled from /history) + measuring/freshness badge. Cache-only, 15s poll, no
device controls, no refresh-from-device. _client_locations() feeds the overview.

Verified: portal.py compiles; both inline scripts balance; all four portal
templates parse in Jinja2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
backend/portal_admin.py (run in-container): create-client, link-project (by id/
number/name -> sets Project.client_id), mint-link (prints the full magic URL once,
stores only the hash), list, revoke. PORTAL_BASE_URL controls the printed link base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`python3 backend/portal_admin.py` set sys.path[0] to backend/, hiding the
`backend` package and breaking `from backend.database import ...`. Insert the
project root on sys.path so the documented script invocation works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a "View client portal" button on the project detail page that opens the
client portal scoped to that project — no CLI. GET /projects/{id}/portal-preview
auto-provisions a client + access token for the project (provision_preview_session)
and seals a portal session cookie, then redirects to /portal.

- Reuses the project's linked client if it has one; otherwise creates/reuses a
  per-project 'preview-<id>' client. Only sets project.client_id when unset, so it
  never clobbers a real client link. Idempotent — repeat clicks reuse the same
  client/token.
- Lives under /projects (not /portal), so a future public proxy exposing only
  /portal/* won't expose this operator shortcut.

Verified: provisioning (unlinked creates+links, idempotent, linked-no-clobber) 7/7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The portal location view is now genuinely live, not a 15s poll. Scoped WS endpoint
/portal/api/location/{id}/stream: authenticates via the session cookie, enforces
ownership (resolve_client_location), then bridges the unit's shared SLMM /monitor
fan-out feed to the browser — a viewer is just one more subscriber, no extra
device connection. Frames are scrubbed to the portal whitelist (drops unit_id,
raw_payload, counter, lmin) before reaching the client.

location.html: cache prefill for instant first paint, then upgrades to the live
socket (cards tick ~1Hz, chart scrolls). Auto-close so an abandoned tab can't pin
the device at 1Hz polling (~8x cellular data):
- closes when the tab is hidden, reopens when visible (Page Visibility) — the main
  guard;
- hard 15-min cap -> "Live paused — click to resume" overlay.

Refactor: client_from_cookie() extracted from get_current_client so the WS handler
(no Request-based Depends) can auth the same way.

Verified: scrub drops internal fields / keeps metrics + heartbeat (7/7), auth
refactor (3/3), portal compiles, location.html JS balances + parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lp (instantaneous) twitches every reading and makes a poor at-a-glance headline;
Leq (energy-average) is the stable, standard sound-monitoring/compliance metric.
Overview tiles now lead with Leq. Design doc: live project map (status-colored
pins + current-reading popups) recorded as an M2 item; headline-metric rationale
noted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swap Leaflet's default teardrop pins for L.circleMarker (radius 8, seismo-orange
fill, white border) + a name tooltip, same as partials/projects/location_map.html.
Also disables scroll-wheel zoom to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reuses the existing per-location /live fetch (no backend change):
- Map dots recolor live by current level (green/amber/red bands, grey when
  not measuring/offline) and the tooltip shows the live Leq. Bands are
  placeholders until M2 alert thresholds drive the color.
- Status rollup header: total locations, # live vs offline, and a "Loudest now"
  Leq callout. Aggregated each 15s refresh.

Refactored the refresh into refreshAll() (Promise.all over loadTile -> updateRollup);
loadTile now also feeds liveState + recolors the matching map dot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No-CLI way to get a real shareable magic link (/portal/enter/<token>) for a
project's client. Project page gets a "Copy client link" button next to the
preview; opens a modal that lists active links (with revoke), generates a fresh
one, and copies it to the clipboard.

Backend (operator, internal /projects/*):
- POST /projects/{id}/portal-link  -> mint a fresh token, return the full URL
  (built from request.base_url so it uses the operator's host).
- GET  /projects/{id}/portal-links -> list active links (label/created/last-used).
- POST /projects/{id}/portal-link/{tid}/revoke -> revoke one (scoped to the
  project's client).

Refactor: split ensure_project_client() + mint_link_token() out of
provision_preview_session() so minting a shareable link and the preview cookie
share one provisioning path.

Verified: ensure/mint persistence across commits + sessions, minted link resolves,
token stored hashed, second mint = distinct active link (4/4); compiles; share
script balances; detail.html parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a frictionless shareable link so anyone can open a project's client portal
during dev without minting/copying a magic token. GET /portal/open/{project_id}
(gated by PORTAL_OPEN_LINKS) provisions the client session and lands on /portal;
lives under /portal so it works through a proxy exposing only /portal/*.

The project page's "Copy client link" modal now leads with this Quick share link
(amber, host taken from window.location.origin so it always matches the host you
copied it from — no more LAN-vs-public foot-gun). The token-based generate/list/
revoke stays below for the eventual secure path.

PORTAL_OPEN_LINKS defaults ON for the prototype (whole app is open anyway) and logs
a warning; set =false before real clients. The get_current_client seam is
untouched, so M4 auth still layers in front of the same routes regardless.

Verified: compiles, share script balances, detail.html parses, flag default
on / =false off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an "Alerts" card to /slm/{id}: lists rules and a create/edit/delete form
(simple-first — "Alert when [Leq] is [above] [65] dB for [N] s", optional
time-of-day window + day picker, advanced hysteresis/cooldown collapsed). Talks
to the existing SLMM alert CRUD via the proxy (/api/slmm/{unit}/alerts/rules);
no SLMM changes. Rule changes invalidate the evaluator's cache server-side.

Verified: alerts script JS balances, slm_detail.html parses, and the TV proxy
forwards method + JSON body + query params for POST/PUT/DELETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Internal (SLM detail page): live alarm-state badge in the Alerts header
(● N active / ✓ all clear), a History list of fired events (onset → clear, peak
dB, ack status) with an Ack button, refreshed every 20s. Reads the existing SLMM
/alerts/events + /ack via the proxy.

Portal (client, read-only, scoped): new GET /portal/api/location/{id}/events —
ownership-gated, returns a scrubbed projection (rule_name/metric/threshold/onset/
peak/clear/status only; no internal ids or ack-by) plus an `active` count. The
location page shows a red "Currently above threshold" banner when active and a
read-only breach history, polled every 20s. No ack on the client side.

Verified: portal.py compiles; both scripts balance; both templates parse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI note on the SLM alerts card reflecting the SLMM keepalive coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A refined dark "field instrument" aesthetic for the client-facing portal:
- Type: Hanken Grotesk UI + IBM Plex Mono for readings (dB values feel like real
  instrumentation). Tabular numerals.
- Atmosphere: deep navy-black base with a navy/burgundy aurora and a faint fixed
  instrument grid; sticky blurred header with an animated signal-bars mark.
- Panel system (.panel/.panel-hover): translucent, hairline-lit, depth + hover
  lift. Pulsing live dot; staggered load reveal.
- Overview: mono Leq hero on each tile (colored by level when live), pill badges
  with the pulsing dot, rollup pills, dark CARTO map tiles, level-colored dots.

All live-data JS hook IDs preserved (verified). No backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Location page rebuilt as a monitoring console: Leq hero readout (mono, level-
  colored, auto-flips with theme), instrument strip for Lp/Lmax/L1/L10, refined
  dark Chart.js (mono ticks, thin lines), panel-styled alert history, polished
  pause overlay. All live-stream/chart/alert JS hooks preserved.
- Access page → centered branded splash.
- Light/Dark toggle: CSS-variable theme system (structure + level/metric accent
  colors flip), header sun/moon button, localStorage + no-flash boot script,
  smooth body transition. On toggle, a 'portal-theme' event re-skins the Chart.js
  trace and swaps Leaflet tiles (CARTO dark <-> light) + recolors map dots.

All JS hook IDs intact (verified); both themes validated to parse + balance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Light mode was washed out. Switch the background to warm paper (#f7f5ef), make
panels solid white (no longer translucent/ghostly) with a warm hairline border
and a grounded two-layer shadow, and warm the text ink. Light-specific hover
shadow (the dark one is invisible on paper). Also fix two dark-only reds — the
alarm banner and active-event text now use var(--lvl-bad) so they read on both
themes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Light is now the default for new visitors/clients (was dark); the toggle still
flips to dark and the choice persists. Also fixed the mobile theme-color meta to
update the actual <meta> tag (was setting a no-op attribute on <html>) and use the
warm paper shade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts the light-mode ground to a cool light (#eef2f9) with cool navy ink,
borders, and shadow — keeping the solid (opaque, defined) cards from the
un-ghosting pass so it's clean rather than dull. theme-color meta updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New scoped GET /portal/api/location/{id}/thresholds returns the enabled alert
rules (scrubbed: name/metric/comparison/threshold/duration/schedule — no cooldown
or hysteresis internals). Location page renders an "Alert limits" panel above the
history, e.g. "Night noise · Leq above 65 dB for 60s · 22:00–07:00", hidden when
no limits are set. Gives the breach history context.

Verified: portal.py compiles; location script balances; template parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PORTAL_OPEN_LINKS now defaults OFF — /portal/open/* is an unauthenticated,
  proxy-reachable session-minting path (and a linked project's open link grants
  the whole client's scope), so it must be explicitly enabled in dev.
- Session cookie: enforce server-side expiry (check iat vs COOKIE_MAX_AGE — was
  browser-only) and guard a non-dict signed body (was an uncaught AttributeError →
  500, reachable if SECRET_KEY is the insecure default).
- Escape operator-set strings (location/rule/event names) before innerHTML +
  Leaflet tooltips — they're client-facing, so a name with markup was stored XSS
  in the client's browser. Global esc() helper applied at every injection point.
- WS _scrub_frame drops a non-JSON frame instead of forwarding it raw; /history
  rows now whitelisted like the other scoped endpoints.
- Preview-client slug uses the full project id (an 8-char prefix could collide
  two projects onto one client).

Verified: cookie reader (fresh/expired/non-dict/missing-iat) + open-links default
off; templates parse; scoped scrubbing intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidates the deferred items (reverse proxy exposing only /portal/*, TLS,
SECRET_KEY, PORTAL_OPEN_LINKS off, M4 auth incl. the operator app + currently-
unauthenticated operator endpoints, and the smaller code-review items) into an
actionable checklist so the hardening session starts from a list, not a recall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documents the read-only client portal under [Unreleased] alongside the SLM
live-monitoring work: per-client scoping + interim auth, live location view with
the auto-closing WS stream, locations overview map + rollup, the alerts
config→surface→24/7 track, operator sharing tools, the field-instrument design +
light/dark toggle, the security posture, and upgrade notes (migration, SECRET_KEY,
SLMM alert-engine pairing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
serversdown merged commit 5f02a0bc21 into dev 2026-06-11 23:21:53 -04:00
Sign in to join this conversation.