33356ca978
state.js's currentTeam() was hard-coded to teams[0] and never really meant "the team currently selected" — team.js's settings page and queue.js's filter chips each kept their own separate, unsynchronized notion of "which team" instead, so picking one on one page had no effect on the other. Replaces both with a single state.selectedTeamID, set only through the new setSelectedTeam (persisted in localStorage, unlike the queue's old per-tab sessionStorage filter) and broadcast to listeners via onTeamChange. A new teamselector.js control — a coloured dot plus the team's name, or "All teams" — sits at the top of both the desktop sidebar and the mobile topbar, opening the existing bottom-sheet menu to switch. Shown only once someone is in more than one team, matching every other team-aware control in this app. Colours come from a new teamColorClass() in format.js, hashing a team's id into the six-colour rc1..rc6 palette already used for the rota's per-person chips, so no schema or API change is needed. The queue's team filter chips pick up the same colours.
506 lines
18 KiB
JavaScript
506 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 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.',
|
|
}[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();
|