diff --git a/README.md b/README.md index 7354ab8..e8ef997 100644 --- a/README.md +++ b/README.md @@ -92,14 +92,17 @@ the server refuses their writes anyway. Somebody in more than one team picks between them at the top. The **Admin** tab appears only for a system administrator, and holds what -belongs to the whole server rather than to one team: every team, every user, and -the settings that used to be environment variables. Adding somebody is minting -them an invite link into a team, rather than creating a bare account: the person -who accepts it picks their own password, so one never passes through an -administrator, and the link carries the team, so they land somewhere with a +belongs to the whole server rather than to one team. It has three sub-sections, +each with a URL of its own and a strip across the top to move between them: +every team (`/admin/teams`), every user (`/admin/users`), and the settings that +used to be environment variables (`/admin/settings`). `/admin` itself is an +overview — how many of each, and what each section is for. Adding somebody is +minting them an invite link into a team, rather than creating a bare account: +the person who accepts it picks their own password, so one never passes through +an administrator, and the link carries the team, so they land somewhere with a queue in it. -A name in that list opens **that person's page**, at `/admin/users/{id}`: their +A name in the user list opens **that person's page**, at `/admin/users/{id}`: their email and when they joined, where their notifications go, whether they are an administrator, whether the account is disabled, the teams they are in with their role in each, a password field for a first or forgotten one, and deletion. It is diff --git a/internal/web/static/app.css b/internal/web/static/app.css index a0ec5bc..4d22a7f 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -765,3 +765,35 @@ kbd { .step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; } .signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); } + +/* --- admin sub-navigation ------------------------------------------------ + A strip of links across the top of every admin page, one per sub-section. + Deliberately not .chip: chips filter what a page already shows, here and in + the queue, and these four go somewhere. Same aria-current convention as the + tab bar, so the state lives on the attribute rather than in a class. */ +.subnav { + display: flex; gap: 2px; + margin: 12px auto 0; + border-bottom: 1px solid var(--border); + overflow-x: auto; scrollbar-width: none; +} +.subnav::-webkit-scrollbar { display: none; } +.subnav-link { + flex: none; + padding: 8px 12px; margin-bottom: -1px; + border-bottom: 2px solid transparent; + color: var(--muted); font-size: 14px; font-weight: 600; white-space: nowrap; +} +.subnav-link:hover { color: var(--text); } +.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); } + +/* The overview at /admin. The strip above already links to the three, so these + carry the counts, which is the part a menu cannot say. */ +.admin-menu { display: grid; gap: 10px; margin-top: 16px; } +/* The grid's gap is the spacing here, so .card + .card must not add its own. */ +.admin-menu .card + .card { margin-top: 0; } +.admin-menu-item { display: block; padding: 14px; } +.admin-menu-item:hover { background: var(--surface-hover); } +.admin-menu-head { display: flex; align-items: baseline; gap: 8px; } +.admin-menu-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; } +.admin-menu-item p { margin: 4px 0 0; } diff --git a/internal/web/static/js/admin.js b/internal/web/static/js/admin.js index 4b81ec8..28f60aa 100644 --- a/internal/web/static/js/admin.js +++ b/internal/web/static/js/admin.js @@ -1,6 +1,12 @@ // Administration: the teams on this server, the people who can sign in, and // the settings that change how the server behaves. // +// Each of those three is a route of its own, reached from a strip across the +// top, with /admin itself an overview. They used to be three cards stacked on +// one page, which meant no way to link to the settings, no way back to the top +// of the user list but scrolling, and a poll that refetched all three endpoints +// however little of the page you were looking at. +// // Only rendered for a system administrator. The server enforces that on every // endpoint regardless — hiding a section is a courtesy to the reader, not a // permission — so this view simply says so rather than pretending to be a @@ -12,12 +18,34 @@ import { state, myID } from './state.js'; const view = () => document.getElementById('view-admin'); -let data = null; // { teams, users, settings } +// The sub-sections, in the order the strip shows them. The overview is /admin +// itself, so it has no tab of its own. This table is the only place the four +// routes are written down: app.js parses against it and the strip is built +// from it, so adding a fifth is one line here. +export const TABS = [ + { tab: null, path: '/admin', label: 'Overview' }, + { tab: 'teams', path: '/admin/teams', label: 'Teams' }, + { tab: 'users', path: '/admin/users', label: 'Users' }, + { tab: 'settings', path: '/admin/settings', label: 'Settings' }, +]; + +// Which sub-section is open. Remembered rather than passed, because the poll +// loop calls refresh() with no route — the same reason adminuser.js keeps its +// user ID in the module. +let tab = null; +let data = null; // whatever the current tab needs; the shape varies by tab let error = null; let busy = false; -export function show() { - if (!data) clear(view(), spinner()); +export function show(route) { + const next = route?.tab ?? null; + // A different sub-section wants different data, so the old answer goes + // rather than being shown under the new heading until the fetch lands. + if (next !== tab) { + tab = next; + data = null; + } + if (!data) clear(view(), subnav(), spinner()); refresh(); } @@ -28,12 +56,7 @@ export async function refresh() { return; } try { - const [teams, users, settings] = await Promise.all([ - api.adminTeams(), - api.users(), - api.adminSettings(), - ]); - data = { teams, users, settings }; + data = await load(); error = null; } catch (err) { error = err.message; @@ -41,6 +64,16 @@ export async function refresh() { render(); } +// Only what the open sub-section shows. Users is the one that needs two: its +// invite form has to offer a team to invite somebody into, and the overview +// counts both. +async function load() { + if (tab === 'teams') return { teams: await api.adminTeams() }; + if (tab === 'settings') return { settings: await api.adminSettings() }; + const [teams, users] = await Promise.all([api.adminTeams(), api.users()]); + return { teams, users }; +} + function render() { if (!state.me?.user?.is_admin) { clear(view(), h('div', { class: 'card' }, @@ -48,14 +81,65 @@ function render() { return; } if (!data) { - clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner()); + clear(view(), subnav(), error ? h('div', { class: 'load-error', text: error }) : spinner()); return; } clear(view(), + subnav(), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), - teamsCard(), - usersCard(), - settingsCard(), + section(), + ); +} + +function section() { + if (tab === 'teams') return teamsCard(); + if (tab === 'users') return usersCard(); + if (tab === 'settings') return settingsCard(); + return overview(); +} + +// The strip across the top of every admin page. Ordinary links rather than +// buttons, because these are four URLs: app.js intercepts the click, the +// browser's Back walks them, and a reload lands where you were. +function subnav() { + return h('nav', { class: 'subnav', 'aria-label': 'Administration' }, + TABS.map((t) => h('a', { + class: 'subnav-link', + href: t.path, + text: t.label, + 'aria-current': t.tab === tab ? 'page' : null, + }))); +} + +// --- overview -------------------------------------------------------------- + +// /admin itself. The strip already links to the three, so this earns its place +// by saying how much of each there is — the one thing a menu cannot. +function overview() { + const admins = data.users.filter((u) => u.is_admin).length; + const disabled = data.users.filter((u) => u.disabled_at).length; + const open = data.teams.reduce((n, t) => n + t.open_incidents, 0); + + const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`]; + if (disabled > 0) people.push(`${disabled} disabled`); + + return h('div', { class: 'admin-menu' }, + menuItem('/admin/teams', 'Teams', data.teams.length, + open > 0 + ? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.` + : 'Nothing open anywhere.'), + menuItem('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`), + menuItem('/admin/settings', 'Settings', null, + 'How the server behaves, and where it is plugged in.'), + ); +} + +function menuItem(href, label, count, note) { + return h('a', { class: 'card admin-menu-item', href }, + h('div', { class: 'admin-menu-head' }, + h('strong', { text: label }), + count != null && h('span', { class: 'admin-menu-count', text: String(count) })), + h('p', { class: 'muted small', text: note }), ); } diff --git a/internal/web/static/js/adminuser.js b/internal/web/static/js/adminuser.js index af0f7c1..d73b98c 100644 --- a/internal/web/static/js/adminuser.js +++ b/internal/web/static/js/adminuser.js @@ -83,7 +83,7 @@ function render() { } function backLink() { - return h('a', { class: 'back-link', href: '/admin' }, icon('chevronLeft'), h('span', { text: 'Admin' })); + return h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' })); } // --- identity -------------------------------------------------------------- @@ -271,7 +271,7 @@ async function deleteUser() { return; } toast('User deleted.'); - navigate('/admin'); + navigate('/admin/users'); } // --- plumbing -------------------------------------------------------------- diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 835a83a..41d9634 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -34,10 +34,24 @@ function parseRoute(pathname) { const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/); if (u) return { section: 'adminuser', user: Number(u[1]) }; const name = pathname.replace(/^\/|\/$/g, ''); - if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name }; + // 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. @@ -70,10 +84,10 @@ function render() { route = parseRoute(location.pathname); const app = $('app'); - for (const [name, s] of Object.entries(SECTIONS)) { + for (const name of Object.keys(SECTIONS)) { const el = $(`view-${name}`); el.hidden = name !== route.section; - if (name === route.section) $('topbar-title').textContent = s.title; + 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. @@ -99,7 +113,9 @@ function render() { 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); + // A changed tab counts as a changed page: stepping from a long user list to + // the settings should not land you halfway down them. + else if (prev.section !== route.section || prev.tab !== route.tab) window.scrollTo(0, 0); updateTitle(); } @@ -138,7 +154,7 @@ function updateBadges() { function updateTitle() { const triggered = state.open.filter((i) => i.status === 'triggered').length; - const section = SECTIONS[route.section].title; + const section = title(route); const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`; document.title = triggered ? `(${triggered}) ${base}` : base; }