feat: operator Portal access panel (enable + password + link)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 — 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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user