ac9af8e4f5
The Admin tab could make somebody an administrator and disable them, and
nothing else. Setting a first password, deleting an account and seeing
which teams a person is in all meant curl, and the last one meant opening
each team in turn — the Team tab answers "who is in this team", which is
the wrong way round when the question is about a person.
A name in the user list now opens /admin/users/{id}: their email and when
they joined, where their notifications go, the administrator and disabled
flags, the teams they are in with their role in each, a password field
for a first or forgotten one, and deletion. A section of its own rather
than an expanding row, because memberships and the account actions
together are more than a table row can hold and still be read on a phone.
Adding somebody mints an invite link into a chosen team rather than
creating a bare account. POST /api/users makes a user with no password
and no team, who can sign in nowhere and would see nothing if they did;
the invite machinery from #7 already solves both, and the password is
chosen by the person it belongs to instead of passing through an
administrator.
One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is
always about the caller and cannot be asked about anybody else. It 404s
for a user who does not exist, so the page can tell "in no teams" from
"no such person" — an empty list is a real answer and needed to stay one.
No authorisation changed, and the interesting part is why it did not.
requireTeamOwner has accepted the administrator flag since a4fbd60, with
the reason in its own comment: somebody has to be able to repair a team
whose owner has left. It guards nine call sites, so an administrator has
always been able to configure any team on this server — while #1's
decision table and this README both said an admin "is not implicitly in
every team", full stop. The code was right and the prose was wrong in the
safe-sounding direction, which is the worse way round to have it.
So the documentation moved to meet the code. The Teams table marks owner
as owner-or-admin, and the Authentication section states the two
directions separately: an administrator configures any team, and reads
none, because callerTeamIDs is built from real memberships only. Joining
a team to see its queue is a membership change and shows as one.
TestAdmin_ConfiguresATeamTheyAreNotIn pins both halves — the admin
renames, invites, adds and removes on a team they are not in, then sees
zero of its incidents. Nothing tested this from v0.12.0 to here, which is
why four releases of prose could contradict it quietly.
The UI has not been opened in a browser. Its wiring is checked — every
cross-module import resolves, every api.* call exists, every CSS class
has a rule, and the deep link serves index.html — but nobody has clicked
through it, least of all at phone width.
Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
363 lines
11 KiB
JavaScript
363 lines
11 KiB
JavaScript
// 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)),
|
|
);
|
|
}
|