// 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();