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);