// Administration: the teams on this server, the people who can sign in, and // the settings that change how the server behaves. // // 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'); let data = null; // { teams, users, settings } let error = null; let busy = false; export function show() { if (!data) clear(view(), spinner()); refresh(); } export async function refresh() { if (!state.me?.user?.is_admin) { data = null; render(); return; } try { const [teams, users, settings] = await Promise.all([ api.adminTeams(), api.users(), api.adminSettings(), ]); data = { teams, users, settings }; error = null; } catch (err) { error = err.message; } render(); } 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(), error ? h('div', { class: 'load-error', text: error }) : spinner()); return; } clear(view(), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), teamsCard(), usersCard(), settingsCard(), ); } // --- 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)), ); }