Files
Backup-Dashboard/public/js/admin.js
T
kyleandClaude Opus 5.5 d4e821799c Add an admin page for managing collector API keys
Admins (Microsoft 365 sign-in names in BACKUP_DASH_ADMINS) get an
Admin link to /admin.html, where they can create, list and revoke the
API keys collectors use, instead of running manage.py on the server.

Backend
- /api/v1/admin/keys (list, create, revoke), admins only (403
  otherwise); the user comes from the headers nginx sets after sign-in
- Changes need an X-Requested-By header, which another site's page
  can't add, so a signed-in admin's browser can't be used to forge them
- A new key is returned once; only its hash is stored. Names are unique
  for good, so revoked keys' history stays unambiguous
- Keys record who created/revoked them and the company they last
  reported for (schema v2; databases migrate automatically on start)
- /api/v1/me reports is_admin; manage.py shares the key code and
  records itself as creator/revoker

Frontend
- admin.html / admin.js: name validation shown after interaction
  (:user-invalid, aria-invalid kept in sync), the one-time key with a
  copy button (falls back to selecting it if clipboard access is
  refused), a key table flagging keys quiet for over 24 hours, and
  revoking confirmed in a <dialog>; results announced in a status region
- Shared el() and signed-in helpers moved to dom.js / signed-in.js
- Mock mode (?mock=1) includes sample keys

Tested: 15 new backend tests (access, forged requests, names,
duplicates, revoking, last-used company, v1 -> v2 upgrade); in the
browser, every admin flow in mock mode and at phone width; end to end
through the nginx config with the real API (a key created on the API
works for ingest; revoking it makes ingest 401; non-admins see an
explanation and no Admin link).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 13:52:29 -06:00

229 lines
7.2 KiB
JavaScript

// Admin page: create, list and revoke the API keys collectors use.
import { getApi } from './api.js';
import { el } from './dom.js';
import { parseDate, formatDateTime, formatRelative } from './format.js';
import { showSignedInUser } from './signed-in.js';
const api = await getApi();
// A collector runs hourly; an active key quiet for longer than this probably has a problem
const STALE_HOURS = 24;
const $ = (sel) => document.querySelector(sel);
const dom = {
backLink: $('#back-link'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
errorBanner: $('#error-banner'),
notAdmin: $('#not-admin'),
content: $('#admin-content'),
form: $('#new-key-form'),
name: $('#key-name'),
createBtn: $('#create-btn'),
createError: $('#create-error'),
result: $('#new-key-result'),
resultTitle: $('#new-key-result-title'),
resultName: $('#new-key-name'),
secret: $('#new-key-secret'),
copyBtn: $('#copy-btn'),
doneBtn: $('#done-btn'),
keysTitle: $('#keys-title'),
tbody: $('#keys-body'),
empty: $('#keys-empty'),
status: $('#status'),
dialog: $('#revoke-dialog'),
revokeName: $('#revoke-name'),
};
let keys = [];
let pendingRevoke = null;
// ---------- Feedback ----------
// Screen-reader announcement. Cleared first so repeating the same message is announced again.
// (A timer rather than requestAnimationFrame, which doesn't run while the tab is in the background.)
function announce(message) {
dom.status.textContent = '';
setTimeout(() => { dom.status.textContent = message; }, 50);
}
function showError(message) {
dom.errorBanner.textContent = message;
dom.errorBanner.hidden = false;
}
// ---------- Key table ----------
function timeBlock(date, ...extra) {
return el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
el('span', {}, formatDateTime(date)),
el('span', { class: 'time__rel' }, formatRelative(date), ...extra));
}
function statusCell(key) {
if (key.revoked_at) {
return el('td', {},
el('span', { class: 'badge badge--unknown' }, 'Revoked'),
el('span', { class: 'time__rel' }, formatDateTime(parseDate(key.revoked_at)),
key.revoked_by ? ` by ${key.revoked_by}` : null));
}
return el('td', {}, el('span', { class: 'badge badge--success' }, 'Active'));
}
function lastUsedCell(key) {
const lastUsed = parseDate(key.last_used_at);
if (!lastUsed) {
return el('td', {}, el('span', { class: 'muted' }, key.revoked_at ? 'Never' : 'Never: not installed yet?'));
}
const stale = !key.revoked_at && Date.now() - lastUsed.getTime() > STALE_HOURS * 3_600_000;
return el('td', { class: stale ? 'is-stale' : null },
timeBlock(lastUsed, key.last_used_tenant ? ` · ${key.last_used_tenant}` : null),
stale ? el('span', { class: 'stale-note' }, `Quiet for over ${STALE_HOURS} hours`) : null);
}
function createdCell(key) {
const created = parseDate(key.created_at);
return el('td', {},
created ? timeBlock(created) : el('span', { class: 'muted' }, 'Unknown'),
key.created_by ? el('span', { class: 'time__rel' }, `by ${key.created_by}`) : null);
}
function renderKeys() {
dom.tbody.replaceChildren(...keys.map((key) => el('tr', { class: key.revoked_at ? 'is-revoked' : null },
el('td', {}, el('code', {}, key.name)),
statusCell(key),
lastUsedCell(key),
createdCell(key),
el('td', { class: 'actions' }, key.revoked_at ? null : el('button', {
type: 'button',
class: 'btn btn--danger-outline',
'aria-haspopup': 'dialog',
'aria-label': `Revoke ${key.name}`,
dataset: { keyId: String(key.id) },
}, 'Revoke')))));
dom.empty.hidden = keys.length > 0;
}
async function loadKeys() {
try {
({ keys } = await api.listKeys());
dom.errorBanner.hidden = true;
renderKeys();
} catch (err) {
showError(`Couldn't load the keys: ${err.message}`);
}
}
// ---------- Creating a key ----------
// Keep aria-invalid in step with :user-invalid (the browser doesn't sync them)
function syncInvalid() {
dom.name.setAttribute('aria-invalid', dom.name.matches(':user-invalid') ? 'true' : 'false');
}
dom.name.addEventListener('blur', syncInvalid);
dom.name.addEventListener('invalid', syncInvalid); // a submit attempt with a bad name
dom.name.addEventListener('input', () => { if (dom.name.hasAttribute('aria-invalid')) syncInvalid(); });
function showSecret(name, secret) {
dom.resultName.textContent = name;
dom.secret.textContent = secret;
dom.copyBtn.textContent = 'Copy';
dom.result.hidden = false;
dom.resultTitle.focus();
}
function hideSecret() {
dom.result.hidden = true;
dom.secret.textContent = '';
}
// Only fires once the browser's own validation (required, pattern) has passed
dom.form.addEventListener('submit', async (e) => {
e.preventDefault();
dom.createBtn.disabled = true;
dom.createError.hidden = true;
hideSecret();
try {
const { key, secret } = await api.createKey(dom.name.value.trim());
dom.form.reset();
dom.name.removeAttribute('aria-invalid');
showSecret(key.name, secret);
announce(`Key ${key.name} created. Copy it now: it won't be shown again.`);
await loadKeys();
} catch (err) {
dom.createError.textContent = err.message;
dom.createError.hidden = false;
} finally {
dom.createBtn.disabled = false;
}
});
dom.copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(dom.secret.textContent);
dom.copyBtn.textContent = 'Copied';
announce('Key copied to the clipboard.');
} catch {
// Clipboard access can be refused (permissions, non-secure context): select it for Ctrl+C instead
getSelection().selectAllChildren(dom.secret);
dom.copyBtn.textContent = 'Selected: press Ctrl+C';
announce('Copy blocked by the browser. The key is selected: press Ctrl+C to copy it.');
}
});
dom.doneBtn.addEventListener('click', () => {
hideSecret();
dom.name.focus();
});
// ---------- Revoking a key ----------
dom.tbody.addEventListener('click', (e) => {
const button = e.target.closest('button[data-key-id]');
if (!button) return;
pendingRevoke = keys.find((k) => String(k.id) === button.dataset.keyId) ?? null;
if (!pendingRevoke) return;
dom.revokeName.textContent = pendingRevoke.name;
dom.dialog.returnValue = '';
dom.dialog.showModal();
});
// returnValue is the clicked button's value; Esc leaves it empty, which cancels
dom.dialog.addEventListener('close', async () => {
const key = pendingRevoke;
pendingRevoke = null;
if (dom.dialog.returnValue !== 'revoke' || !key) return;
try {
await api.revokeKey(key.id);
announce(`Key ${key.name} revoked.`);
await loadKeys();
} catch (err) {
showError(`Couldn't revoke ${key.name}: ${err.message}`);
}
// Its Revoke button is gone, so focus would otherwise fall back to the page
dom.keysTitle.focus();
});
// ---------- Start ----------
async function main() {
dom.backLink.href = `/${location.search}`; // keeps ?mock=1
let me;
try {
me = await api.getMe();
} catch (err) {
showError(`Couldn't check who's signed in: ${err.message}`);
return;
}
showSignedInUser(me, dom);
if (!me?.is_admin) {
dom.notAdmin.hidden = false;
return;
}
dom.content.hidden = false;
await loadKeys();
}
main();