diff --git a/README.md b/README.md index b1fa734..9f1da61 100644 --- a/README.md +++ b/README.md @@ -715,8 +715,8 @@ administrator who is not in the team gets the same `404` as anybody else. | `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner | | `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken | | `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents | -| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team | -| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` | +| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team, with `status` (`oncall` if the rota has them today, `unpageable` when a page to them would go nowhere — even if they are on call — else `reachable`), `on_call`, `next_shift` (first rota day after today), `pageable` and `problem` (`has no ntfy topic` / `account is disabled`; never the topic itself) and `last_active_at` (their newest session or API-key use). Every member sees the same list | +| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}`. `409` when it would demote the last owner | | `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner | | `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them | | `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change | diff --git a/internal/api/members_test.go b/internal/api/members_test.go new file mode 100644 index 0000000..a527ceb --- /dev/null +++ b/internal/api/members_test.go @@ -0,0 +1,154 @@ +package api_test + +import ( + "net/http" + "testing" + "time" + + "git.ryuvia.com/niklas/terdut-server/internal/api" +) + +func testNotify() api.NotifyConfig { + return api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute} +} + +type memberView struct { + Username string `json:"username"` + Role string `json:"role"` + Status string `json:"status"` + OnCall bool `json:"on_call"` + NextShift *string `json:"next_shift"` + Pageable bool `json:"pageable"` + Problem string `json:"problem"` + LastActiveAt *string `json:"last_active_at"` +} + +func readMembers(t *testing.T, s *ts) map[string]memberView { + t.Helper() + var list []memberView + decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/members", nil), &list) + out := map[string]memberView{} + for _, m := range list { + out[m.Username] = m + } + return out +} + +// The list says who is on call, who could not be woken, and who is merely +// there — and an on-call person who cannot be paged is the red one. +func TestMembers_StatusReflectsRotaAndPageability(t *testing.T) { + s, _ := notifyTS(t, testNotify()) // admin is on call today, with a topic + teamUser(t, s, "reachable", "terdut-reachable") + silent := teamUser(t, s, "silent", "terdut-silent") + s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent) + + got := readMembers(t, s) + if m := got["admin"]; m.Status != "oncall" || !m.OnCall || !m.Pageable { + t.Errorf("the person on call should read on call, got %+v", m) + } + if m := got["reachable"]; m.Status != "reachable" || m.OnCall { + t.Errorf("a member with a topic who is off the rota is reachable, got %+v", m) + } + if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no ntfy topic" { + t.Errorf("no topic means they cannot be paged, got %+v", m) + } + + // Being on call does not rescue an account that cannot be woken. + s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE username = 'admin'") + if m := readMembers(t, s)["admin"]; m.Status != "unpageable" || !m.OnCall { + t.Errorf("an on-call person with no topic is the red case, got %+v", m) + } + + s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent) + if m := readMembers(t, s)["silent"]; m.Problem != "account is disabled" { + t.Errorf("a disabled account should say so, got %+v", m) + } +} + +// The next shift is the next day after today, not today itself. +func TestMembers_NextShiftIsAfterToday(t *testing.T) { + s, _ := notifyTS(t, testNotify()) + tomorrow := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02") + resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule", + map[string]any{"user_id": 1, "dates": []string{tomorrow}}) + resp.Body.Close() + + m := readMembers(t, s)["admin"] + if !m.OnCall || m.NextShift == nil || *m.NextShift != tomorrow { + t.Errorf("want on call today with the next shift on %s, got %+v", tomorrow, m) + } + teamUser(t, s, "idle", "terdut-idle") + if m := readMembers(t, s)["idle"]; m.NextShift != nil { + t.Errorf("somebody not on the rota has no next shift, got %v", *m.NextShift) + } +} + +// Last active is the newer of a session and an API key, and absent when neither +// has ever been used. +func TestMembers_LastActive(t *testing.T) { + s, _ := notifyTS(t, testNotify()) + idle := teamUser(t, s, "idle", "terdut-idle") + + if m := readMembers(t, s)["idle"]; m.LastActiveAt != nil { + t.Errorf("nobody has used idle's account, got %v", *m.LastActiveAt) + } + + old := time.Now().Add(-48 * time.Hour).Unix() + s.exec(t, `INSERT INTO api_keys (user_id, key_hash, name, last_used_at) VALUES ($1, 'h1', 'k', $2)`, idle, old) + s.exec(t, `INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at) + VALUES ('h2', $1, $2, $3, $4)`, idle, old, old+3600, time.Now().Add(time.Hour).Unix()) + + m := readMembers(t, s)["idle"] + if m.LastActiveAt == nil { + t.Fatal("expected a last active time") + } + got, _ := time.Parse(time.RFC3339, *m.LastActiveAt) + if got.Unix() != old+3600 { + t.Errorf("last active should be the newer session (%d), got %d", old+3600, got.Unix()) + } +} + +// The last owner can be neither removed nor demoted; with another owner in +// place, both are fine. +func TestMembers_LastOwnerIsProtected(t *testing.T) { + s, _ := notifyTS(t, testNotify()) + tm := newTeam(t, s, "red") + base := "/api/teams/" + id64(tm.id) + "/members" + + // Creating a team makes the creator an owner too; step the admin out so + // "red-user" is the only one left. + resp := s.req(t, http.MethodDelete, base+"/1", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("removing the creator: %d", resp.StatusCode) + } + + var members []map[string]any + decode(t, tm.call(http.MethodGet, base, nil), &members) + var owner int64 + for _, m := range members { + if m["username"] == "red-user" { + owner = int64(m["user_id"].(float64)) + } + } + + resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"}) + resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Errorf("demoting the last owner: expected 409, got %d", resp.StatusCode) + } + resp = tm.call(http.MethodDelete, base+"/"+id64(owner), nil) + resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Errorf("removing the last owner: expected 409, got %d", resp.StatusCode) + } + + // A second owner frees the first to step down. + resp = s.req(t, http.MethodPost, base, map[string]any{"user_id": 1, "role": "owner"}) + resp.Body.Close() + resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"}) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("demoting one of two owners: expected 204, got %d", resp.StatusCode) + } +} diff --git a/internal/api/teams.go b/internal/api/teams.go index 0d0cb93..66c3714 100644 --- a/internal/api/teams.go +++ b/internal/api/teams.go @@ -210,8 +210,40 @@ func handleDeleteTeam(db *sql.DB) http.HandlerFunc { } } -// handleListTeamMembers names everybody in a team. Visible to any member: you -// can see who else is on the rota you are on. +// Member statuses, as the Members page colours them. +const ( + memberOnCall = "oncall" + memberReachable = "reachable" + memberUnpageable = "unpageable" +) + +// memberStatus is a team member with what matters about them at 03:00: whether +// they are on call, whether a page to them would go anywhere, and whether they +// have been around. The extra fields are output only. +type memberStatus struct { + models.TeamMember + + // Status is unpageable when a page to them would go nowhere — even when + // they are on call, since that is the case that matters most — on_call when + // the rota has them today, reachable otherwise. + Status string `json:"status"` + + OnCall bool `json:"on_call"` + + // NextShift is the first day after today the rota has them (YYYY-MM-DD). + NextShift *string `json:"next_shift,omitempty"` + + // Pageable is whether they have an ntfy topic and an enabled account — the + // conditions pageLevel and the notifier skip on. Never the topic itself. + Pageable bool `json:"pageable"` + Problem string `json:"problem,omitempty"` + + // LastActiveAt is the last time they used a session or an API key. + LastActiveAt *time.Time `json:"last_active_at,omitempty"` +} + +// handleListTeamMembers names everybody in a team, with their status. Visible to +// any member: you can see who else is on the rota you are on. func handleListTeamMembers(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { teamID, ok := teamParam(w, r) @@ -223,26 +255,56 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc { } rows, err := db.QueryContext(r.Context(), ` - SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at + SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, + u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '', + u.disabled_at IS NOT NULL, + GREATEST( + COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0), + COALESCE((SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id), 0)), + EXISTS (SELECT 1 FROM schedule_entries s + WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date = $2), + (SELECT MIN(date) FROM schedule_entries s + WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date > $2) FROM team_members m JOIN users u ON u.id = m.user_id WHERE m.team_id = $1 - ORDER BY u.username`, teamID) + ORDER BY u.username`, teamID, todayUTC()) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } defer rows.Close() - members := []models.TeamMember{} + members := []memberStatus{} for rows.Next() { - var m models.TeamMember - var joined int64 - if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil { + var m memberStatus + var joined, lastActive int64 + var hasTopic, disabled bool + if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, + &hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } m.JoinedAt = time.Unix(joined, 0).UTC() + if lastActive > 0 { + t := time.Unix(lastActive, 0).UTC() + m.LastActiveAt = &t + } + switch { + case disabled: + m.Problem = "account is disabled" + case !hasTopic: + m.Problem = "has no ntfy topic" + } + m.Pageable = m.Problem == "" + switch { + case !m.Pageable: + m.Status = memberUnpageable + case m.OnCall: + m.Status = memberOnCall + default: + m.Status = memberReachable + } members = append(members, m) } if err := rows.Err(); err != nil { @@ -281,6 +343,20 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc { return } + // Demoting the last owner is removing them by another route: the team + // would have nobody who can edit it. + if req.Role == models.RoleMember { + last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if last { + respond(w, http.StatusConflict, errResp("cannot demote the last owner of a team")) + return + } + } + _, err := db.ExecContext(r.Context(), ` INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index a228297..ca0fb02 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -173,7 +173,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh transition: background 0.12s, border-color 0.12s, opacity 0.12s; } .btn:hover { background: var(--surface-hover); } -.btn:disabled { opacity: 0.55; cursor: default; } +.btn:disabled, .btn-sm:disabled { opacity: 0.55; cursor: default; } .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } .btn-primary:hover { background: var(--accent); filter: brightness(1.06); } .btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; } @@ -366,8 +366,9 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh .badge.st-dormant, .badge.st-never { background: var(--surface-2); color: var(--muted); } .badge.st-active { background: var(--ok-soft); color: var(--ok); } .badge.st-quiet, .badge.st-escalating { background: var(--warn-soft); color: var(--warn); } -.badge.st-ready { background: var(--ok-soft); color: var(--ok); } -.badge.st-unreachable { background: var(--crit-soft); color: var(--crit); } +.badge.st-ready, .badge.st-oncall { background: var(--ok-soft); color: var(--ok); } +.badge.st-reachable { background: var(--surface-2); color: var(--muted); } +.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); } .badge.sev-critical { background: var(--crit-soft); color: var(--crit); } .badge.sev-warning { background: var(--warn-soft); color: var(--warn); } .badge.sev-info { background: var(--info-soft); color: var(--info); } diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index 94ec8ad..2a2f366 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -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 --------------------------------------------------------------