07914d5cdb
Administration was one scrolling page with three cards on it: the teams, the people, and the settings. There was no way to link somebody to the settings, no way back to the top of the user list but scrolling, and the poll loop refetched all three endpoints every tick however little of the page you were looking at. Each is now a route -- /admin/teams, /admin/users, /admin/settings -- reached from a strip across the top, with /admin an overview that says how many of each there are. The three cards themselves are untouched; they are simply rendered one at a time, so a tab fetches only what it shows. The Users page is the exception and fetches the teams too, since its invite form has to offer a team to invite somebody into. The strip is ordinary links rather than chips. Chips filter what a page already shows, here and in the queue, and these four go somewhere: the browser's Back walks them, a reload lands where you were, and the click is intercepted by the same handler every other link in the app uses. The current one is marked with aria-current="page", the convention the tab bar has used since it existed, so the state lives on the attribute and not in a class. admin.js owns the table of the four routes, because it also builds the strip that links to them; app.js parses against that table rather than keeping a second list to drift from it. Adding a fifth sub-section is one line. The bottom tab bar still has six items.56b8191made it count-agnostic when Admin arriving pushed it past four, and the note there records that six at 420px already leaves 55-65px each -- so the sub-sections went inside the Admin page rather than beside it. A person's page keeps its own route at /admin/users/{id}; its back link now returns to the user list rather than to the top of everything. Nobody has looked at this in a browser, the same caveatac9af8ecarried. What is checked is the wiring: the module graph evaluates at every admin URL, all four tabs render against live server responses with one aria-current each and the fetches the table above describes, the non-administrator branch still refuses without fetching, and the server serves index.html for each new path so a reload survives. The strip's appearance at phone and desktop width is not checked. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
447 lines
15 KiB
JavaScript
447 lines
15 KiB
JavaScript
// 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)),
|
|
);
|
|
}
|