a8dc89e23d
Secondary text was too dim to read: --faint sat at about 4.0:1 in the dark theme and 3.3:1 in the light one, and it carries row ages, hints and labels. It now clears 4.5:1 in both. The dark surfaces and borders are a step further apart so cards stand out from the page, and the light borders a touch stronger. Account has an Appearance section with System, Light and Dark. System is the old behaviour. The choice is per browser, kept in localStorage, and is applied by a small js/theme.js loaded from <head> so there is no flash of the other theme; the CSP allows no inline script, hence a file of its own. On-call is redesigned: a hero card for who is on call now, with when the shift ends for the selected team, and the week as seven day cells instead of grouped rows. Today is marked with a neutral tint and a bar rather than the accent colour, which now means only things you can act on; your own days are marked by the "you" badge, not a fill. On a wide screen the hero and week sit beside your shifts under a page title. The page stays read-only: shifts are still edited in the TUI. Smaller fixes: the team switcher no longer appears in the phone's bottom bar as well as the top bar (a shared rule overrode the one that hides it); the Team and Admin tab strips fade at the edge and scroll the open section into view, so "Sources" is no longer cut off; and "All clear" is a green status badge with a check instead of a grey pill that looked like a button. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
381 lines
13 KiB
JavaScript
381 lines
13 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 } 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' },
|
|
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.
|
|
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' },
|
|
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', 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' },
|
|
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' },
|
|
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)),
|
|
);
|
|
}
|