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:
+20
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user