a6fa673e08
The Admin tab's team list was growing controls the way the user list did beforeac9af8e: a Rename button behind window.prompt, a Delete beside it, and -- on the Users page, of all places -- an invite form with a team picker in front of it. The picker was the admission that an invite is a fact about a team rather than about the server, and a prompt() is the wrong place to read a 409 about a name already taken. So a team is now a subject with a page, at /admin/teams/{id}, the mirror of /admin/users/{id}: when it was created, how many are in it and how much is open, a field to rename it, the members with their roles, the invites into it, and deletion. The list goes back to being a list, and the name in it is the way in. The member list is the one thing there that needed a new endpoint. GET /api/teams/{id}/members is requireTeamMember and answers 404 to an administrator who is not in the team, and that stays exactly as it is: member means membership and nothing else. Reading a team's shape is a different question from reading its work, so it gets an endpoint of its own under AdminOnly -- GET /api/admin/teams/{id}, returning {"team", "members"} -- rather than an exception carved into that rule. It is a wrapper and not a team with the members hung off it, because "members" already means a count on the list endpoint and one name must not be a number in one answer and an array in the next. The query and its ordering are copied from handleListTeamMembers so the two answers to "who is in this team" cannot disagree. An administrator still sees none of that team's incidents, alerts or rota. Nothing about what the flag may do changed; it could already rename and delete any team, and staff one it is not in. Rename now trims what it is given, as creation has always trimmed. Before this, " " was a legal name to rename a team to but not to create one with, which is one rule stated twice and applied once. Nobody has looked at this in a browser, the caveatac9af8eand07914d5both carried. What is checked is the wiring: admin_test.go covers the new endpoint for an administrator outside the team, the 404 the member-only endpoint still gives that same administrator, the 403 for a member who is not one, a 404 for a team that does not exist, a 400 for an id that is not a number, and the trim; the module graph evaluates at /admin/teams/{id}, and the server serves index.html there, so a reload survives. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
369 lines
12 KiB
JavaScript
369 lines
12 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';
|
|
import * as adminuser from './adminuser.js';
|
|
import * as adminteam from './adminteam.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 },
|
|
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 },
|
|
};
|
|
|
|
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 tab's sub-sections are routes of their own. admin.js owns the
|
|
// table of them, since it also builds the strip that links to them.
|
|
const t = admin.TABS.find((x) => x.path === `/${name}`);
|
|
if (t) return { section: 'admin', tab: t.tab };
|
|
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'more') return { section: name };
|
|
return { section: 'queue', incident: null };
|
|
}
|
|
|
|
// What the top bar and the document title call this route. Admin's sub-sections
|
|
// are pages in their own right, so they say which one rather than "Admin" four
|
|
// times; the overview keeps the tab's own name.
|
|
function title(r) {
|
|
const t = r.section === 'admin' && r.tab
|
|
? admin.TABS.find((x) => x.tab === r.tab)
|
|
: null;
|
|
return t ? 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();
|
|
}
|
|
|
|
// ---------- 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 = 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);
|
|
|
|
// /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();
|