bcf1a3e99b
The desktop sidebar is now grouped: Queue, On-call, Alerts and Stats; Team and Admin; then Account at the foot, shown as the signed-in person with an avatar and their name. The active item has a bar as well as a tint. The queue's row of team chips is gone: the team selector is the one place the team is chosen, and the chips offered the same choice a second time. Tables get 16px between columns, so a right-aligned count no longer touches the text beside it. An empty SSO group reads "Not configured" instead of a dash, and the main action on each of those pages (New source, New switch, Add member, Assign, and the admin Add, Create invite and Create) is a filled button, with Edit and Cancel staying secondary. The bare triangles on "Grouped by" and the label lists are real disclosure buttons: a chevron that turns and a 44px target on a touch screen. On a phone the More tab was a <button> that kept the browser's grey box, so it looked highlighted next to four plain links. It now matches them, the labels are 12px and the open tab's icon is filled. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
306 lines
11 KiB
JavaScript
306 lines
11 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, ssoBadge, SSO_MANAGED } 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() {
|
|
// Wrapped in a div: .back-link is inline-flex, and only a block-level
|
|
// element picks up .view-page > *'s margin:auto centering (app.css:316).
|
|
return h('div', {}, 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.is_admin && u.admin_source === 'oidc' && ssoBadge(),
|
|
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',
|
|
// The server refuses to revoke what the groups grant.
|
|
disabled: u.is_admin && u.admin_source === 'oidc',
|
|
title: u.is_admin && u.admin_source === 'oidc' ? SSO_MANAGED : null,
|
|
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' }, t.role, t.source === 'oidc' && ssoBadge()),
|
|
h('td', { class: 'row-actions' },
|
|
h('button', {
|
|
class: 'btn-sm', type: 'button',
|
|
text: t.role === 'owner' ? 'Make member' : 'Make owner',
|
|
// The server refuses to edit a membership the groups grant.
|
|
disabled: t.source === 'oidc',
|
|
title: t.source === 'oidc' ? SSO_MANAGED : null,
|
|
onclick: () => act(() =>
|
|
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
|
|
}),
|
|
h('button', {
|
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
|
disabled: t.source === 'oidc',
|
|
title: t.source === 'oidc' ? SSO_MANAGED : null,
|
|
// 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 btn-primary', 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();
|
|
}
|