Files
terdut-server/internal/web/static/js/app.js
T
Niklas Ye b39aac36b7
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m28s
Add the sign-up page and the first-run checklist
Second half of #7. The API could create accounts from invite links since
the last change; this is the part somebody can actually use.

/signup is the one route that works without a session. It asks the server
what it may offer before showing anything: an invite link that is good
names the team it leads to, a link that is not says so before somebody
picks a password rather than after, and an invite-only server with no
link says that instead of presenting a form it will refuse. The login
card only offers "create one" when sign-up is open, so the door nobody
can walk through is not advertised.

Signing up signs you in and lands on the queue, because the alternative
is a form saying "now go and log in" about the credential just chosen.

The checklist is the other half. Four things have to be true before an
alert reaches a phone -- a notification topic, somebody on the rota, an
alert source, and an alert that has actually arrived -- and on a fresh
install none of them are. It sits above the queue until they are.

It is computed from the data rather than from stored progress: a topic is
set or it is not, an integration exists or it does not. That means it
cannot claim a step is done when it is not, and it comes back by itself
if somebody deletes their integration a month later. The only stored
state is the dismissal, which is per user and not per browser --
finishing on a laptop should not leave the phone nagging.

The topic step is the only one the checklist can finish itself, and the
only proof that counts is a phone buzzing, so there is a test push.
POST /api/me/notify/test publishes directly rather than through the
outbox, which requires an incident this deliberately does not have. Its
failure is the useful part: a wrong topic, a rejected token and an ntfy
that is down all look identical from the phone, which is silence, so the
error comes back to the browser instead.

Verified against a live server with a real ntfy stand-in, the whole path:
an owner mints an invite, the sign-up page reports it valid and names the
team, the invitee signs up and is signed in as a member of that team, the
checklist's four questions answer correctly on a fresh install, a test
push is refused with no topic and delivered with one -- "PAGED
terdut-owner | terdut test" -- and the dismissal survives a reload.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 10:47:15 +02:00

336 lines
10 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 account from './account.js';
import * as team from './team.js';
import * as admin from './admin.js';
const $ = (id) => document.getElementById(id);
// One route per section; /incidents/{id} is the queue with a detail open.
const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
team: { title: 'Team', view: team },
admin: { title: 'Admin', view: admin },
more: { title: 'Account', view: account },
};
function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) };
const name = pathname.replace(/^\/|\/$/g, '');
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
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, s] of Object.entries(SECTIONS)) {
const el = $(`view-${name}`);
el.hidden = name !== route.section;
if (name === route.section) $('topbar-title').textContent = s.title;
}
for (const link of document.querySelectorAll('.nav-link')) {
if (link.dataset.section === route.section) 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();
}
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
else if (prev.section !== route.section) window.scrollTo(0, 0);
updateTitle();
}
// ---------- 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);
const badge = document.querySelector('[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 = SECTIONS[route.section].title;
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);
// /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') {
$('boot').hidden = true;
await showSignup();
return;
}
try {
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;
}
}
function showLogin() {
poll.stop();
ui.closeSheet(null);
reset();
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('signup-form').hidden = true;
$('login-form').hidden = false;
const form = $('login-form');
form.querySelector('.form-error').hidden = true;
// 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;
const err = form.querySelector('.form-error');
const btn = form.querySelector('button[type=submit]');
err.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() {
$('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();