fc9f47cc8d
Closes the race terdut-operator#1 found: /api/bootstrap and OIDC auto-provisioning both key off the same signal (SELECT COUNT(*) FROM users) with no coordination between them, so an otherwise-ordinary OIDC sign-in against a freshly-created, not-yet-bootstrapped install could create user #1 itself and take the one slot /api/bootstrap expects to win uncontested (terdut-operator's own design, DESIGN.md §1/§6, assumes it is the only caller). The operator has no way to recover from losing that race -- it never gets a credential, and nothing it owns can clear the occupying user row. resolveSSOUser now checks the same gate handleBootstrap already does, right where it's about to create a brand-new user (an identity nobody has linked yet, that also matches no existing local account by email) -- not anywhere else, since every other sign-in on an already-bootstrapped install is unaffected. New sso_error code `not_bootstrapped`: the person sees "this install is still setting up, try again in a moment" and a second attempt once something has actually bootstrapped succeeds normally, same as any other first sign-in. Does not fix the other half of that issue (BootstrapStateLost's own "delete and recreate" instructions still don't work once something has occupied the slot some other way) -- this closes the specific race, not every path to that state.
507 lines
19 KiB
JavaScript
507 lines
19 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 teamselector from './teamselector.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);
|
|
teamselector.init();
|
|
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();
|
|
teamselector.render();
|
|
// 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();
|
|
teamselector.render();
|
|
$('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.',
|
|
not_bootstrapped: 'This install is still setting up. Try again in a moment.',
|
|
}[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();
|