[grade=B] DC-058: complete Share UI — admin modal + public preview page + grid share button + 3 frontend tests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

This commit is contained in:
Hermes
2026-08-06 15:11:31 -07:00
parent f8b088916b
commit a7057e4fba
10 changed files with 1394 additions and 228 deletions
+382
View File
@@ -0,0 +1,382 @@
// Share Modal — DC-058
//
// Admin UI for DC-053 share routes. Opened from the "Share" button on each
// service card (added in core/grid.js next to the existing options/delete
// buttons). Two tabs:
// - Public link: pick a TTL (1h/24h/7d), POST /api/v1/share, render the
// returned urlPath with a copy button + revoke control.
// - Tailscale invite: enter an email, POST /api/v1/share/tailscale, render
// delivered status (inbox vs dev-console fallback vs URL fallback).
//
// Modal also lists outstanding shares for the selected service (GET /api/v1/share)
// with revoke buttons. The list refreshes after every issue/revoke.
//
// Both issue endpoints are Pro-gated on the server — the modal surfaces a 402
// as an upgrade prompt ("Share is a Pro feature. Activate a license to unlock.").
// Tailscale invites additionally require tailscaleCoord configured on the host;
// the server returns 400 with a clear message when missing.
(function() {
'use strict';
if (window.__dc_058_share_modal_loaded) return;
window.__dc_058_share_modal_loaded = true;
const TTL_OPTIONS = [
{ ms: 60 * 60 * 1000, label: '1 hour' },
{ ms: 24 * 60 * 60 * 1000, label: '24 hours' },
{ ms: 7 * 24 * 60 * 60 * 1000, label: '7 days' },
];
injectModal('share-modal', `
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
<h3 id="share-modal-title">Share <span id="share-modal-service-name">…</span></h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
Create a public link or Tailscale invite so someone outside your network can access this service.
</p>
<div class="share-tabs" style="display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border);">
<button class="share-tab active" data-tab="public" type="button"
style="background: transparent; border: 0; border-bottom: 2px solid var(--accent); padding: 8px 14px; color: var(--fg); font-weight: 600; cursor: pointer;">
Public link
</button>
<button class="share-tab" data-tab="tailscale" type="button"
style="background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 8px 14px; color: var(--muted); cursor: pointer;">
Tailscale invite
</button>
</div>
<div class="share-tab-panel" data-panel="public">
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
${TTL_OPTIONS.map(o => `<option value="${o.ms}">${o.label}</option>`).join('')}
</select>
<button id="share-public-create" class="btn-accent" type="button"
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
Create share link
</button>
</div>
<div class="share-tab-panel" data-panel="tailscale" style="display: none;">
<label class="form-label-bold" for="share-ts-email">Recipient email:</label>
<input id="share-ts-email" type="email" placeholder="alice@example.com" autocomplete="off"
style="width: 100%; padding: 10px 12px; margin: 6px 0 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; font-size: 0.95rem;" />
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
A single-use Tailscale pre-auth key is generated and emailed. The device joins your tailnet and is routed to this service via Caddy.
</p>
<button id="share-ts-create" class="btn-accent" type="button"
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
Create Tailscale invite
</button>
</div>
<div id="share-issued" style="display: none; margin-top: 16px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;">
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 6px;">Share link:</div>
<div style="display: flex; gap: 6px; align-items: center;">
<input id="share-issued-url" type="text" readonly
style="flex: 1; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; font-family: monospace; font-size: 0.85rem;" />
<button id="share-issued-copy" type="button"
style="padding: 8px 14px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
Copy
</button>
</div>
<div id="share-issued-meta" style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;"></div>
</div>
<div id="share-error" style="display: none; margin-top: 12px; padding: 10px; border-radius: 4px; background: rgba(231,76,60,0.15); color: var(--bad-fg); font-size: 0.85rem;"></div>
<div id="share-success" style="display: none; margin-top: 12px; padding: 10px; border-radius: 4px; background: rgba(46,204,113,0.15); color: var(--ok-fg); font-size: 0.85rem;"></div>
<div id="share-outstanding" style="margin-top: 16px;">
<label class="form-label-bold">Outstanding shares for this service</label>
<div id="share-outstanding-list" style="margin-top: 6px; font-size: 0.85rem;"></div>
</div>
<div class="weather-modal-buttons" style="margin-top: 18px;">
<button id="share-cancel" type="button">Close</button>
</div>
</div>
</div>
`);
const modal = document.getElementById('share-modal');
const serviceNameEl = document.getElementById('share-modal-service-name');
const issuedEl = document.getElementById('share-issued');
const issuedUrlInput = document.getElementById('share-issued-url');
const issuedCopyBtn = document.getElementById('share-issued-copy');
const issuedMetaEl = document.getElementById('share-issued-meta');
const errorEl = document.getElementById('share-error');
const successEl = document.getElementById('share-success');
const outstandingListEl = document.getElementById('share-outstanding-list');
const cancelBtn = document.getElementById('share-cancel');
const publicCreateBtn = document.getElementById('share-public-create');
const tsCreateBtn = document.getElementById('share-ts-create');
const tsEmailInput = document.getElementById('share-ts-email');
const publicTtlSelect = document.getElementById('share-public-ttl');
let currentService = null; // { id, name }
let activeTab = 'public';
function hideMessages() {
errorEl.style.display = 'none';
successEl.style.display = 'none';
issuedEl.style.display = 'none';
}
function showError(msg) {
successEl.style.display = 'none';
issuedEl.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
function showSuccess(msg) {
errorEl.style.display = 'none';
successEl.textContent = msg;
successEl.style.display = 'block';
}
function _originFromPage() {
// Build the absolute share URL from the page's current origin so the
// link is correct regardless of whether the user is on http://localhost
// (dev) or https://status.sami (prod). The urlPath returned by the API
// is a path-only string starting with /share/.
return window.location.origin;
}
function _absUrl(urlPath) {
if (urlPath.startsWith('http')) return urlPath;
return _originFromPage() + urlPath;
}
function _formatExpiry(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString();
}
function _formatRemaining(ms) {
if (ms <= 0) return 'expired';
const h = Math.floor(ms / 3600000);
if (h >= 24) return `${Math.floor(h / 24)}d ${h % 24}h left`;
const m = Math.floor((ms % 3600000) / 60000);
return `${h}h ${m}m left`;
}
function setActiveTab(name) {
activeTab = name;
hideMessages();
modal.querySelectorAll('.share-tab').forEach(btn => {
const isActive = btn.dataset.tab === name;
btn.classList.toggle('active', isActive);
btn.style.borderBottomColor = isActive ? 'var(--accent)' : 'transparent';
btn.style.color = isActive ? 'var(--fg)' : 'var(--muted)';
btn.style.fontWeight = isActive ? '600' : '400';
});
modal.querySelectorAll('.share-tab-panel').forEach(p => {
p.style.display = p.dataset.panel === name ? '' : 'none';
});
}
async function loadOutstanding() {
outstandingListEl.innerHTML = '<span style="color: var(--muted);">Loading…</span>';
try {
const resp = await fetch('/api/v1/share', { credentials: 'same-origin' });
if (!resp.ok) {
outstandingListEl.innerHTML = '<span style="color: var(--muted);">No shares listed.</span>';
return;
}
const body = await resp.json();
const list = (body && body.data) || [];
const filtered = list.filter(s => s.serviceId === currentService.id);
if (filtered.length === 0) {
outstandingListEl.innerHTML = '<span style="color: var(--muted);">No outstanding shares for this service.</span>';
return;
}
outstandingListEl.innerHTML = filtered.map(s => {
const remaining = s.expiresAt ? _formatRemaining(new Date(s.expiresAt).getTime() - Date.now()) : '';
return `
<div class="share-row" data-share-id="${escapeHtml(s.id)}"
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span style="flex: 1;">
<strong>${s.kind === 'tailscale' ? 'Tailscale' : 'Public'}</strong>
<span style="color: var(--muted);"> · expires ${escapeHtml(_formatExpiry(s.expiresAt))} (${remaining})</span>
</span>
<button class="share-revoke" data-share-id="${escapeHtml(s.id)}" type="button"
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
Revoke
</button>
</div>
`;
}).join('');
outstandingListEl.querySelectorAll('.share-revoke').forEach(btn => {
btn.addEventListener('click', () => revokeShare(btn.dataset.shareId));
});
} catch (e) {
outstandingListEl.innerHTML = '<span style="color: var(--muted);">Could not load outstanding shares.</span>';
}
}
async function revokeShare(id) {
try {
const resp = await fetch(`/api/v1/share/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
showError(body && body.error || `Revoke failed (HTTP ${resp.status}).`);
return;
}
showSuccess('Share revoked.');
loadOutstanding();
} catch (e) {
showError('Network error while revoking.');
}
}
async function issuePublic() {
if (!currentService) return;
hideMessages();
publicCreateBtn.disabled = true;
const original = publicCreateBtn.textContent;
publicCreateBtn.textContent = 'Creating…';
try {
const ttlMs = parseInt(publicTtlSelect.value, 10);
const resp = await fetch('/api/v1/share', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceId: currentService.id, ttlMs }),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || !body.success) {
_renderApiError(resp.status, body);
return;
}
const urlPath = body.data.urlPath;
issuedUrlInput.value = _absUrl(urlPath);
issuedMetaEl.textContent = `Public link · expires ${_formatExpiry(body.data.expiresAt)}`;
issuedEl.style.display = 'block';
showSuccess('Share link created.');
loadOutstanding();
} catch (e) {
showError('Network error while creating share.');
} finally {
publicCreateBtn.disabled = false;
publicCreateBtn.textContent = original;
}
}
async function issueTailscale() {
if (!currentService) return;
const email = (tsEmailInput.value || '').trim();
if (!email || !email.includes('@')) {
showError('A valid recipient email is required.');
return;
}
hideMessages();
tsCreateBtn.disabled = true;
const original = tsCreateBtn.textContent;
tsCreateBtn.textContent = 'Creating…';
try {
const resp = await fetch('/api/v1/share/tailscale', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceId: currentService.id, email }),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || !body.success) {
_renderApiError(resp.status, body);
return;
}
// Tailscale shares show the raw urlPath (which is the share URL) if
// email delivery failed. The share itself is one-shot so the admin
// can manually paste the link into a chat if SMTP failed.
const fallback = body.data && body.data.urlPath;
if (fallback) {
issuedUrlInput.value = _absUrl(fallback);
issuedMetaEl.textContent = `Tailscale invite · emailed to ${email} (or use this URL manually if delivery failed).`;
} else {
issuedUrlInput.value = '';
issuedMetaEl.textContent = `Tailscale invite sent to ${email}.`;
}
issuedEl.style.display = 'block';
showSuccess('Tailscale invite created.');
tsEmailInput.value = '';
loadOutstanding();
} catch (e) {
showError('Network error while creating Tailscale invite.');
} finally {
tsCreateBtn.disabled = false;
tsCreateBtn.textContent = original;
}
}
function _renderApiError(status, body) {
const err = (body && body.error) || '';
if (status === 402) {
showError('Share is a Pro feature. Activate a license to unlock.');
return;
}
if (status === 400 && /tailscale/i.test(err)) {
showError('Tailscale is not configured on this host. Set up Tailscale in the dashboard first.');
return;
}
if (status === 403) {
showError('You do not have permission to share services.');
return;
}
if (status === 404) {
showError('Service not found. It may have been deleted.');
return;
}
showError(err || `Request failed (HTTP ${status}).`);
}
function openShareModal(service) {
if (!service || !service.id) return;
currentService = { id: service.id, name: service.name || service.id };
serviceNameEl.textContent = currentService.name;
hideMessages();
setActiveTab('public');
tsEmailInput.value = '';
publicTtlSelect.value = String(TTL_OPTIONS[1].ms); // 24h default
modal.classList.add('show');
loadOutstanding();
}
// Wire events
modal.querySelectorAll('.share-tab').forEach(btn => {
btn.addEventListener('click', () => setActiveTab(btn.dataset.tab));
});
publicCreateBtn.addEventListener('click', issuePublic);
tsCreateBtn.addEventListener('click', issueTailscale);
cancelBtn.addEventListener('click', () => modal.classList.remove('show'));
wireModal(modal, cancelBtn);
// Copy-to-clipboard for the issued share URL
issuedCopyBtn.addEventListener('click', async () => {
const url = issuedUrlInput.value;
if (!url) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
} else {
// Fallback for older browsers / unsafe contexts
issuedUrlInput.select();
document.execCommand('copy');
}
const original = issuedCopyBtn.textContent;
issuedCopyBtn.textContent = 'Copied!';
setTimeout(() => { issuedCopyBtn.textContent = original; }, 1200);
} catch (e) {
showError('Could not copy to clipboard. Select the URL manually.');
}
});
// Expose for other modules to open
window.openShareModal = openShareModal;
})();