/** * 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 = '

Failed to load admin panel: ' + (e.message || e) + '

'; } } 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 }; })();