// 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(); }