Give every team a page of its own

The Admin tab's team list was growing controls the way the user list did
before ac9af8e: 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 caveat ac9af8e and 07914d5
both 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
This commit is contained in:
Niklas Ye
2026-09-22 09:13:03 +02:00
parent ee22eb000c
commit a6fa673e08
10 changed files with 624 additions and 110 deletions
+26 -95
View File
@@ -64,9 +64,9 @@ 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.
// Only what the open sub-section shows. Users is the one that needs two: it
// only points at Teams for an invite if there is a team to point at, and the
// overview counts both.
async function load() {
if (tab === 'teams') return { teams: await api.adminTeams() };
if (tab === 'settings') return { settings: await api.adminSettings() };
@@ -148,37 +148,24 @@ function menuItem(href, label, count, note) {
function teamsCard() {
const rows = data.teams.map((t) =>
h('tr', {},
h('td', {}, h('strong', { text: t.name })),
// The name is the way in: everything about one team lives on its own
// page, and this table stays a list rather than becoming a form.
h('td', {}, h('a', { class: 'row-link', href: `/admin/teams/${t.id}`, text: t.name })),
h('td', { class: 'num', text: String(t.members) }),
h('td', { class: 'num', text: String(t.open_incidents) }),
h('td', {},
h('button', {
class: 'btn-sm',
type: 'button',
text: 'Rename',
onclick: () => renameTeam(t),
}),
// A team with open incidents cannot be deleted, and saying so before
// the click is kinder than a 409 afterwards.
h('button', {
class: 'btn-sm danger',
type: 'button',
text: 'Delete',
disabled: t.open_incidents > 0,
title: t.open_incidents > 0 ? 'Resolve its open incidents first' : '',
onclick: () => deleteTeam(t),
}),
),
));
return h('div', { class: 'card' },
h('h2', { text: 'Teams' }),
h('p', { class: 'muted small' },
'Open a team for who is in it, the invites into it, and renaming or ',
'deleting it. Deleting takes its alerts, incidents, schedule and ',
'integrations with it, and is refused while anything is still open.'),
h('table', { class: 'admin-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Name' }),
h('th', { class: 'num', text: 'Members' }),
h('th', { class: 'num', text: 'Open' }),
h('th', { text: '' }))),
h('th', { class: 'num', text: 'Open' }))),
h('tbody', {}, rows)),
newTeamForm(),
);
@@ -206,32 +193,6 @@ function newTeamForm() {
return form;
}
async function renameTeam(team) {
const next = window.prompt(`Rename ${team.name} to:`, team.name);
if (!next || next === team.name) return;
try {
await api.renameTeam(team.id, next);
} catch (err) {
error = err.message;
}
refresh();
}
async function deleteTeam(team) {
if (!(await confirm({
title: `Delete ${team.name}?`,
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
}))) return;
try {
await api.deleteTeam(team.id);
} catch (err) {
error = err.message;
}
refresh();
}
// --- users -----------------------------------------------------------------
function usersCard() {
@@ -241,7 +202,7 @@ function usersCard() {
h('td', {},
// The name is the way in: everything about one person lives on their
// own page, and this table stays a list rather than becoming a form.
h('a', { class: 'user-link', href: `/admin/users/${u.id}`, text: u.username }),
h('a', { class: 'row-link', href: `/admin/users/${u.id}`, text: u.username }),
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })),
h('td', { class: 'muted', text: u.email }),
@@ -279,57 +240,27 @@ function usersCard() {
h('th', { text: '' }),
h('th', { text: '' }))),
h('tbody', {}, rows)),
inviteForm(),
invitePointer(),
);
}
// Adding a person is minting them an invite, not creating a row. The account
// is created by whoever accepts it, so they pick their own password and it
// never passes through an administrator — and the link carries the team, which
// a bare POST /api/users cannot, leaving an account with nothing to work on.
// Adding a person is minting them an invite into a team, not creating a row:
// whoever accepts it picks their own password, so one never passes through an
// administrator, and the link carries the team, so they do not land on an empty
// queue.
//
// Minting for a team the administrator is not in is allowed: the flag passes
// every team-owner check, so a server administrator can staff any team. The
// link shows up in that team's own invite list, where an owner can revoke it.
function inviteForm() {
const team = h('select', {},
...data.teams.map((t) => h('option', { value: String(t.id), text: t.name })));
const role = h('select', {},
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const out = h('p', { class: 'invite-out', hidden: true });
const form = h('form', { class: 'inline-form' }, team, role,
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
busy = true;
try {
const inv = await api.createInvite(Number(team.value), role.value, 1);
// Shown once and never stored, so it is put on the page to be copied
// rather than toasted away after three seconds.
clear(out, h('strong', { text: 'Send them this link. It is shown once.' }),
h('code', { class: 'invite-link', text: inv.url }));
out.hidden = false;
error = null;
} catch (err) {
error = err.message;
render();
return;
} finally {
busy = false;
}
});
// The form for it lives on the team's own page. It always needed a team beside
// it, and a picker here was the admission that an invite is a fact about a team
// rather than about the server.
function invitePointer() {
return h('div', { class: 'invite-block' },
h('h3', { text: 'Add someone' }),
h('p', { class: 'muted small' },
'An invite link puts them in a team and lets them choose their own ',
'password. It lasts a week and can be used once.'),
data.teams.length > 0 ? form
data.teams.length > 0
? h('p', { class: 'muted small' },
'Open the team you want them in, under ',
h('a', { class: 'row-link', href: '/admin/teams', text: 'Teams' }),
', and mint an invite there.')
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
out,
);
}
+341
View File
@@ -0,0 +1,341 @@
// One team, at /admin/teams/{id}: what it is, who is in it, the invites into
// it, and the two destructive things an administrator can do to it.
//
// The mirror of adminuser.js. That page answers "which teams is this person
// in"; this one answers "who is in this team" for a team the administrator
// need not be a member of — which the Team tab cannot do, because it only
// offers teams the viewer is in.
//
// Only rendered for a system administrator. The server enforces that on every
// endpoint regardless, so this view says so rather than pretending to be a
// gate.
import * as api from './api.js';
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
import { state } from './state.js';
import { navigate } from './app.js';
import { when } from './format.js';
const view = () => document.getElementById('view-adminteam');
let teamID = null;
let data = null; // { team, members, users, invites }
let error = null;
let busy = false;
// An invite link is shown once and never stored, so it lives here until the
// page is left rather than being toasted away after three seconds.
let freshInvite = null;
export function show(route) {
const next = route && route.team != null ? route.team : null;
if (next !== teamID) {
teamID = next;
data = null;
error = null;
freshInvite = null;
}
if (!data) clear(view(), spinner());
refresh();
}
export async function refresh() {
if (teamID == null || !state.me?.user?.is_admin) {
render();
return;
}
try {
// The team and its members come from the admin endpoint in one answer:
// /teams/{id}/members is member-only and 404s an administrator from
// outside the team, deliberately. users() is the add-a-member picker.
const [team, users, invites] = await Promise.all([
api.adminTeam(teamID),
api.users(),
api.invites(teamID),
]);
data = { team: team.team, members: team.members, users, invites };
error = null;
} catch (err) {
// A team that is gone answers 404, where a missing user is simply absent
// from a list adminuser.js already has. So the "no such team" state has to
// be recognised here; left to the error banner it would read as a fetch
// that failed, which is a different thing and invites a retry.
if (err.status === 404) {
data = { team: null, members: [], users: [], invites: [] };
error = null;
} else {
error = err.message;
}
}
render();
}
function render() {
const el = view();
if (!state.me?.user?.is_admin) {
clear(el, backLink(), h('div', { class: 'card' },
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
return;
}
if (!data) {
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
if (!data.team) {
clear(el, backLink(), h('div', { class: 'card' },
h('p', { class: 'muted', text: 'No such team. It may have just been deleted.' })));
return;
}
clear(el,
backLink(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
identityCard(),
membersCard(),
invitesCard(),
dangerCard(),
);
}
function backLink() {
return h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' }));
}
// --- identity --------------------------------------------------------------
function identityCard() {
const t = data.team;
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const name = h('input', {
name: 'name', type: 'text', value: t.name, required: true,
autocomplete: 'off', spellcheck: false,
});
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save name' });
// A field rather than the window.prompt this used to be. The server answers
// 409 for a name already taken, and a dialog is the wrong place to read that.
const form = h('form', { class: 'inline-form' }, name, submit);
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
err.hidden = true;
ok.hidden = true;
const next = name.value.trim();
if (!next || next === t.name) return;
busy = true;
submit.disabled = true;
try {
await api.renameTeam(teamID, next);
ok.textContent = 'Name saved.';
ok.hidden = false;
error = null;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
busy = false;
submit.disabled = false;
return;
}
busy = false;
submit.disabled = false;
await refresh();
});
return h('div', { class: 'card' },
h('div', { class: 'user-head' }, h('h2', { text: t.name })),
h('dl', { class: 'user-facts' },
fact('Created', when(t.created_at)),
fact('Members', String(t.members)),
fact('Open incidents', String(t.open_incidents)),
),
form, err, ok,
);
}
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
// --- members ---------------------------------------------------------------
// An administrator passes every team-owner check without being in the team,
// which is what lets them repair a team whose owner has left. So this card
// edits rather than reporting what somebody else would have to do.
function membersCard() {
const rows = data.members.map((m) =>
h('tr', {},
// Unlike the Team tab's own member list, the name is a link: that
// person's page is where the rest of them lives.
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
h('td', { class: 'muted small', text: m.role }),
h('td', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button',
text: m.role === 'owner' ? 'Make member' : 'Make owner',
// The same endpoint both ways: adding is an upsert on the role.
onclick: () => act(() =>
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
// The server refuses the last owner with a 409, which act() shows.
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
}),
),
));
const inTeam = new Set(data.members.map((m) => m.user_id));
// A disabled account cannot sign in, so putting one on a rota would be
// staffing the team with somebody who cannot answer.
const candidates = data.users.filter((u) => !inTeam.has(u.id) && !u.disabled_at);
const pick = h('select', {},
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
const role = h('select', {},
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
});
return h('div', { class: 'card' },
h('h2', { text: 'Members' }),
data.members.length === 0 && h('p', { class: 'muted small' },
'Nobody is in this team. Its queue has no one to work it and its ',
'escalation has no one to reach — add somebody, or delete it.'),
data.members.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
candidates.length > 0 && form,
);
}
// --- invites ---------------------------------------------------------------
// Adding a person to the server is minting them an invite into a team, not
// creating a row: whoever accepts it picks their own password, so one never
// passes through an administrator, and the link carries the team, so they do
// not land on an empty queue.
//
// This lives on the team rather than on the Users page, where it used to be
// with a team picker beside it. The picker was the admission that an invite is
// a fact about a team.
function invitesCard() {
const role = h('select', {},
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, role,
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
busy = true;
try {
const inv = await api.createInvite(teamID, role.value, 1);
freshInvite = inv.url;
error = null;
} catch (err) {
error = err.message;
} finally {
busy = false;
}
await refresh();
});
// The server lists spent and revoked invites too, and they are worth seeing:
// "who was invited here" is part of the answer to "who is in this team".
// Only a live one can be revoked, so only a live one offers the button.
const rows = (data.invites || []).map((inv) => {
const state = inviteState(inv);
return h('tr', { class: state === 'live' ? '' : 'disabled-row' },
h('td', {}, h('strong', { text: inv.role })),
h('td', { class: 'muted small', text: `${inv.uses}/${inv.max_uses} used` }),
h('td', { class: 'muted small', text: state === 'live' ? `expires ${when(inv.expires_at)}` : state }),
h('td', { class: 'row-actions' },
state === 'live' && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Revoke',
onclick: () => act(() => api.revokeInvite(teamID, inv.id)),
})),
);
});
return h('div', { class: 'card' },
h('h2', { text: 'Invites' }),
h('p', { class: 'muted small' },
'An invite link puts somebody in this team and lets them choose their ',
'own password. It lasts a week and can be used once.'),
rows.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
form,
// Shown once and never stored, so it goes on the page to be copied.
freshInvite && h('p', { class: 'invite-out' },
h('strong', { text: 'Send them this link. It is shown once.' }),
h('code', { class: 'invite-link', text: freshInvite })),
);
}
// Why a link no longer works, in the server's own order of precedence: revoked
// beats spent beats expired. Only 'live' is still usable.
function inviteState(inv) {
if (inv.revoked) return 'revoked';
if (inv.uses >= inv.max_uses) return 'used up';
if (new Date(inv.expires_at).getTime() <= Date.now()) return 'expired';
return 'live';
}
// --- delete ----------------------------------------------------------------
function dangerCard() {
const t = data.team;
const blocked = t.open_incidents > 0;
return h('div', { class: 'card' },
h('h2', { text: 'Delete' }),
h('p', { class: 'muted small' },
'Its alerts, incidents, schedule and integrations go with it. This ',
'cannot be undone. Everybody in it keeps their account and stays in ',
'whatever other teams they are in.'),
h('button', {
class: 'btn btn-danger', type: 'button', text: `Delete ${t.name}`,
// Saying so before the click is kinder than a 409 afterwards.
disabled: blocked,
title: blocked ? 'Resolve its open incidents first' : '',
onclick: deleteTeam,
}),
);
}
async function deleteTeam() {
if (!(await confirm({
title: `Delete ${data.team.name}?`,
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
}))) return;
try {
await api.deleteTeam(teamID);
} catch (err) {
error = err.message;
render();
return;
}
toast('Team deleted.');
// Not act(): there is no longer a page here to refresh.
navigate('/admin/teams');
}
// --- plumbing --------------------------------------------------------------
// act runs a write and reloads. Errors are shown rather than thrown away: the
// 409 from the last-owner guard, and the one for a duplicate name, are the
// server explaining itself, and the reader needs to see it.
async function act(fn) {
if (busy) return;
busy = true;
try {
await fn();
error = null;
} catch (err) {
error = err.message;
} finally {
busy = false;
}
await refresh();
}
+4
View File
@@ -142,6 +142,10 @@ export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/sc
// Administration. Every one of these is refused with 403 for anybody without
// the flag, so the UI hides the section rather than guarding it.
export const adminTeams = () => call('GET', '/admin/teams');
// One team and who is in it: { team, members }. /teams/{id}/members is
// member-only and answers 404 to an administrator from outside the team, which
// is the rule rather than an oversight -- this asks the other question.
export const adminTeam = (id) => call('GET', `/admin/teams/${id}`);
export const adminSettings = () => call('GET', '/admin/settings');
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
export const setUserAdmin = (id, isAdmin) =>
+10 -2
View File
@@ -12,6 +12,7 @@ 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);
@@ -25,6 +26,7 @@ const SECTIONS = {
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 },
};
@@ -33,6 +35,10 @@ function parseRoute(pathname) {
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.
@@ -114,8 +120,10 @@ function render() {
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.
else if (prev.section !== route.section || prev.tab !== route.tab) window.scrollTo(0, 0);
// 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();
}