5b4683febf
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.
Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.
This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.
TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
354 lines
13 KiB
JavaScript
354 lines
13 KiB
JavaScript
// 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();
|
|
}
|