DC-048: multi-user bootstrap + admin invites (opt-in)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Implements the user-store + invite-store + admin routes. The whole
system is opt-in via siteConfig.authProviders.email.enabled = true;
single-user TOTP-only installs see zero behavior change.

Backend:
- src/security/user-store.js: users + allowlist + bootstrap sentinel,
  atomic writes, last-admin protection, defensive dataDir resolver.
- src/security/invite-store.js: single-use tokens (SHA-256 hashed on
  disk), TTL, auto-prune, defensive dataDir resolver.
- routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist,
  /admin/invites (CRUD), public /invites/:token (peek + accept).
- routes/auth/index.js: wires userStore, gates admin router on
  email auth being enabled.
- src/auth/providers/email.js: verify() enforces allowlist, creates
  user record, tags req.user; default-enabled flipped to opt-in.
- src/auth/providers/totp.js: bootstraps system@totp.local admin on
  first verify so current DNS2 operator shows in /admin/users.
- src/security/audit-logger.js: middleware adds userId/userEmail/
  userRole/viaProvider to log details when req.user is tagged.
- PUBLIC_ROUTES + CSRF allowlists updated for invite redemption.

Frontend:
- status/js/admin.js: modal overlay with users list (role-edit,
  delete), invite form (email/role/TTL), copy-link button,
  outstanding-invites list with revoke. Exports window.AdminPanel.
- status/js/core/init.js: calls AdminPanel.attachTrigger so the
  Admin button only appears when /me returns isAdmin=true.

Tests: 35 new tests across 3 files (user-store, invite-store, auth
multistore integration). Full suite: 1298/1298 passing.

Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased]
section gets the DC-048 entry.
This commit is contained in:
hermes
2026-07-20 17:44:11 -07:00
parent bd480a69a7
commit 321334cd33
23 changed files with 2964 additions and 325 deletions
+461
View File
@@ -0,0 +1,461 @@
/**
* 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: true }),
_el('span', { text: 'Send email' }),
));
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);
if (invite.deliveredVia === 'dev-console') {
banner.appendChild(_el('p', {
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
}));
} 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 + '.',
}));
}
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 };
})();