docs: client portal design + milestone plan (M1 live view → M4 full auth) #61
Reference in New Issue
Block a user
Delete Branch "feat/client-portal"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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>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>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>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>