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