// Administration: the teams on this server, the people who can sign in, and // the settings that change how the server behaves. // // Each of those three is a route of its own, reached from a strip across the // top, with /admin itself an overview. They used to be three cards stacked on // one page, which meant no way to link to the settings, no way back to the top // of the user list but scrolling, and a poll that refetched all three endpoints // however little of the page you were looking at. // // Only rendered for a system administrator. The server enforces that on every // endpoint regardless — hiding a section is a courtesy to the reader, not a // permission — so this view simply says so rather than pretending to be a // gate. import * as api from './api.js'; import { h, clear, spinner, confirm } from './ui.js'; import { state, myID } from './state.js'; const view = () => document.getElementById('view-admin'); // The sub-sections, in the order the strip shows them. The overview is /admin // itself, so it has no tab of its own. This table is the only place the four // routes are written down: app.js parses against it and the strip is built // from it, so adding a fifth is one line here. export const TABS = [ { tab: null, path: '/admin', label: 'Overview' }, { tab: 'teams', path: '/admin/teams', label: 'Teams' }, { tab: 'users', path: '/admin/users', label: 'Users' }, { tab: 'settings', path: '/admin/settings', label: 'Settings' }, ]; // Which sub-section is open. Remembered rather than passed, because the poll // loop calls refresh() with no route — the same reason adminuser.js keeps its // user ID in the module. let tab = null; let data = null; // whatever the current tab needs; the shape varies by tab let error = null; let busy = false; export function show(route) { const next = route?.tab ?? null; // A different sub-section wants different data, so the old answer goes // rather than being shown under the new heading until the fetch lands. if (next !== tab) { tab = next; data = null; } if (!data) clear(view(), subnav(), spinner()); refresh(); } export async function refresh() { if (!state.me?.user?.is_admin) { data = null; render(); return; } try { data = await load(); error = null; } catch (err) { error = err.message; } render(); } // Only what the open sub-section shows. Users is the one that needs two: its // invite form has to offer a team to invite somebody into, and the overview // counts both. async function load() { if (tab === 'teams') return { teams: await api.adminTeams() }; if (tab === 'settings') return { settings: await api.adminSettings() }; const [teams, users] = await Promise.all([api.adminTeams(), api.users()]); return { teams, users }; } function render() { if (!state.me?.user?.is_admin) { clear(view(), h('div', { class: 'card' }, h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' }))); return; } if (!data) { clear(view(), subnav(), error ? h('div', { class: 'load-error', text: error }) : spinner()); return; } clear(view(), subnav(), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), section(), ); } function section() { if (tab === 'teams') return teamsCard(); if (tab === 'users') return usersCard(); if (tab === 'settings') return settingsCard(); return overview(); } // The strip across the top of every admin page. Ordinary links rather than // buttons, because these are four URLs: app.js intercepts the click, the // browser's Back walks them, and a reload lands where you were. function subnav() { return h('nav', { class: 'subnav', 'aria-label': 'Administration' }, TABS.map((t) => h('a', { class: 'subnav-link', href: t.path, text: t.label, 'aria-current': t.tab === tab ? 'page' : null, }))); } // --- overview -------------------------------------------------------------- // /admin itself. The strip already links to the three, so this earns its place // by saying how much of each there is — the one thing a menu cannot. function overview() { const admins = data.users.filter((u) => u.is_admin).length; const disabled = data.users.filter((u) => u.disabled_at).length; const open = data.teams.reduce((n, t) => n + t.open_incidents, 0); const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`]; if (disabled > 0) people.push(`${disabled} disabled`); return h('div', { class: 'admin-menu' }, menuItem('/admin/teams', 'Teams', data.teams.length, open > 0 ? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.` : 'Nothing open anywhere.'), menuItem('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`), menuItem('/admin/settings', 'Settings', null, 'How the server behaves, and where it is plugged in.'), ); } function menuItem(href, label, count, note) { return h('a', { class: 'card admin-menu-item', href }, h('div', { class: 'admin-menu-head' }, h('strong', { text: label }), count != null && h('span', { class: 'admin-menu-count', text: String(count) })), h('p', { class: 'muted small', text: note }), ); } // --- teams ----------------------------------------------------------------- function teamsCard() { const rows = data.teams.map((t) => h('tr', {}, h('td', {}, h('strong', { text: t.name })), h('td', { class: 'num', text: String(t.members) }), h('td', { class: 'num', text: String(t.open_incidents) }), h('td', {}, h('button', { class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => renameTeam(t), }), // A team with open incidents cannot be deleted, and saying so before // the click is kinder than a 409 afterwards. h('button', { class: 'btn-sm danger', type: 'button', text: 'Delete', disabled: t.open_incidents > 0, title: t.open_incidents > 0 ? 'Resolve its open incidents first' : '', onclick: () => deleteTeam(t), }), ), )); return h('div', { class: 'card' }, h('h2', { text: 'Teams' }), h('table', { class: 'admin-table' }, h('thead', {}, h('tr', {}, h('th', { text: 'Name' }), h('th', { class: 'num', text: 'Members' }), h('th', { class: 'num', text: 'Open' }), h('th', { text: '' }))), h('tbody', {}, rows)), newTeamForm(), ); } function newTeamForm() { const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true }); const form = h('form', { class: 'inline-form' }, name, h('button', { class: 'btn', type: 'submit', text: 'Create' })); form.addEventListener('submit', async (e) => { e.preventDefault(); if (busy) return; busy = true; try { await api.createTeam(name.value.trim()); name.value = ''; await refresh(); } catch (err) { error = err.message; render(); } finally { busy = false; } }); return form; } async function renameTeam(team) { const next = window.prompt(`Rename ${team.name} to:`, team.name); if (!next || next === team.name) return; try { await api.renameTeam(team.id, next); } catch (err) { error = err.message; } refresh(); } async function deleteTeam(team) { if (!(await confirm({ title: `Delete ${team.name}?`, text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.', confirmLabel: 'Delete', danger: true, }))) return; try { await api.deleteTeam(team.id); } catch (err) { error = err.message; } refresh(); } // --- users ----------------------------------------------------------------- function usersCard() { const rows = data.users.map((u) => { const self = u.id === myID(); return h('tr', { class: u.disabled_at ? 'disabled-row' : '' }, h('td', {}, // The name is the way in: everything about one person lives on their // own page, and this table stays a list rather than becoming a form. h('a', { class: 'user-link', href: `/admin/users/${u.id}`, text: u.username }), u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }), self && h('span', { class: 'you', text: 'you' })), h('td', { class: 'muted', text: u.email }), h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null), h('td', {}, // Neither action is offered for your own account: the server refuses // both, and an enabled-looking button that always fails is worse than // no button. !self && h('button', { class: 'btn-sm', type: 'button', text: u.is_admin ? 'Revoke admin' : 'Make admin', onclick: () => setAdmin(u, !u.is_admin), }), !self && h('button', { class: 'btn-sm danger', type: 'button', text: u.disabled_at ? 'Enable' : 'Disable', onclick: () => setDisabled(u, !u.disabled_at), }), ), ); }); return h('div', { class: 'card' }, h('h2', { text: 'Users' }), h('p', { class: 'muted small' }, 'Disabling an account stops it signing in and stops its API keys, and keeps ', 'its acknowledgements and timeline entries. Deleting a user erases those. ', 'Open a name for their teams, their password and the rest.'), h('table', { class: 'admin-table' }, h('thead', {}, h('tr', {}, h('th', { text: 'User' }), h('th', { text: 'Email' }), h('th', { text: '' }), h('th', { text: '' }))), h('tbody', {}, rows)), inviteForm(), ); } // Adding a person is minting them an invite, not creating a row. The account // is created by whoever accepts it, so they pick their own password and it // never passes through an administrator — and the link carries the team, which // a bare POST /api/users cannot, leaving an account with nothing to work on. // // Minting for a team the administrator is not in is allowed: the flag passes // every team-owner check, so a server administrator can staff any team. The // link shows up in that team's own invite list, where an owner can revoke it. function inviteForm() { const team = h('select', {}, ...data.teams.map((t) => h('option', { value: String(t.id), text: t.name }))); const role = h('select', {}, h('option', { value: 'member', text: 'member' }), h('option', { value: 'owner', text: 'owner' })); const out = h('p', { class: 'invite-out', hidden: true }); const form = h('form', { class: 'inline-form' }, team, role, h('button', { class: 'btn', type: 'submit', text: 'Create invite' })); form.addEventListener('submit', async (e) => { e.preventDefault(); if (busy) return; busy = true; try { const inv = await api.createInvite(Number(team.value), role.value, 1); // Shown once and never stored, so it is put on the page to be copied // rather than toasted away after three seconds. clear(out, h('strong', { text: 'Send them this link. It is shown once.' }), h('code', { class: 'invite-link', text: inv.url })); out.hidden = false; error = null; } catch (err) { error = err.message; render(); return; } finally { busy = false; } }); return h('div', { class: 'invite-block' }, h('h3', { text: 'Add someone' }), h('p', { class: 'muted small' }, 'An invite link puts them in a team and lets them choose their own ', 'password. It lasts a week and can be used once.'), data.teams.length > 0 ? form : h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }), out, ); } async function setAdmin(user, next) { if (next && !(await confirm({ title: `Make ${user.username} an administrator?`, text: 'They will be able to create and delete users, and grant this to others.', confirmLabel: 'Make admin', }))) return; try { await api.setUserAdmin(user.id, next); } catch (err) { error = err.message; } refresh(); } async function setDisabled(user, next) { if (next && !(await confirm({ title: `Disable ${user.username}?`, text: 'They cannot sign in and their API keys stop working. Their history stays.', confirmLabel: 'Disable', danger: true, }))) return; try { await api.setUserDisabled(user.id, next); } catch (err) { error = err.message; } refresh(); } // --- settings -------------------------------------------------------------- // Seconds are what the API speaks; people think in minutes and hours. The two // are converted here rather than in the server, which should keep exactly one // unit. const UNITS = [ { label: 'minutes', seconds: 60 }, { label: 'hours', seconds: 3600 }, { label: 'days', seconds: 86400 }, ]; function bestUnit(seconds) { for (const u of [...UNITS].reverse()) { if (seconds > 0 && seconds % u.seconds === 0) return u; } return UNITS[0]; } function settingsCard() { const editable = data.settings.editable || {}; const inputs = new Map(); const rows = Object.entries(editable).map(([key, s]) => { const unit = bestUnit(s.seconds); const value = h('input', { type: 'number', min: '0', value: String(Math.round(s.seconds / unit.seconds)), class: 'setting-value', }); const select = h('select', { class: 'setting-unit' }, ...UNITS.map((u) => h('option', { value: String(u.seconds), text: u.label, selected: u.seconds === unit.seconds, }))); inputs.set(key, () => Number(value.value) * Number(select.value)); return h('tr', {}, h('td', {}, h('strong', { text: key.replace(/_seconds$/, '').replace(/_/g, ' ') })), h('td', { class: 'muted small', text: s.description }), h('td', {}, value, select), ); }); const form = h('form', { class: 'admin-settings' }, h('table', { class: 'admin-table' }, h('tbody', {}, rows)), h('button', { class: 'btn', type: 'submit', text: 'Save settings' })); form.addEventListener('submit', async (e) => { e.preventDefault(); if (busy) return; busy = true; const body = {}; for (const [key, read] of inputs) body[key] = read(); try { await api.setAdminSettings(body); await refresh(); } catch (err) { error = err.message; render(); } finally { busy = false; } }); const env = Object.entries(data.settings.from_env || {}).map(([k, v]) => h('tr', {}, h('td', {}, h('code', { text: k })), h('td', { class: 'muted', text: v === '' ? '(unset)' : v }))); return h('div', { class: 'card' }, h('h2', { text: 'Settings' }), h('p', { class: 'muted small', text: 'Saved changes take effect on the next sweep — no restart.' }), form, h('h3', { text: 'From the environment' }), h('p', { class: 'muted small' }, 'Where the server is plugged in, rather than how it behaves. These are set ', 'in the deployment and are read-only here. Credentials are never shown.'), h('table', { class: 'admin-table' }, h('tbody', {}, env)), ); }