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:
2026-10-08 13:52:29 -06:00
co-authored by Claude Opus 5.5
parent c5fe69a813
commit d4e821799c
24 changed files with 887 additions and 69 deletions
+103
View File
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API keys · Backup Monitor</title>
<link rel="stylesheet" href="/css/styles.css">
<script type="module" src="/js/admin.js"></script>
</head>
<body>
<header class="topbar">
<div class="topbar__nav">
<a id="back-link" class="btn" href="/">← Dashboard</a>
<h1 class="topbar__title">API keys</h1>
</div>
<div class="topbar__meta">
<span id="signed-in" class="topbar__user" hidden>
<span id="signed-in-email" class="muted"></span>
<a id="sign-out" class="btn" href="#">Sign out</a>
</span>
</div>
</header>
<main class="container container--narrow">
<div id="error-banner" class="banner banner--error" role="alert" hidden></div>
<p id="not-admin" class="banner banner--info" hidden>
Your account isn't an admin. Admins are the sign-in names listed in <code>BACKUP_DASH_ADMINS</code>
in the server's settings file.
</p>
<div id="admin-content" hidden>
<section class="panel" aria-labelledby="new-key-title">
<h2 id="new-key-title" class="panel__title">New key</h2>
<p class="muted">Create one key per collector install, so each can be revoked on its own.</p>
<form id="new-key-form" class="new-key">
<div class="field">
<label for="key-name">Name</label>
<span id="key-name-hint" class="hint">For example <code>veeam-acme</code> or <code>mars-acme-fs01</code>:
letters, digits, <code>.</code>, <code>_</code> and <code>-</code>.</span>
<input id="key-name" name="name" class="input" required maxlength="64"
pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" autocomplete="off" spellcheck="false"
aria-describedby="key-name-hint" aria-errormessage="key-name-error">
<div id="key-name-error" class="error-msg">
Use up to 64 letters, digits, <code>.</code>, <code>_</code> or <code>-</code>, starting with a letter or digit.
</div>
</div>
<button id="create-btn" class="btn btn--primary" type="submit">Create key</button>
<p id="create-error" class="form-error" role="alert" hidden></p>
</form>
<div id="new-key-result" class="secret" hidden>
<h3 id="new-key-result-title" class="secret__title" tabindex="-1">Copy the key for <span id="new-key-name"></span> now</h3>
<p>It won't be shown again: the dashboard only keeps a hash of it. Paste it when the collector's
installer asks for the API key.</p>
<div class="secret__row">
<code id="new-key-secret" class="secret__value"></code>
<button id="copy-btn" class="btn" type="button">Copy</button>
</div>
<button id="done-btn" class="btn" type="button">Done, hide it</button>
</div>
</section>
<section aria-labelledby="keys-title">
<h2 id="keys-title" class="panel__title" tabindex="-1">Keys</h2>
<div class="table-wrap">
<table class="jobs keys">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Status</th>
<th scope="col">Last used</th>
<th scope="col">Created</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="keys-body"></tbody>
</table>
<p id="keys-empty" class="empty" hidden>No keys yet. Create one above for each collector.</p>
</div>
</section>
</div>
<!-- Announces results (created, copied, revoked) to screen readers -->
<p id="status" class="visually-hidden" role="status"></p>
</main>
<dialog id="revoke-dialog" class="modal modal--small" aria-labelledby="revoke-title">
<form method="dialog">
<div class="modal__header">
<h2 id="revoke-title" class="modal__title">Revoke <span id="revoke-name"></span>?</h2>
</div>
<div class="modal__body">
<p>Collectors using this key will be refused from their next run. This can't be undone: to
reconnect them, create a new key and re-run their installer.</p>
<div class="dialog-actions">
<button class="btn" value="cancel" autofocus>Cancel</button>
<button class="btn btn--danger" value="revoke">Revoke key</button>
</div>
</div>
</form>
</dialog>
</body>
</html>
+78 -1
View File
@@ -344,4 +344,81 @@ button.badge:hover { filter: brightness(0.95); }
}
.runs tbody tr:last-child td { border-bottom: none; }
.runs tr.is-selected td { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; }
.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; }
/* ---------- Admin page ---------- */
.container--narrow { max-width: 1100px; }
.topbar__nav { display: flex; align-items: center; gap: 1rem; }
.banner--info { background: var(--surface-alt); color: var(--text); border-color: var(--border); }
.panel {
margin-bottom: 1.5rem;
padding: 1rem 1.25rem 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.panel__title { font-size: 1.05rem; margin: 0 0 0.25rem; }
section > .panel__title { margin: 0 0 0.75rem; }
.panel__title:focus { outline: none; } /* focused from script after a change, not by the user */
.panel p { margin: 0 0 0.75rem; }
code {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.9em;
overflow-wrap: anywhere;
}
/* New key form: label, hint above the input (so autocomplete can't cover it), error below */
.new-key { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.75rem; }
.field { display: flex; flex-direction: column; gap: 0.25rem; flex: 1 1 22rem; max-width: 30rem; }
.field label { font-weight: 600; }
.hint { font-size: 0.85rem; color: var(--muted); }
.field .input { width: 100%; }
.error-msg { display: none; font-size: 0.85rem; color: var(--failed-fg); }
/* Only after the user has interacted with the field (or tried to submit) */
.field .input:user-invalid { border-color: var(--failed-bd); background: var(--failed-bg); }
.field .input:user-invalid + .error-msg { display: block; }
.form-error { flex-basis: 100%; margin: 0; color: var(--failed-fg); }
.btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.btn--primary:hover { filter: brightness(1.08); border-color: var(--accent); }
.btn--danger { background: var(--failed-bd); border-color: var(--failed-bd); color: #fff; font-weight: 600; }
.btn--danger-outline { color: var(--failed-fg); border-color: var(--failed-bd); background: transparent; }
.btn--danger-outline:hover { background: var(--failed-bg); border-color: var(--failed-bd); }
/* The one-time key */
.secret {
margin-top: 1rem;
padding: 1rem;
border: 1px solid var(--warning-bd);
border-left-width: 4px;
border-radius: 6px;
background: var(--warning-bg);
}
.secret__title { margin: 0 0 0.5rem; font-size: 1rem; }
.secret__title:focus { outline: none; }
.secret__row { display: flex; gap: 0.5rem; align-items: stretch; margin-bottom: 0.75rem; flex-wrap: wrap; }
.secret__value {
flex: 1 1 20rem;
padding: 0.45rem 0.75rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
user-select: all; /* one click selects the whole key */
}
/* Key table */
.keys td { vertical-align: top; }
.keys td .badge { margin-bottom: 0.25rem; }
.keys td > .time__rel { display: block; }
.keys .actions { text-align: right; width: 1%; }
.keys tr.is-revoked td { color: var(--muted); }
.keys tr.is-revoked code { text-decoration: line-through; }
.stale-note { display: block; font-size: 0.8rem; font-weight: 600; color: var(--warning-fg); }
/* Confirm dialog */
.modal--small { width: min(460px, calc(100vw - 2rem)); }
.modal--small .modal__body p { margin-top: 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
+1
View File
@@ -14,6 +14,7 @@
<div class="topbar__meta">
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
<button id="refresh-btn" class="btn" type="button">Refresh</button>
<a id="admin-link" class="btn" href="/admin.html" hidden>Admin</a>
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
<span id="signed-in" class="topbar__user" hidden>
<span id="signed-in-email" class="muted"></span>
+228
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+12
View File
@@ -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
View File
@@ -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 };
},
};
+18
View File
@@ -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;
}
}