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.
461 lines
18 KiB
JavaScript
461 lines
18 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: 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 };
|
|
})(); |