feat(portal): one-click "View client portal" preview from the project page

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-<id>' 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 02:18:06 +00:00
parent 2031681d0f
commit 3fc20e104a
3 changed files with 67 additions and 2 deletions
+20 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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 fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict, Optional 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) @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
async def nrl_detail_page( async def nrl_detail_page(
request: Request, request: Request,
+35
View File
@@ -16,9 +16,11 @@ import os
import hmac import hmac
import json import json
import time import time
import uuid
import base64 import base64
import hashlib import hashlib
import logging import logging
import secrets
from datetime import datetime from datetime import datetime
from fastapi import Request, Depends from fastapi import Request, Depends
@@ -112,3 +114,36 @@ def resolve_token(raw_token: str, db: Session):
tok.last_used_at = datetime.utcnow() tok.last_used_at = datetime.utcnow()
db.commit() db.commit()
return tok, client 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-<id>' 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
+12 -1
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<!-- Breadcrumb Navigation --> <!-- Breadcrumb Navigation -->
<div class="mb-6"> <div class="mb-6 flex items-center justify-between gap-3">
<nav class="flex items-center space-x-2 text-sm"> <nav class="flex items-center space-x-2 text-sm">
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center"> <a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -17,6 +17,17 @@
</svg> </svg>
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span> <span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
</nav> </nav>
<!-- Preview the client portal for this project (opens scoped, read-only) -->
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
class="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
title="Preview this project's client portal in a new tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
View client portal
</a>
</div> </div>
<!-- Header (loads dynamically) --> <!-- Header (loads dynamically) -->