Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a03698f65 | |||
| 67d68ce058 | |||
| a6fa673e08 |
@@ -91,6 +91,12 @@ membership. An owner edits it; a member sees the same page read-only, because
|
|||||||
the server refuses their writes anyway. Somebody in more than one team picks
|
the server refuses their writes anyway. Somebody in more than one team picks
|
||||||
between them at the top.
|
between them at the top.
|
||||||
|
|
||||||
|
The rota is a month at a time, one coloured initial per day with a legend
|
||||||
|
underneath, and it says how many days are left uncovered — the question a rota
|
||||||
|
is read for is who holds which stretch, and a run of one colour answers it
|
||||||
|
where a list of dates does not. An owner taps a day to hand it to somebody or
|
||||||
|
empty it, and fills a whole shift from the range form folded in below.
|
||||||
|
|
||||||
The **Admin** tab appears only for a system administrator, and holds what
|
The **Admin** tab appears only for a system administrator, and holds what
|
||||||
belongs to the whole server rather than to one team. It has three sub-sections,
|
belongs to the whole server rather than to one team. It has three sub-sections,
|
||||||
each with a URL of its own and a strip across the top to move between them:
|
each with a URL of its own and a strip across the top to move between them:
|
||||||
@@ -100,7 +106,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 +669,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 |
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.17.0
|
version: 0.18.0
|
||||||
appVersion: "v0.17.0"
|
appVersion: "v0.18.0"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
--snooze: #6b5bd2;
|
--snooze: #6b5bd2;
|
||||||
--snooze-soft: #efedfb;
|
--snooze-soft: #efedfb;
|
||||||
|
|
||||||
|
/* Two hues that mean nothing on their own. The rota needs six colours to
|
||||||
|
tell six people apart and the palette above only has four that are not
|
||||||
|
already an alarm. */
|
||||||
|
--teal: #0f7d8c;
|
||||||
|
--teal-soft: #e3f4f6;
|
||||||
|
--pink: #b3427e;
|
||||||
|
--pink-soft: #fbe8f2;
|
||||||
|
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
|
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
|
||||||
@@ -72,6 +80,11 @@
|
|||||||
--snooze: #a89bff;
|
--snooze: #a89bff;
|
||||||
--snooze-soft: #262245;
|
--snooze-soft: #262245;
|
||||||
|
|
||||||
|
--teal: #4fc2d4;
|
||||||
|
--teal-soft: #0f2e33;
|
||||||
|
--pink: #f07fb8;
|
||||||
|
--pink-soft: #3a1c2d;
|
||||||
|
|
||||||
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
}
|
}
|
||||||
@@ -680,9 +693,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 +709,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;
|
||||||
@@ -726,6 +742,60 @@ kbd {
|
|||||||
.stacked-form input.wide { min-width: min(420px, 100%); }
|
.stacked-form input.wide { min-width: min(420px, 100%); }
|
||||||
.team-picker { margin-top: 8px; max-width: 100%; }
|
.team-picker { margin-top: 8px; max-width: 100%; }
|
||||||
|
|
||||||
|
/* The rota, a month at a time. A name is too wide to print thirty times and
|
||||||
|
too alike down a column to read, so a day carries an initial in that
|
||||||
|
person's colour and the legend underneath says whose. A shift is then a run
|
||||||
|
of one colour, which is the shape the question actually has. */
|
||||||
|
.rota-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; padding: 10px; }
|
||||||
|
.rota-wd {
|
||||||
|
padding-bottom: 4px; text-align: center;
|
||||||
|
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.rota-day {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
min-height: 52px; padding: 6px 0 8px;
|
||||||
|
border: 0; border-radius: var(--radius-sm); background: none;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
button.rota-day { cursor: pointer; }
|
||||||
|
button.rota-day:hover { background: var(--surface-2); }
|
||||||
|
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
|
.rota-day.today { background: var(--accent-soft); }
|
||||||
|
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
||||||
|
.rota-day.past { opacity: 0.55; }
|
||||||
|
/* The days either side of the month are real days and are drawn, but they
|
||||||
|
belong to the month you are not looking at. */
|
||||||
|
.rota-day.outside { opacity: 0.35; }
|
||||||
|
|
||||||
|
.rota-chip {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
font-size: 12px; font-weight: 750; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
/* An empty day is a dot rather than a hole, and keeps the chip's box so the
|
||||||
|
rows stay on one baseline. */
|
||||||
|
.rota-chip.none { width: 8px; height: 8px; margin: 9px; background: var(--border-strong); }
|
||||||
|
|
||||||
|
/* Six colours, then they repeat; the initial inside still tells two people
|
||||||
|
apart. Deliberately not the severity palette — nothing here is critical. */
|
||||||
|
.rc1 { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.rc2 { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.rc3 { background: var(--snooze-soft); color: var(--snooze); }
|
||||||
|
.rc4 { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.rc5 { background: var(--teal-soft); color: var(--teal); }
|
||||||
|
.rc6 { background: var(--pink-soft); color: var(--pink); }
|
||||||
|
|
||||||
|
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||||
|
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
|
||||||
|
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
|
||||||
|
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
|
||||||
|
.rota-note:first-child { margin-top: 0; }
|
||||||
|
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||||
|
.rota-bulk .stacked-form { margin-top: 4px; }
|
||||||
|
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||||
|
|
||||||
.ladder-level {
|
.ladder-level {
|
||||||
border-left: 3px solid var(--border-strong);
|
border-left: 3px solid var(--border-strong);
|
||||||
padding: 8px 0 8px 12px; margin: 12px 0;
|
padding: 8px 0 8px 12px; margin: 12px 0;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-22
@@ -10,9 +10,9 @@
|
|||||||
// than no form, but it is not the thing enforcing anything.
|
// than no form, but it is not the thing enforcing anything.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, spinner, confirm } from './ui.js';
|
import { h, clear, spinner, confirm, icon, openSheet, closeSheet } from './ui.js';
|
||||||
import { state, currentTeam, users as allUsers } from './state.js';
|
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||||||
import { isoDate, addDays } from './format.js';
|
import { isoDate, addDays, mondayOf, initial } from './format.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-team');
|
const view = () => document.getElementById('view-team');
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ export async function refresh() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
teamID = team.id;
|
teamID = team.id;
|
||||||
|
const grid = gridDays();
|
||||||
try {
|
try {
|
||||||
// A member may read all of this; only the writes are owner-only.
|
// A member may read all of this; only the writes are owner-only.
|
||||||
const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([
|
const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([
|
||||||
@@ -46,7 +47,7 @@ export async function refresh() {
|
|||||||
api.integrations(team.id),
|
api.integrations(team.id),
|
||||||
api.escalation(team.id),
|
api.escalation(team.id),
|
||||||
api.deadman(team.id),
|
api.deadman(team.id),
|
||||||
api.schedule(team.id, isoDate(new Date()), isoDate(addDays(new Date(), 30))),
|
api.schedule(team.id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||||||
allUsers(),
|
allUsers(),
|
||||||
]);
|
]);
|
||||||
data = { team, members, integrations, escalation, deadman, schedule, users };
|
data = { team, members, integrations, escalation, deadman, schedule, users };
|
||||||
@@ -104,30 +105,200 @@ function teamPicker() {
|
|||||||
// The rota is one person per UTC day. The on-call page shows it; this is where
|
// The rota is one person per UTC day. The on-call page shows it; this is where
|
||||||
// it is set, which until now was the TUI's job and the TUI cannot do it any
|
// it is set, which until now was the TUI's job and the TUI cannot do it any
|
||||||
// more.
|
// more.
|
||||||
function scheduleCard() {
|
//
|
||||||
const rows = (data.schedule || []).map((e) =>
|
// A month of it, as a grid. It used to be thirty rows of "date — username",
|
||||||
h('tr', {},
|
// which is a rota spelled out one day at a time: the question asked of it is
|
||||||
h('td', { text: e.date }),
|
// "who has which stretch", and thirty names down a column is the one shape
|
||||||
h('td', {}, h('strong', { text: e.username })),
|
// that answer cannot be read in. So each day carries a coloured initial
|
||||||
h('td', {}, isOwner() && h('button', {
|
// instead, the legend says whose, and a shift becomes a run of one colour.
|
||||||
class: 'btn-sm danger', type: 'button', text: 'Clear',
|
//
|
||||||
onclick: () => act(() => api.unassignSchedule(teamID, e.id)),
|
// The same month laid out the same way as the on-call page's week, because it
|
||||||
})),
|
// is the same rota — heading and arrows outside the card, days inside it.
|
||||||
));
|
|
||||||
|
|
||||||
return h('div', { class: 'card' },
|
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||||||
|
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
|
||||||
|
const longDayFmt = new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long',
|
||||||
|
});
|
||||||
|
|
||||||
|
let monthStart = firstOfMonth(new Date());
|
||||||
|
|
||||||
|
function firstOfMonth(d) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grid runs Monday to Sunday, so it starts before the 1st and ends after
|
||||||
|
// the last. Both overhangs are fetched and drawn: a shift that begins on the
|
||||||
|
// 30th is a fact about this month even though the days it runs into are not.
|
||||||
|
function gridDays() {
|
||||||
|
const start = mondayOf(monthStart);
|
||||||
|
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0);
|
||||||
|
const span = Math.round((last - start) / 86400000) + 1;
|
||||||
|
return { start, count: Math.ceil(span / 7) * 7 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftMonth(n) {
|
||||||
|
monthStart = new Date(monthStart.getFullYear(), monthStart.getMonth() + n, 1);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCard() {
|
||||||
|
const { start, count } = gridDays();
|
||||||
|
const byDate = new Map((data.schedule || []).map((e) => [e.date, e]));
|
||||||
|
const today = isoDate(new Date());
|
||||||
|
const month = monthStart.getMonth();
|
||||||
|
|
||||||
|
// Whose colours to explain, in the order the month meets them. Only the days
|
||||||
|
// of this month count: a name that appears solely in the overhang belongs to
|
||||||
|
// the month next door and would be explaining a chip nobody asked about.
|
||||||
|
const seen = new Map();
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const d = addDays(start, i);
|
||||||
|
const key = isoDate(d);
|
||||||
|
const e = byDate.get(key);
|
||||||
|
const inMonth = d.getMonth() === month;
|
||||||
|
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
|
||||||
|
cells.push(dayCell(d, key, e, inMonth, today));
|
||||||
|
}
|
||||||
|
|
||||||
|
const heads = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
// Any Monday will do; this one is a Monday.
|
||||||
|
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
h('div', { class: 'page-head' },
|
||||||
h('h2', { text: 'On-call rota' }),
|
h('h2', { text: 'On-call rota' }),
|
||||||
h('p', { class: 'muted small', text: 'One person per UTC day, for the next 30 days.' }),
|
h('div', { class: 'week-nav' },
|
||||||
rows.length
|
h('button', {
|
||||||
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
|
class: 'btn btn-ghost btn-icon', type: 'button',
|
||||||
: h('p', { class: 'muted', text: 'Nobody is scheduled.' }),
|
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
|
||||||
isOwner() && assignForm(),
|
}, icon('chevronLeft')),
|
||||||
);
|
h('button', {
|
||||||
|
class: 'btn btn-ghost label', type: 'button',
|
||||||
|
title: 'Back to this month',
|
||||||
|
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
|
||||||
|
text: monthFmt.format(monthStart),
|
||||||
|
}),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn btn-ghost btn-icon', type: 'button',
|
||||||
|
'aria-label': 'Next month', onclick: () => shiftMonth(1),
|
||||||
|
}, icon('chevronRight')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
h('div', { class: 'card' },
|
||||||
|
h('div', { class: 'rota-grid' }, heads, cells),
|
||||||
|
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
|
||||||
|
// The range form is the way to fill a whole shift at once, but it is not
|
||||||
|
// what the page is for, so it stays folded away under the month it edits.
|
||||||
|
isOwner() && h('details', { class: 'rota-bulk' },
|
||||||
|
h('summary', { text: 'Assign a range of days' }),
|
||||||
|
assignForm()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayCell(d, key, e, inMonth, today) {
|
||||||
|
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
|
||||||
|
.filter(Boolean).join(' ');
|
||||||
|
const label = `${key} · ${e ? e.username : 'nobody'}`;
|
||||||
|
const body = [
|
||||||
|
h('span', { class: 'rota-num', text: String(d.getDate()) }),
|
||||||
|
e
|
||||||
|
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
|
||||||
|
: h('span', { class: 'rota-chip none' }),
|
||||||
|
];
|
||||||
|
// A member sees the same grid without the affordance, the way every other
|
||||||
|
// control on this page is hidden rather than shown and refused.
|
||||||
|
return isOwner()
|
||||||
|
? h('button', {
|
||||||
|
class: cls, type: 'button', title: label, 'aria-label': label,
|
||||||
|
onclick: () => daySheet(key, e),
|
||||||
|
}, body)
|
||||||
|
: h('div', { class: cls, title: label }, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A colour per person, taken from their place in the member list so that it
|
||||||
|
// holds still as you page between months. Somebody who holds days but has
|
||||||
|
// since left the team is not in that list and falls back to their id.
|
||||||
|
function colorClass(userID) {
|
||||||
|
const i = (data.members || []).findIndex((m) => m.user_id === userID);
|
||||||
|
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legend(seen) {
|
||||||
|
if (!seen.size) return null;
|
||||||
|
return h('div', { class: 'rota-legend' },
|
||||||
|
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
|
||||||
|
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
|
||||||
|
h('span', { text: name }),
|
||||||
|
id === myID() && h('span', { class: 'you', text: 'you' }),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gap count, which is the one thing the grid states only by omission. Days
|
||||||
|
// already past are not counted: an empty Tuesday last week is history, not a
|
||||||
|
// hole somebody still has to fill.
|
||||||
|
function coverNote(byDate) {
|
||||||
|
const today = isoDate(new Date());
|
||||||
|
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0).getDate();
|
||||||
|
let gaps = 0;
|
||||||
|
for (let day = 1; day <= last; day++) {
|
||||||
|
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
|
||||||
|
if (key >= today && !byDate.has(key)) gaps++;
|
||||||
|
}
|
||||||
|
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
|
||||||
|
return h('p', { class: 'rota-note' },
|
||||||
|
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
||||||
|
' left this month with nobody on call.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// One day, in the sheet: who has it, who should, and the way to empty it. This
|
||||||
|
// is where the per-row Clear button went — the grid has no room for thirty of
|
||||||
|
// them, and the day you want to change is the one you just tapped.
|
||||||
|
function daySheet(date, entry) {
|
||||||
|
const who = memberSelect(entry ? entry.user_id : undefined);
|
||||||
|
openSheet(() => [
|
||||||
|
h('h2', { class: 'sheet-title', text: longDayFmt.format(parseISO(date)) }),
|
||||||
|
h('p', { class: 'sheet-text', text: entry ? `${entry.username} is on call.` : 'Nobody is on call.' }),
|
||||||
|
h('label', { class: 'sheet-pick' }, 'On call ', who),
|
||||||
|
h('div', { class: 'sheet-actions' },
|
||||||
|
entry && h('button', {
|
||||||
|
class: 'btn btn-danger', type: 'button', text: 'Clear',
|
||||||
|
onclick: () => { closeSheet(); act(() => api.unassignSchedule(teamID, entry.id)); },
|
||||||
|
}),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign',
|
||||||
|
// replace, where the range form asks first: the sheet has just named
|
||||||
|
// whoever holds the day, so taking it from them is the thing that was
|
||||||
|
// asked for rather than something to be warned about.
|
||||||
|
onclick: () => {
|
||||||
|
closeSheet();
|
||||||
|
act(() => api.assignSchedule(teamID, Number(who.value), [date], true));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseISO(s) {
|
||||||
|
const [y, m, d] = s.split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignForm() {
|
function assignForm() {
|
||||||
const who = memberSelect();
|
const who = memberSelect();
|
||||||
const from = h('input', { type: 'date', required: true, value: isoDate(new Date()) });
|
// The form opens on the month above it rather than on today: it is folded
|
||||||
|
// into that month's card, and paging to March to fill March and being handed
|
||||||
|
// today's date would be the card and the form disagreeing about the subject.
|
||||||
|
const now = new Date();
|
||||||
|
const sameMonth = monthStart.getFullYear() === now.getFullYear()
|
||||||
|
&& monthStart.getMonth() === now.getMonth();
|
||||||
|
const from = h('input', {
|
||||||
|
type: 'date', required: true, value: isoDate(sameMonth ? now : monthStart),
|
||||||
|
});
|
||||||
const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' });
|
const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' });
|
||||||
const replace = h('input', { type: 'checkbox' });
|
const replace = h('input', { type: 'checkbox' });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user