// 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', ` `); 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 = 'Loading…'; try { const resp = await fetch('/api/v1/share', { credentials: 'same-origin' }); if (!resp.ok) { outstandingListEl.innerHTML = 'No shares listed.'; 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 = 'No outstanding shares for this service.'; return; } outstandingListEl.innerHTML = filtered.map(s => { const remaining = s.expiresAt ? _formatRemaining(new Date(s.expiresAt).getTime() - Date.now()) : ''; return `
${s.kind === 'tailscale' ? 'Tailscale' : 'Public'} · expires ${escapeHtml(_formatExpiry(s.expiresAt))} (${remaining})
`; }).join(''); outstandingListEl.querySelectorAll('.share-revoke').forEach(btn => { btn.addEventListener('click', () => revokeShare(btn.dataset.shareId)); }); } catch (e) { outstandingListEl.innerHTML = 'Could not load outstanding shares.'; } } 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; })();