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
4 changed files with 58 additions and 2 deletions
Showing only changes of commit bececafe78 - Show all commits
+3 -2
View File
@@ -69,7 +69,7 @@ from backend.templates_config import templates
# Client-portal auth: an unauthenticated portal request renders the access page # Client-portal auth: an unauthenticated portal request renders the access page
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every # (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
# portal route can simply Depends(get_current_client). # 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) @app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: 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""" """Project detail dashboard"""
return templates.TemplateResponse("projects/detail.html", { return templates.TemplateResponse("projects/detail.html", {
"request": request, "request": request,
"project_id": project_id "project_id": project_id,
"portal_open_links": PORTAL_OPEN_LINKS,
}) })
+9
View File
@@ -40,6 +40,15 @@ if SECRET_KEY == "dev-insecure-change-me":
COOKIE_NAME = "portal_session" COOKIE_NAME = "portal_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days 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): class PortalAuthError(Exception):
"""Raised by get_current_client when there's no valid portal session. """Raised by get_current_client when there's no valid portal session.
+23
View File
@@ -24,6 +24,7 @@ from backend.models import Client, MonitoringLocation, Project, UnitAssignment
from backend.templates_config import templates from backend.templates_config import templates
from backend.portal_auth import ( from backend.portal_auth import (
get_current_client, client_from_cookie, make_session_cookie, resolve_token, get_current_client, client_from_cookie, make_session_cookie, resolve_token,
provision_preview_session, PORTAL_OPEN_LINKS,
COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_NAME, COOKIE_MAX_AGE,
) )
@@ -109,6 +110,28 @@ def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
return resp 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") @router.get("/logout")
def portal_logout(): def portal_logout():
resp = RedirectResponse(url="/portal/access", status_code=303) resp = RedirectResponse(url="/portal/access", status_code=303)
+23
View File
@@ -2112,6 +2112,19 @@ document.addEventListener('DOMContentLoaded', function() {
Anyone with a link can view this project's client portal (read-only). Links are revocable. Anyone with a link can view this project's client portal (read-only). Links are revocable.
</p> </p>
{% if portal_open_links %}
<!-- Dev quick link: plain, no-token URL anyone can open (PORTAL_OPEN_LINKS on) -->
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<label class="block text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">Quick share link (dev — anyone can open, no login)</label>
<div class="flex gap-2">
<input id="open-url" readonly
class="flex-1 px-3 py-2 text-sm rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyOpenUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">For feedback during development. Disable <code>PORTAL_OPEN_LINKS</code> before real clients.</p>
</div>
{% endif %}
<div id="share-new" class="hidden mb-4"> <div id="share-new" class="hidden mb-4">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link &mdash; copy it now</label> <label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link &mdash; copy it now</label>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -2134,10 +2147,20 @@ const SHARE_PROJECT_ID = "{{ project_id }}";
function openShareModal() { function openShareModal() {
document.getElementById('share-modal').classList.remove('hidden'); document.getElementById('share-modal').classList.remove('hidden');
document.getElementById('share-new').classList.add('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(); loadShareLinks();
} }
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); } 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() { async function loadShareLinks() {
const list = document.getElementById('share-list'); const list = document.getElementById('share-list');
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>'; list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>';