fa6d82d6e5
On-call, the incident queue and the account page all had latent CSS bugs that only show up once the browser is wide enough to hit the desktop breakpoint (900px+): - On-call: .days reset its own margin to 0, which canceled the page-wide auto-centering on just that element, leaving the day list pinned to the left edge while every other card on the page centered normally. - Queue: the list pane stayed capped at 340-420px even with nothing selected, leaving the rest of the screen empty. It now fills the width until an incident is picked, then goes back to list+detail. - Account, admin user and admin team: .btn and .back-link are inline-flex, and margin:auto only centers a block box, so the Sign-out button and the two admin back-links sat left of their sibling cards instead of matching their width. Wrapped each in a block div. The incident detail action bar also folded Assign, Add note, Resolve and Clear acknowledgement into a "More" sheet sized for a phone's width. Desktop has the room, so it now shows them as direct buttons and hides More instead; Copy incident stays out of the bar since the header already has its own button for it. Filed as niklas/terdut-server#31, #32, #33, #34, each with a screenshot.
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', 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();
|
|
}
|