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]>
102 lines
3.5 KiB
JavaScript
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;
|
|
} |