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
6 changed files with 286 additions and 0 deletions
Showing only changes of commit 6c048a9c30 - Show all commits
+19
View File
@@ -66,6 +66,21 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Use shared templates configuration with timezone filters
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
@app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: PortalAuthError):
if request.url.path.startswith("/portal/api"):
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "required"},
status_code=401,
)
# Add custom context processor to inject environment variable into all templates
@app.middleware("http")
async def add_environment_to_context(request: Request, call_next):
@@ -97,6 +112,10 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router)
# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md
from backend.routers import portal
app.include_router(portal.router)
app.include_router(sfm.router)
app.include_router(modem_dashboard.router)
+114
View File
@@ -0,0 +1,114 @@
"""
Client-portal auth — the swappable gate (see docs/CLIENT_PORTAL.md).
M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
mints a signed session cookie. Every portal route depends on get_current_client();
M4 replaces the backing (magic-link / accounts) without touching routes/templates.
The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
against the DB on every request, so revoking a link (revoked_at) kills its live
sessions on the next request — not just future clicks.
No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
"""
import os
import hmac
import json
import time
import base64
import hashlib
import logging
from datetime import datetime
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import Client, ClientAccessToken
logger = logging.getLogger(__name__)
# Signing secret for portal session cookies. MUST be set to a real secret in prod
# (env). The insecure default only exists so dev/test boots without config.
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
if SECRET_KEY == "dev-insecure-change-me":
logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.")
COOKIE_NAME = "portal_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
class PortalAuthError(Exception):
"""Raised by get_current_client when there's no valid portal session.
Handled centrally in main.py: HTML routes get the access-required page,
/portal/api/* routes get a 401 JSON."""
# -- token + cookie primitives ----------------------------------------------
def hash_token(raw: str) -> str:
"""sha256 hex of a raw access-token secret (what we store + look up by)."""
return hashlib.sha256(raw.encode()).hexdigest()
def _sign(body: str) -> str:
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
def make_session_cookie(token_id: str) -> str:
body = base64.urlsafe_b64encode(
json.dumps({"tid": token_id, "iat": int(time.time())}).encode()
).decode()
return f"{body}.{_sign(body)}"
def _read_session_cookie(value: str):
"""Return the token id from a signed cookie, or None if missing/tampered."""
try:
body, sig = value.rsplit(".", 1)
except (ValueError, AttributeError):
return None
if not hmac.compare_digest(sig, _sign(body)):
return None
try:
data = json.loads(base64.urlsafe_b64decode(body.encode()))
except Exception:
return None
return data.get("tid")
# -- the dependency every portal route uses ---------------------------------
def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
"""Resolve the authenticated client, or raise PortalAuthError.
Re-validates the access token on every request so a revoked link / disabled
client drops the session immediately."""
cookie = request.cookies.get(COOKIE_NAME)
token_id = _read_session_cookie(cookie) if cookie else None
if not token_id:
raise PortalAuthError()
tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
if not tok:
raise PortalAuthError()
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
if not client:
raise PortalAuthError()
return client
def resolve_token(raw_token: str, db: Session):
"""Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on
success, or (None, None). Also stamps last_used_at."""
tok = db.query(ClientAccessToken).filter_by(
token_hash=hash_token(raw_token), revoked_at=None
).first()
if not tok:
return None, None
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
if not client:
return None, None
tok.last_used_at = datetime.utcnow()
db.commit()
return tok, client
+67
View File
@@ -0,0 +1,67 @@
"""
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed
session cookie, then sees their locations (overview) and per-location read-only
live data sourced from SLMM's cache. Every data route re-checks ownership.
"""
import logging
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import Client
from backend.templates_config import templates
from backend.portal_auth import (
get_current_client, make_session_cookie, resolve_token,
COOKIE_NAME, COOKIE_MAX_AGE,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/portal", tags=["portal"])
@router.get("/enter/{token}")
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
tok, client = resolve_token(token, db)
if not client:
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "invalid"},
status_code=403,
)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(
COOKIE_NAME, make_session_cookie(tok.id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
)
logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}")
return resp
@router.get("/logout")
def portal_logout():
resp = RedirectResponse(url="/portal/access", status_code=303)
resp.delete_cookie(COOKIE_NAME)
return resp
@router.get("/access")
def portal_access(request: Request):
"""Landing for an unauthenticated visitor (no valid link)."""
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "required"}
)
@router.get("")
def portal_home(request: Request, client: Client = Depends(get_current_client)):
"""Client overview. (M1 task 4 fills in the scoped location list + map.)"""
return templates.TemplateResponse(
"portal/overview.html",
{"request": request, "client": client, "locations": []},
)
+20
View File
@@ -0,0 +1,20 @@
{% extends "portal/base.html" %}
{% block title %}Access{% endblock %}
{% block content %}
<div class="max-w-md mx-auto mt-16 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-slate-800 border border-slate-700 mb-5">
<svg class="w-7 h-7 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
{% if reason == "invalid" %}
<h1 class="text-xl font-semibold mb-2">This link isn't valid</h1>
<p class="text-gray-400 text-sm">The access link is expired or has been revoked.
Please contact TMI for a new link.</p>
{% else %}
<h1 class="text-xl font-semibold mb-2">Access link required</h1>
<p class="text-gray-400 text-sm">Open the monitoring link TMI sent you to view your locations.</p>
{% endif %}
</div>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" class="h-full dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Monitoring{% endblock %} · TMI</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { seismo: {
orange: '#f48b1c', navy: '#142a66', burgundy: '#7d234d'
} } } }
}
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
<meta name="theme-color" content="#142a66">
{% block head %}{% endblock %}
</head>
<body class="h-full bg-slate-900 text-gray-100 antialiased">
<header class="border-b border-slate-700/70 bg-slate-800/60 backdrop-blur">
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/portal" class="flex items-center gap-2 font-semibold">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-seismo-orange"></span>
TMI Monitoring{% if client %} <span class="text-gray-500 font-normal">·</span>
<span class="text-seismo-orange">{{ client.name }}</span>{% endif %}
</a>
{% if client %}
<a href="/portal/logout" class="text-sm text-gray-400 hover:text-gray-200">Sign out</a>
{% endif %}
</div>
</header>
<main class="max-w-5xl mx-auto px-4 py-6">
{% block content %}{% endblock %}
</main>
<footer class="max-w-5xl mx-auto px-4 py-8 text-xs text-gray-600">
Read-only monitoring view. Data is provided as-is for informational purposes.
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{% extends "portal/base.html" %}
{% block title %}Your locations{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-1">Your monitoring locations</h1>
<p class="text-gray-400 text-sm mb-6">Live sound levels for your active locations.</p>
{# M1 task 4 fleshes this out into location tiles + a map. #}
{% if locations %}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for loc in locations %}
<a href="/portal/location/{{ loc.id }}" class="block rounded-xl border border-slate-700 bg-slate-800/50 p-4 hover:border-seismo-orange transition-colors">
<div class="font-semibold">{{ loc.name }}</div>
<div class="text-xs text-gray-400 mt-1">{{ loc.address or loc.project_name }}</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-8 text-center text-gray-400">
No active monitoring locations yet.
</div>
{% endif %}
{% endblock %}