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:
+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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user