a27ff49171
terdut can now sign people in through any OIDC provider (written against Authentik), and let groups at the provider decide who may sign in, which teams they belong to and whether they administer the install. Password login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off, and is refused at startup unless SSO is configured. With no TERDUT_OIDC_* setting nothing changes, so every existing install behaves as before. Identity is (issuer, subject), never email or username: those are mutable at the provider and a recycled address must not inherit an account. An existing user is linked by email only when the provider marks it verified, or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs. Group grants are marked source='oidc' on team_members and users, and the sync changes only those rows. Hand-made memberships and administrators are left alone, and the sync bypasses the last-owner and last-admin guards because the provider is the source of truth for what it grants. Editing managed access by hand is refused with 409, since the next sign-in would undo it. The web UI badges it as SSO and disables the controls. Groups are read only at sign-in, so an SSO session carries a hard ceiling (sessions.max_expires_at, 12h by default) that sliding never extends. There is no refresh token, which means API keys of somebody removed at the provider stay valid until an administrator disables the user. That is accepted and documented, not fixed. A client with no browser, the TUI over SSH, signs in with a device code run by terdut itself (POST /api/oidc/device and /device/token), so the terminal never talks to the provider and ends up with the ordinary terdut_session cookie. Only a browser session can approve a code; an API key cannot. /device?code= sends a signed-out visitor through sign-in and back, which is what oidc_logins.next is for. oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile builds on 1.25. Migrations 011 and 012 add tables and defaulted columns only.
304 lines
11 KiB
JavaScript
304 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() {
|
|
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.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();
|
|
}
|