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
+35
View File
@@ -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-<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