Files
terdut-server/internal/web/static/js/app.js
T
Niklas Ye a27ff49171 Sign in through an OpenID Connect provider, and from a terminal
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.
2026-09-26 21:37:40 +02:00

502 lines
18 KiB
JavaScript

// Entry point: session, routing, badges and keyboard.
import * as api from './api.js';
import * as ui from './ui.js';
import * as poll from './poll.js';
import { state, reset, loadTeams } from './state.js';
import * as queue from './queue.js';
import * as incident from './incident.js';
import * as oncall from './oncall.js';
import * as alerts from './alerts.js';
import * as stats from './stats.js';
import * as account from './account.js';
import * as team from './team.js';
import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
import * as device from './device.js';
const $ = (id) => document.getElementById(id);
// One route per section; /incidents/{id} is the queue with a detail open, and
// /admin/users/{id} is a section of its own rather than a mode of the Admin
// tab, because it replaces the page rather than opening beside it.
const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
stats: { title: 'Stats', view: stats },
team: { title: 'Team', view: team },
admin: { title: 'Admin', view: admin },
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
more: { title: 'Account', view: account },
// Reached by link from a terminal's sign-in prompt, not from the nav.
device: { title: 'Sign in a terminal', view: device },
};
// The mobile hamburger menu's contents — the same sections the desktop
// sidebar's .nav-link list carries in index.html, in the same order.
const NAV_ITEMS = [
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
];
function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) };
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
if (u) return { section: 'adminuser', user: Number(u[1]) };
// Before the TABS lookup below, which matches a path exactly and would let
// /admin/teams/7 fall through to the queue.
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
if (g) return { section: 'adminteam', team: Number(g[1]) };
const name = pathname.replace(/^\/|\/$/g, '');
// The Admin and Team tabs' sub-sections are routes of their own. Each view
// owns the table of its own, since each also builds the strip that links to
// them; /team is in team.TABS as the overview, so it is matched here too.
const t = admin.TABS.find((x) => x.path === `/${name}`);
if (t) return { section: 'admin', tab: t.tab };
const tt = team.TABS.find((x) => x.path === `/${name}`);
if (tt) return { section: 'team', tab: tt.tab };
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more' || name === 'device') return { section: name };
return { section: 'queue', incident: null };
}
// What the top bar and the document title call this route. The sub-sections of
// Admin and Team are pages in their own right, so they say which one rather
// than the tab's name four or six times; either overview keeps the tab's own
// name. A tab may carry a `title` where its strip label is too short to name a
// page on its own.
function title(r) {
const tabs = r.section === 'admin' ? admin.TABS : r.section === 'team' ? team.TABS : null;
const t = tabs && r.tab ? tabs.find((x) => x.tab === r.tab) : null;
return t ? (t.title || t.label) : SECTIONS[r.section].title;
}
let route = parseRoute(location.pathname);
// How many in-app navigations deep we are, so Back can use the browser's
// history when there is somewhere to go back to, and the queue otherwise.
let depth = 0;
let listScroll = 0;
export function navigate(path, { replace = false } = {}) {
if (path === location.pathname + location.search) return;
if (replace) {
history.replaceState({ depth }, '', path);
} else {
depth += 1;
history.pushState({ depth }, '', path);
}
render();
}
export function back() {
if (depth > 0) history.back();
else navigate('/', { replace: true });
}
window.addEventListener('popstate', (e) => {
depth = (e.state && e.state.depth) || 0;
render();
});
function render() {
const prev = route;
route = parseRoute(location.pathname);
const app = $('app');
for (const name of Object.keys(SECTIONS)) {
const el = $(`view-${name}`);
el.hidden = name !== route.section;
if (name === route.section) $('topbar-title').textContent = title(route);
}
// A section may light up somebody else's tab: /admin/users/{id} is still the
// Admin tab as far as the nav is concerned, since there is no tab of its own.
const current = SECTIONS[route.section].nav || route.section;
for (const link of document.querySelectorAll('.nav-link')) {
if (link.dataset.section === current) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
const detailOpen = route.section === 'queue' && route.incident != null;
const wasOpen = prev.section === 'queue' && prev.incident != null;
if (detailOpen && !wasOpen) listScroll = window.scrollY;
app.classList.toggle('detail-open', detailOpen);
$('view-queue').classList.toggle('has-detail', detailOpen);
if (route.section === 'queue') {
queue.show(route.incident);
incident.show(route.incident);
} else {
incident.show(null);
SECTIONS[route.section].view.show(route);
}
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
// A changed tab counts as a changed page: stepping from a long user list to
// the settings should not land you halfway down them. So does a changed
// subject — one team to the next is two pages, not one scrolled page.
else if (prev.section !== route.section || prev.tab !== route.tab
|| prev.user !== route.user || prev.team !== route.team) window.scrollTo(0, 0);
updateTitle();
}
// ---------- nav menu ----------
// The mobile hamburger menu: same shape as the sheet-based action menus in
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
function openNavMenu() {
const current = SECTIONS[route.section].nav || route.section;
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
ui.openSheet(() => [
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
ui.h('li', {}, ui.h('button', {
class: 'menu-item',
type: 'button',
role: 'menuitemradio',
'aria-checked': String(n.section === current),
onclick: () => ui.closeSheet(n.path),
},
ui.icon(n.icon),
n.label,
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
))),
),
]).then((path) => {
if (path) navigate(path);
});
}
// ---------- refresh + badges ----------
async function refresh() {
state.open = await api.incidents({ sort: 'severity' });
updateBadges();
const jobs = [];
if (route.section === 'queue') {
jobs.push(queue.refresh());
if (route.incident != null) jobs.push(incident.refresh());
} else {
const v = SECTIONS[route.section].view;
if (v.refresh) jobs.push(v.refresh());
}
await Promise.allSettled(jobs);
}
function updateBadges() {
const open = state.open.length;
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const pill = $('open-pill');
pill.hidden = false;
pill.textContent = open ? `${open} open` : 'All clear';
pill.classList.toggle('has-triggered', triggered > 0);
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
// hamburger button (mobile) — only one of the two is ever visible at once.
for (const badge of document.querySelectorAll('[data-badge]')) {
badge.hidden = triggered === 0;
badge.textContent = String(triggered);
}
updateTitle();
}
function updateTitle() {
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const section = title(route);
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
document.title = triggered ? `(${triggered}) ${base}` : base;
}
// ---------- session ----------
async function boot() {
ui.initSheet();
api.setUnauthorizedHandler(showLogin);
document.addEventListener('click', interceptLinks);
document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu);
ssoErrorCode = takeSSOError();
// /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') {
$('boot').hidden = true;
await showSignup();
return;
}
try {
// Before anything renders: the account view decides from it whether a
// password is worth offering to set.
await loadAuthConfig();
state.me = await api.me();
await loadTeams();
// The Admin tab exists only for an administrator. Somebody who types /admin
// anyway gets the view's own "ask an administrator" card, not a blank page.
$('nav-admin').hidden = !state.me?.user?.is_admin;
showApp();
} catch (err) {
if (err.status === 401) showLogin();
else showBootError(err);
}
}
function showBootError(err) {
ui.clear($('boot'), ui.emptyState('Cannot load terdut', err.message));
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
}
// The sign-up screen. Reached at /signup, with an optional ?invite= that the
// server has already judged — the form says whether the link is good before
// somebody picks a password, rather than after.
async function showSignup() {
poll.stop();
ui.closeSheet(null);
reset();
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('login-form').hidden = true;
$('signup-form').hidden = false;
const invite = new URLSearchParams(location.search).get('invite');
const intro = $('signup-intro');
const form = $('signup-form');
const teamLabel = $('signup-team-label');
form.querySelector('.form-error').hidden = true;
let info;
try {
info = await api.signupInfo(invite);
} catch (err) {
intro.textContent = err.message;
return;
}
if (invite && info.invite_valid) {
intro.textContent = `You have been invited to ${info.invite_team}.`;
teamLabel.hidden = true;
form.team_name.required = false;
} else if (invite) {
// One answer for expired, revoked, used up and never existed, matching the
// server: which it was is not a stranger's business.
intro.textContent = 'That invite link is not usable. Ask whoever sent it for a new one.';
form.querySelector('button[type=submit]').disabled = true;
} else if (info.mode === 'open') {
intro.textContent = 'Create an account and a team to put your alerts in.';
teamLabel.hidden = false;
form.team_name.required = true;
} else {
intro.textContent = 'Sign-up on this server is invite-only. Ask a team owner for a link.';
form.querySelector('button[type=submit]').disabled = true;
}
form.username.focus();
}
async function onSignup(e) {
e.preventDefault();
const form = e.currentTarget;
const err = form.querySelector('.form-error');
const btn = form.querySelector('button[type=submit]');
err.hidden = true;
btn.disabled = true;
try {
state.me = await api.signup({
username: form.username.value.trim(),
email: form.email.value.trim(),
password: form.password.value,
invite: new URLSearchParams(location.search).get('invite') || undefined,
team_name: form.team_name.value.trim() || undefined,
});
form.password.value = '';
// Signing up signs you in, so go straight to the queue rather than to a
// login form asking for the credential just chosen.
history.replaceState({ depth: 0 }, '', '/');
route = parseRoute('/');
await loadTeams();
$('nav-admin').hidden = !state.me?.user?.is_admin;
showApp();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
btn.disabled = false;
}
}
// Why single sign-on sent the browser back, by the code the server puts in
// ?sso_error=. The provider's name is the one the administrator configured.
function ssoErrorText(code, name) {
const sso = name || 'single sign-on';
return {
denied: `Signing in with ${sso} was cancelled or refused.`,
expired: 'That sign-in expired or was already used. Try again.',
failed: `Signing in with ${sso} failed. Try again, and tell an administrator if it keeps happening.`,
unavailable: `${sso} could not be reached. Try again in a moment.`,
not_allowed: 'Your account is not allowed to use terdut. Ask an administrator to add you to the right group.',
no_email: `${sso} did not send an email address for you, which terdut needs.`,
email_conflict: 'An account with your email address already exists and could not be linked to this sign-in. Ask an administrator.',
disabled: 'Your account is disabled. Ask an administrator.',
}[code] || `Signing in with ${sso} failed.`;
}
// Reads how the server can be signed in to. An older server has no such
// endpoint, and one that cannot be asked is treated as offering passwords only:
// the form that always existed is better than a blank page.
async function loadAuthConfig() {
try {
state.auth = await api.authConfig();
} catch {
/* keep the last answer, or the defaults */
}
return state.auth;
}
// The reason the last single sign-on attempt failed, read once at boot. It is
// held here rather than re-read from the address because showLogin runs more
// than once on the way to the form (the 401 from /api/me reaches it through the
// API layer and again through boot's own catch), and only the first would see it.
let ssoErrorCode = null;
// Takes ?sso_error= off the address, so a reload does not repeat the message.
function takeSSOError() {
const params = new URLSearchParams(location.search);
const code = params.get('sso_error');
if (code === null) return null;
params.delete('sso_error');
const query = params.toString();
history.replaceState(null, '', location.pathname + (query ? `?${query}` : '') + location.hash);
return code;
}
async function showLogin() {
poll.stop();
ui.closeSheet(null);
reset();
$('app').hidden = true;
$('signup-form').hidden = true;
// Ask before showing anything, so the form does not flash the password
// fields at somebody whose server has turned them off.
const auth = await loadAuthConfig();
const sso = auth.oidc?.enabled ? auth.oidc : null;
const passwords = auth.password_login !== false;
const link = $('sso-link');
link.hidden = !sso;
if (sso) {
link.textContent = `Sign in with ${sso.name || 'SSO'}`;
// Come back to the page that was asked for: a link to /device?code=... has
// to survive the trip through the provider. The server only honours paths
// on this server, and ignores the front page.
const here = location.pathname + location.search;
link.href = here === '/' ? '/api/oidc/login' : `/api/oidc/login?next=${encodeURIComponent(here)}`;
}
$('login-or').hidden = !(sso && passwords);
$('password-login').hidden = !passwords;
const ssoErr = $('sso-error');
ssoErr.hidden = ssoErrorCode === null;
if (ssoErrorCode !== null) ssoErr.textContent = ssoErrorText(ssoErrorCode, sso?.name);
$('boot').hidden = true;
$('login').hidden = false;
$('login-form').hidden = false;
const form = $('login-form');
form.querySelector('#password-login .form-error').hidden = true;
if (!passwords) return;
// Only offer the door that is open. Somebody without an invite on an
// invite-only server should be told, not sent to a form that refuses them.
api.signupInfo().then((info) => {
$('signup-link').hidden = info.mode !== 'open';
}).catch(() => {});
form.password.value = '';
(form.username.value ? form.password : form.username).focus();
}
async function onLogin(e) {
e.preventDefault();
const form = e.currentTarget;
// Not the first .form-error: that one is the single sign-on message above.
const err = form.querySelector('#password-login .form-error');
const btn = form.querySelector('button[type=submit]');
err.hidden = true;
// Whatever single sign-on said is about the last attempt, not this one.
ssoErrorCode = null;
$('sso-error').hidden = true;
btn.disabled = true;
try {
state.me = await api.login(form.username.value.trim(), form.password.value);
form.password.value = '';
showApp();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
btn.disabled = false;
}
}
export async function signOut() {
try {
await api.logout();
} catch {
/* the cookie is cleared server-side or already gone */
}
showLogin();
}
function showApp() {
ssoErrorCode = null;
$('boot').hidden = true;
$('login').hidden = true;
$('app').hidden = false;
render();
poll.start(refresh);
poll.now();
}
// ---------- links + keys ----------
function interceptLinks(e) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest('a[href]');
if (!a || a.target || a.origin !== location.origin || a.pathname.startsWith('/api/')) return;
e.preventDefault();
navigate(a.pathname + a.search);
}
function onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey || ui.sheetIsOpen() || $('app').hidden) return;
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.key === 'r') {
poll.now();
e.preventDefault();
return;
}
if (route.section !== 'queue') return;
if (route.incident != null && incident.key(e)) {
e.preventDefault();
return;
}
if (queue.key(e)) e.preventDefault();
}
boot();