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
295 lines
10 KiB
JavaScript
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/users' }, icon('chevronLeft'), h('span', { text: 'Users' }));
|
|
}
|
|
|
|
// --- 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/users');
|
|
}
|
|
|
|
// --- 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();
|
|
}
|