Show team members as a list, with who is on call and who cannot be paged
Team -> Members was a two-column table and an inline add form. It is now a table in the style of Switches, Sources and Escalation: a status badge, the member, their role, their next rota day, when they were last active and when they joined. Adding a member and changing a role moved into sheets, and removing one asks first. The badge is the one that matters at 03:00: On call if the rota has them today, Reachable if they have an ntfy topic, and Can't be paged when a page to them would go nowhere -- no topic, or a disabled account -- with the reason under their name. Not being pageable wins over being on call, since an on-call person nobody can reach is the case worth seeing before an incident finds it. The rules are the notifier's own. The topic itself is never in the response, only whether one is set. Last active is the newer of a member's newest session and API-key use, and is shown to every member of the team like the rest of the list. Rota days are UTC dates, and the page formats them as such so a day cannot show up as the one before. Removing a member leaves the rota days already assigned to them alone, which the confirm says, so they are reassigned from the Rota tab rather than silently dropped. Demoting the last owner is now refused with 409, as removing them already was: it was the same outcome by another route, a team with nobody who can edit it. API: GET /members gains status, on_call, next_shift, pageable, problem and last_active_at; additive, no migration, and terdut-tui needs nothing. POST /members answers 409 for the last-owner demotion.
This commit is contained in:
+137
-28
@@ -930,41 +930,150 @@ function openNewSwitch() {
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
|
||||
function membersCard() {
|
||||
const rows = (data.members || []).map((m) =>
|
||||
h('tr', {},
|
||||
h('td', {}, h('strong', { text: m.username })),
|
||||
h('td', { class: 'muted small', text: m.role }),
|
||||
h('td', {}, isOwner() && h('button', {
|
||||
class: 'btn-sm', type: 'button',
|
||||
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||||
onclick: () => act(() =>
|
||||
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||||
}), isOwner() && h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||||
})),
|
||||
));
|
||||
const MEMBER_STATUS = {
|
||||
oncall: { label: 'On call', hint: 'The rota has them today.' },
|
||||
reachable: { label: 'Reachable', hint: 'Has an ntfy topic, so a page would reach them.' },
|
||||
unpageable: { label: 'Can’t be paged', hint: 'A page to them would go nowhere.' },
|
||||
};
|
||||
|
||||
const memberBadge = (m) => {
|
||||
const el = statusBadge(MEMBER_STATUS, m.status, 'reachable');
|
||||
if (m.problem) el.title = `${MEMBER_STATUS.unpageable.hint} ${m.problem}.`;
|
||||
return el;
|
||||
};
|
||||
|
||||
// Rota days are UTC dates with no time in them; formatting one in the viewer's
|
||||
// zone could show the day before.
|
||||
const shiftFmt = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC',
|
||||
});
|
||||
const shiftDay = (ymd) => shiftFmt.format(new Date(`${ymd}T00:00:00Z`));
|
||||
|
||||
function shiftCell(m) {
|
||||
if (m.on_call) {
|
||||
return h('span', { text: m.next_shift ? `today, then ${shiftDay(m.next_shift)}` : 'today' });
|
||||
}
|
||||
return m.next_shift
|
||||
? h('span', { text: shiftDay(m.next_shift) })
|
||||
: h('span', { text: 'not scheduled' });
|
||||
}
|
||||
|
||||
function membersCard() {
|
||||
const members = data.members || [];
|
||||
const owners = members.filter((m) => m.role === 'owner').length;
|
||||
|
||||
const rows = members.map((m) => {
|
||||
const lastOwner = m.role === 'owner' && owners === 1;
|
||||
return h('tr', {},
|
||||
h('td', {}, memberBadge(m)),
|
||||
h('td', { class: 'wrap' },
|
||||
h('strong', { text: m.username }),
|
||||
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
|
||||
m.problem && h('div', { class: 'target-problem', text: m.problem })),
|
||||
h('td', { class: 'muted small', text: m.role }),
|
||||
h('td', { class: 'muted small' }, shiftCell(m)),
|
||||
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
|
||||
h('td', { class: 'muted small' },
|
||||
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
||||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: 'Edit', onclick: () => openEditMember(m),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
disabled: lastOwner,
|
||||
title: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
|
||||
onclick: async () => {
|
||||
if (!(await confirm({
|
||||
title: `Remove ${m.username}?`,
|
||||
text: 'They lose access to this team. Rota days already assigned to them are not '
|
||||
+ 'changed, so reassign those from the Rota tab.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
}))) return;
|
||||
act(() => api.removeTeamMember(teamID, m.user_id));
|
||||
},
|
||||
}))),
|
||||
);
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Members' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'Owners set up the team; members work its incidents. Somebody who can’t be ',
|
||||
'paged is worth fixing before their next shift.'),
|
||||
members.length
|
||||
? h('div', { class: 'table-scroll' },
|
||||
h('table', { class: 'admin-table status-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
|
||||
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
|
||||
h('th'))),
|
||||
h('tbody', {}, rows)))
|
||||
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
|
||||
);
|
||||
}
|
||||
|
||||
// One sheet for both jobs a member's row has: who, and as what. Adding is
|
||||
// choosing a person and a role; editing is the same with the person fixed. The
|
||||
// API is one call either way — POST upserts the role.
|
||||
function openMemberSheet({ title, submit, person, role, run }) {
|
||||
const roleSelect = h('select', {},
|
||||
...['member', 'owner'].map((r) => h('option', { value: r, text: r, selected: r === role })));
|
||||
const problem = h('p', { class: 'load-error', hidden: true });
|
||||
const form = h('form', { class: 'stacked-form' },
|
||||
person.label,
|
||||
h('label', {}, 'Role ', roleSelect),
|
||||
problem,
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: submit })));
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await run(person.userID(), roleSelect.value);
|
||||
} catch (err) {
|
||||
problem.textContent = err.message;
|
||||
problem.hidden = false;
|
||||
return;
|
||||
}
|
||||
closeSheet(true);
|
||||
refresh();
|
||||
});
|
||||
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
|
||||
}
|
||||
|
||||
function openAddMember() {
|
||||
const inTeam = new Set((data.members || []).map((m) => m.user_id));
|
||||
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));
|
||||
openMemberSheet({
|
||||
title: 'Add member', submit: 'Add member', role: 'member',
|
||||
person: {
|
||||
label: candidates.length
|
||||
? h('label', {}, 'Person ', pick)
|
||||
: h('p', { class: 'muted', text: 'Everybody with an account is already in this team.' }),
|
||||
userID: () => Number(pick.value),
|
||||
},
|
||||
run: (userID, role) => {
|
||||
if (!userID) throw new Error('Nobody to add');
|
||||
return api.addTeamMember(teamID, userID, role);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Members' }),
|
||||
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||
isOwner() && candidates.length > 0 && form,
|
||||
);
|
||||
function openEditMember(m) {
|
||||
openMemberSheet({
|
||||
title: `Edit ${m.username}`, submit: 'Save', role: m.role,
|
||||
person: { label: h('p', { class: 'muted small', text: m.username }), userID: () => m.user_id },
|
||||
run: (userID, role) => api.addTeamMember(teamID, userID, role),
|
||||
});
|
||||
}
|
||||
|
||||
// --- plumbing --------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user