feat: operator Portal access panel (enable + password + link)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:59:41 +00:00
parent 25a4a28433
commit eb91441904
+56 -97
View File
@@ -18,16 +18,16 @@
<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>
<!-- Client portal actions for this project --> <!-- Client portal access for this project -->
<div class="shrink-0 flex items-center gap-2"> <div class="shrink-0 flex items-center gap-2">
<button type="button" onclick="openShareModal()" <button type="button" onclick="openPortalAccess()"
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" 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"> title="Manage this project's client portal access">
<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="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"
<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> d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg> </svg>
Copy client link Portal access
</button> </button>
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener" <a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
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" 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"
@@ -36,7 +36,7 @@
<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>
<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> <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> </svg>
View client portal Preview
</a> </a>
</div> </div>
</div> </div>
@@ -2098,123 +2098,82 @@ document.addEventListener('DOMContentLoaded', function() {
</script> </script>
<!-- Share client portal link modal --> <!-- Portal access modal -->
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" <div id="portal-access-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick="if(event.target===this)closeShareModal()"> onclick="if(event.target===this)closePortalAccess()">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6"> <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"> <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> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal access</h3>
<button onclick="closeShareModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"> <button onclick="closePortalAccess()" 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> <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> </button>
</div> </div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4"> <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. Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
</p> </p>
{% if portal_open_links %} <div class="flex items-center justify-between mb-4">
<!-- Dev quick link: plain, no-token URL anyone can open (PORTAL_OPEN_LINKS on) --> <span class="text-sm font-medium text-gray-700 dark:text-gray-200">Portal enabled</span>
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50"> <button id="pa-toggle" onclick="togglePortalEnabled()"
<label class="block text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">Quick share link (dev — anyone can open, no login)</label> class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600"></button>
<div class="flex gap-2">
<input id="open-url" readonly
class="flex-1 px-3 py-2 text-sm rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyOpenUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div> </div>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">For feedback during development. Disable <code>PORTAL_OPEN_LINKS</code> before real clients.</p>
</div>
{% endif %}
<div id="share-new" class="hidden mb-4"> <div id="pa-details" class="hidden space-y-4">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link &mdash; copy it now</label> <div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Portal link</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input id="share-new-url" readonly <input id="pa-link" 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="copyField('pa-link', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Password</label>
<div class="flex gap-2">
<input id="pa-pass" readonly placeholder="•••••••• (set one below)"
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" /> 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> <button onclick="copyField('pa-pass', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
<button onclick="regeneratePassword()" class="mt-2 text-sm text-seismo-orange hover:text-seismo-navy font-medium">↻ Generate new password</button>
<p class="text-xs text-gray-400 mt-1">Shown once — copy it now. Regenerating invalidates the old one.</p>
</div> </div>
</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>
</div> </div>
<script> <script>
const SHARE_PROJECT_ID = "{{ project_id }}"; const PA_PROJECT_ID = "{{ project_id }}";
function openShareModal() { function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
document.getElementById('share-modal').classList.remove('hidden'); function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }
document.getElementById('share-new').classList.add('hidden');
const ou = document.getElementById('open-url'); // only present when PORTAL_OPEN_LINKS on
if (ou) ou.value = `${location.origin}/portal/open/${SHARE_PROJECT_ID}`;
loadShareLinks();
}
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
function copyOpenUrl(btn) { function copyField(id, btn) {
const inp = document.getElementById('open-url'); const inp = document.getElementById(id); inp.select();
inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); }; 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(); }); if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); } else { document.execCommand('copy'); done(); }
} }
async function loadShareLinks() { async function loadPortalAccess() {
const list = document.getElementById('share-list'); const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access`)).json();
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>'; renderPortalAccess(j);
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 = ''; function renderPortalAccess(j) {
for (const l of j.links) { const toggle = document.getElementById('pa-toggle');
const last = l.last_used_at ? ('last used ' + new Date(l.last_used_at + 'Z').toLocaleString()) : 'never used'; const details = document.getElementById('pa-details');
const row = document.createElement('div'); toggle.textContent = j.enabled ? 'On — click to disable' : 'Off — click to enable';
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700'; toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
row.innerHTML = `<div class="text-sm min-w-0"> (j.enabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
<div class="text-gray-800 dark:text-gray-200 truncate">${l.label || 'Link'}</div> details.classList.toggle('hidden', !j.enabled);
<div class="text-xs text-gray-400">${last}</div></div>`; if (j.enabled && j.link_url) document.getElementById('pa-link').value = j.link_url;
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) { async function togglePortalEnabled() {
list.innerHTML = '<div class="text-sm text-red-500">Failed to load links.</div>'; const on = document.getElementById('pa-toggle').textContent.startsWith('On');
const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${on ? 'disable' : 'enable'}`, { method: 'POST' })).json();
if (on) renderPortalAccess({ enabled: false, link_url: null });
else renderPortalAccess(j);
} }
} async function regeneratePassword() {
const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' })).json();
async function generateShareLink() { if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
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 %}