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]>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// 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();
|
||||
+42
-5
@@ -8,18 +8,41 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path) {
|
||||
// The API's explanation from an error response: FastAPI's {"detail": "..."}, or for validation
|
||||
// errors {"detail": [{"msg": "..."}, ...]}
|
||||
async function errorDetail(res) {
|
||||
try {
|
||||
const { detail } = await res.json();
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) return detail.map((d) => d.msg).filter(Boolean).join('; ');
|
||||
} catch {
|
||||
// not JSON: fall back to the status code
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (method !== 'GET') {
|
||||
// Required by the API for changes: another site's page can't add it to a request here
|
||||
headers['X-Requested-By'] = 'backup-dashboard';
|
||||
}
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
try {
|
||||
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin', // ready for cookie/session auth behind nginx
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
credentials: 'same-origin', // the sign-in session cookie
|
||||
signal: controller.signal,
|
||||
});
|
||||
// 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in
|
||||
if (res.status === 401) throw new ApiError('Your session has expired. Reload the page to sign in again', 401);
|
||||
if (!res.ok) throw new ApiError(`Server returned HTTP ${res.status}`, res.status);
|
||||
if (!res.ok) {
|
||||
throw new ApiError((await errorDetail(res)) ?? `Server returned HTTP ${res.status}`, res.status);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') throw new ApiError('Request timed out');
|
||||
@@ -49,10 +72,24 @@ const httpApi = {
|
||||
getRun(id) {
|
||||
return request(`/runs/${encodeURIComponent(id)}`);
|
||||
},
|
||||
/** GET /me → { email, name } of the Microsoft 365 user, both null without sign-in */
|
||||
/** GET /me → { email, name, is_admin } of the Microsoft 365 user; email/name null without sign-in */
|
||||
getMe() {
|
||||
return request('/me');
|
||||
},
|
||||
|
||||
// ----- Admin (403 for non-admins) -----
|
||||
/** GET /admin/keys → { keys: ApiKey[] } (active first) */
|
||||
listKeys() {
|
||||
return request('/admin/keys');
|
||||
},
|
||||
/** POST /admin/keys → { key: ApiKey, secret } — the secret is only ever returned here */
|
||||
createKey(name) {
|
||||
return request('/admin/keys', { method: 'POST', body: { name } });
|
||||
},
|
||||
/** POST /admin/keys/:id/revoke → { key: ApiKey } */
|
||||
revokeKey(id) {
|
||||
return request(`/admin/keys/${encodeURIComponent(id)}/revoke`, { method: 'POST' });
|
||||
},
|
||||
};
|
||||
|
||||
// Mock is loaded lazily so it never ships in the production request path.
|
||||
|
||||
+7
-28
@@ -1,5 +1,7 @@
|
||||
import { CONFIG } from './config.js';
|
||||
import { getApi } from './api.js';
|
||||
import { el } from './dom.js';
|
||||
import { showSignedInUser } from './signed-in.js';
|
||||
import {
|
||||
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
||||
} from './format.js';
|
||||
@@ -59,6 +61,7 @@ const dom = {
|
||||
signedIn: $('#signed-in'),
|
||||
signedInEmail: $('#signed-in-email'),
|
||||
signOut: $('#sign-out'),
|
||||
adminLink: $('#admin-link'),
|
||||
lastUpdated: $('#last-updated'),
|
||||
errorBanner: $('#error-banner'),
|
||||
dialog: $('#detail-dialog'),
|
||||
@@ -67,19 +70,6 @@ const dom = {
|
||||
detailBody: $('#detail-body'),
|
||||
};
|
||||
|
||||
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
|
||||
function el(tag, props = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') node.className = v;
|
||||
else if (k === 'dataset') Object.assign(node.dataset, v);
|
||||
else node.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
node.append(...children.flat().filter((c) => c != null && c !== false));
|
||||
return node;
|
||||
}
|
||||
|
||||
// ---------- Rendering ----------
|
||||
function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
|
||||
const { label, icon } = STATUS[status];
|
||||
@@ -527,20 +517,9 @@ dom.search.addEventListener('input', () => {
|
||||
|
||||
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
||||
|
||||
// ---------- Signed-in user ----------
|
||||
async function showSignedInUser() {
|
||||
try {
|
||||
const me = await api.getMe();
|
||||
const who = me?.email || me?.name;
|
||||
if (!who) return; // no Microsoft 365 sign-in in front of the dashboard (dev, mock)
|
||||
dom.signedInEmail.textContent = who;
|
||||
dom.signOut.href = CONFIG.signOutUrl;
|
||||
dom.signedIn.hidden = false;
|
||||
} catch (err) {
|
||||
console.error(err); // not worth a banner: the jobs request will surface real problems
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Boot ----------
|
||||
showSignedInUser();
|
||||
api.getMe().then(
|
||||
(me) => showSignedInUser(me, dom),
|
||||
(err) => console.error(err), // not worth a banner: the jobs request will surface real problems
|
||||
);
|
||||
refresh().then(schedulePoll);
|
||||
@@ -0,0 +1,12 @@
|
||||
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
|
||||
export function el(tag, props = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') node.className = v;
|
||||
else if (k === 'dataset') Object.assign(node.dataset, v);
|
||||
else node.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
node.append(...children.flat().filter((c) => c != null && c !== false));
|
||||
return node;
|
||||
}
|
||||
+38
-1
@@ -96,6 +96,18 @@ for (const job of JOBS) {
|
||||
}
|
||||
|
||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Sample API keys for the admin page: one healthy, one gone quiet, one never used, one revoked
|
||||
const KEYS = [
|
||||
{ id: 1, name: 'veeam-acme', created_at: iso(-40 * DAY), created_by: '[email protected]',
|
||||
last_used_at: iso(-12 * MIN), last_used_tenant: 'Acme Corp', revoked_at: null, revoked_by: null },
|
||||
{ id: 2, name: 'mars-bluewater-fs01', created_at: iso(-20 * DAY), created_by: 'manage.py (backupdash)',
|
||||
last_used_at: iso(-3 * DAY), last_used_tenant: 'Bluewater Dental', revoked_at: null, revoked_by: null },
|
||||
{ id: 3, name: 'wsb-northfield-dc01', created_at: iso(-2 * HR), created_by: '[email protected]',
|
||||
last_used_at: null, last_used_tenant: null, revoked_at: null, revoked_by: null },
|
||||
{ id: 4, name: 'veeam-old-server', created_at: iso(-90 * DAY), created_by: 'manage.py (backupdash)',
|
||||
last_used_at: iso(-31 * DAY), last_used_tenant: 'Acme Corp', revoked_at: iso(-30 * DAY), revoked_by: '[email protected]' },
|
||||
];
|
||||
const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest;
|
||||
|
||||
export const mockApi = {
|
||||
@@ -127,6 +139,31 @@ export const mockApi = {
|
||||
throw new Error('Run not found');
|
||||
},
|
||||
async getMe() {
|
||||
return { email: null, name: null }; // no sign-in in mock mode
|
||||
// No sign-in in mock mode, but show the admin pages so they can be tried out
|
||||
return { email: null, name: null, is_admin: true };
|
||||
},
|
||||
|
||||
async listKeys() {
|
||||
await delay(200);
|
||||
return { keys: [...KEYS].sort((a, b) => (!!a.revoked_at - !!b.revoked_at) || a.name.localeCompare(b.name)) };
|
||||
},
|
||||
async createKey(name) {
|
||||
await delay(300);
|
||||
if (KEYS.some((k) => k.name === name)) {
|
||||
throw new Error(`A key named '${name}' already exists (revoked keys keep their names)`);
|
||||
}
|
||||
const key = { id: KEYS.length + 1, name, created_at: new Date().toISOString(), created_by: '[email protected]',
|
||||
last_used_at: null, last_used_tenant: null, revoked_at: null, revoked_by: null };
|
||||
KEYS.push(key);
|
||||
const random = crypto.getRandomValues(new Uint8Array(32));
|
||||
return { key, secret: `bdk_mock_${btoa(String.fromCharCode(...random)).replace(/[+/=]/g, '')}` };
|
||||
},
|
||||
async revokeKey(id) {
|
||||
await delay(200);
|
||||
const key = KEYS.find((k) => k.id === id);
|
||||
if (!key) throw new Error('Key not found');
|
||||
key.revoked_at ??= new Date().toISOString();
|
||||
key.revoked_by ??= '[email protected]';
|
||||
return { key };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CONFIG } from './config.js';
|
||||
|
||||
/**
|
||||
* Fills in the top bar's "signed in as … / Sign out" from /api/v1/me, and shows the Admin link
|
||||
* to admins. Pass the elements that exist on the page; missing ones are skipped.
|
||||
*/
|
||||
export function showSignedInUser(me, { signedIn, signedInEmail, signOut, adminLink } = {}) {
|
||||
const who = me?.email || me?.name;
|
||||
if (who && signedIn) { // nothing to show without Microsoft 365 sign-in (dev, mock)
|
||||
signedInEmail.textContent = who;
|
||||
signOut.href = CONFIG.signOutUrl;
|
||||
signedIn.hidden = false;
|
||||
}
|
||||
if (me?.is_admin && adminLink) {
|
||||
adminLink.href = `/admin.html${location.search}`; // keeps ?mock=1
|
||||
adminLink.hidden = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user