// 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, ssoBadge, SSO_MANAGED } 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)), // Read-only here: an administrator can see why a team's OIDC-sourced // membership looks the way it does, but setting it is the team's own // owner's call, from the Team tab. ...(state.auth?.oidc?.enabled ? [ fact('OIDC member group', t.oidc_member_group || '—'), fact('OIDC owner group', t.oidc_owner_group || '—'), ] : []), ), 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' }, m.role, m.source === 'oidc' && ssoBadge()), h('td', { class: 'row-actions' }, h('button', { class: 'btn-sm', type: 'button', text: m.role === 'owner' ? 'Make member' : 'Make owner', // The server refuses to edit a membership the groups grant. disabled: m.source === 'oidc', title: m.source === 'oidc' ? SSO_MANAGED : null, // 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', disabled: m.source === 'oidc', title: m.source === 'oidc' ? SSO_MANAGED : null, // 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(); }