Files
terdut-server/internal/web/static/js/adminuser.js
T
Niklas Ye ac9af8e4f5 Manage a person's account and teams from one page
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
2026-09-21 12:55:42 +02:00

295 lines
10 KiB
JavaScript

// One person, at /admin/users/{id}: what they are, what they are in, and the
// levers an administrator has over the account.
//
// A section of its own rather than an expanding row in the Admin tab's table,
// because memberships and the account actions together are more than a row can
// hold and still be read on a phone.
//
// Like the Admin tab, this hides nothing the server would allow and shows
// nothing it would refuse: every write here is an endpoint that answers 403
// without the flag, so the view is a description of the rules rather than an
// enforcement of them.
import * as api from './api.js';
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
import { state, myID } from './state.js';
import { navigate } from './app.js';
import { when } from './format.js';
const view = () => document.getElementById('view-adminuser');
let userID = null;
let data = null; // { user, teams, allTeams }
let error = null;
let busy = false;
export function show(route) {
const next = route && route.user != null ? route.user : null;
if (next !== userID) {
userID = next;
data = null;
error = null;
}
if (!data) clear(view(), spinner());
refresh();
}
export async function refresh() {
if (userID == null || !state.me?.user?.is_admin) {
render();
return;
}
try {
// The user comes from the list rather than a show endpoint: there is no
// GET /api/users/{id}, and adding one for a row the list already carries
// would be a second way to say the same thing.
const [users, teams, allTeams] = await Promise.all([
api.users(),
api.userTeams(userID),
api.adminTeams(),
]);
const user = users.find((u) => u.id === userID) || null;
data = { user, teams, allTeams };
error = null;
} catch (err) {
error = err.message;
}
render();
}
function render() {
const el = view();
if (!state.me?.user?.is_admin) {
clear(el, backLink(), h('div', { class: 'card' },
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
return;
}
if (!data) {
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
if (!data.user) {
clear(el, backLink(), h('div', { class: 'card' },
h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' })));
return;
}
clear(el,
backLink(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
identityCard(),
teamsCard(),
accountCard(),
);
}
function backLink() {
return h('a', { class: 'back-link', href: '/admin' }, icon('chevronLeft'), h('span', { text: 'Admin' }));
}
// --- identity --------------------------------------------------------------
function identityCard() {
const u = data.user;
const self = u.id === myID();
return h('div', { class: 'card' },
h('div', { class: 'user-head' },
h('h2', { text: u.username }),
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })),
h('dl', { class: 'user-facts' },
fact('Email', u.email),
fact('Joined', when(u.created_at)),
fact('Notifications', u.ntfy_topic ? `ntfy: ${u.ntfy_topic}` : 'None of their own'),
u.disabled_at && fact('Disabled', when(u.disabled_at)),
),
// Both of these refuse your own account, and the last administrator's. An
// enabled button that always fails is worse than no button.
h('div', { class: 'row-actions' },
!self && h('button', {
class: 'btn', type: 'button',
text: u.is_admin ? 'Revoke admin' : 'Make admin',
onclick: () => setAdmin(!u.is_admin),
}),
!self && h('button', {
class: 'btn', type: 'button',
text: u.disabled_at ? 'Enable account' : 'Disable account',
onclick: () => setDisabled(!u.disabled_at),
}),
),
self && h('p', { class: 'muted small' },
'You cannot change your own administrator flag or disable yourself — ',
'that is how an install ends up with nobody who can administer it.'),
);
}
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
async function setAdmin(next) {
if (next && !(await confirm({
title: `Make ${data.user.username} an administrator?`,
text: 'They will be able to manage every account, configure any team, and grant this to others.',
confirmLabel: 'Make admin',
}))) return;
await act(() => api.setUserAdmin(userID, next));
}
async function setDisabled(next) {
if (next && !(await confirm({
title: `Disable ${data.user.username}?`,
text: 'They cannot sign in and their API keys stop working. Their acknowledgements and timeline entries stay.',
confirmLabel: 'Disable',
danger: true,
}))) return;
await act(() => api.setUserDisabled(userID, next));
}
// --- teams -----------------------------------------------------------------
// An administrator passes every team-owner check without being in the team,
// which is what lets them repair a team whose owner has left. So this card
// edits, rather than reporting what somebody else would have to do.
//
// It is the one place membership can be changed from the person's side: the
// Team tab asks "who is in this team", and answering "which teams is this
// person in" there means visiting each team in turn.
function teamsCard() {
const rows = data.teams.map((t) =>
h('tr', {},
// Not a link: the Team tab always shows the viewer's own team, so
// sending them there from somebody else's membership would be a lie.
h('td', {}, h('strong', { text: t.name })),
h('td', { class: 'muted small', text: t.role }),
h('td', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button',
text: t.role === 'owner' ? 'Make member' : 'Make owner',
onclick: () => act(() =>
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
// The server refuses the last owner with a 409, which act() shows.
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
}),
),
));
const inTeam = new Set(data.teams.map((t) => t.id));
const candidates = (data.allTeams || []).filter((t) => !inTeam.has(t.id));
const pick = h('select', {},
...candidates.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 form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
});
return h('div', { class: 'card' },
h('h2', { text: 'Teams' }),
data.teams.length === 0 && h('p', { class: 'muted small' },
'In no team. They can sign in, but there is no queue for them to work ',
'and nothing to page them about.'),
data.teams.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
candidates.length > 0 && form,
);
}
// --- account ---------------------------------------------------------------
function accountCard() {
const u = data.user;
const self = u.id === myID();
const pw = h('input', {
type: 'password', name: 'password', autocomplete: 'new-password',
minlength: '10', required: true, placeholder: 'At least 10 characters',
});
const form = h('form', { class: 'inline-form' }, pw,
h('button', { class: 'btn', type: 'submit', text: 'Set password' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
busy = true;
try {
// No current password: that check is for changing your own, and an
// administrator setting somebody else's does not know it by design.
await api.setPassword(userID, pw.value);
pw.value = '';
toast(`Password set for ${u.username}. Their other sessions are signed out.`);
error = null;
} catch (err) {
error = err.message;
} finally {
busy = false;
}
await refresh();
});
return h('div', { class: 'card' },
h('h2', { text: 'Account' }),
h('p', { class: 'muted small' },
'Setting a password here is how somebody gets their first one, or a new ',
'one after forgetting it. It signs them out everywhere else. They change ',
'it themselves under Account afterwards.'),
self ? h('p', { class: 'muted small' },
'Change your own password under Account, where the current one is asked for.')
: form,
h('h3', { text: 'Delete' }),
h('p', { class: 'muted small' },
'Deleting erases their acknowledgements and timeline entries — incidents ',
'they handled stop saying who did. Disabling keeps the history and is ',
'almost always what is meant.'),
h('button', {
class: 'btn btn-danger', type: 'button', text: `Delete ${u.username}`,
disabled: self,
title: self ? 'You cannot delete your own account' : '',
onclick: deleteUser,
}),
);
}
async function deleteUser() {
if (!(await confirm({
title: `Delete ${data.user.username}?`,
text: 'Their API keys go with them, and their name comes off every incident they acknowledged. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
}))) return;
try {
await api.deleteUser(userID);
} catch (err) {
error = err.message;
render();
return;
}
toast('User deleted.');
navigate('/admin');
}
// --- plumbing --------------------------------------------------------------
// act runs a write and reloads. Errors are shown rather than thrown away: the
// 409 from the last-owner or last-administrator guard is the server explaining
// itself, and the reader needs to see it.
async function act(fn) {
if (busy) return;
busy = true;
try {
await fn();
error = null;
} catch (err) {
error = err.message;
} finally {
busy = false;
}
await refresh();
}