From ac9af8e4f531a31cb2a63d1eab950d85c4d2c834 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Mon, 21 Sep 2026 12:55:42 +0200 Subject: [PATCH] Manage a person's account and teams from one page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Admin tab could make somebody an administrator and disable them, and nothing else. Setting a first password, deleting an account and seeing which teams a person is in all meant curl, and the last one meant opening each team in turn — the Team tab answers "who is in this team", which is the wrong way round when the question is about a person. A name in the user list now opens /admin/users/{id}: their email and when they joined, where their notifications go, the administrator and disabled flags, the teams they are in with their role in each, a password field for a first or forgotten one, and deletion. A section of its own rather than an expanding row, because memberships and the account actions together are more than a table row can hold and still be read on a phone. Adding somebody mints an invite link into a chosen team rather than creating a bare account. POST /api/users makes a user with no password and no team, who can sign in nowhere and would see nothing if they did; the invite machinery from #7 already solves both, and the password is chosen by the person it belongs to instead of passing through an administrator. One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is always about the caller and cannot be asked about anybody else. It 404s for a user who does not exist, so the page can tell "in no teams" from "no such person" — an empty list is a real answer and needed to stay one. No authorisation changed, and the interesting part is why it did not. requireTeamOwner has accepted the administrator flag since a4fbd60, with the reason in its own comment: somebody has to be able to repair a team whose owner has left. It guards nine call sites, so an administrator has always been able to configure any team on this server — while #1's decision table and this README both said an admin "is not implicitly in every team", full stop. The code was right and the prose was wrong in the safe-sounding direction, which is the worse way round to have it. So the documentation moved to meet the code. The Teams table marks owner as owner-or-admin, and the Authentication section states the two directions separately: an administrator configures any team, and reads none, because callerTeamIDs is built from real memberships only. Joining a team to see its queue is a membership change and shows as one. TestAdmin_ConfiguresATeamTheyAreNotIn pins both halves — the admin renames, invites, adds and removes on a team they are not in, then sees zero of its incidents. Nothing tested this from v0.12.0 to here, which is why four releases of prose could contradict it quietly. The UI has not been opened in a browser. Its wiring is checked — every cross-module import resolves, every api.* call exists, every CSS class has a rule, and the deep link serves index.html — but nobody has clicked through it, least of all at phone width. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 --- README.md | 36 +++- internal/api/admin_test.go | 119 +++++++++++ internal/api/router.go | 1 + internal/api/teams.go | 62 ++++++ internal/web/static/app.css | 36 ++++ internal/web/static/index.html | 3 + internal/web/static/js/admin.js | 58 +++++- internal/web/static/js/adminuser.js | 294 ++++++++++++++++++++++++++++ internal/web/static/js/api.js | 5 + internal/web/static/js/app.js | 15 +- 10 files changed, 618 insertions(+), 11 deletions(-) create mode 100644 internal/web/static/js/adminuser.js diff --git a/README.md b/README.md index 123ee3b..6d48095 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ archive), who is on call, the alert feed, and changing your own password. It is built for a phone first. On a phone it has a bottom tab bar and a sticky action bar, it follows the system's dark mode, and it can be added to the home screen. From 900px wide it switches to a sidebar with the queue and the incident side by -side. Schedule editing, statistics and user management remain in +side. Statistics remain in [terdut-tui](https://github.com/yeniklas/terdut-tui) for now. You sign in with a username and password. Users have no password until one is @@ -93,7 +93,19 @@ between them at the top. The **Admin** tab appears only for a system administrator, and holds what belongs to the whole server rather than to one team: every team, every user, and -the settings that used to be environment variables. +the settings that used to be environment variables. 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. + +A name in that list opens **that person's page**, at `/admin/users/{id}`: their +email and when they joined, where their notifications go, whether they are an +administrator, whether the account is disabled, the teams they are in with their +role in each, a password field for a first or forgotten one, and deletion. It is +the one place membership is edited from the person's side — the Team tab answers +"who is in this team", and answering "which teams is this person in" there means +visiting each team in turn. ### Docker @@ -579,10 +591,16 @@ Endpoints that require the flag answer `403` with **Teams** are the unit of tenancy, and are a separate axis from the administrator flag. A team owns its incidents, alerts, schedule and integrations, and a user -sees exactly the teams they belong to — an administrator is not implicitly in -every team, because administration is about accounts, not about reading other -people's incidents. Within a team an **owner** configures it (schedule, -integrations, membership) and a **member** works its incidents. +sees exactly the teams they belong to. Within a team an **owner** configures it +(schedule, integrations, membership) and a **member** works its incidents. + +An administrator crosses that line in one direction only. They **configure any +team** without being in it — every owner-only endpoint accepts the flag, because +otherwise a team whose last owner left could never be repaired. They do **not +read any team**: the queue, the alerts and the incidents are filtered by real +membership, so an administrator sees a team's work only by joining it, which is +a membership change and shows up as one. Administration is about accounts and +the shape of a team, not about reading other people's incidents. Anything belonging to a team you are not in answers `404`, not `403`: whether an incident exists is itself something only its team should learn. @@ -607,6 +625,7 @@ on anybody's. | `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only | | `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator | | `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people | +| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable | | `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator | | `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator | | `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator | @@ -641,6 +660,11 @@ and was removed in v0.13.0 once senders had moved onto keys. ### Teams +**owner** below means an owner of that team *or* a system administrator, who +passes every one of these without being a member — see +[Authentication](#authentication). **member** means membership and nothing else: an +administrator who is not in the team gets the same `404` as anybody else. + | Method | Path | Who | Description | |---|---|---|---| | `GET` | `/api/teams` | any | The caller's own teams, each with their role | diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index 97f199d..799bd56 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -237,6 +237,125 @@ func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) { } } +// An administrator passes every team-owner check without being in the team, +// which is what lets them repair a team whose owner has left. It has been true +// since teams landed and nothing pinned it, so a later reading of the epic's +// "an admin is not implicitly in every team" could quietly take it away. +// +// The line it draws: configuring a team, yes; reading what the team owns, no. +// The queue below is the half that stays shut. +func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) { + s := newTS(t) + + // A team the admin is deliberately not a member of. It is created by + // somebody else, so the admin's only claim on it is the flag. + _, 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") + } + + var mine []struct { + ID int64 `json:"id"` + } + decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine) + for _, m := range mine { + if m.ID == team.ID { + t.Fatalf("the admin should not be a member of team %d", team.ID) + } + } + + path := "/api/teams/" + id64(team.ID) + for _, c := range []struct { + name string + method string + path string + body any + want int + }{ + {"rename it", http.MethodPut, path, + map[string]string{"name": "theirs, renamed"}, http.StatusNoContent}, + {"mint an invite", http.MethodPost, path + "/invites", + map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated}, + {"add a member", http.MethodPost, path + "/members", + map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent}, + {"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent}, + } { + resp := s.req(t, c.method, c.path, c.body) + resp.Body.Close() + if resp.StatusCode != c.want { + t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode) + } + } + + // The other half of the rule. An incident in that team is not the admin's + // to read, because administration is about accounts — and the last case + // above has just taken the admin back out of the membership. + var integration struct { + Key string `json:"key"` + } + decode(t, call(http.MethodPost, path+"/integrations", + map[string]string{"name": "theirs alertmanager"}), &integration) + postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull") + + var incidents []struct { + ID int64 `json:"id"` + } + decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents) + if len(incidents) != 0 { + t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents)) + } +} + +// 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) { + s := newTS(t) + memberID, call := member(t, s, "joiner") + path := "/api/users/" + id64(memberID) + "/teams" + + // member() puts them in the default team, so both readings agree on one. + for _, c := range []struct { + name string + do func() *http.Response + }{ + {"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }}, + {"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }}, + } { + var teams []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + } + decode(t, c.do(), &teams) + if len(teams) != 1 { + t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams)) + } + if teams[0].Role != "member" { + t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role) + } + } + + // Somebody else's is not theirs to read. + otherID, _ := member(t, s, "nosy") + resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode) + } + + // A user who does not exist is a 404 rather than an empty list, which is + // how the page tells "no teams" from "no such person". + resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("a missing user: expected 404, got %d", resp.StatusCode) + } +} + // The flag has to reach the client, or the web UI cannot decide what to show. func TestAdmin_MeReportsTheFlag(t *testing.T) { s := newTS(t) diff --git a/internal/api/router.go b/internal/api/router.go index deccb3e..54cd6d9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -74,6 +74,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler // Your own account, or anybody's if you are an admin. The handlers call // requireSelfOrAdmin rather than sitting behind AdminOnly, because // which rule applies depends on the {id} in the path. + r.Get("/api/users/{id}/teams", handleUserTeams(db)) r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db)) r.Put("/api/users/{id}/password", handleSetPassword(db)) r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db)) diff --git a/internal/api/teams.go b/internal/api/teams.go index 7645063..20de78f 100644 --- a/internal/api/teams.go +++ b/internal/api/teams.go @@ -51,6 +51,68 @@ func handleListTeams(db *sql.DB) http.HandlerFunc { } } +// handleUserTeams lists one user's teams, for the admin page's per-user view: +// "what is this person in", which /api/teams cannot answer because it is always +// about the caller. +// +// Self or admin, matching the other per-user endpoints. It says which teams +// somebody belongs to and in what role — not anything those teams own, so it +// stays on the accounts side of the line the administrator flag draws. +func handleUserTeams(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + respond(w, http.StatusBadRequest, errResp("invalid user id")) + return + } + if !requireSelfOrAdmin(w, r, id) { + return + } + + // A user with no teams and a user who does not exist both list nothing, + // so the existence check is what tells them apart. + var exists bool + if err := db.QueryRowContext(r.Context(), + "SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if !exists { + respond(w, http.StatusNotFound, errResp("user not found")) + return + } + + rows, err := db.QueryContext(r.Context(), ` + SELECT t.id, t.name, t.created_at, m.role + FROM teams t + JOIN team_members m ON m.team_id = t.id + WHERE m.user_id = $1 + ORDER BY t.name`, id) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer rows.Close() + + teams := []models.Team{} + for rows.Next() { + var t models.Team + var created int64 + if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + t.CreatedAt = time.Unix(created, 0).UTC() + teams = append(teams, t) + } + if err := rows.Err(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, teams) + } +} + // handleCreateTeam creates a team and makes its creator the first owner. A team // with no owner would need an administrator to repair before anybody could use // it, so the two happen in one transaction. diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 7c759cd..a0ec5bc 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -680,6 +680,42 @@ 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; } + +.invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; } +.invite-block h3 { margin: 0 0 4px; font-size: 14px; } +/* The link is shown once and never stored, so it has to be selectable and + wrap rather than scroll off the side of a phone. */ +.invite-out { margin-top: 12px; font-size: 13px; } +.invite-link { + display: block; margin-top: 6px; padding: 8px; border-radius: var(--radius-sm); + background: var(--surface-2); font-family: var(--mono); font-size: 12px; + word-break: break-all; user-select: all; +} + +/* --- one user ------------------------------------------------------------ */ +.back-link { + display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px; + color: var(--muted); font-size: 14px; text-decoration: none; +} +.back-link:hover { color: var(--text); } +.back-link svg { width: 18px; height: 18px; } + +.user-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.user-head h2 { margin: 0; } + +.user-facts { + display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; + margin: 12px 0 0; font-size: 14px; +} +.user-facts dt { color: var(--muted); } +.user-facts dd { margin: 0; overflow-wrap: anywhere; } + +.row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.admin-table .row-actions { margin-top: 0; gap: 6px; } + /* --- team settings ------------------------------------------------------- Forms with a label above each control, rather than the queue's rows of links. The escalation ladder is the only nested structure in the app, so it diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 873aaec..6e01cbd 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -124,6 +124,9 @@ + + diff --git a/internal/web/static/js/admin.js b/internal/web/static/js/admin.js index beeb97a..4b81ec8 100644 --- a/internal/web/static/js/admin.js +++ b/internal/web/static/js/admin.js @@ -155,7 +155,9 @@ function usersCard() { const self = u.id === myID(); return h('tr', { class: u.disabled_at ? 'disabled-row' : '' }, h('td', {}, - h('strong', { text: u.username }), + // 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 }), u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }), self && h('span', { class: 'you', text: 'you' })), h('td', { class: 'muted', text: u.email }), @@ -184,7 +186,8 @@ function usersCard() { h('h2', { text: 'Users' }), h('p', { class: 'muted small' }, 'Disabling an account stops it signing in and stops its API keys, and keeps ', - 'its acknowledgements and timeline entries. Deleting a user erases those.'), + 'its acknowledgements and timeline entries. Deleting a user erases those. ', + 'Open a name for their teams, their password and the rest.'), h('table', { class: 'admin-table' }, h('thead', {}, h('tr', {}, h('th', { text: 'User' }), @@ -192,6 +195,57 @@ function usersCard() { h('th', { text: '' }), h('th', { text: '' }))), h('tbody', {}, rows)), + inviteForm(), + ); +} + +// 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. +// +// 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; + } + }); + + 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 + : h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }), + out, ); } diff --git a/internal/web/static/js/adminuser.js b/internal/web/static/js/adminuser.js new file mode 100644 index 0000000..af0f7c1 --- /dev/null +++ b/internal/web/static/js/adminuser.js @@ -0,0 +1,294 @@ +// One person, at /admin/users/{id}: what they are, what they are in, and the +// levers an administrator has over the account. +// +// A section of its own rather than an expanding row in the Admin tab's table, +// because memberships and the account actions together are more than a row can +// hold and still be read on a phone. +// +// Like the Admin tab, this hides nothing the server would allow and shows +// nothing it would refuse: every write here is an endpoint that answers 403 +// without the flag, so the view is a description of the rules rather than an +// enforcement of them. + +import * as api from './api.js'; +import { h, clear, spinner, confirm, toast, icon } from './ui.js'; +import { state, myID } from './state.js'; +import { navigate } from './app.js'; +import { when } from './format.js'; + +const view = () => document.getElementById('view-adminuser'); + +let userID = null; +let data = null; // { user, teams, allTeams } +let error = null; +let busy = false; + +export function show(route) { + const next = route && route.user != null ? route.user : null; + if (next !== userID) { + userID = next; + data = null; + error = null; + } + if (!data) clear(view(), spinner()); + refresh(); +} + +export async function refresh() { + if (userID == null || !state.me?.user?.is_admin) { + render(); + return; + } + try { + // The user comes from the list rather than a show endpoint: there is no + // GET /api/users/{id}, and adding one for a row the list already carries + // would be a second way to say the same thing. + const [users, teams, allTeams] = await Promise.all([ + api.users(), + api.userTeams(userID), + api.adminTeams(), + ]); + const user = users.find((u) => u.id === userID) || null; + data = { user, teams, allTeams }; + error = null; + } catch (err) { + 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.user) { + clear(el, backLink(), h('div', { class: 'card' }, + h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' }))); + return; + } + clear(el, + backLink(), + error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), + identityCard(), + teamsCard(), + accountCard(), + ); +} + +function backLink() { + return h('a', { class: 'back-link', href: '/admin' }, icon('chevronLeft'), h('span', { text: 'Admin' })); +} + +// --- identity -------------------------------------------------------------- + +function identityCard() { + const u = data.user; + const self = u.id === myID(); + + return h('div', { class: 'card' }, + h('div', { class: 'user-head' }, + h('h2', { text: u.username }), + u.is_admin && h('span', { class: 'row-team', text: 'admin' }), + u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }), + self && h('span', { class: 'you', text: 'you' })), + h('dl', { class: 'user-facts' }, + fact('Email', u.email), + fact('Joined', when(u.created_at)), + fact('Notifications', u.ntfy_topic ? `ntfy: ${u.ntfy_topic}` : 'None of their own'), + u.disabled_at && fact('Disabled', when(u.disabled_at)), + ), + // Both of these refuse your own account, and the last administrator's. An + // enabled button that always fails is worse than no button. + h('div', { class: 'row-actions' }, + !self && h('button', { + class: 'btn', type: 'button', + text: u.is_admin ? 'Revoke admin' : 'Make admin', + onclick: () => setAdmin(!u.is_admin), + }), + !self && h('button', { + class: 'btn', type: 'button', + text: u.disabled_at ? 'Enable account' : 'Disable account', + onclick: () => setDisabled(!u.disabled_at), + }), + ), + self && h('p', { class: 'muted small' }, + 'You cannot change your own administrator flag or disable yourself — ', + 'that is how an install ends up with nobody who can administer it.'), + ); +} + +function fact(label, value) { + return [h('dt', { text: label }), h('dd', { text: value })]; +} + +async function setAdmin(next) { + if (next && !(await confirm({ + title: `Make ${data.user.username} an administrator?`, + text: 'They will be able to manage every account, configure any team, and grant this to others.', + confirmLabel: 'Make admin', + }))) return; + await act(() => api.setUserAdmin(userID, next)); +} + +async function setDisabled(next) { + if (next && !(await confirm({ + title: `Disable ${data.user.username}?`, + text: 'They cannot sign in and their API keys stop working. Their acknowledgements and timeline entries stay.', + confirmLabel: 'Disable', + danger: true, + }))) return; + await act(() => api.setUserDisabled(userID, next)); +} + +// --- teams ----------------------------------------------------------------- + +// 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. +// +// It is the one place membership can be changed from the person's side: the +// Team tab asks "who is in this team", and answering "which teams is this +// person in" there means visiting each team in turn. +function teamsCard() { + const rows = data.teams.map((t) => + h('tr', {}, + // Not a link: the Team tab always shows the viewer's own team, so + // sending them there from somebody else's membership would be a lie. + h('td', {}, h('strong', { text: t.name })), + h('td', { class: 'muted small', text: t.role }), + h('td', { class: 'row-actions' }, + h('button', { + class: 'btn-sm', type: 'button', + text: t.role === 'owner' ? 'Make member' : 'Make owner', + onclick: () => act(() => + api.addTeamMember(t.id, userID, t.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(t.id, userID)), + }), + ), + )); + + const inTeam = new Set(data.teams.map((t) => t.id)); + const candidates = (data.allTeams || []).filter((t) => !inTeam.has(t.id)); + const pick = h('select', {}, + ...candidates.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 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(Number(pick.value), userID, role.value)); + }); + + return h('div', { class: 'card' }, + h('h2', { text: 'Teams' }), + data.teams.length === 0 && h('p', { class: 'muted small' }, + 'In no team. They can sign in, but there is no queue for them to work ', + 'and nothing to page them about.'), + data.teams.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)), + candidates.length > 0 && form, + ); +} + +// --- account --------------------------------------------------------------- + +function accountCard() { + const u = data.user; + const self = u.id === myID(); + + const pw = h('input', { + type: 'password', name: 'password', autocomplete: 'new-password', + minlength: '10', required: true, placeholder: 'At least 10 characters', + }); + const form = h('form', { class: 'inline-form' }, pw, + h('button', { class: 'btn', type: 'submit', text: 'Set password' })); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (busy) return; + busy = true; + try { + // No current password: that check is for changing your own, and an + // administrator setting somebody else's does not know it by design. + await api.setPassword(userID, pw.value); + pw.value = ''; + toast(`Password set for ${u.username}. Their other sessions are signed out.`); + error = null; + } catch (err) { + error = err.message; + } finally { + busy = false; + } + await refresh(); + }); + + return h('div', { class: 'card' }, + h('h2', { text: 'Account' }), + h('p', { class: 'muted small' }, + 'Setting a password here is how somebody gets their first one, or a new ', + 'one after forgetting it. It signs them out everywhere else. They change ', + 'it themselves under Account afterwards.'), + self ? h('p', { class: 'muted small' }, + 'Change your own password under Account, where the current one is asked for.') + : form, + h('h3', { text: 'Delete' }), + h('p', { class: 'muted small' }, + 'Deleting erases their acknowledgements and timeline entries — incidents ', + 'they handled stop saying who did. Disabling keeps the history and is ', + 'almost always what is meant.'), + h('button', { + class: 'btn btn-danger', type: 'button', text: `Delete ${u.username}`, + disabled: self, + title: self ? 'You cannot delete your own account' : '', + onclick: deleteUser, + }), + ); +} + +async function deleteUser() { + if (!(await confirm({ + title: `Delete ${data.user.username}?`, + text: 'Their API keys go with them, and their name comes off every incident they acknowledged. This cannot be undone.', + confirmLabel: 'Delete', + danger: true, + }))) return; + try { + await api.deleteUser(userID); + } catch (err) { + error = err.message; + render(); + return; + } + toast('User deleted.'); + navigate('/admin'); +} + +// --- plumbing -------------------------------------------------------------- + +// act runs a write and reloads. Errors are shown rather than thrown away: the +// 409 from the last-owner or last-administrator guard is 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 8e08aaa..b45fd6f 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -67,6 +67,10 @@ export const setPassword = (userID, password, currentPassword) => // users export const users = () => call('GET', '/users'); +// What one person is in. /teams answers "what am I in" and cannot be asked +// about anybody else, which is what the admin page's per-user view needs. +export const userTeams = (id) => call('GET', `/users/${id}/teams`); + // incidents export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts }); export const incident = (id) => call('GET', `/incidents/${id}`); @@ -139,6 +143,7 @@ export const setUserAdmin = (id, isAdmin) => call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } }); export const setUserDisabled = (id, disabled) => call('PUT', `/users/${id}/disabled`, { body: { disabled } }); +export const deleteUser = (id) => call('DELETE', `/users/${id}`); export const schedule = (teamID, from, to) => call('GET', `/teams/${teamID}/schedule`, { query: { from, to } }); diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index c34334b..835a83a 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -11,22 +11,28 @@ import * as alerts from './alerts.js'; import * as account from './account.js'; import * as team from './team.js'; import * as admin from './admin.js'; +import * as adminuser from './adminuser.js'; const $ = (id) => document.getElementById(id); -// One route per section; /incidents/{id} is the queue with a detail open. +// One route per section; /incidents/{id} is the queue with a detail open, and +// /admin/users/{id} is a section of its own rather than a mode of the Admin +// tab, because it replaces the page rather than opening beside it. const SECTIONS = { queue: { title: 'Queue', view: queue }, oncall: { title: 'On-call', view: oncall }, alerts: { title: 'Alerts', view: alerts }, team: { title: 'Team', view: team }, admin: { title: 'Admin', view: admin }, + adminuser: { title: 'User', view: adminuser, nav: 'admin' }, more: { title: 'Account', view: account }, }; function parseRoute(pathname) { const m = pathname.match(/^\/incidents\/(\d+)\/?$/); 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]) }; const name = pathname.replace(/^\/|\/$/g, ''); if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name }; return { section: 'queue', incident: null }; @@ -69,8 +75,11 @@ function render() { el.hidden = name !== route.section; if (name === route.section) $('topbar-title').textContent = s.title; } + // A section may light up somebody else's tab: /admin/users/{id} is still the + // Admin tab as far as the nav is concerned, since there is no tab of its own. + const current = SECTIONS[route.section].nav || route.section; for (const link of document.querySelectorAll('.nav-link')) { - if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page'); + if (link.dataset.section === current) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current'); } @@ -85,7 +94,7 @@ function render() { incident.show(route.incident); } else { incident.show(null); - SECTIONS[route.section].view.show(); + SECTIONS[route.section].view.show(route); } if (detailOpen && !wasOpen) window.scrollTo(0, 0);