feat(portal): plain no-token "open" links for dev feedback (PORTAL_OPEN_LINKS)

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>
This commit is contained in:
2026-06-11 17:26:37 +00:00
parent 2da9493cb5
commit bececafe78
4 changed files with 58 additions and 2 deletions
+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
# (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,
})
+9
View File
@@ -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.
+23
View File
@@ -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)