diff --git a/README.md b/README.md index e8ef997..c08d0ce 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,17 @@ overview — how many of each, and what each section is for. Adding somebody is minting them an invite link into a team, rather than creating a bare account: the person who accepts it picks their own password, so one never passes through an administrator, and the link carries the team, so they land somewhere with a -queue in it. +queue in it. That happens on the team's own page, since an invite is a fact +about a team; the user list points there rather than asking which team beside a +form. + +A name in the team list opens **that team's page**, at `/admin/teams/{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 member list is the +one thing there that needed a new endpoint — `GET /api/teams/{id}/members` is +member-only and answers `404` to an administrator who is not in the team, which is +the rule and not an oversight, so the page reads `GET /api/admin/teams/{id}` instead. +An administrator still sees none of that team's incidents, alerts or rota. A name in the user list opens **that person's page**, at `/admin/users/{id}`: their email and when they joined, where their notifications go, whether they are an @@ -653,6 +663,7 @@ on anybody's. | Method | Path | Who | Description | |---|---|---|---| | `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" | +| `GET` | `/api/admin/teams/{teamID}` | **admin** | One team and who is in it: `{"team", "members"}`. `404` for a team that does not exist. `GET /api/teams/{teamID}/members` is **member**-only and still `404`s an administrator from outside the team — reading a team's shape and reading its work are different questions, so they are different endpoints | | `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials | | `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds | diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index 799bd56..dd6eefd 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -310,6 +310,127 @@ func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) { } } +// The team page at /admin/teams/{id} needs the one question the test above +// leaves shut: who is in a team the administrator is not in. +// +// It is answered by a separate endpoint under AdminOnly rather than by letting +// the admin flag through requireTeamMember, and the second half of this test is +// the reason — /api/teams/{id}/members must keep answering 404, so that "member +// means membership and nothing else" stays true of the endpoint it was said +// about. Reading a team's shape and reading a team's work are different things. +func TestAdminGetTeam_ReadsAnyTeamWithoutJoiningIt(t *testing.T) { + s := newTS(t) + + founderID, call := member(t, s, "founder") + var team struct { + ID int64 `json:"id"` + } + decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team) + if team.ID == 0 { + t.Fatal("no team was created") + } + + // The admin reads it whole, without being in it. + var got struct { + Team struct { + ID int64 `json:"id"` + Name string `json:"name"` + Members int64 `json:"members"` + OpenIncidents int64 `json:"open_incidents"` + } `json:"team"` + Members []struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + } `json:"members"` + } + decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got) + + if got.Team.ID != team.ID || got.Team.Name != "theirs" { + t.Errorf("expected team %d named theirs, got %d named %q", team.ID, got.Team.ID, got.Team.Name) + } + if got.Team.Members != 1 { + t.Errorf("expected a member count of 1, got %d", got.Team.Members) + } + if len(got.Members) != 1 { + t.Fatalf("expected one member, got %d", len(got.Members)) + } + if got.Members[0].UserID != founderID || got.Members[0].Username != "founder" { + t.Errorf("expected founder (%d), got %q (%d)", + founderID, got.Members[0].Username, got.Members[0].UserID) + } + // Whoever creates a team owns it, and the page's role toggle depends on + // that being reported rather than assumed. + if got.Members[0].Role != "owner" { + t.Errorf("expected the creator to be owner, got %q", got.Members[0].Role) + } + + // The rule this endpoint exists in order not to break. Same admin, same + // team, the member-only endpoint: still not found. + resp := s.req(t, http.MethodGet, "/api/teams/"+id64(team.ID)+"/members", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("an admin outside the team must still get 404 from the member-only list, got %d", + resp.StatusCode) + } + + // And the new one is administration, not membership: being in the team is + // not enough. + resp = call(http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("a non-admin member must get 403, got %d", resp.StatusCode) + } + + for _, c := range []struct { + name string + path string + want int + }{ + {"a team that does not exist", "/api/admin/teams/999999", http.StatusNotFound}, + {"a team id that is not a number", "/api/admin/teams/nonsense", http.StatusBadRequest}, + } { + resp := s.req(t, http.MethodGet, c.path, nil) + resp.Body.Close() + if resp.StatusCode != c.want { + t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode) + } + } +} + +// A team name is trimmed when it is created, and renaming had not been, so " " +// was a legal name to rename to and an illegal one to start with. +func TestRenameTeam_TrimsTheName(t *testing.T) { + s := newTS(t) + var team struct { + ID int64 `json:"id"` + } + decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "trimmed"}), &team) + + path := "/api/teams/" + id64(team.ID) + resp := s.req(t, http.MethodPut, path, map[string]string{"name": " "}) + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("a blank name must be refused, got %d", resp.StatusCode) + } + + resp = s.req(t, http.MethodPut, path, map[string]string{"name": " padded "}) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("expected 204, got %d", resp.StatusCode) + } + + var got struct { + Team struct { + Name string `json:"name"` + } `json:"team"` + } + decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got) + if got.Team.Name != "padded" { + t.Errorf("expected the name to be trimmed to %q, got %q", "padded", got.Team.Name) + } +} + // The admin page's per-user view asks what somebody is in. Self or admin, like // the rest of the per-user endpoints. func TestUserTeams_SelfOrAdmin(t *testing.T) { diff --git a/internal/api/router.go b/internal/api/router.go index 54cd6d9..02703f9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -94,6 +94,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler // What exists on this server, and how it behaves. /api/teams // answers "what am I in"; this one answers "what is there". r.Get("/api/admin/teams", handleAdminListTeams(db)) + // One team and who is in it. The member list under + // /api/teams/{id}/members stays member-only and still 404s + // an administrator from outside; this is a different + // question, so it is a different endpoint. + r.Get("/api/admin/teams/{teamID}", handleAdminGetTeam(db)) r.Get("/api/admin/settings", handleGetSettings(db, cfg)) r.Put("/api/admin/settings", handleSetSettings(db)) }) diff --git a/internal/api/settings.go b/internal/api/settings.go index c263506..702e8f4 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -6,9 +6,11 @@ import ( "errors" "net/http" "strconv" + "strings" "time" "git.ryuvia.com/niklas/terdut-server/internal/config" + "git.ryuvia.com/niklas/terdut-server/internal/models" "github.com/go-chi/chi/v5" ) @@ -226,6 +228,17 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc { } } +// adminTeam is a team as an administrator sees it: what it is, plus how big it +// is and how much is on fire in it. One definition, so a team in the list and a +// team on its own page cannot describe themselves differently. +type adminTeam struct { + ID int64 `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Members int64 `json:"members"` + OpenIncidents int64 `json:"open_incidents"` +} + // handleAdminListTeams lists every team on the server, with its size. The // ordinary /api/teams answers "what am I in"; this one answers "what exists", // which only an administrator may ask. @@ -244,13 +257,6 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc { } defer rows.Close() - type adminTeam struct { - ID int64 `json:"id"` - Name string `json:"name"` - CreatedAt time.Time `json:"created_at"` - Members int64 `json:"members"` - OpenIncidents int64 `json:"open_incidents"` - } teams := []adminTeam{} for rows.Next() { var t adminTeam @@ -270,6 +276,79 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc { } } +// handleAdminGetTeam answers "what is this team, and who is in it" for any team +// on the server, which is the one question an administrator could not ask. +// +// GET /api/teams/{id}/members is requireTeamMember and answers 404 to somebody +// outside the team, administrator or not, and that stays exactly as it is: +// member means membership and nothing else. Reading a team's shape is a +// different thing from reading its work, so it gets an endpoint of its own +// under AdminOnly rather than an exception carved into that rule. An +// administrator still sees none of the team's incidents, alerts or rota. +func handleAdminGetTeam(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + teamID, ok := teamParam(w, r) + if !ok { + return + } + + var t adminTeam + var created int64 + err := db.QueryRowContext(r.Context(), ` + SELECT t.id, t.name, t.created_at, + (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id), + (SELECT COUNT(*) FROM incidents i + WHERE i.team_id = t.id AND i.resolved_at IS NULL) + FROM teams t + WHERE t.id = $1`, teamID). + Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents) + if errors.Is(err, sql.ErrNoRows) { + respond(w, http.StatusNotFound, errResp("not found")) + return + } + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + t.CreatedAt = time.Unix(created, 0).UTC() + + // Same query and same ordering as handleListTeamMembers, so the two + // answers to "who is in this team" cannot disagree about the answer. + rows, err := db.QueryContext(r.Context(), ` + SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at + FROM team_members m + JOIN users u ON u.id = m.user_id + WHERE m.team_id = $1 + ORDER BY u.username`, teamID) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer rows.Close() + + members := []models.TeamMember{} + 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 { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + m.JoinedAt = time.Unix(joined, 0).UTC() + members = append(members, m) + } + if err := rows.Err(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + // A wrapper rather than a team with the members hung off it: "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. + respond(w, http.StatusOK, map[string]any{"team": t, "members": members}) + } +} + // handleRenameTeam renames a team. An owner's job, and an administrator's when // a team has nobody left to do it. func handleRenameTeam(db *sql.DB) http.HandlerFunc { @@ -285,7 +364,15 @@ func handleRenameTeam(db *sql.DB) http.HandlerFunc { var req struct { Name string `json:"name"` } - if err := decodeJSON(r, &req); err != nil || req.Name == "" { + // Trimmed, as handleCreateTeam trims: without it " " is a team name + // here but not at creation, which is one rule stated twice and only + // half applied. + if err := decodeJSON(r, &req); err != nil { + respond(w, http.StatusBadRequest, errResp("name is required")) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { respond(w, http.StatusBadRequest, errResp("name is required")) return } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 4d22a7f..d612d55 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -680,9 +680,10 @@ kbd { .admin-settings button[type="submit"] { margin-top: 12px; } .small { font-size: 13px; } -/* The name in the user list is the way to that person's page. */ -.user-link { color: var(--text); font-weight: 650; text-decoration: none; } -.user-link:hover { color: var(--accent); text-decoration: underline; } +/* A name in an admin table is the way to that row's own page -- a person's or + a team's. */ +.row-link { color: var(--text); font-weight: 650; text-decoration: none; } +.row-link:hover { color: var(--accent); text-decoration: underline; } .invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; } .invite-block h3 { margin: 0 0 4px; font-size: 14px; } @@ -695,7 +696,9 @@ kbd { word-break: break-all; user-select: all; } -/* --- one user ------------------------------------------------------------ */ +/* --- one user, one team -------------------------------------------------- + Both subject pages share this: .user-head and .user-facts are generic + despite the names, and a team fills them with its own facts. */ .back-link { display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px; color: var(--muted); font-size: 14px; text-decoration: none; diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 6e01cbd..01960bb 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -127,6 +127,9 @@ + + diff --git a/internal/web/static/js/admin.js b/internal/web/static/js/admin.js index 28f60aa..1e2a766 100644 --- a/internal/web/static/js/admin.js +++ b/internal/web/static/js/admin.js @@ -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, ); } diff --git a/internal/web/static/js/adminteam.js b/internal/web/static/js/adminteam.js new file mode 100644 index 0000000..ae72cf2 --- /dev/null +++ b/internal/web/static/js/adminteam.js @@ -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(); +} diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index becc435..ea400af 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -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) => diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 41d9634..4c96e2d 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -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(); }