From bececafe78e92be3f6adb93ff17a9e8551bf6f9a Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 17:26:37 +0000 Subject: [PATCH] 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 %} +