From 3fc20e104a8ee6cb61caa41c9120549e86bfbfbf Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 11 Jun 2026 02:18:06 +0000 Subject: [PATCH] feat(portal): one-click "View client portal" preview from the project page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "View client portal" button on the project detail page that opens the client portal scoped to that project — no CLI. GET /projects/{id}/portal-preview auto-provisions a client + access token for the project (provision_preview_session) and seals a portal session cookie, then redirects to /portal. - Reuses the project's linked client if it has one; otherwise creates/reuses a per-project 'preview-' client. Only sets project.client_id when unset, so it never clobbers a real client link. Idempotent — repeat clicks reuse the same client/token. - Lives under /projects (not /portal), so a future public proxy exposing only /portal/* won't expose this operator shortcut. Verified: provisioning (unlinked creates+links, idempotent, linked-no-clobber) 7/7. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 21 +++++++++++++++++++- backend/portal_auth.py | 35 ++++++++++++++++++++++++++++++++++ templates/projects/detail.html | 13 ++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index 2f025fd..5305c37 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse, FileResponse, JSONResponse +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse from fastapi.exceptions import RequestValidationError from sqlalchemy.orm import Session from typing import List, Dict, Optional @@ -413,6 +413,25 @@ async def project_detail_page(request: Request, project_id: str): }) +@app.get("/projects/{project_id}/portal-preview") +async def project_portal_preview(project_id: str, db: Session = Depends(get_db)): + """Operator testing shortcut: log into the client portal scoped to this project + (auto-provisioning a client/link if needed), no CLI. Lives under /projects (not + /portal), so a public proxy that exposes only /portal/* won't expose this.""" + from backend.models import Project + from backend.portal_auth import ( + provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, + ) + project = db.query(Project).filter_by(id=project_id).first() + if not project: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + 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 + + @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) async def nrl_detail_page( request: Request, diff --git a/backend/portal_auth.py b/backend/portal_auth.py index 02933b0..da869a2 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -16,9 +16,11 @@ import os import hmac import json import time +import uuid import base64 import hashlib import logging +import secrets from datetime import datetime from fastapi import Request, Depends @@ -112,3 +114,36 @@ def resolve_token(raw_token: str, db: Session): tok.last_used_at = datetime.utcnow() db.commit() return tok, client + + +def provision_preview_session(project, db) -> str: + """Testing convenience (operator-side): ensure a Client + access token exist for + a project so an operator can preview the client portal without the CLI. Returns + the token id to seal into a session cookie. + + Reuses the project's linked client if it has one; otherwise creates/uses a + per-project 'preview-' client. Only sets project.client_id when it's unset, + so previewing never clobbers a real client link. The token's raw secret is + discarded (preview rides the cookie, not a magic link).""" + client = None + if project.client_id: + client = db.query(Client).filter_by(id=project.client_id, active=True).first() + if client is None: + slug = f"preview-{str(project.id)[:8]}" + client = db.query(Client).filter_by(slug=slug).first() + if client is None: + client = Client(id=str(uuid.uuid4()), + name=(project.client_name or project.name or "Preview"), + slug=slug, active=True) + db.add(client) + db.flush() + if not project.client_id: + project.client_id = client.id + tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first() + if tok is None: + tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, + token_hash=hash_token(secrets.token_urlsafe(32)), + label="preview") + db.add(tok) + db.commit() + return tok.id diff --git a/templates/projects/detail.html b/templates/projects/detail.html index ba971f3..1542c2f 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -4,7 +4,7 @@ {% block content %} -