@@ -20,3 +33,67 @@
{% endif %}
{% endblock %}
+
+{% block scripts %}
+
+
+{% endblock %}
--
2.52.0
From 26b4b1e7e402348ae5dfb528c4e1c53c1ae38d7b Mon Sep 17 00:00:00 2001
From: serversdown
Date: Wed, 10 Jun 2026 21:43:28 +0000
Subject: [PATCH 06/27] =?UTF-8?q?feat(portal):=20M1=20admin=20CLI=20?=
=?UTF-8?q?=E2=80=94=20create=20client,=20link=20projects,=20mint/revoke?=
=?UTF-8?q?=20links?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
backend/portal_admin.py | 165 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 165 insertions(+)
create mode 100644 backend/portal_admin.py
diff --git a/backend/portal_admin.py b/backend/portal_admin.py
new file mode 100644
index 0000000..c69e4db
--- /dev/null
+++ b/backend/portal_admin.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+"""
+Client-portal admin CLI (M1). Operator tooling — run inside the terra-view
+container against the live DB. The raw magic-link token is shown ONCE on mint;
+only its hash is stored.
+
+ # create a client
+ python3 backend/portal_admin.py create-client --name "Myler Co" --slug myler [--email dave@x.com]
+
+ # attach a project to a client (sets Project.client_id) — by id, number, or name
+ python3 backend/portal_admin.py link-project --slug myler --project-id
+ python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
+ python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
+
+ # mint a magic access link (FULL URL PRINTED ONCE — copy it now)
+ python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"]
+
+ # list clients, their projects, and active links
+ python3 backend/portal_admin.py list
+
+ # revoke a link (stops the link AND any live session it minted)
+ python3 backend/portal_admin.py revoke --token-id
+
+The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001).
+"""
+
+import os
+import sys
+import uuid
+import secrets
+import argparse
+from datetime import datetime
+
+from backend.database import SessionLocal
+from backend.models import Client, ClientAccessToken, Project
+from backend.portal_auth import hash_token
+
+PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/")
+
+
+def _get_client(db, slug):
+ c = db.query(Client).filter_by(slug=slug).first()
+ if not c:
+ sys.exit(f"No client with slug '{slug}'. Create it first.")
+ return c
+
+
+def create_client(args):
+ db = SessionLocal()
+ try:
+ if db.query(Client).filter_by(slug=args.slug).first():
+ sys.exit(f"A client with slug '{args.slug}' already exists.")
+ c = Client(id=str(uuid.uuid4()), name=args.name, slug=args.slug,
+ contact_email=args.email, active=True)
+ db.add(c)
+ db.commit()
+ print(f"✓ Created client '{c.name}' (slug={c.slug}, id={c.id})")
+ print(" Next: link-project, then mint-link.")
+ finally:
+ db.close()
+
+
+def link_project(args):
+ db = SessionLocal()
+ try:
+ c = _get_client(db, args.slug)
+ q = db.query(Project)
+ if args.project_id:
+ p = q.filter_by(id=args.project_id).first()
+ elif args.project_number:
+ p = q.filter_by(project_number=args.project_number).first()
+ elif args.project_name:
+ p = q.filter_by(name=args.project_name).first()
+ else:
+ sys.exit("Specify --project-id, --project-number, or --project-name.")
+ if not p:
+ sys.exit("Project not found.")
+ p.client_id = c.id
+ db.commit()
+ print(f"✓ Linked project '{p.name}' (id={p.id}) -> client '{c.name}'")
+ finally:
+ db.close()
+
+
+def mint_link(args):
+ db = SessionLocal()
+ try:
+ c = _get_client(db, args.slug)
+ raw = secrets.token_urlsafe(32)
+ tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id,
+ token_hash=hash_token(raw), label=args.label)
+ db.add(tok)
+ db.commit()
+ print(f"✓ Minted access link for '{c.name}'"
+ f"{f' ({args.label})' if args.label else ''} — token id {tok.id}")
+ print("\n COPY THIS NOW (shown only once):\n")
+ print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n")
+ finally:
+ db.close()
+
+
+def revoke(args):
+ db = SessionLocal()
+ try:
+ tok = db.query(ClientAccessToken).filter_by(id=args.token_id).first()
+ if not tok:
+ sys.exit("No token with that id.")
+ if tok.revoked_at:
+ print("○ Already revoked.")
+ return
+ tok.revoked_at = datetime.utcnow()
+ db.commit()
+ print(f"✓ Revoked token {tok.id} — the link and any live sessions it minted are dead.")
+ finally:
+ db.close()
+
+
+def list_all(args):
+ db = SessionLocal()
+ try:
+ clients = db.query(Client).order_by(Client.name).all()
+ if not clients:
+ print("No clients yet.")
+ return
+ for c in clients:
+ state = "" if c.active else " [INACTIVE]"
+ print(f"\n● {c.name} (slug={c.slug}){state}")
+ projs = db.query(Project).filter_by(client_id=c.id).all()
+ print(" projects: " + (", ".join(p.name for p in projs) or "(none linked)"))
+ toks = db.query(ClientAccessToken).filter_by(client_id=c.id).all()
+ if not toks:
+ print(" links: (none — run mint-link)")
+ for t in toks:
+ status = "revoked" if t.revoked_at else "active"
+ last = t.last_used_at.strftime("%Y-%m-%d %H:%M") if t.last_used_at else "never used"
+ print(f" link {t.id} [{status}] {t.label or ''} (last: {last})")
+ print()
+ finally:
+ db.close()
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Client-portal admin (M1)")
+ sub = ap.add_subparsers(dest="cmd", required=True)
+
+ p = sub.add_parser("create-client"); p.add_argument("--name", required=True)
+ p.add_argument("--slug", required=True); p.add_argument("--email"); p.set_defaults(fn=create_client)
+
+ p = sub.add_parser("link-project"); p.add_argument("--slug", required=True)
+ p.add_argument("--project-id"); p.add_argument("--project-number"); p.add_argument("--project-name")
+ p.set_defaults(fn=link_project)
+
+ p = sub.add_parser("mint-link"); p.add_argument("--slug", required=True)
+ p.add_argument("--label"); p.set_defaults(fn=mint_link)
+
+ p = sub.add_parser("revoke"); p.add_argument("--token-id", required=True); p.set_defaults(fn=revoke)
+
+ p = sub.add_parser("list"); p.set_defaults(fn=list_all)
+
+ args = ap.parse_args()
+ args.fn(args)
+
+
+if __name__ == "__main__":
+ main()
--
2.52.0
From 1cf80ea7ea6a6d02a26d3b34a03a28542908db5d Mon Sep 17 00:00:00 2001
From: serversdown
Date: Thu, 11 Jun 2026 01:16:30 +0000
Subject: [PATCH 07/27] fix(portal): portal_admin.py runnable as a script, not
just -m
`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
---
backend/portal_admin.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/backend/portal_admin.py b/backend/portal_admin.py
index c69e4db..2d8b346 100644
--- a/backend/portal_admin.py
+++ b/backend/portal_admin.py
@@ -31,6 +31,10 @@ import secrets
import argparse
from datetime import datetime
+# Allow `python3 backend/portal_admin.py ...` (which puts backend/ on sys.path[0],
+# hiding the `backend` package) in addition to `python3 -m backend.portal_admin`.
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
from backend.database import SessionLocal
from backend.models import Client, ClientAccessToken, Project
from backend.portal_auth import hash_token
--
2.52.0
From 2031681d0f2b7b4c7454fefb875bbf2ad47548af Mon Sep 17 00:00:00 2001
From: serversdown
Date: Thu, 11 Jun 2026 02:01:58 +0000
Subject: [PATCH 08/27] docs(portal): add "Going to prod" checklist (migration,
SECRET_KEY, exposure)
Co-Authored-By: Claude Opus 4.8
---
docs/CLIENT_PORTAL.md | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/docs/CLIENT_PORTAL.md b/docs/CLIENT_PORTAL.md
index b74a94d..0fc2e88 100644
--- a/docs/CLIENT_PORTAL.md
+++ b/docs/CLIENT_PORTAL.md
@@ -132,6 +132,32 @@ live data, all from cache.
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)
+
+1. **Run the migration on the prod DB** — `migrate_add_client_portal.py` adds
+ `projects.client_id` (the new tables auto-create via `create_all`). Skipping it
+ 500s anything that touches `Project.client_id`. This is the silent killer.
+ ```bash
+ docker compose exec web-app python3 backend/migrate_add_client_portal.py
+ ```
+2. **Set a real `SECRET_KEY`** in 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.
+3. **SLMM_BASE_URL** — prod base compose already points at `:8100` (correct; the
+ `:9100` mismatch is a dev-only override quirk). For full live data (L1/L10 +
+ chart backfill) prod SLMM must be on the `dev` build 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.
+4. **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).
+5. **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
--
2.52.0
From 3fc20e104a8ee6cb61caa41c9120549e86bfbfbf Mon Sep 17 00:00:00 2001
From: serversdown
Date: Thu, 11 Jun 2026 02:18:06 +0000
Subject: [PATCH 09/27] feat(portal): one-click "View client portal" preview
from the project page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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-' 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
---
backend/main.py | 21 +++++++++++++++++++-
backend/portal_auth.py | 35 ++++++++++++++++++++++++++++++++++
templates/projects/detail.html | 13 ++++++++++++-
3 files changed, 67 insertions(+), 2 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index 2f025fd..5305c37 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
-from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
+from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session
from typing import List, Dict, Optional
@@ -413,6 +413,25 @@ async def project_detail_page(request: Request, project_id: str):
})
+@app.get("/projects/{project_id}/portal-preview")
+async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
+ """Operator testing shortcut: log into the client portal scoped to this project
+ (auto-provisioning a client/link if needed), no CLI. Lives under /projects (not
+ /portal), so a public proxy that exposes only /portal/* won't expose this."""
+ from backend.models import Project
+ from backend.portal_auth import (
+ provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE,
+ )
+ project = db.query(Project).filter_by(id=project_id).first()
+ if not project:
+ return JSONResponse(status_code=404, content={"detail": "Project not found"})
+ token_id = provision_preview_session(project, db)
+ resp = RedirectResponse(url="/portal", status_code=303)
+ resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
+ max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
+ return resp
+
+
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
async def nrl_detail_page(
request: Request,
diff --git a/backend/portal_auth.py b/backend/portal_auth.py
index 02933b0..da869a2 100644
--- a/backend/portal_auth.py
+++ b/backend/portal_auth.py
@@ -16,9 +16,11 @@ import os
import hmac
import json
import time
+import uuid
import base64
import hashlib
import logging
+import secrets
from datetime import datetime
from fastapi import Request, Depends
@@ -112,3 +114,36 @@ def resolve_token(raw_token: str, db: Session):
tok.last_used_at = datetime.utcnow()
db.commit()
return tok, client
+
+
+def provision_preview_session(project, db) -> str:
+ """Testing convenience (operator-side): ensure a Client + access token exist for
+ a project so an operator can preview the client portal without the CLI. Returns
+ the token id to seal into a session cookie.
+
+ Reuses the project's linked client if it has one; otherwise creates/uses a
+ per-project 'preview-' client. Only sets project.client_id when it's unset,
+ so previewing never clobbers a real client link. The token's raw secret is
+ discarded (preview rides the cookie, not a magic link)."""
+ client = None
+ if project.client_id:
+ client = db.query(Client).filter_by(id=project.client_id, active=True).first()
+ if client is None:
+ slug = f"preview-{str(project.id)[:8]}"
+ client = db.query(Client).filter_by(slug=slug).first()
+ if client is None:
+ client = Client(id=str(uuid.uuid4()),
+ name=(project.client_name or project.name or "Preview"),
+ slug=slug, active=True)
+ db.add(client)
+ db.flush()
+ if not project.client_id:
+ project.client_id = client.id
+ tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
+ if tok is None:
+ tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
+ token_hash=hash_token(secrets.token_urlsafe(32)),
+ label="preview")
+ db.add(tok)
+ db.commit()
+ return tok.id
diff --git a/templates/projects/detail.html b/templates/projects/detail.html
index ba971f3..1542c2f 100644
--- a/templates/projects/detail.html
+++ b/templates/projects/detail.html
@@ -4,7 +4,7 @@
{% block content %}
-
@@ -38,6 +54,20 @@
{% endblock %}
--
2.52.0
From 2da9493cb58ef4478dc9a9197e112caece75c65f Mon Sep 17 00:00:00 2001
From: serversdown
Date: Thu, 11 Jun 2026 17:11:34 +0000
Subject: [PATCH 14/27] =?UTF-8?q?feat(portal):=20"Copy=20client=20link"=20?=
=?UTF-8?q?=E2=80=94=20generate/copy/revoke=20shareable=20links=20from=20t?=
=?UTF-8?q?he=20project=20page?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
No-CLI way to get a real shareable magic link (/portal/enter/) 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
---
backend/main.py | 54 ++++++++++++++
backend/portal_auth.py | 32 ++++++---
templates/projects/detail.html | 128 ++++++++++++++++++++++++++++++---
3 files changed, 195 insertions(+), 19 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index 5305c37..cad523a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -432,6 +432,60 @@ async def project_portal_preview(project_id: str, db: Session = Depends(get_db))
return resp
+@app.post("/projects/{project_id}/portal-link")
+async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)):
+ """Mint a fresh shareable client link for this project's client. Returns the
+ full /portal/enter/ URL (shown once). Operator-only (internal app)."""
+ from backend.models import Project
+ from backend.portal_auth import ensure_project_client, mint_link_token
+ project = db.query(Project).filter_by(id=project_id).first()
+ if not project:
+ return JSONResponse(status_code=404, content={"detail": "Project not found"})
+ client = ensure_project_client(project, db)
+ raw = mint_link_token(client, db, label="shared link")
+ url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}"
+ return {"url": url, "client_name": client.name}
+
+
+@app.get("/projects/{project_id}/portal-links")
+async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)):
+ """List active (non-revoked) shareable links for this project's client."""
+ from backend.models import Project, ClientAccessToken, Client
+ project = db.query(Project).filter_by(id=project_id).first()
+ if not project or not project.client_id:
+ return {"client_name": None, "links": []}
+ client = db.query(Client).filter_by(id=project.client_id).first()
+ toks = (db.query(ClientAccessToken)
+ .filter_by(client_id=project.client_id, revoked_at=None)
+ .order_by(ClientAccessToken.created_at.desc()).all())
+ return {
+ "client_name": client.name if client else None,
+ "links": [{
+ "id": t.id, "label": t.label,
+ "created_at": t.created_at.isoformat() if t.created_at else None,
+ "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
+ } for t in toks],
+ }
+
+
+@app.post("/projects/{project_id}/portal-link/{token_id}/revoke")
+async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)):
+ """Revoke one shareable link (scoped to this project's client). Kills the link
+ and any live session minted from it on the next request."""
+ from datetime import datetime as _dt
+ from backend.models import Project, ClientAccessToken
+ project = db.query(Project).filter_by(id=project_id).first()
+ if not project or not project.client_id:
+ return JSONResponse(status_code=404, content={"detail": "Not found"})
+ tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first()
+ if not tok:
+ return JSONResponse(status_code=404, content={"detail": "Link not found"})
+ if not tok.revoked_at:
+ tok.revoked_at = _dt.utcnow()
+ db.commit()
+ return {"ok": True}
+
+
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
async def nrl_detail_page(
request: Request,
diff --git a/backend/portal_auth.py b/backend/portal_auth.py
index ac52ae7..cb4dc8c 100644
--- a/backend/portal_auth.py
+++ b/backend/portal_auth.py
@@ -120,15 +120,10 @@ def resolve_token(raw_token: str, db: Session):
return tok, client
-def provision_preview_session(project, db) -> str:
- """Testing convenience (operator-side): ensure a Client + access token exist for
- a project so an operator can preview the client portal without the CLI. Returns
- the token id to seal into a session cookie.
-
- Reuses the project's linked client if it has one; otherwise creates/uses a
- per-project 'preview-' client. Only sets project.client_id when it's unset,
- so previewing never clobbers a real client link. The token's raw secret is
- discarded (preview rides the cookie, not a magic link)."""
+def ensure_project_client(project, db) -> Client:
+ """Find or create the Client for a project. Reuses the project's linked client
+ if it has one; otherwise creates/uses a per-project 'preview-' client and
+ sets project.client_id (only when unset, so it never clobbers a real link)."""
client = None
if project.client_id:
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
@@ -143,6 +138,25 @@ def provision_preview_session(project, db) -> str:
db.flush()
if not project.client_id:
project.client_id = client.id
+ return client
+
+
+def mint_link_token(client, db, label=None) -> str:
+ """Mint a fresh access token for a client and return the RAW secret (caller
+ builds the /portal/enter/ URL and shows it once). Only the hash is stored."""
+ raw = secrets.token_urlsafe(32)
+ db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
+ token_hash=hash_token(raw), label=label))
+ db.commit()
+ return raw
+
+
+def provision_preview_session(project, db) -> str:
+ """Operator preview shortcut: ensure a Client + access token exist for a project
+ and return a token id to seal into a session cookie (no shared link). Reuses an
+ existing token so repeat previews don't accumulate clutter; the raw secret is
+ discarded (preview rides the cookie)."""
+ client = ensure_project_client(project, db)
tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
if tok is None:
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
diff --git a/templates/projects/detail.html b/templates/projects/detail.html
index 1542c2f..94675ed 100644
--- a/templates/projects/detail.html
+++ b/templates/projects/detail.html
@@ -18,16 +18,27 @@
Project
-
-
-
- View client portal
-
+
+
+ Anyone with a link can view this project's client portal (read-only). Links are revocable.
+
+
+
+
+
+
+
+
+
+
+
+ Active links
+
+
+
+
+
+
+
{% endblock %}
--
2.52.0
From bececafe78e92be3f6adb93ff17a9e8551bf6f9a Mon Sep 17 00:00:00 2001
From: serversdown
Date: Thu, 11 Jun 2026 17:26:37 +0000
Subject: [PATCH 15/27] feat(portal): plain no-token "open" links for dev
feedback (PORTAL_OPEN_LINKS)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
backend/main.py | 5 +++--
backend/portal_auth.py | 9 +++++++++
backend/routers/portal.py | 23 +++++++++++++++++++++++
templates/projects/detail.html | 23 +++++++++++++++++++++++
4 files changed, 58 insertions(+), 2 deletions(-)
diff --git a/backend/main.py b/backend/main.py
index cad523a..799d943 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -69,7 +69,7 @@ from backend.templates_config import templates
# Client-portal auth: an unauthenticated portal request renders the access page
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
# portal route can simply Depends(get_current_client).
-from backend.portal_auth import PortalAuthError
+from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS
@app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: PortalAuthError):
@@ -409,7 +409,8 @@ async def project_detail_page(request: Request, project_id: str):
"""Project detail dashboard"""
return templates.TemplateResponse("projects/detail.html", {
"request": request,
- "project_id": project_id
+ "project_id": project_id,
+ "portal_open_links": PORTAL_OPEN_LINKS,
})
diff --git a/backend/portal_auth.py b/backend/portal_auth.py
index cb4dc8c..72537cc 100644
--- a/backend/portal_auth.py
+++ b/backend/portal_auth.py
@@ -40,6 +40,15 @@ if SECRET_KEY == "dev-insecure-change-me":
COOKIE_NAME = "portal_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
+# Dev convenience: plain, no-token portal links (/portal/open/{project_id}) so
+# anyone can open a client portal for feedback without minting a magic link.
+# Defaults ON for the current prototype (the whole app is open anyway); set
+# PORTAL_OPEN_LINKS=false before real clients are on the portal.
+PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "true").lower() in ("1", "true", "yes")
+if PORTAL_OPEN_LINKS:
+ logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. "
+ "Set PORTAL_OPEN_LINKS=false before real clients.")
+
class PortalAuthError(Exception):
"""Raised by get_current_client when there's no valid portal session.
diff --git a/backend/routers/portal.py b/backend/routers/portal.py
index abbea7b..6af574e 100644
--- a/backend/routers/portal.py
+++ b/backend/routers/portal.py
@@ -24,6 +24,7 @@ from backend.models import Client, MonitoringLocation, Project, UnitAssignment
from backend.templates_config import templates
from backend.portal_auth import (
get_current_client, client_from_cookie, make_session_cookie, resolve_token,
+ provision_preview_session, PORTAL_OPEN_LINKS,
COOKIE_NAME, COOKIE_MAX_AGE,
)
@@ -109,6 +110,28 @@ def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
return resp
+@router.get("/open/{project_id}")
+def portal_open(project_id: str, request: Request, db: Session = Depends(get_db)):
+ """Dev-only plain shareable link: open a project's client portal with no token
+ (gated by PORTAL_OPEN_LINKS). Lets anyone with the URL view it for feedback —
+ sets the session cookie and lands on /portal. Lives under /portal so it works
+ through a reverse proxy that exposes only /portal/*."""
+ if not PORTAL_OPEN_LINKS:
+ return templates.TemplateResponse(
+ "portal/access_required.html", {"request": request, "reason": "required"},
+ status_code=404)
+ project = db.query(Project).filter_by(id=project_id).first()
+ if not project:
+ return templates.TemplateResponse(
+ "portal/access_required.html", {"request": request, "reason": "invalid"},
+ status_code=404)
+ token_id = provision_preview_session(project, db)
+ resp = RedirectResponse(url="/portal", status_code=303)
+ resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
+ max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
+ return resp
+
+
@router.get("/logout")
def portal_logout():
resp = RedirectResponse(url="/portal/access", status_code=303)
diff --git a/templates/projects/detail.html b/templates/projects/detail.html
index 94675ed..572493a 100644
--- a/templates/projects/detail.html
+++ b/templates/projects/detail.html
@@ -2112,6 +2112,19 @@ document.addEventListener('DOMContentLoaded', function() {
Anyone with a link can view this project's client portal (read-only). Links are revocable.
+ {% if portal_open_links %}
+
+
+
+
+
+
+
+
For feedback during development. Disable PORTAL_OPEN_LINKS before real clients.
+
+ {% endif %}
+
@@ -2134,10 +2147,20 @@ const SHARE_PROJECT_ID = "{{ project_id }}";
function openShareModal() {
document.getElementById('share-modal').classList.remove('hidden');
document.getElementById('share-new').classList.add('hidden');
+ const ou = document.getElementById('open-url'); // only present when PORTAL_OPEN_LINKS on
+ if (ou) ou.value = `${location.origin}/portal/open/${SHARE_PROJECT_ID}`;
loadShareLinks();
}
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
+function copyOpenUrl(btn) {
+ const inp = document.getElementById('open-url');
+ inp.select();
+ const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
+ if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
+ else { document.execCommand('copy'); done(); }
+}
+
async function loadShareLinks() {
const list = document.getElementById('share-list');
list.innerHTML = '
Loading…
';
--
2.52.0
From 29b974a1f7d7d61345f37189b5c142d571512e7c Mon Sep 17 00:00:00 2001
From: serversdown
Date: Thu, 11 Jun 2026 18:01:27 +0000
Subject: [PATCH 16/27] =?UTF-8?q?feat(portal):=20M2b-1=20=E2=80=94=20alert?=
=?UTF-8?q?=20rule=20config=20UI=20on=20the=20SLM=20detail=20page?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
templates/slm_detail.html | 186 ++++++++++++++++++++++++++++++++++++++
1 file changed, 186 insertions(+)
diff --git a/templates/slm_detail.html b/templates/slm_detail.html
index 6a17ea8..def5ed4 100644
--- a/templates/slm_detail.html
+++ b/templates/slm_detail.html
@@ -112,4 +112,190 @@
+
+
+
+
+
+
Alerts
+
Threshold rules evaluated on this device's live feed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Alert when
+
+ is
+
+ dB
+ for
+ seconds
+
+
+
+
+
+ from
+ to
+ on
+
+
+
+
+
+ Advanced
+
+ Clear margindB (hysteresis)
+ Cooldowns
+
+
+
+
+
+
+
+
+
+
+
{% endblock %}
--
2.52.0
From 0914cf0a75326488c63d62939fcc4c80be7a547a Mon Sep 17 00:00:00 2001
From: serversdown
Date: Thu, 11 Jun 2026 19:02:56 +0000
Subject: [PATCH 17/27] =?UTF-8?q?feat(portal):=20M2b-2=20=E2=80=94=20surfa?=
=?UTF-8?q?ce=20alert=20state=20+=20breach=20history=20(internal=20+=20por?=
=?UTF-8?q?tal)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
backend/routers/portal.py | 28 ++++++++++++
templates/portal/location.html | 48 +++++++++++++++++++++
templates/slm_detail.html | 79 +++++++++++++++++++++++++++++++++-
3 files changed, 154 insertions(+), 1 deletion(-)
diff --git a/backend/routers/portal.py b/backend/routers/portal.py
index 6af574e..6f86108 100644
--- a/backend/routers/portal.py
+++ b/backend/routers/portal.py
@@ -214,6 +214,34 @@ async def portal_location_history(location_id: str, hours: float = 2.0,
return {"status": "ok", "readings": (r.json() or {}).get("readings", [])}
+# Whitelist of alert-event fields exposed to a client (no internal ids/ack-by).
+_PORTAL_EVENT_FIELDS = ("rule_name", "metric", "threshold_db", "onset_at",
+ "onset_value", "peak_value", "clear_at", "status")
+
+
+@router.get("/api/location/{location_id}/events")
+async def portal_location_events(location_id: str, limit: int = 20,
+ client: Client = Depends(get_current_client),
+ db: Session = Depends(get_db)):
+ """Scrubbed breach history for a location the client owns (read-only)."""
+ resolve_client_location(client, location_id, db)
+ unit_id = active_unit_for_location(location_id, db)
+ if not unit_id:
+ return {"status": "ok", "events": []}
+ limit = max(1, min(limit, 100))
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as hc:
+ r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/events",
+ params={"limit": limit})
+ except Exception:
+ return {"status": "ok", "events": []}
+ if r.status_code != 200:
+ return {"status": "ok", "events": []}
+ raw = (r.json() or {}).get("events", [])
+ events = [{k: e.get(k) for k in _PORTAL_EVENT_FIELDS} for e in raw]
+ return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
+
+
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
def _scrub_frame(raw: str) -> str:
diff --git a/templates/portal/location.html b/templates/portal/location.html
index 1a3148c..7ebe04e 100644
--- a/templates/portal/location.html
+++ b/templates/portal/location.html
@@ -16,6 +16,13 @@
No device is currently assigned to this location.
{% else %}
+