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:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -28,6 +38,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
View client portal
|
View client portal
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header (loads dynamically) -->
|
<!-- Header (loads dynamically) -->
|
||||||
@@ -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 — 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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user