feat(portal): "Copy client link" — generate/copy/revoke shareable links from the project page

No-CLI way to get a real shareable magic link (/portal/enter/<token>) for a
project's client. Project page gets a "Copy client link" button next to the
preview; opens a modal that lists active links (with revoke), generates a fresh
one, and copies it to the clipboard.

Backend (operator, internal /projects/*):
- POST /projects/{id}/portal-link  -> mint a fresh token, return the full URL
  (built from request.base_url so it uses the operator's host).
- GET  /projects/{id}/portal-links -> list active links (label/created/last-used).
- POST /projects/{id}/portal-link/{tid}/revoke -> revoke one (scoped to the
  project's client).

Refactor: split ensure_project_client() + mint_link_token() out of
provision_preview_session() so minting a shareable link and the preview cookie
share one provisioning path.

Verified: ensure/mint persistence across commits + sessions, minted link resolves,
token stored hashed, second mint = distinct active link (4/4); compiles; share
script balances; detail.html parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:11:34 +00:00
parent b908f394ed
commit 2da9493cb5
3 changed files with 195 additions and 19 deletions
+23 -9
View File
@@ -120,15 +120,10 @@ def resolve_token(raw_token: str, db: Session):
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)."""
def ensure_project_client(project, db) -> Client:
"""Find or create the Client for a project. Reuses the project's linked client
if it has one; otherwise creates/uses a per-project 'preview-<id>' client and
sets project.client_id (only when unset, so it never clobbers a real link)."""
client = None
if project.client_id:
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
@@ -143,6 +138,25 @@ def provision_preview_session(project, db) -> str:
db.flush()
if not project.client_id:
project.client_id = client.id
return client
def mint_link_token(client, db, label=None) -> str:
"""Mint a fresh access token for a client and return the RAW secret (caller
builds the /portal/enter/<raw> URL and shows it once). Only the hash is stored."""
raw = secrets.token_urlsafe(32)
db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
token_hash=hash_token(raw), label=label))
db.commit()
return raw
def provision_preview_session(project, db) -> str:
"""Operator preview shortcut: ensure a Client + access token exist for a project
and return a token id to seal into a session cookie (no shared link). Reuses an
existing token so repeat previews don't accumulate clutter; the raw secret is
discarded (preview rides the cookie)."""
client = ensure_project_client(project, db)
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,