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 %}
+
@@ -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 = '