383 lines
16 KiB
JavaScript
383 lines
16 KiB
JavaScript
// 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;
|
|
})();
|