c67dc80a8f
The admin views used bare .card, which has no padding; add card-pad. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
382 lines
14 KiB
JavaScript
382 lines
14 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, menuCard, ssoBadge, SSO_MANAGED, fadeOnOverflow } 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: it
|
|
// only points at Teams for an invite if there is a team to point at, 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 card-pad' },
|
|
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() {
|
|
const nav = 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,
|
|
})));
|
|
// On a phone the strip overflows; bring the open section into view so a
|
|
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
|
fadeOnOverflow(nav);
|
|
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
|
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
|
return nav;
|
|
}
|
|
|
|
// --- 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: 'overview-menu' },
|
|
menuCard('/admin/teams', 'Teams', data.teams.length,
|
|
open > 0
|
|
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
|
: 'Nothing open anywhere.'),
|
|
menuCard('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
|
menuCard('/admin/settings', 'Settings', null,
|
|
'How the server behaves, and where it is plugged in.'),
|
|
);
|
|
}
|
|
|
|
// --- teams -----------------------------------------------------------------
|
|
|
|
function teamsCard() {
|
|
const rows = data.teams.map((t) =>
|
|
h('tr', {},
|
|
// The name is the way in: everything about one team lives on its own
|
|
// page, and this table stays a list rather than becoming a form.
|
|
h('td', {}, h('a', { class: 'row-link', href: `/admin/teams/${t.id}`, text: t.name })),
|
|
h('td', { class: 'num', text: String(t.members) }),
|
|
h('td', { class: 'num', text: String(t.open_incidents) }),
|
|
));
|
|
|
|
return h('div', { class: 'card card-pad' },
|
|
h('h2', { text: 'Teams' }),
|
|
h('p', { class: 'muted small' },
|
|
'Open a team for who is in it, the invites into it, and renaming or ',
|
|
'deleting it. Deleting takes its alerts, incidents, schedule and ',
|
|
'integrations with it, and is refused while anything is still open.'),
|
|
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('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 btn-primary', 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;
|
|
}
|
|
|
|
// --- 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: 'row-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,
|
|
u.is_admin && u.admin_source === 'oidc' ? ssoBadge() : 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',
|
|
// The server refuses to revoke what the groups grant.
|
|
disabled: ssoAdmin(u),
|
|
title: ssoAdmin(u) ? SSO_MANAGED : null,
|
|
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 card-pad' },
|
|
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)),
|
|
invitePointer(),
|
|
);
|
|
}
|
|
|
|
// Adding a person is minting them an invite into a team, not creating a row:
|
|
// whoever accepts it picks their own password, so one never passes through an
|
|
// administrator, and the link carries the team, so they do not land on an empty
|
|
// queue.
|
|
//
|
|
// The form for it lives on the team's own page. It always needed a team beside
|
|
// it, and a picker here was the admission that an invite is a fact about a team
|
|
// rather than about the server.
|
|
function invitePointer() {
|
|
return h('div', { class: 'invite-block' },
|
|
h('h3', { text: 'Add someone' }),
|
|
data.teams.length > 0
|
|
? h('p', { class: 'muted small' },
|
|
'Open the team you want them in, under ',
|
|
h('a', { class: 'row-link', href: '/admin/teams', text: 'Teams' }),
|
|
', and mint an invite there.')
|
|
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
|
|
);
|
|
}
|
|
|
|
const ssoAdmin = (u) => u.is_admin && u.admin_source === 'oidc';
|
|
|
|
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 card-pad' },
|
|
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)),
|
|
);
|
|
}
|