[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
+253
View File
@@ -0,0 +1,253 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DashCaddy Share</title>
<link rel="canonical" href="/share">
<link rel="stylesheet" href="/assets/dashboard.css">
<style>
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --ok:#7cf2c0; --danger:#ff9090; --warn:#ffd07f; }
* { box-sizing: border-box; }
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
main { width: min(640px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,48px); }
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
h1 { margin: 8px 0 0; font-size: clamp(1.8rem,5vw,2.5rem); }
.lede { color: var(--muted); }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; margin-top: 24px; }
.card.error { border-color: var(--danger); }
.meta { color: var(--muted); font-size: .9rem; margin-top: 12px; }
.field { margin-top: 16px; }
.field label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 6px; }
.field input { width: 100%; padding: 10px 12px; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; font: inherit; font-size: 0.95rem; }
.button { display: inline-block; cursor: pointer; border: 0; padding: 12px 22px; border-radius: 10px; font: inherit; font-weight: 600; background: var(--accent); color: #04141f; margin-top: 12px; text-decoration: none; }
.button:disabled { opacity: .6; cursor: not-allowed; }
.button.secondary { background: #1a2742; color: var(--text); border: 1px solid var(--border); }
.status-ok { color: var(--ok); }
.status-err { color: var(--danger); }
.status-warn { color: var(--warn); }
.health { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
.health.up { background: rgba(124,242,192,0.12); color: var(--ok); }
.health.down { background: rgba(255,144,144,0.12); color: var(--danger); }
.health.unknown { background: rgba(255,208,127,0.12); color: var(--warn); }
.service-meta { display: grid; gap: 4px; font-size: 0.9rem; color: var(--muted); margin-top: 12px; }
.service-meta strong { color: var(--text); }
.footer { color: var(--muted); font-size: 0.85rem; margin-top: 24px; text-align: center; }
.footer a { color: var(--accent); }
@media (max-width: 600px) { .card { padding: 20px; } }
</style>
</head>
<body>
<main>
<div class="eyebrow">DashCaddy</div>
<h1 id="title">Loading share…</h1>
<div id="card" class="card">
<div id="loading">Loading service details…</div>
<div id="ready" hidden>
<div id="health-badge" class="health unknown">Checking status…</div>
<h2 id="service-name" style="margin: 12px 0 0; font-size: 1.5rem;"></h2>
<p id="service-description" class="lede" style="margin-top: 6px;"></p>
<div id="service-meta" class="service-meta"></div>
<div id="cta-public" style="margin-top: 24px;">
<a id="open-service" class="button" href="#" target="_blank" rel="noopener">Open service</a>
</div>
<div id="cta-tailscale" style="display: none; margin-top: 24px;">
<p class="lede">To join this service, install <a href="https://tailscale.com/download" target="_blank" rel="noopener" style="color: var(--accent);">Tailscale</a> on your device, then visit the service URL. Your device will be authorized automatically via the share token carried in this link.</p>
<a id="tailscale-open" class="button" href="#" target="_blank" rel="noopener">Open service</a>
<p class="meta" style="margin-top: 12px;">This link is single-use. Only one device can join. The host's operator issued it specifically for you.</p>
</div>
<div id="subscribe-section" style="margin-top: 28px;">
<div class="eyebrow" style="font-size: 0.75rem;">Get notified</div>
<p class="lede" style="margin-top: 4px; font-size: 0.9rem;">Enter your email to be notified when this service goes down or recovers.</p>
<div class="field">
<label for="subscribe-email">Email address</label>
<input id="subscribe-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button id="subscribe-btn" class="button secondary" type="button">Subscribe</button>
<div id="subscribe-status" class="meta"></div>
</div>
<div id="expires" class="meta" style="margin-top: 24px;"></div>
</div>
<div id="error" hidden>
<h2 style="margin: 0 0 8px; color: var(--danger);">Share link unavailable</h2>
<p id="error-message" class="lede">This share link is invalid, expired, or has been revoked.</p>
</div>
</div>
<p class="footer">
<a href="/legal/privacy">Privacy</a> · <a href="/legal/terms">Terms</a>
</p>
</main>
<script>
(function () {
'use strict';
// The token is the last path segment of /share/<token>. Falls back to
// empty string if the URL pattern is wrong (handled by the "invalid link"
// error path below).
function _tokenFromPath() {
var parts = window.location.pathname.split('/').filter(Boolean);
// parts[0] === 'share', parts[1] === token
return parts.length >= 2 ? parts[parts.length - 1] : '';
}
var token = _tokenFromPath();
var card = document.getElementById('card');
var loadingEl = document.getElementById('loading');
var readyEl = document.getElementById('ready');
var errorEl = document.getElementById('error');
var errorMsgEl = document.getElementById('error-message');
var titleEl = document.getElementById('title');
var serviceNameEl = document.getElementById('service-name');
var serviceDescEl = document.getElementById('service-description');
var serviceMetaEl = document.getElementById('service-meta');
var healthBadgeEl = document.getElementById('health-badge');
var openLinkEl = document.getElementById('open-service');
var tailscaleOpenEl = document.getElementById('tailscale-open');
var ctaPublicEl = document.getElementById('cta-public');
var ctaTailscaleEl = document.getElementById('cta-tailscale');
var subscribeBtn = document.getElementById('subscribe-btn');
var subscribeEmailEl = document.getElementById('subscribe-email');
var subscribeStatusEl = document.getElementById('subscribe-status');
var expiresEl = document.getElementById('expires');
function showError(message) {
loadingEl.hidden = true;
readyEl.hidden = true;
errorEl.hidden = false;
card.classList.add('error');
titleEl.textContent = 'Share link unavailable';
if (message) errorMsgEl.textContent = message;
}
function showReady(data) {
loadingEl.hidden = true;
errorEl.hidden = true;
readyEl.hidden = false;
var service = data.service || {};
serviceNameEl.textContent = service.name || data.serviceId || 'Unknown service';
titleEl.textContent = service.name || data.serviceId || 'Shared service';
serviceDescEl.textContent = service.description || '';
if (!service.description) serviceDescEl.style.display = 'none';
// Service metadata: tags, category
var meta = [];
if (service.category) meta.push('<strong>Category:</strong> ' + escapeHtml(service.category));
if (Array.isArray(service.tags) && service.tags.length) {
meta.push('<strong>Tags:</strong> ' + service.tags.map(escapeHtml).join(', '));
}
serviceMetaEl.innerHTML = meta.join(' · ');
// Health badge
var health = (service.health || 'unknown').toLowerCase();
healthBadgeEl.className = 'health ' + (health === 'up' ? 'up' : health === 'down' ? 'down' : 'unknown');
healthBadgeEl.textContent = health === 'up' ? 'Online' : health === 'down' ? 'Offline' : 'Status unknown';
// Kind-specific CTA. Both kinds show an "Open service" link — the
// service URL is the same destination in both cases. For Tailscale
// shares, the share token is also the auth credential that Caddy
// forward_auth checks against the store; the user just needs to
// open the URL after installing Tailscale and the host has already
// authorized the share via the email they were sent. The redemption
// flow lives on the SERVER side (Caddy forward_auth checks the share
// store on each request) — never on the client.
if (service.url) {
openLinkEl.href = service.url;
openLinkEl.textContent = 'Open ' + (service.name || 'service');
tailscaleOpenEl.href = service.url;
} else {
openLinkEl.style.display = 'none';
tailscaleOpenEl.style.display = 'none';
}
if (data.kind === 'tailscale') {
ctaPublicEl.style.display = 'none';
ctaTailscaleEl.style.display = '';
} else {
ctaPublicEl.style.display = '';
ctaTailscaleEl.style.display = 'none';
}
// Expiry footer
if (data.expiresAt) {
var exp = new Date(data.expiresAt);
if (!isNaN(exp.getTime())) {
expiresEl.textContent = 'This share expires ' + exp.toLocaleString() + '.';
}
}
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function fetchPreview() {
if (!token) {
showError('Invalid share link.');
return;
}
fetch('/api/v1/share/' + encodeURIComponent(token) + '/preview', { cache: 'no-store' })
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
.then(function (resp) {
if (resp.status === 200 && resp.body && resp.body.success && resp.body.data) {
showReady(resp.body.data);
return;
}
showError((resp.body && resp.body.error) ? resp.body.error.replace(/^\[DC-\d+\]\s*/, '') : 'This share link is invalid, expired, or has been revoked.');
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
});
}
// Subscribe handler (public — no auth required). Marks the share record
// as having a subscriber; the host's check-event workflow can then notify
// on status changes. This is the only POST the public preview page
// makes against the share API — the Tailscale redemption path is
// server-side (Caddy forward_auth on each request to the shared service).
subscribeBtn.addEventListener('click', function () {
var email = (subscribeEmailEl.value || '').trim();
if (!email || !email.includes('@')) {
subscribeStatusEl.textContent = 'Please enter a valid email.';
subscribeStatusEl.className = 'meta status-err';
return;
}
subscribeBtn.disabled = true;
subscribeStatusEl.textContent = 'Subscribing…';
subscribeStatusEl.className = 'meta';
fetch('/api/v1/share/' + encodeURIComponent(token) + '/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email }),
})
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
.then(function (resp) {
if (resp.status === 200 && resp.body && resp.body.success) {
subscribeStatusEl.textContent = 'You will be notified when this service changes status.';
subscribeStatusEl.className = 'meta status-ok';
subscribeEmailEl.value = '';
} else {
subscribeStatusEl.textContent = (resp.body && resp.body.error) ? resp.body.error.replace(/^\[DC-\d+\]\s*/, '') : 'Subscription failed. Please try again.';
subscribeStatusEl.className = 'meta status-err';
}
})
.catch(function () {
subscribeStatusEl.textContent = 'Network error. Please try again.';
subscribeStatusEl.className = 'meta status-err';
})
.finally(function () { subscribeBtn.disabled = false; });
});
fetchPreview();
})();
</script>
</body>
</html>