Give every team a page of its own
The Admin tab's team list was growing controls the way the user list did beforeac9af8e: a Rename button behind window.prompt, a Delete beside it, and -- on the Users page, of all places -- an invite form with a team picker in front of it. The picker was the admission that an invite is a fact about a team rather than about the server, and a prompt() is the wrong place to read a 409 about a name already taken. So a team is now a subject with a page, at /admin/teams/{id}, the mirror of /admin/users/{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 list goes back to being a list, and the name in it is the way in. The member list is the one thing there that needed a new endpoint. GET /api/teams/{id}/members is requireTeamMember and answers 404 to an administrator who is not in the team, and that stays exactly as it is: member means membership and nothing else. Reading a team's shape is a different question from reading its work, so it gets an endpoint of its own under AdminOnly -- GET /api/admin/teams/{id}, returning {"team", "members"} -- rather than an exception carved into that rule. It is a wrapper and not a team with the members hung off it, because "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. The query and its ordering are copied from handleListTeamMembers so the two answers to "who is in this team" cannot disagree. An administrator still sees none of that team's incidents, alerts or rota. Nothing about what the flag may do changed; it could already rename and delete any team, and staff one it is not in. Rename now trims what it is given, as creation has always trimmed. Before this, " " was a legal name to rename a team to but not to create one with, which is one rule stated twice and applied once. Nobody has looked at this in a browser, the caveatac9af8eand07914d5both carried. What is checked is the wiring: admin_test.go covers the new endpoint for an administrator outside the team, the 404 the member-only endpoint still gives that same administrator, the 403 for a member who is not one, a 404 for a team that does not exist, a 400 for an id that is not a number, and the trim; the module graph evaluates at /admin/teams/{id}, and the server serves index.html there, so a reload survives. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
@@ -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:
|
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
|
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
|
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
|
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
|
email and when they joined, where their notifications go, whether they are an
|
||||||
@@ -653,6 +663,7 @@ on anybody's.
|
|||||||
| Method | Path | Who | Description |
|
| 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` | **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 |
|
| `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 |
|
| `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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 admin page's per-user view asks what somebody is in. Self or admin, like
|
||||||
// the rest of the per-user endpoints.
|
// the rest of the per-user endpoints.
|
||||||
func TestUserTeams_SelfOrAdmin(t *testing.T) {
|
func TestUserTeams_SelfOrAdmin(t *testing.T) {
|
||||||
|
|||||||
@@ -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
|
// What exists on this server, and how it behaves. /api/teams
|
||||||
// answers "what am I in"; this one answers "what is there".
|
// answers "what am I in"; this one answers "what is there".
|
||||||
r.Get("/api/admin/teams", handleAdminListTeams(db))
|
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.Get("/api/admin/settings", handleGetSettings(db, cfg))
|
||||||
r.Put("/api/admin/settings", handleSetSettings(db))
|
r.Put("/api/admin/settings", handleSetSettings(db))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
"github.com/go-chi/chi/v5"
|
"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
|
// handleAdminListTeams lists every team on the server, with its size. The
|
||||||
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
|
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
|
||||||
// which only an administrator may ask.
|
// which only an administrator may ask.
|
||||||
@@ -244,13 +257,6 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
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{}
|
teams := []adminTeam{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t adminTeam
|
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
|
// handleRenameTeam renames a team. An owner's job, and an administrator's when
|
||||||
// a team has nobody left to do it.
|
// a team has nobody left to do it.
|
||||||
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
||||||
@@ -285,7 +364,15 @@ func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
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"))
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -680,9 +680,10 @@ kbd {
|
|||||||
.admin-settings button[type="submit"] { margin-top: 12px; }
|
.admin-settings button[type="submit"] { margin-top: 12px; }
|
||||||
.small { font-size: 13px; }
|
.small { font-size: 13px; }
|
||||||
|
|
||||||
/* The name in the user list is the way to that person's page. */
|
/* A name in an admin table is the way to that row's own page -- a person's or
|
||||||
.user-link { color: var(--text); font-weight: 650; text-decoration: none; }
|
a team's. */
|
||||||
.user-link:hover { color: var(--accent); text-decoration: underline; }
|
.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 { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
|
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
|
||||||
@@ -695,7 +696,9 @@ kbd {
|
|||||||
word-break: break-all; user-select: all;
|
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 {
|
.back-link {
|
||||||
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
|
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
|
||||||
color: var(--muted); font-size: 14px; text-decoration: none;
|
color: var(--muted); font-size: 14px; text-decoration: none;
|
||||||
|
|||||||
@@ -127,6 +127,9 @@
|
|||||||
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
||||||
list, and a section of its own so a deep link survives a reload. -->
|
list, and a section of its own so a deep link survives a reload. -->
|
||||||
<section id="view-adminuser" class="view view-page" data-view="adminuser" hidden></section>
|
<section id="view-adminuser" class="view view-page" data-view="adminuser" hidden></section>
|
||||||
|
<!-- One team, at /admin/teams/{id}: who is in it and the invites into it,
|
||||||
|
which the Team tab cannot show for a team you are not a member of. -->
|
||||||
|
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
|
||||||
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ export async function refresh() {
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only what the open sub-section shows. Users is the one that needs two: its
|
// Only what the open sub-section shows. Users is the one that needs two: it
|
||||||
// invite form has to offer a team to invite somebody into, and the overview
|
// only points at Teams for an invite if there is a team to point at, and the
|
||||||
// counts both.
|
// overview counts both.
|
||||||
async function load() {
|
async function load() {
|
||||||
if (tab === 'teams') return { teams: await api.adminTeams() };
|
if (tab === 'teams') return { teams: await api.adminTeams() };
|
||||||
if (tab === 'settings') return { settings: await api.adminSettings() };
|
if (tab === 'settings') return { settings: await api.adminSettings() };
|
||||||
@@ -148,37 +148,24 @@ function menuItem(href, label, count, note) {
|
|||||||
function teamsCard() {
|
function teamsCard() {
|
||||||
const rows = data.teams.map((t) =>
|
const rows = data.teams.map((t) =>
|
||||||
h('tr', {},
|
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.members) }),
|
||||||
h('td', { class: 'num', text: String(t.open_incidents) }),
|
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' },
|
return h('div', { class: 'card' },
|
||||||
h('h2', { text: 'Teams' }),
|
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('table', { class: 'admin-table' },
|
||||||
h('thead', {}, h('tr', {},
|
h('thead', {}, h('tr', {},
|
||||||
h('th', { text: 'Name' }),
|
h('th', { text: 'Name' }),
|
||||||
h('th', { class: 'num', text: 'Members' }),
|
h('th', { class: 'num', text: 'Members' }),
|
||||||
h('th', { class: 'num', text: 'Open' }),
|
h('th', { class: 'num', text: 'Open' }))),
|
||||||
h('th', { text: '' }))),
|
|
||||||
h('tbody', {}, rows)),
|
h('tbody', {}, rows)),
|
||||||
newTeamForm(),
|
newTeamForm(),
|
||||||
);
|
);
|
||||||
@@ -206,32 +193,6 @@ function newTeamForm() {
|
|||||||
return form;
|
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 -----------------------------------------------------------------
|
// --- users -----------------------------------------------------------------
|
||||||
|
|
||||||
function usersCard() {
|
function usersCard() {
|
||||||
@@ -241,7 +202,7 @@ function usersCard() {
|
|||||||
h('td', {},
|
h('td', {},
|
||||||
// The name is the way in: everything about one person lives on their
|
// 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.
|
// 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' }),
|
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||||
self && h('span', { class: 'you', text: 'you' })),
|
self && h('span', { class: 'you', text: 'you' })),
|
||||||
h('td', { class: 'muted', text: u.email }),
|
h('td', { class: 'muted', text: u.email }),
|
||||||
@@ -279,57 +240,27 @@ function usersCard() {
|
|||||||
h('th', { text: '' }),
|
h('th', { text: '' }),
|
||||||
h('th', { text: '' }))),
|
h('th', { text: '' }))),
|
||||||
h('tbody', {}, rows)),
|
h('tbody', {}, rows)),
|
||||||
inviteForm(),
|
invitePointer(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adding a person is minting them an invite, not creating a row. The account
|
// Adding a person is minting them an invite into a team, not creating a row:
|
||||||
// is created by whoever accepts it, so they pick their own password and it
|
// whoever accepts it picks their own password, so one never passes through an
|
||||||
// never passes through an administrator — and the link carries the team, which
|
// administrator, and the link carries the team, so they do not land on an empty
|
||||||
// a bare POST /api/users cannot, leaving an account with nothing to work on.
|
// queue.
|
||||||
//
|
//
|
||||||
// Minting for a team the administrator is not in is allowed: the flag passes
|
// The form for it lives on the team's own page. It always needed a team beside
|
||||||
// every team-owner check, so a server administrator can staff any team. The
|
// it, and a picker here was the admission that an invite is a fact about a team
|
||||||
// link shows up in that team's own invite list, where an owner can revoke it.
|
// rather than about the server.
|
||||||
function inviteForm() {
|
function invitePointer() {
|
||||||
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' },
|
return h('div', { class: 'invite-block' },
|
||||||
h('h3', { text: 'Add someone' }),
|
h('h3', { text: 'Add someone' }),
|
||||||
h('p', { class: 'muted small' },
|
data.teams.length > 0
|
||||||
'An invite link puts them in a team and lets them choose their own ',
|
? h('p', { class: 'muted small' },
|
||||||
'password. It lasts a week and can be used once.'),
|
'Open the team you want them in, under ',
|
||||||
data.teams.length > 0 ? form
|
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.' }),
|
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
|
||||||
out,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
// Administration. Every one of these is refused with 403 for anybody without
|
||||||
// the flag, so the UI hides the section rather than guarding it.
|
// the flag, so the UI hides the section rather than guarding it.
|
||||||
export const adminTeams = () => call('GET', '/admin/teams');
|
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 adminSettings = () => call('GET', '/admin/settings');
|
||||||
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
|
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
|
||||||
export const setUserAdmin = (id, isAdmin) =>
|
export const setUserAdmin = (id, isAdmin) =>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import * as account from './account.js';
|
|||||||
import * as team from './team.js';
|
import * as team from './team.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
import * as adminuser from './adminuser.js';
|
import * as adminuser from './adminuser.js';
|
||||||
|
import * as adminteam from './adminteam.js';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ const SECTIONS = {
|
|||||||
team: { title: 'Team', view: team },
|
team: { title: 'Team', view: team },
|
||||||
admin: { title: 'Admin', view: admin },
|
admin: { title: 'Admin', view: admin },
|
||||||
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
||||||
|
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
|
||||||
more: { title: 'Account', view: account },
|
more: { title: 'Account', view: account },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,6 +35,10 @@ function parseRoute(pathname) {
|
|||||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
if (m) return { section: 'queue', incident: Number(m[1]) };
|
||||||
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
|
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
|
||||||
if (u) return { section: 'adminuser', user: Number(u[1]) };
|
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, '');
|
const name = pathname.replace(/^\/|\/$/g, '');
|
||||||
// The Admin tab's sub-sections are routes of their own. admin.js owns the
|
// 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.
|
// 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);
|
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
|
||||||
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
|
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
|
||||||
// A changed tab counts as a changed page: stepping from a long user list to
|
// A changed tab counts as a changed page: stepping from a long user list to
|
||||||
// the settings should not land you halfway down them.
|
// the settings should not land you halfway down them. So does a changed
|
||||||
else if (prev.section !== route.section || prev.tab !== route.tab) window.scrollTo(0, 0);
|
// 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();
|
updateTitle();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user