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.
211 lines
7.8 KiB
JavaScript
211 lines
7.8 KiB
JavaScript
// Account: who you are signed in as, changing your password, signing out.
|
||
|
||
import * as api from './api.js';
|
||
import { h, clear, icon, toast } from './ui.js';
|
||
import { initial } from './format.js';
|
||
import { state } from './state.js';
|
||
import { signOut } from './app.js';
|
||
|
||
const view = () => document.getElementById('view-more');
|
||
|
||
// Rendered once per visit rather than on every poll, so a half-typed password
|
||
// is never wiped out from under you.
|
||
export function show() {
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
const { user, has_password: hasPassword } = state.me;
|
||
clear(view(),
|
||
h('div', { class: 'card account-card' },
|
||
h('div', { class: 'avatar', text: initial(user.username) }),
|
||
h('div', {},
|
||
h('div', { class: 'account-name', text: user.username }),
|
||
h('div', { class: 'account-email', text: user.email }))),
|
||
|
||
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||
notifyForm(user),
|
||
|
||
...passwordSection(user, hasPassword),
|
||
|
||
h('div', { class: 'only-desktop' },
|
||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||
h('div', { class: 'card' }, shortcuts())),
|
||
|
||
h('div', { class: 'page-head' }),
|
||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||
);
|
||
}
|
||
|
||
// Where this user's pages go. The onboarding checklist's first step sends
|
||
// people here for it, and until now there was nothing here to send them to:
|
||
// the topic could only be set with curl or by an administrator.
|
||
//
|
||
// The topic is the whole address — the server it is published to is the
|
||
// install's one ntfy, set in the deployment and not something a user picks.
|
||
function notifyForm(user) {
|
||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||
const topic = h('input', {
|
||
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||
autocapitalize: 'none', spellcheck: false,
|
||
value: user.ntfy_topic || '',
|
||
placeholder: 'terdut-a7f3c91e',
|
||
});
|
||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||
|
||
// Only offered once a topic is saved: the test publishes to whatever the
|
||
// server has stored, not to whatever is half-typed in the field.
|
||
const test = h('button', {
|
||
class: 'btn', type: 'button', text: 'Send a test push',
|
||
hidden: !user.ntfy_topic,
|
||
onclick: async () => {
|
||
err.hidden = true;
|
||
ok.hidden = true;
|
||
test.disabled = true;
|
||
try {
|
||
await api.testNotification();
|
||
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
|
||
ok.hidden = false;
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
err.hidden = false;
|
||
} finally {
|
||
test.disabled = false;
|
||
}
|
||
},
|
||
});
|
||
|
||
const form = h('form', { class: 'card pw-form' },
|
||
h('label', {},
|
||
h('span', { text: 'ntfy topic' }),
|
||
topic),
|
||
h('p', { class: 'muted small' },
|
||
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
|
||
'reach your phone. Leave it empty and they page the team’s fallback ',
|
||
'topic instead.'),
|
||
// Worth saying plainly: people reach for their own name, and the topic is
|
||
// the only thing standing between a stranger and their pages.
|
||
h('p', { class: 'muted small' },
|
||
'Anyone who knows the topic can read your pages and publish to it, so ',
|
||
'pick something unguessable rather than your name.'),
|
||
err, ok,
|
||
h('div', { class: 'row-actions' }, submit, test),
|
||
);
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
err.hidden = true;
|
||
ok.hidden = true;
|
||
submit.disabled = true;
|
||
try {
|
||
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
||
// Keep the cached user in step, so the onboarding checklist stops
|
||
// asking for this and the test button appears without a reload.
|
||
state.me.user = updated;
|
||
ok.textContent = updated.ntfy_topic
|
||
? 'Topic saved.'
|
||
: 'Topic cleared. Your pages go to the team’s fallback topic.';
|
||
ok.hidden = false;
|
||
test.hidden = !updated.ntfy_topic;
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
err.hidden = false;
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
});
|
||
return form;
|
||
}
|
||
|
||
// With password login switched off a password opens nothing, so somebody who
|
||
// has none is not asked to make one. Somebody who does keeps the form: it is
|
||
// how they change or get rid of a credential the server still remembers.
|
||
function passwordSection(user, hasPassword) {
|
||
if (!hasPassword && state.auth.password_login === false) {
|
||
const name = state.auth.oidc?.name || 'single sign-on';
|
||
return [
|
||
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
|
||
h('div', { class: 'card' },
|
||
h('p', { class: 'muted', text: `You sign in with ${name}, and this server has turned password login off.` })),
|
||
];
|
||
}
|
||
return [
|
||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||
passwordForm(user, hasPassword),
|
||
];
|
||
}
|
||
|
||
function passwordForm(user, hasPassword) {
|
||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||
const current = hasPassword
|
||
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
|
||
: null;
|
||
const next = h('input', { name: 'next', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
|
||
const again = h('input', { name: 'again', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
|
||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save password' });
|
||
|
||
// A hidden username field lets password managers file the new password
|
||
// under the right account.
|
||
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
|
||
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
|
||
current && h('label', {}, h('span', { text: 'Current password' }), current),
|
||
h('label', {}, h('span', { text: 'New password' }), next),
|
||
h('label', {}, h('span', { text: 'Repeat new password' }), again),
|
||
err, ok, submit,
|
||
);
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
err.hidden = true;
|
||
ok.hidden = true;
|
||
if (next.value !== again.value) {
|
||
err.textContent = 'The new passwords do not match.';
|
||
err.hidden = false;
|
||
return;
|
||
}
|
||
submit.disabled = true;
|
||
try {
|
||
await api.setPassword(user.id, next.value, current ? current.value : '');
|
||
state.me.has_password = true;
|
||
form.reset();
|
||
if (!current) {
|
||
// From now on the form needs the current-password field.
|
||
render();
|
||
toast('Password saved');
|
||
return;
|
||
}
|
||
ok.textContent = 'Password saved. Other devices have been signed out.';
|
||
ok.hidden = false;
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
err.hidden = false;
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
});
|
||
return form;
|
||
}
|
||
|
||
function shortcuts() {
|
||
const rows = [
|
||
['j / k', 'Move through the queue'],
|
||
['Enter', 'Open incident'],
|
||
['Esc', 'Back to the queue'],
|
||
['f', 'Cycle the queue filter'],
|
||
['a / A', 'Acknowledge / clear acknowledgement'],
|
||
['R', 'Resolve (asks first)'],
|
||
['s', 'Assign'],
|
||
['z / Z', 'Snooze / end snooze'],
|
||
['c', 'Add a note'],
|
||
['x', 'Archive / unarchive a resolved incident'],
|
||
['r', 'Refresh now'],
|
||
];
|
||
return h('table', { class: 'kbd-table' },
|
||
h('tbody', {}, rows.map(([k, v]) =>
|
||
h('tr', {},
|
||
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
|
||
h('td', { text: v })))));
|
||
}
|