Files
Backup-Dashboard/public/js/api.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

102 lines
3.5 KiB
JavaScript

import { CONFIG } from './config.js';
export class ApiError extends Error {
constructor(message, status = null) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
// 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}`, {
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((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');
if (err instanceof ApiError) throw err;
throw new ApiError(`Network error: ${err.message}`);
} finally {
clearTimeout(timer);
}
}
const httpApi = {
/** GET /jobs → { generated_at, jobs: Job[] } */
async listJobs() {
const data = await request('/jobs');
if (!Array.isArray(data?.jobs)) throw new ApiError('Unexpected response shape from /jobs');
return data;
},
/** GET /jobs/:id → JobDetail */
getJob(id) {
return request(`/jobs/${encodeURIComponent(id)}`);
},
/** GET /jobs/:id/runs?days=N → { job_id, since, runs: RunSummary[] } (newest first, no messages) */
listRuns(id, days) {
return request(`/jobs/${encodeURIComponent(id)}/runs?days=${encodeURIComponent(days)}`);
},
/** GET /runs/:id → Run (with message and details) */
getRun(id) {
return request(`/runs/${encodeURIComponent(id)}`);
},
/** 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.
export async function getApi() {
if (CONFIG.useMock) {
const { mockApi } = await import('./mock.js');
return mockApi;
}
return httpApi;
}