Files
dashcaddy/status/js/admin.js
T
Hermes d8459a4a87 DC-085 link-first invite — Discord-style share it however you want
Flip POST /api/v1/auth/admin/invites default to no email; always return
the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/
Discord/paste-in-email. Email becomes an opt-in checkbox (was the default).
Add shareText field with pre-formatted message for one-tap paste. Stop
logging raw invite URLs to error.log when SMTP is unconfigured (was just
a dev fallback — link is now in the response). Frontend flips the
checkbox default to unchecked and renders shareText + native share sheet
button (navigator.share) alongside the raw copy-link button. 9 new tests
covering default-no-send, link-always-returned, shareText-shape, opt-in
SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
2026-08-20 04:46:12 -07:00

522 lines
21 KiB
JavaScript

/**
* Admin panel — DC-048.
*
* Minimal admin UI for managing users + invites. Rendered as a modal overlay
* triggered by an "Admin" button in the top bar that only appears when
* /api/v1/auth/me returns isAdmin=true. The panel renders three sections:
*
* 1. Users — list of authorized users with role badges, role-edit,
* delete actions.
* 2. Invite a user — form to issue a single-use invite (email, role,
* TTL). The accept-link is shown post-issue so the admin can copy it.
* 3. Outstanding invites — list of issued-not-yet-accepted invites
* with a revoke button.
*
* The panel does NOT add a tab to the dashboard nav — it lives as a modal
* to keep DC-048 surgical. Future DC-049 work can promote it to a tab.
*
* Behaviour:
* - On load: GET /me; if !isAdmin → show "admin only" placeholder
* - Then GET /admin/users + /admin/invites in parallel
* - Forms POST to the admin endpoints, refresh lists on success
* - "Copy link" button writes the acceptUrl to the clipboard
*
* Wires into the global error-handler (window.errorHandler) for failure
* surfaces. Uses window.SITE for any UI constants (none currently).
*/
(function () {
'use strict';
const API = {
me: '/api/v1/auth/me',
users: '/api/v1/auth/admin/users',
allowlist: '/api/v1/auth/admin/allowlist',
invites: '/api/v1/auth/admin/invites',
};
function _el(tag, attrs, ...children) {
const node = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v === null || v === undefined || v === false) continue;
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else if (k === 'html') node.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else node.setAttribute(k, v);
}
}
for (const c of children) {
if (c === null || c === undefined || c === false) continue;
if (typeof c === 'string') node.appendChild(document.createTextNode(c));
else node.appendChild(c);
}
return node;
}
async function _fetchJSON(url, opts) {
const csrf = (window.SITE && window.SITE.csrfToken) || '';
opts = opts || {};
opts.headers = Object.assign(
{ 'Content-Type': 'application/json' },
opts.headers || {},
csrf ? { 'X-CSRF-Token': csrf } : {}
);
if (opts.body && typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body);
const r = await fetch(url, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = (data && (data.message || data.error)) || ('HTTP ' + r.status);
const err = new Error(msg);
err.status = r.status;
throw err;
}
return data;
}
function _renderBadge(role) {
const colors = {
admin: 'background:#7c3aed;color:#fff',
operator: 'background:#2563eb;color:#fff',
viewer: 'background:#6b7280;color:#fff',
};
return _el('span', {
class: 'role-badge',
style: 'display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;' +
(colors[role] || colors.viewer),
text: role,
});
}
function _renderUsersList(container, users, onChange) {
container.innerHTML = '';
if (!users || users.length === 0) {
container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No users yet.' }));
return;
}
const table = _el('table', {
style: 'width:100%;border-collapse:collapse;font-size:0.9rem',
});
table.appendChild(_el('thead', null,
_el('tr', { style: 'border-bottom:1px solid var(--border)' },
_el('th', { style: 'text-align:left;padding:8px', text: 'Email' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Role' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Created' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Last login' }),
_el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }),
),
));
const tbody = _el('tbody');
for (const u of users) {
const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' });
const emailCell = _el('td', { style: 'padding:8px' });
emailCell.appendChild(_el('span', { text: u.email || '(no email)' }));
if (u.displayName && u.displayName !== (u.email || '').split('@')[0]) {
emailCell.appendChild(_el('br'));
emailCell.appendChild(_el('small', {
style: 'color:var(--muted)', text: u.displayName,
}));
}
row.appendChild(emailCell);
const roleCell = _el('td', { style: 'padding:8px' });
roleCell.appendChild(_renderBadge(u.role));
row.appendChild(roleCell);
row.appendChild(_el('td', {
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
text: u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—',
}));
row.appendChild(_el('td', {
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
text: u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '—',
}));
const actionsCell = _el('td', { style: 'padding:8px;text-align:right' });
const roleSelect = _el('select', {
style: 'padding:2px 6px;margin-right:6px',
onchange: async (ev) => {
try {
await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), {
method: 'PATCH', body: { role: ev.target.value },
});
onChange && onChange();
} catch (e) {
window.errorHandler && window.errorHandler.show('Role update failed: ' + e.message);
ev.target.value = u.role;
}
},
});
for (const r of ['admin', 'operator', 'viewer']) {
const opt = _el('option', { value: r, text: r });
if (r === u.role) opt.selected = true;
roleSelect.appendChild(opt);
}
actionsCell.appendChild(roleSelect);
const delBtn = _el('button', {
class: 'btn-sm', style: 'padding:2px 8px',
text: 'Delete',
onclick: async () => {
if (!confirm('Delete user ' + (u.email || u.id) + '? This cannot be undone.')) return;
try {
await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), { method: 'DELETE' });
onChange && onChange();
} catch (e) {
window.errorHandler && window.errorHandler.show('Delete failed: ' + e.message);
}
},
});
actionsCell.appendChild(delBtn);
row.appendChild(actionsCell);
tbody.appendChild(row);
}
table.appendChild(tbody);
container.appendChild(table);
}
function _renderInviteForm(container, onIssued) {
const form = _el('form', {
style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:end',
onsubmit: async (ev) => {
ev.preventDefault();
const fd = new FormData(ev.target);
const body = {
email: fd.get('email'),
role: fd.get('role'),
ttlHours: parseInt(fd.get('ttlHours'), 10) || 24,
sendEmail: fd.get('sendEmail') === 'on',
};
try {
const r = await _fetchJSON(API.invites, { method: 'POST', body });
ev.target.reset();
onIssued && onIssued(r);
} catch (e) {
window.errorHandler && window.errorHandler.show('Invite failed: ' + e.message);
}
},
});
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
_el('span', { text: 'Email' }),
_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }),
));
const roleSel = _el('select', { name: 'role', style: 'padding:6px' });
for (const r of ['operator', 'viewer', 'admin']) {
roleSel.appendChild(_el('option', { value: r, text: r }));
}
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
_el('span', { text: 'Role' }), roleSel,
));
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
_el('span', { text: 'TTL (hours)' }),
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
));
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
_el('input', { name: 'sendEmail', type: 'checkbox', checked: false }),
_el('span', { text: 'Also send via email (optional)' }),
));
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
container.appendChild(form);
}
function _renderInvitesList(container, invites, onChange) {
container.innerHTML = '';
if (!invites || invites.length === 0) {
container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No outstanding invites.' }));
return;
}
const table = _el('table', {
style: 'width:100%;border-collapse:collapse;font-size:0.9rem',
});
table.appendChild(_el('thead', null,
_el('tr', { style: 'border-bottom:1px solid var(--border)' },
_el('th', { style: 'text-align:left;padding:8px', text: 'Email' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Role' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Invited by' }),
_el('th', { style: 'text-align:left;padding:8px', text: 'Expires' }),
_el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }),
),
));
const tbody = _el('tbody');
for (const inv of invites) {
const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' });
row.appendChild(_el('td', { style: 'padding:8px', text: inv.email }));
row.appendChild(_el('td', { style: 'padding:8px' }, _renderBadge(inv.role)));
row.appendChild(_el('td', { style: 'padding:8px;color:var(--muted)', text: inv.invitedBy || '—' }));
row.appendChild(_el('td', {
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
text: inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : '—',
}));
const actionsCell = _el('td', { style: 'padding:8px;text-align:right' });
actionsCell.appendChild(_el('button', {
class: 'btn-sm', style: 'padding:2px 8px',
text: 'Revoke',
onclick: async () => {
if (!confirm('Revoke invite for ' + inv.email + '?')) return;
try {
await _fetchJSON(API.invites + '/' + encodeURIComponent(inv.id), { method: 'DELETE' });
onChange && onChange();
} catch (e) {
window.errorHandler && window.errorHandler.show('Revoke failed: ' + e.message);
}
},
}));
row.appendChild(actionsCell);
tbody.appendChild(row);
}
table.appendChild(tbody);
container.appendChild(table);
}
function _renderIssuedInviteBanner(invite, parent) {
const banner = _el('div', {
style: 'margin-top:12px;padding:12px;border:1px solid #16a34a;border-radius:6px;background:#052e1a;color:#bbf7d0;font-size:0.85rem',
});
banner.appendChild(_el('strong', { text: 'Invite issued — copy the link below. ' +
'It will not be shown again.' }));
banner.appendChild(_el('br'));
banner.appendChild(_el('code', {
style: 'display:block;margin-top:8px;padding:8px;background:#000;border-radius:4px;word-break:break-all;color:#d1fae5',
text: invite.acceptUrl,
}));
const copyBtn = _el('button', {
class: 'btn-sm', style: 'margin-top:8px;padding:4px 10px',
text: 'Copy link',
onclick: async () => {
try {
await navigator.clipboard.writeText(invite.acceptUrl);
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy link'; }, 2000);
} catch (e) {
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the link manually.');
}
},
});
banner.appendChild(copyBtn);
// DC-085: pre-formatted message for one-tap paste into iMessage / WhatsApp /
// Telegram / SMS / Signal / Discord / paste-into-email. The operator can
// copy this as a sentence instead of dealing with the raw URL.
if (invite.shareText) {
const shareBlock = _el('div', { style: 'margin-top:12px' });
shareBlock.appendChild(_el('div', {
style: 'font-size:0.8rem;color:#86efac;margin-bottom:4px',
text: 'Share this message:',
}));
shareBlock.appendChild(_el('div', {
style: 'padding:8px;background:#000;border-radius:4px;color:#d1fae5;white-space:pre-wrap',
text: invite.shareText,
}));
const shareActions = _el('div', { style: 'margin-top:6px;display:flex;gap:6px;flex-wrap:wrap' });
const copyTextBtn = _el('button', {
class: 'btn-sm', style: 'padding:4px 10px',
text: 'Copy message',
onclick: async () => {
try {
await navigator.clipboard.writeText(invite.shareText);
copyTextBtn.textContent = 'Copied!';
setTimeout(() => { copyTextBtn.textContent = 'Copy message'; }, 2000);
} catch (e) {
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the text manually.');
}
},
});
shareActions.appendChild(copyTextBtn);
// Native share sheet on mobile / supported browsers. Falls back silently
// (the copy buttons cover the same intent).
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
const nativeShareBtn = _el('button', {
class: 'btn-sm', style: 'padding:4px 10px',
text: 'Share via…',
onclick: async () => {
try {
await navigator.share({
title: 'DashCaddy invite',
text: invite.shareText,
url: invite.acceptUrl,
});
} catch (e) {
// User-cancelled throws AbortError — that's fine, just stay quiet.
if (e && e.name && e.name !== 'AbortError') {
window.errorHandler && window.errorHandler.show('Share failed: ' + e.message);
}
}
},
});
shareActions.appendChild(nativeShareBtn);
}
shareBlock.appendChild(shareActions);
banner.appendChild(shareBlock);
}
if (invite.deliveredVia === 'failed') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
text: 'Email could not be sent (SMTP not configured). Share the link above instead — it works the same way.',
}));
} else if (invite.deliveredVia === 'email') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
text: 'Email sent to ' + invite.email + '.',
}));
} else if (invite.deliveredVia === 'manual') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
text: 'Share the link above via text, chat, or any messenger.',
}));
}
parent.appendChild(banner);
}
/**
* Mount the admin panel. Called by the open trigger; safe to call multiple
* times (re-renders into the same container).
*/
async function mount(container) {
container.innerHTML = '';
container.appendChild(_el('h2', { style: 'margin:0 0 16px', text: 'Admin · Users & Invites' }));
const meData = await _fetchJSON(API.me).catch(() => ({}));
if (!meData || !meData.user || meData.user.role !== 'admin') {
container.appendChild(_el('p', {
style: 'color:var(--muted)',
text: 'Admin role required to view this panel. If multi-user mode is enabled and you should have access, check /api/v1/auth/me.',
}));
return;
}
// Refresh button
const refreshBtn = _el('button', {
class: 'btn-sm', style: 'float:right;padding:4px 10px',
text: 'Refresh',
onclick: () => mount(container),
});
container.appendChild(refreshBtn);
// ── Users section ────────────────────────────────────────────────────
const usersHeader = _el('h3', { style: 'margin:24px 0 8px;clear:both', text: 'Users' });
container.appendChild(usersHeader);
const usersList = _el('div', { id: 'admin-users-list' });
container.appendChild(usersList);
const usersData = await _fetchJSON(API.users).catch(() => ({ users: [] }));
_renderUsersList(usersList, usersData.users, () => mount(container));
// Add user form (pre-authorize an email without issuing an invite).
container.appendChild(_el('h4', { style: 'margin:24px 0 8px;font-size:0.95rem', text: 'Pre-authorize email' }));
const addUserForm = _el('form', {
style: 'display:flex;gap:8px;align-items:end',
onsubmit: async (ev) => {
ev.preventDefault();
const email = ev.target.email.value.trim();
if (!email) return;
try {
await _fetchJSON(API.users, { method: 'POST', body: { email } });
ev.target.reset();
mount(container);
} catch (e) {
window.errorHandler && window.errorHandler.show('Add failed: ' + e.message);
}
},
});
addUserForm.appendChild(_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }));
addUserForm.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Add to allowlist' }));
container.appendChild(addUserForm);
// ── Invites section ──────────────────────────────────────────────────
container.appendChild(_el('h3', { style: 'margin:24px 0 8px', text: 'Issue invite' }));
const inviteFormContainer = _el('div');
container.appendChild(inviteFormContainer);
const invitesList = _el('div', { id: 'admin-invites-list', style: 'margin-top:16px' });
container.appendChild(invitesList);
const invitesData = await _fetchJSON(API.invites).catch(() => ({ invites: [] }));
_renderInvitesList(invitesList, invitesData.invites, () => mount(container));
_renderInviteForm(inviteFormContainer, (issued) => {
_renderIssuedInviteBanner(issued, inviteFormContainer);
mount(container);
});
}
// ── Public API ────────────────────────────────────────────────────────
/**
* Open the admin panel as a modal overlay. Closes on backdrop click or
* the close button. Renders into document.body so it floats above the
* dashboard chrome.
*/
async function open() {
// If already open, just focus.
const existing = document.getElementById('admin-panel-root');
if (existing) return;
const backdrop = _el('div', {
id: 'admin-panel-root',
style: 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;',
onclick: (ev) => {
if (ev.target === backdrop) close();
},
});
const card = _el('div', {
style: 'background:var(--card-base,#1f2937);color:var(--text,#f3f4f6);border-radius:8px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;box-shadow:0 10px 30px rgba(0,0,0,0.3)',
});
card.appendChild(_el('button', {
class: 'btn-sm', style: 'position:absolute;top:12px;right:12px;padding:4px 10px',
text: 'Close', onclick: close,
}));
const body = _el('div', { id: 'admin-panel-body' });
card.appendChild(body);
backdrop.appendChild(card);
document.body.appendChild(backdrop);
try {
await mount(body);
} catch (e) {
body.innerHTML = '<p style="color:#f87171">Failed to load admin panel: ' + (e.message || e) + '</p>';
}
}
function close() {
const existing = document.getElementById('admin-panel-root');
if (existing) existing.remove();
}
/**
* Inject an "Admin" button into the top bar. Only renders when the
* current /me response says isAdmin=true. Re-checks periodically
* (every 60s) so a permission downgrade takes effect without a reload.
*/
async function attachTrigger(barContainer) {
async function _maybeShow() {
const me = await _fetchJSON(API.me).catch(() => ({}));
const existing = document.getElementById('admin-trigger-btn');
if (me && me.user && me.user.role === 'admin') {
if (existing) return;
const btn = _el('button', {
id: 'admin-trigger-btn',
class: 'btn-sm',
style: 'margin-left:8px;padding:6px 12px',
text: 'Admin',
onclick: open,
});
if (barContainer) barContainer.appendChild(btn);
else if (document.body) document.body.appendChild(btn);
} else if (existing) {
existing.remove();
}
}
await _maybeShow();
setInterval(_maybeShow, 60_000);
}
window.AdminPanel = { open, close, attachTrigger };
})();