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
+54
View File
@@ -432,6 +432,60 @@ async def project_portal_preview(project_id: str, db: Session = Depends(get_db))
return resp return resp
@app.post("/projects/{project_id}/portal-link")
async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Mint a fresh shareable client link for this project's client. Returns the
full /portal/enter/<token> URL (shown once). Operator-only (internal app)."""
from backend.models import Project
from backend.portal_auth import ensure_project_client, mint_link_token
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"})
client = ensure_project_client(project, db)
raw = mint_link_token(client, db, label="shared link")
url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}"
return {"url": url, "client_name": client.name}
@app.get("/projects/{project_id}/portal-links")
async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)):
"""List active (non-revoked) shareable links for this project's client."""
from backend.models import Project, ClientAccessToken, Client
project = db.query(Project).filter_by(id=project_id).first()
if not project or not project.client_id:
return {"client_name": None, "links": []}
client = db.query(Client).filter_by(id=project.client_id).first()
toks = (db.query(ClientAccessToken)
.filter_by(client_id=project.client_id, revoked_at=None)
.order_by(ClientAccessToken.created_at.desc()).all())
return {
"client_name": client.name if client else None,
"links": [{
"id": t.id, "label": t.label,
"created_at": t.created_at.isoformat() if t.created_at else None,
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
} for t in toks],
}
@app.post("/projects/{project_id}/portal-link/{token_id}/revoke")
async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)):
"""Revoke one shareable link (scoped to this project's client). Kills the link
and any live session minted from it on the next request."""
from datetime import datetime as _dt
from backend.models import Project, ClientAccessToken
project = db.query(Project).filter_by(id=project_id).first()
if not project or not project.client_id:
return JSONResponse(status_code=404, content={"detail": "Not found"})
tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first()
if not tok:
return JSONResponse(status_code=404, content={"detail": "Link not found"})
if not tok.revoked_at:
tok.revoked_at = _dt.utcnow()
db.commit()
return {"ok": True}
@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,
+23 -9
View File
@@ -120,15 +120,10 @@ def resolve_token(raw_token: str, db: Session):
return tok, client return tok, client
def provision_preview_session(project, db) -> str: def ensure_project_client(project, db) -> Client:
"""Testing convenience (operator-side): ensure a Client + access token exist for """Find or create the Client for a project. Reuses the project's linked client
a project so an operator can preview the client portal without the CLI. Returns if it has one; otherwise creates/uses a per-project 'preview-<id>' client and
the token id to seal into a session cookie. sets project.client_id (only when unset, so it never clobbers a real link)."""
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 client = None
if project.client_id: if project.client_id:
client = db.query(Client).filter_by(id=project.client_id, active=True).first() 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() db.flush()
if not project.client_id: if not project.client_id:
project.client_id = 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() tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
if tok is None: if tok is None:
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
+110 -2
View File
@@ -18,9 +18,19 @@
<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) --> <!-- Client portal actions for this project -->
<div class="shrink-0 flex items-center gap-2">
<button type="button" onclick="openShareModal()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
title="Get a shareable link to this project's client portal">
<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="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 11-5.656-5.656l1.5-1.5"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 115.656 5.656l-1.5 1.5"></path>
</svg>
Copy client link
</button>
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener" <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" class="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"> 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"> <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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
@@ -29,6 +39,7 @@
View client portal View client portal
</a> </a>
</div> </div>
</div>
<!-- Header (loads dynamically) --> <!-- Header (loads dynamically) -->
<div id="project-header" hx-get="/api/projects/{{ project_id }}/header" hx-trigger="load" hx-swap="innerHTML"> <div id="project-header" hx-get="/api/projects/{{ project_id }}/header" hx-trigger="load" hx-swap="innerHTML">
@@ -2085,5 +2096,102 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
</script>
<!-- Share client portal link modal -->
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick="if(event.target===this)closeShareModal()">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
<div class="flex items-center justify-between mb-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal link</h3>
<button onclick="closeShareModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Anyone with a link can view this project's client portal (read-only). Links are revocable.
</p>
<div id="share-new" class="hidden mb-4">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link &mdash; copy it now</label>
<div class="flex gap-2">
<input id="share-new-url" readonly
class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyShareUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
</div>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Active links</span>
<button onclick="generateShareLink()" class="text-sm text-seismo-orange hover:text-seismo-navy font-medium">+ Generate new link</button>
</div>
<div id="share-list" class="space-y-2 max-h-56 overflow-y-auto"></div>
</div>
</div>
<script>
const SHARE_PROJECT_ID = "{{ project_id }}";
function openShareModal() {
document.getElementById('share-modal').classList.remove('hidden');
document.getElementById('share-new').classList.add('hidden');
loadShareLinks();
}
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
async function loadShareLinks() {
const list = document.getElementById('share-list');
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>';
try {
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-links`)).json();
if (!j.links || !j.links.length) {
list.innerHTML = '<div class="text-sm text-gray-400">No links yet — generate one above.</div>';
return;
}
list.innerHTML = '';
for (const l of j.links) {
const last = l.last_used_at ? ('last used ' + new Date(l.last_used_at + 'Z').toLocaleString()) : 'never used';
const row = document.createElement('div');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700';
row.innerHTML = `<div class="text-sm min-w-0">
<div class="text-gray-800 dark:text-gray-200 truncate">${l.label || 'Link'}</div>
<div class="text-xs text-gray-400">${last}</div></div>`;
const btn = document.createElement('button');
btn.className = 'shrink-0 text-xs text-red-600 hover:text-red-700';
btn.textContent = 'Revoke';
btn.onclick = () => revokeShareLink(l.id);
row.appendChild(btn);
list.appendChild(row);
}
} catch (e) {
list.innerHTML = '<div class="text-sm text-red-500">Failed to load links.</div>';
}
}
async function generateShareLink() {
try {
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link`, { method: 'POST' })).json();
if (j.url) {
document.getElementById('share-new').classList.remove('hidden');
document.getElementById('share-new-url').value = j.url;
loadShareLinks();
}
} catch (e) {
if (window.showToast) showToast('Failed to generate link', 'error');
}
}
function copyShareUrl(btn) {
const inp = document.getElementById('share-new-url');
inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); }
}
async function revokeShareLink(id) {
if (!confirm('Revoke this link? Anyone using it will be signed out on their next action.')) return;
try { await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link/${id}/revoke`, { method: 'POST' }); loadShareLinks(); }
catch (e) { if (window.showToast) showToast('Failed to revoke', 'error'); }
}
</script> </script>
{% endblock %} {% endblock %}