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.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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) -->
|
||||||
|
|||||||
Reference in New Issue
Block a user