Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee22eb000c | |||
| 07914d5cdb | |||
| 7b9a337d25 | |||
| fc8b0c8d58 | |||
| 828cf87656 | |||
| ac9af8e4f5 | |||
| 8869ac864f | |||
| 0677e74cf8 | |||
| 56b8191a78 |
@@ -47,11 +47,11 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
|
|||||||
|
|
||||||
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
||||||
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
||||||
archive), who is on call, the alert feed, and changing your own password. It is
|
archive), who is on call, the alert feed, and an *Account* tab for your own
|
||||||
built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
password and the ntfy topic your pages go to. It is built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
||||||
bar, it follows the system's dark mode, and it can be added to the home screen.
|
bar, it follows the system's dark mode, and it can be added to the home screen.
|
||||||
From 900px wide it switches to a sidebar with the queue and the incident side by
|
From 900px wide it switches to a sidebar with the queue and the incident side by
|
||||||
side. Schedule editing, statistics and user management remain in
|
side. Statistics remain in
|
||||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
||||||
|
|
||||||
You sign in with a username and password. Users have no password until one is
|
You sign in with a username and password. Users have no password until one is
|
||||||
@@ -92,8 +92,23 @@ the server refuses their writes anyway. Somebody in more than one team picks
|
|||||||
between them at the top.
|
between them at the top.
|
||||||
|
|
||||||
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: every team, every user, and
|
belongs to the whole server rather than to one team. It has three sub-sections,
|
||||||
the settings that used to be environment variables.
|
each with a URL of its own and a strip across the top to move between them:
|
||||||
|
every team (`/admin/teams`), every user (`/admin/users`), and the settings that
|
||||||
|
used to be environment variables (`/admin/settings`). `/admin` itself is an
|
||||||
|
overview — how many of each, and what each section is for. Adding somebody is
|
||||||
|
minting them an invite link into a team, rather than creating a bare account:
|
||||||
|
the person who accepts it picks their own password, so one never passes through
|
||||||
|
an administrator, and the link carries the team, so they land somewhere with a
|
||||||
|
queue in it.
|
||||||
|
|
||||||
|
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
|
||||||
|
administrator, whether the account is disabled, the teams they are in with their
|
||||||
|
role in each, a password field for a first or forgotten one, and deletion. It is
|
||||||
|
the one place membership is edited from the person's side — the Team tab answers
|
||||||
|
"who is in this team", and answering "which teams is this person in" there means
|
||||||
|
visiting each team in turn.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -339,10 +354,20 @@ exactly as it was rather than with a hole in it.
|
|||||||
### Push notifications
|
### Push notifications
|
||||||
|
|
||||||
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
|
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
|
||||||
person's phone through [ntfy](https://ntfy.sh). Set each user's topic with
|
person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic
|
||||||
`PUT /api/users/{id}/notify`; a user with no topic falls back to
|
under *Account* in the web UI, where a **Send a test push** button proves it
|
||||||
`TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with nobody on call.
|
before an incident has to; `PUT /api/users/{id}/notify` is the same thing over
|
||||||
If neither yields a topic, nothing is queued.
|
the API, and an administrator may set somebody else's. A user with no topic
|
||||||
|
falls back to `TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with
|
||||||
|
nobody on call. If neither yields a topic, nothing is queued.
|
||||||
|
|
||||||
|
The **server** is the install's one ntfy, from `TERDUT_NTFY_URL`, and is not
|
||||||
|
something a user picks. Only the topic is per-person.
|
||||||
|
|
||||||
|
A topic is a shared secret with the ntfy server: anyone who knows it can both
|
||||||
|
read the pages and publish to it, so an unguessable one is worth the trouble.
|
||||||
|
That is also why the topic never appears in an incident's timeline, which every
|
||||||
|
API key can read.
|
||||||
|
|
||||||
Three things get pushed:
|
Three things get pushed:
|
||||||
|
|
||||||
@@ -579,10 +604,16 @@ Endpoints that require the flag answer `403` with
|
|||||||
|
|
||||||
**Teams** are the unit of tenancy, and are a separate axis from the administrator
|
**Teams** are the unit of tenancy, and are a separate axis from the administrator
|
||||||
flag. A team owns its incidents, alerts, schedule and integrations, and a user
|
flag. A team owns its incidents, alerts, schedule and integrations, and a user
|
||||||
sees exactly the teams they belong to — an administrator is not implicitly in
|
sees exactly the teams they belong to. Within a team an **owner** configures it
|
||||||
every team, because administration is about accounts, not about reading other
|
(schedule, integrations, membership) and a **member** works its incidents.
|
||||||
people's incidents. Within a team an **owner** configures it (schedule,
|
|
||||||
integrations, membership) and a **member** works its incidents.
|
An administrator crosses that line in one direction only. They **configure any
|
||||||
|
team** without being in it — every owner-only endpoint accepts the flag, because
|
||||||
|
otherwise a team whose last owner left could never be repaired. They do **not
|
||||||
|
read any team**: the queue, the alerts and the incidents are filtered by real
|
||||||
|
membership, so an administrator sees a team's work only by joining it, which is
|
||||||
|
a membership change and shows up as one. Administration is about accounts and
|
||||||
|
the shape of a team, not about reading other people's incidents.
|
||||||
|
|
||||||
Anything belonging to a team you are not in answers `404`, not `403`: whether an
|
Anything belonging to a team you are not in answers `404`, not `403`: whether an
|
||||||
incident exists is itself something only its team should learn.
|
incident exists is itself something only its team should learn.
|
||||||
@@ -607,6 +638,7 @@ on anybody's.
|
|||||||
| `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only |
|
| `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only |
|
||||||
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
|
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
|
||||||
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
|
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
|
||||||
|
| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable |
|
||||||
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
||||||
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
||||||
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
||||||
@@ -641,6 +673,11 @@ and was removed in v0.13.0 once senders had moved onto keys.
|
|||||||
|
|
||||||
### Teams
|
### Teams
|
||||||
|
|
||||||
|
**owner** below means an owner of that team *or* a system administrator, who
|
||||||
|
passes every one of these without being a member — see
|
||||||
|
[Authentication](#authentication). **member** means membership and nothing else: an
|
||||||
|
administrator who is not in the team gets the same `404` as anybody else.
|
||||||
|
|
||||||
| Method | Path | Who | Description |
|
| Method | Path | Who | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
||||||
|
|||||||
@@ -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.15.0
|
version: 0.17.0
|
||||||
appVersion: "v0.15.0"
|
appVersion: "v0.17.0"
|
||||||
|
|||||||
@@ -237,6 +237,125 @@ func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An administrator passes every team-owner check without being in the team,
|
||||||
|
// which is what lets them repair a team whose owner has left. It has been true
|
||||||
|
// since teams landed and nothing pinned it, so a later reading of the epic's
|
||||||
|
// "an admin is not implicitly in every team" could quietly take it away.
|
||||||
|
//
|
||||||
|
// The line it draws: configuring a team, yes; reading what the team owns, no.
|
||||||
|
// The queue below is the half that stays shut.
|
||||||
|
func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
// A team the admin is deliberately not a member of. It is created by
|
||||||
|
// somebody else, so the admin's only claim on it is the flag.
|
||||||
|
_, call := member(t, s, "founder")
|
||||||
|
var team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
|
||||||
|
if team.ID == 0 {
|
||||||
|
t.Fatal("no team was created")
|
||||||
|
}
|
||||||
|
|
||||||
|
var mine []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine)
|
||||||
|
for _, m := range mine {
|
||||||
|
if m.ID == team.ID {
|
||||||
|
t.Fatalf("the admin should not be a member of team %d", team.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/api/teams/" + id64(team.ID)
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"rename it", http.MethodPut, path,
|
||||||
|
map[string]string{"name": "theirs, renamed"}, http.StatusNoContent},
|
||||||
|
{"mint an invite", http.MethodPost, path + "/invites",
|
||||||
|
map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated},
|
||||||
|
{"add a member", http.MethodPost, path + "/members",
|
||||||
|
map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent},
|
||||||
|
{"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, c.method, c.path, c.body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != c.want {
|
||||||
|
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the rule. An incident in that team is not the admin's
|
||||||
|
// to read, because administration is about accounts — and the last case
|
||||||
|
// above has just taken the admin back out of the membership.
|
||||||
|
var integration struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, call(http.MethodPost, path+"/integrations",
|
||||||
|
map[string]string{"name": "theirs alertmanager"}), &integration)
|
||||||
|
postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull")
|
||||||
|
|
||||||
|
var incidents []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents)
|
||||||
|
if len(incidents) != 0 {
|
||||||
|
t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin page's per-user view asks what somebody is in. Self or admin, like
|
||||||
|
// the rest of the per-user endpoints.
|
||||||
|
func TestUserTeams_SelfOrAdmin(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
memberID, call := member(t, s, "joiner")
|
||||||
|
path := "/api/users/" + id64(memberID) + "/teams"
|
||||||
|
|
||||||
|
// member() puts them in the default team, so both readings agree on one.
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
do func() *http.Response
|
||||||
|
}{
|
||||||
|
{"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }},
|
||||||
|
{"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }},
|
||||||
|
} {
|
||||||
|
var teams []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
decode(t, c.do(), &teams)
|
||||||
|
if len(teams) != 1 {
|
||||||
|
t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams))
|
||||||
|
}
|
||||||
|
if teams[0].Role != "member" {
|
||||||
|
t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's is not theirs to read.
|
||||||
|
otherID, _ := member(t, s, "nosy")
|
||||||
|
resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user who does not exist is a 404 rather than an empty list, which is
|
||||||
|
// how the page tells "no teams" from "no such person".
|
||||||
|
resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("a missing user: expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The flag has to reach the client, or the web UI cannot decide what to show.
|
// The flag has to reach the client, or the web UI cannot decide what to show.
|
||||||
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
// Your own account, or anybody's if you are an admin. The handlers call
|
// Your own account, or anybody's if you are an admin. The handlers call
|
||||||
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
||||||
// which rule applies depends on the {id} in the path.
|
// which rule applies depends on the {id} in the path.
|
||||||
|
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
||||||
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
||||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||||
|
|||||||
@@ -51,6 +51,68 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUserTeams lists one user's teams, for the admin page's per-user view:
|
||||||
|
// "what is this person in", which /api/teams cannot answer because it is always
|
||||||
|
// about the caller.
|
||||||
|
//
|
||||||
|
// Self or admin, matching the other per-user endpoints. It says which teams
|
||||||
|
// somebody belongs to and in what role — not anything those teams own, so it
|
||||||
|
// stays on the accounts side of the line the administrator flag draws.
|
||||||
|
func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelfOrAdmin(w, r, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with no teams and a user who does not exist both list nothing,
|
||||||
|
// so the existence check is what tells them apart.
|
||||||
|
var exists bool
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT t.id, t.name, t.created_at, m.role
|
||||||
|
FROM teams t
|
||||||
|
JOIN team_members m ON m.team_id = t.id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
ORDER BY t.name`, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
teams := []models.Team{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Team
|
||||||
|
var created int64
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
teams = append(teams, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, teams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
||||||
// with no owner would need an administrator to repair before anybody could use
|
// with no owner would need an administrator to repair before anybody could use
|
||||||
// it, so the two happen in one transaction.
|
// it, so the two happen in one transaction.
|
||||||
|
|||||||
@@ -199,7 +199,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
/* Bottom tab bar on phones. */
|
/* Bottom tab bar on phones. */
|
||||||
.nav {
|
.nav {
|
||||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
/* One column per link, however many there are. This was repeat(4, 1fr) when
|
||||||
|
there were four tabs; Team and Admin arriving pushed six items into four
|
||||||
|
columns, which on a phone is how they stopped fitting. Auto columns mean
|
||||||
|
the next tab cannot break the row either — and Admin is only rendered for
|
||||||
|
an administrator, so the count genuinely varies between viewers. */
|
||||||
|
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||||
padding-bottom: var(--safe-bottom);
|
padding-bottom: var(--safe-bottom);
|
||||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||||
@@ -212,8 +217,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||||
color: var(--faint); font-size: 11px; font-weight: 600;
|
color: var(--faint); font-size: 11px; font-weight: 600;
|
||||||
|
/* min-width lets a column shrink below its label's natural width, which is
|
||||||
|
what stops six tabs widening the bar past the screen. */
|
||||||
|
min-width: 0; padding: 0 2px;
|
||||||
}
|
}
|
||||||
.nav-link svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
.nav-label {
|
||||||
|
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
|
||||||
|
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
|
||||||
|
the widest label, "On-call", is about 38px at this size, so nothing has to
|
||||||
|
be hidden. The ellipsis above is the backstop if a future tab is named
|
||||||
|
something longer. */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.nav-link { font-size: 10px; gap: 1px; }
|
||||||
|
.nav-link svg { width: 21px; height: 21px; }
|
||||||
|
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link[aria-current="page"] { color: var(--accent); }
|
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||||
.nav-badge {
|
.nav-badge {
|
||||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||||
@@ -658,6 +680,42 @@ 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. */
|
||||||
|
.user-link { color: var(--text); font-weight: 650; text-decoration: none; }
|
||||||
|
.user-link:hover { color: var(--accent); text-decoration: underline; }
|
||||||
|
|
||||||
|
.invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
|
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
|
||||||
|
/* The link is shown once and never stored, so it has to be selectable and
|
||||||
|
wrap rather than scroll off the side of a phone. */
|
||||||
|
.invite-out { margin-top: 12px; font-size: 13px; }
|
||||||
|
.invite-link {
|
||||||
|
display: block; margin-top: 6px; padding: 8px; border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-2); font-family: var(--mono); font-size: 12px;
|
||||||
|
word-break: break-all; user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- one user ------------------------------------------------------------ */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
|
||||||
|
color: var(--muted); font-size: 14px; text-decoration: none;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--text); }
|
||||||
|
.back-link svg { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
.user-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.user-head h2 { margin: 0; }
|
||||||
|
|
||||||
|
.user-facts {
|
||||||
|
display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px;
|
||||||
|
margin: 12px 0 0; font-size: 14px;
|
||||||
|
}
|
||||||
|
.user-facts dt { color: var(--muted); }
|
||||||
|
.user-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||||
|
.admin-table .row-actions { margin-top: 0; gap: 6px; }
|
||||||
|
|
||||||
/* --- team settings -------------------------------------------------------
|
/* --- team settings -------------------------------------------------------
|
||||||
Forms with a label above each control, rather than the queue's rows of
|
Forms with a label above each control, rather than the queue's rows of
|
||||||
links. The escalation ladder is the only nested structure in the app, so it
|
links. The escalation ladder is the only nested structure in the app, so it
|
||||||
@@ -707,3 +765,35 @@ kbd {
|
|||||||
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* --- admin sub-navigation ------------------------------------------------
|
||||||
|
A strip of links across the top of every admin page, one per sub-section.
|
||||||
|
Deliberately not .chip: chips filter what a page already shows, here and in
|
||||||
|
the queue, and these four go somewhere. Same aria-current convention as the
|
||||||
|
tab bar, so the state lives on the attribute rather than in a class. */
|
||||||
|
.subnav {
|
||||||
|
display: flex; gap: 2px;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto; scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.subnav::-webkit-scrollbar { display: none; }
|
||||||
|
.subnav-link {
|
||||||
|
flex: none;
|
||||||
|
padding: 8px 12px; margin-bottom: -1px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--muted); font-size: 14px; font-weight: 600; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.subnav-link:hover { color: var(--text); }
|
||||||
|
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
/* The overview at /admin. The strip above already links to the three, so these
|
||||||
|
carry the counts, which is the part a menu cannot say. */
|
||||||
|
.admin-menu { display: grid; gap: 10px; margin-top: 16px; }
|
||||||
|
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
||||||
|
.admin-menu .card + .card { margin-top: 0; }
|
||||||
|
.admin-menu-item { display: block; padding: 14px; }
|
||||||
|
.admin-menu-item:hover { background: var(--surface-hover); }
|
||||||
|
.admin-menu-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.admin-menu-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||||
|
.admin-menu-item p { margin: 4px 0 0; }
|
||||||
|
|||||||
@@ -77,31 +77,31 @@
|
|||||||
<img src="/icon.svg" alt="" width="28" height="28">
|
<img src="/icon.svg" alt="" width="28" height="28">
|
||||||
<span>terdut</span>
|
<span>terdut</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/" data-section="queue">
|
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||||
<span class="nav-label">Queue</span>
|
<span class="nav-label">Queue</span>
|
||||||
<span class="nav-badge" data-badge hidden></span>
|
<span class="nav-badge" data-badge hidden></span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/oncall" data-section="oncall">
|
<a class="nav-link" href="/oncall" data-section="oncall" aria-label="On-call">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
||||||
<span class="nav-label">On-call</span>
|
<span class="nav-label">On-call</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/alerts" data-section="alerts">
|
<a class="nav-link" href="/alerts" data-section="alerts" aria-label="Alerts">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||||
<span class="nav-label">Alerts</span>
|
<span class="nav-label">Alerts</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/team" data-section="team">
|
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
||||||
<span class="nav-label">Team</span>
|
<span class="nav-label">Team</span>
|
||||||
</a>
|
</a>
|
||||||
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||||
unhides it once /api/me says so. The server refuses every admin
|
unhides it once /api/me says so. The server refuses every admin
|
||||||
endpoint regardless, so this is a courtesy and not a gate. -->
|
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||||
<a class="nav-link" href="/admin" data-section="admin" id="nav-admin" hidden>
|
<a class="nav-link" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
||||||
<span class="nav-label">Admin</span>
|
<span class="nav-label">Admin</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/more" data-section="more">
|
<a class="nav-link" href="/more" data-section="more" aria-label="Account">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
||||||
<span class="nav-label">Account</span>
|
<span class="nav-label">Account</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -124,6 +124,9 @@
|
|||||||
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
||||||
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
||||||
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
||||||
|
<!-- 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. -->
|
||||||
|
<section id="view-adminuser" class="view view-page" data-view="adminuser" 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>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ function render() {
|
|||||||
h('div', { class: 'account-name', text: user.username }),
|
h('div', { class: 'account-name', text: user.username }),
|
||||||
h('div', { class: 'account-email', text: user.email }))),
|
h('div', { class: 'account-email', text: user.email }))),
|
||||||
|
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||||
|
notifyForm(user),
|
||||||
|
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||||||
passwordForm(user, hasPassword),
|
passwordForm(user, hasPassword),
|
||||||
|
|
||||||
@@ -32,10 +35,91 @@ function render() {
|
|||||||
|
|
||||||
h('div', { class: 'page-head' }),
|
h('div', { class: 'page-head' }),
|
||||||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||||||
h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }),
|
h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where this user's pages go. The onboarding checklist's first step sends
|
||||||
|
// people here for it, and until now there was nothing here to send them to:
|
||||||
|
// the topic could only be set with curl or by an administrator.
|
||||||
|
//
|
||||||
|
// The topic is the whole address — the server it is published to is the
|
||||||
|
// install's one ntfy, set in the deployment and not something a user picks.
|
||||||
|
function notifyForm(user) {
|
||||||
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
|
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||||
|
const topic = h('input', {
|
||||||
|
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||||||
|
autocapitalize: 'none', spellcheck: false,
|
||||||
|
value: user.ntfy_topic || '',
|
||||||
|
placeholder: 'terdut-a7f3c91e',
|
||||||
|
});
|
||||||
|
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||||||
|
|
||||||
|
// Only offered once a topic is saved: the test publishes to whatever the
|
||||||
|
// server has stored, not to whatever is half-typed in the field.
|
||||||
|
const test = h('button', {
|
||||||
|
class: 'btn', type: 'button', text: 'Send a test push',
|
||||||
|
hidden: !user.ntfy_topic,
|
||||||
|
onclick: async () => {
|
||||||
|
err.hidden = true;
|
||||||
|
ok.hidden = true;
|
||||||
|
test.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.testNotification();
|
||||||
|
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
|
||||||
|
ok.hidden = false;
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
} finally {
|
||||||
|
test.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = h('form', { class: 'card pw-form' },
|
||||||
|
h('label', {},
|
||||||
|
h('span', { text: 'ntfy topic' }),
|
||||||
|
topic),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
|
||||||
|
'reach your phone. Leave it empty and they page the team’s fallback ',
|
||||||
|
'topic instead.'),
|
||||||
|
// Worth saying plainly: people reach for their own name, and the topic is
|
||||||
|
// the only thing standing between a stranger and their pages.
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Anyone who knows the topic can read your pages and publish to it, so ',
|
||||||
|
'pick something unguessable rather than your name.'),
|
||||||
|
err, ok,
|
||||||
|
h('div', { class: 'row-actions' }, submit, test),
|
||||||
|
);
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
err.hidden = true;
|
||||||
|
ok.hidden = true;
|
||||||
|
submit.disabled = true;
|
||||||
|
try {
|
||||||
|
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
||||||
|
// Keep the cached user in step, so the onboarding checklist stops
|
||||||
|
// asking for this and the test button appears without a reload.
|
||||||
|
state.me.user = updated;
|
||||||
|
ok.textContent = updated.ntfy_topic
|
||||||
|
? 'Topic saved.'
|
||||||
|
: 'Topic cleared. Your pages go to the team’s fallback topic.';
|
||||||
|
ok.hidden = false;
|
||||||
|
test.hidden = !updated.ntfy_topic;
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
function passwordForm(user, hasPassword) {
|
function passwordForm(user, hasPassword) {
|
||||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||||
|
|||||||
+153
-15
@@ -1,6 +1,12 @@
|
|||||||
// Administration: the teams on this server, the people who can sign in, and
|
// Administration: the teams on this server, the people who can sign in, and
|
||||||
// the settings that change how the server behaves.
|
// the settings that change how the server behaves.
|
||||||
//
|
//
|
||||||
|
// Each of those three is a route of its own, reached from a strip across the
|
||||||
|
// top, with /admin itself an overview. They used to be three cards stacked on
|
||||||
|
// one page, which meant no way to link to the settings, no way back to the top
|
||||||
|
// of the user list but scrolling, and a poll that refetched all three endpoints
|
||||||
|
// however little of the page you were looking at.
|
||||||
|
//
|
||||||
// Only rendered for a system administrator. The server enforces that on every
|
// Only rendered for a system administrator. The server enforces that on every
|
||||||
// endpoint regardless — hiding a section is a courtesy to the reader, not a
|
// endpoint regardless — hiding a section is a courtesy to the reader, not a
|
||||||
// permission — so this view simply says so rather than pretending to be a
|
// permission — so this view simply says so rather than pretending to be a
|
||||||
@@ -12,12 +18,34 @@ import { state, myID } from './state.js';
|
|||||||
|
|
||||||
const view = () => document.getElementById('view-admin');
|
const view = () => document.getElementById('view-admin');
|
||||||
|
|
||||||
let data = null; // { teams, users, settings }
|
// The sub-sections, in the order the strip shows them. The overview is /admin
|
||||||
|
// itself, so it has no tab of its own. This table is the only place the four
|
||||||
|
// routes are written down: app.js parses against it and the strip is built
|
||||||
|
// from it, so adding a fifth is one line here.
|
||||||
|
export const TABS = [
|
||||||
|
{ tab: null, path: '/admin', label: 'Overview' },
|
||||||
|
{ tab: 'teams', path: '/admin/teams', label: 'Teams' },
|
||||||
|
{ tab: 'users', path: '/admin/users', label: 'Users' },
|
||||||
|
{ tab: 'settings', path: '/admin/settings', label: 'Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||||
|
// loop calls refresh() with no route — the same reason adminuser.js keeps its
|
||||||
|
// user ID in the module.
|
||||||
|
let tab = null;
|
||||||
|
let data = null; // whatever the current tab needs; the shape varies by tab
|
||||||
let error = null;
|
let error = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
||||||
export function show() {
|
export function show(route) {
|
||||||
if (!data) clear(view(), spinner());
|
const next = route?.tab ?? null;
|
||||||
|
// A different sub-section wants different data, so the old answer goes
|
||||||
|
// rather than being shown under the new heading until the fetch lands.
|
||||||
|
if (next !== tab) {
|
||||||
|
tab = next;
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!data) clear(view(), subnav(), spinner());
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +56,7 @@ export async function refresh() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [teams, users, settings] = await Promise.all([
|
data = await load();
|
||||||
api.adminTeams(),
|
|
||||||
api.users(),
|
|
||||||
api.adminSettings(),
|
|
||||||
]);
|
|
||||||
data = { teams, users, settings };
|
|
||||||
error = null;
|
error = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
@@ -41,6 +64,16 @@ export async function refresh() {
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only what the open sub-section shows. Users is the one that needs two: its
|
||||||
|
// invite form has to offer a team to invite somebody into, and the overview
|
||||||
|
// counts both.
|
||||||
|
async function load() {
|
||||||
|
if (tab === 'teams') return { teams: await api.adminTeams() };
|
||||||
|
if (tab === 'settings') return { settings: await api.adminSettings() };
|
||||||
|
const [teams, users] = await Promise.all([api.adminTeams(), api.users()]);
|
||||||
|
return { teams, users };
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!state.me?.user?.is_admin) {
|
if (!state.me?.user?.is_admin) {
|
||||||
clear(view(), h('div', { class: 'card' },
|
clear(view(), h('div', { class: 'card' },
|
||||||
@@ -48,14 +81,65 @@ function render() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (!data) {
|
||||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
clear(view(), subnav(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clear(view(),
|
clear(view(),
|
||||||
|
subnav(),
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
teamsCard(),
|
section(),
|
||||||
usersCard(),
|
);
|
||||||
settingsCard(),
|
}
|
||||||
|
|
||||||
|
function section() {
|
||||||
|
if (tab === 'teams') return teamsCard();
|
||||||
|
if (tab === 'users') return usersCard();
|
||||||
|
if (tab === 'settings') return settingsCard();
|
||||||
|
return overview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strip across the top of every admin page. Ordinary links rather than
|
||||||
|
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||||
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
|
function subnav() {
|
||||||
|
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||||
|
TABS.map((t) => h('a', {
|
||||||
|
class: 'subnav-link',
|
||||||
|
href: t.path,
|
||||||
|
text: t.label,
|
||||||
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- overview --------------------------------------------------------------
|
||||||
|
|
||||||
|
// /admin itself. The strip already links to the three, so this earns its place
|
||||||
|
// by saying how much of each there is — the one thing a menu cannot.
|
||||||
|
function overview() {
|
||||||
|
const admins = data.users.filter((u) => u.is_admin).length;
|
||||||
|
const disabled = data.users.filter((u) => u.disabled_at).length;
|
||||||
|
const open = data.teams.reduce((n, t) => n + t.open_incidents, 0);
|
||||||
|
|
||||||
|
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
||||||
|
if (disabled > 0) people.push(`${disabled} disabled`);
|
||||||
|
|
||||||
|
return h('div', { class: 'admin-menu' },
|
||||||
|
menuItem('/admin/teams', 'Teams', data.teams.length,
|
||||||
|
open > 0
|
||||||
|
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
||||||
|
: 'Nothing open anywhere.'),
|
||||||
|
menuItem('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
||||||
|
menuItem('/admin/settings', 'Settings', null,
|
||||||
|
'How the server behaves, and where it is plugged in.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuItem(href, label, count, note) {
|
||||||
|
return h('a', { class: 'card admin-menu-item', href },
|
||||||
|
h('div', { class: 'admin-menu-head' },
|
||||||
|
h('strong', { text: label }),
|
||||||
|
count != null && h('span', { class: 'admin-menu-count', text: String(count) })),
|
||||||
|
h('p', { class: 'muted small', text: note }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +239,9 @@ function usersCard() {
|
|||||||
const self = u.id === myID();
|
const self = u.id === myID();
|
||||||
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
||||||
h('td', {},
|
h('td', {},
|
||||||
h('strong', { text: u.username }),
|
// The name is the way in: everything about one person lives on their
|
||||||
|
// own page, and this table stays a list rather than becoming a form.
|
||||||
|
h('a', { class: 'user-link', href: `/admin/users/${u.id}`, text: u.username }),
|
||||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
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 }),
|
||||||
@@ -184,7 +270,8 @@ function usersCard() {
|
|||||||
h('h2', { text: 'Users' }),
|
h('h2', { text: 'Users' }),
|
||||||
h('p', { class: 'muted small' },
|
h('p', { class: 'muted small' },
|
||||||
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
||||||
'its acknowledgements and timeline entries. Deleting a user erases those.'),
|
'its acknowledgements and timeline entries. Deleting a user erases those. ',
|
||||||
|
'Open a name for their teams, their password and the rest.'),
|
||||||
h('table', { class: 'admin-table' },
|
h('table', { class: 'admin-table' },
|
||||||
h('thead', {}, h('tr', {},
|
h('thead', {}, h('tr', {},
|
||||||
h('th', { text: 'User' }),
|
h('th', { text: 'User' }),
|
||||||
@@ -192,6 +279,57 @@ function usersCard() {
|
|||||||
h('th', { text: '' }),
|
h('th', { text: '' }),
|
||||||
h('th', { text: '' }))),
|
h('th', { text: '' }))),
|
||||||
h('tbody', {}, rows)),
|
h('tbody', {}, rows)),
|
||||||
|
inviteForm(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding a person is minting them an invite, not creating a row. The account
|
||||||
|
// is created by whoever accepts it, so they pick their own password and it
|
||||||
|
// never passes through an administrator — and the link carries the team, which
|
||||||
|
// a bare POST /api/users cannot, leaving an account with nothing to work on.
|
||||||
|
//
|
||||||
|
// Minting for a team the administrator is not in is allowed: the flag passes
|
||||||
|
// every team-owner check, so a server administrator can staff any team. The
|
||||||
|
// link shows up in that team's own invite list, where an owner can revoke it.
|
||||||
|
function inviteForm() {
|
||||||
|
const team = h('select', {},
|
||||||
|
...data.teams.map((t) => h('option', { value: String(t.id), text: t.name })));
|
||||||
|
const role = h('select', {},
|
||||||
|
h('option', { value: 'member', text: 'member' }),
|
||||||
|
h('option', { value: 'owner', text: 'owner' }));
|
||||||
|
const out = h('p', { class: 'invite-out', hidden: true });
|
||||||
|
|
||||||
|
const form = h('form', { class: 'inline-form' }, team, role,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const inv = await api.createInvite(Number(team.value), role.value, 1);
|
||||||
|
// Shown once and never stored, so it is put on the page to be copied
|
||||||
|
// rather than toasted away after three seconds.
|
||||||
|
clear(out, h('strong', { text: 'Send them this link. It is shown once.' }),
|
||||||
|
h('code', { class: 'invite-link', text: inv.url }));
|
||||||
|
out.hidden = false;
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'invite-block' },
|
||||||
|
h('h3', { text: 'Add someone' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'An invite link puts them in a team and lets them choose their own ',
|
||||||
|
'password. It lasts a week and can be used once.'),
|
||||||
|
data.teams.length > 0 ? form
|
||||||
|
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
|
||||||
|
out,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
// One person, at /admin/users/{id}: what they are, what they are in, and the
|
||||||
|
// levers an administrator has over the account.
|
||||||
|
//
|
||||||
|
// A section of its own rather than an expanding row in the Admin tab's table,
|
||||||
|
// because memberships and the account actions together are more than a row can
|
||||||
|
// hold and still be read on a phone.
|
||||||
|
//
|
||||||
|
// Like the Admin tab, this hides nothing the server would allow and shows
|
||||||
|
// nothing it would refuse: every write here is an endpoint that answers 403
|
||||||
|
// without the flag, so the view is a description of the rules rather than an
|
||||||
|
// enforcement of them.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
|
||||||
|
import { state, myID } from './state.js';
|
||||||
|
import { navigate } from './app.js';
|
||||||
|
import { when } from './format.js';
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-adminuser');
|
||||||
|
|
||||||
|
let userID = null;
|
||||||
|
let data = null; // { user, teams, allTeams }
|
||||||
|
let error = null;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
export function show(route) {
|
||||||
|
const next = route && route.user != null ? route.user : null;
|
||||||
|
if (next !== userID) {
|
||||||
|
userID = next;
|
||||||
|
data = null;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
if (!data) clear(view(), spinner());
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
if (userID == null || !state.me?.user?.is_admin) {
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// The user comes from the list rather than a show endpoint: there is no
|
||||||
|
// GET /api/users/{id}, and adding one for a row the list already carries
|
||||||
|
// would be a second way to say the same thing.
|
||||||
|
const [users, teams, allTeams] = await Promise.all([
|
||||||
|
api.users(),
|
||||||
|
api.userTeams(userID),
|
||||||
|
api.adminTeams(),
|
||||||
|
]);
|
||||||
|
const user = users.find((u) => u.id === userID) || null;
|
||||||
|
data = { user, teams, allTeams };
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const el = view();
|
||||||
|
if (!state.me?.user?.is_admin) {
|
||||||
|
clear(el, backLink(), h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.user) {
|
||||||
|
clear(el, backLink(), h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear(el,
|
||||||
|
backLink(),
|
||||||
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
identityCard(),
|
||||||
|
teamsCard(),
|
||||||
|
accountCard(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backLink() {
|
||||||
|
return h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identity --------------------------------------------------------------
|
||||||
|
|
||||||
|
function identityCard() {
|
||||||
|
const u = data.user;
|
||||||
|
const self = u.id === myID();
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('div', { class: 'user-head' },
|
||||||
|
h('h2', { text: u.username }),
|
||||||
|
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
|
||||||
|
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||||
|
self && h('span', { class: 'you', text: 'you' })),
|
||||||
|
h('dl', { class: 'user-facts' },
|
||||||
|
fact('Email', u.email),
|
||||||
|
fact('Joined', when(u.created_at)),
|
||||||
|
fact('Notifications', u.ntfy_topic ? `ntfy: ${u.ntfy_topic}` : 'None of their own'),
|
||||||
|
u.disabled_at && fact('Disabled', when(u.disabled_at)),
|
||||||
|
),
|
||||||
|
// Both of these refuse your own account, and the last administrator's. An
|
||||||
|
// enabled button that always fails is worse than no button.
|
||||||
|
h('div', { class: 'row-actions' },
|
||||||
|
!self && h('button', {
|
||||||
|
class: 'btn', type: 'button',
|
||||||
|
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||||
|
onclick: () => setAdmin(!u.is_admin),
|
||||||
|
}),
|
||||||
|
!self && h('button', {
|
||||||
|
class: 'btn', type: 'button',
|
||||||
|
text: u.disabled_at ? 'Enable account' : 'Disable account',
|
||||||
|
onclick: () => setDisabled(!u.disabled_at),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
self && h('p', { class: 'muted small' },
|
||||||
|
'You cannot change your own administrator flag or disable yourself — ',
|
||||||
|
'that is how an install ends up with nobody who can administer it.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fact(label, value) {
|
||||||
|
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAdmin(next) {
|
||||||
|
if (next && !(await confirm({
|
||||||
|
title: `Make ${data.user.username} an administrator?`,
|
||||||
|
text: 'They will be able to manage every account, configure any team, and grant this to others.',
|
||||||
|
confirmLabel: 'Make admin',
|
||||||
|
}))) return;
|
||||||
|
await act(() => api.setUserAdmin(userID, next));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDisabled(next) {
|
||||||
|
if (next && !(await confirm({
|
||||||
|
title: `Disable ${data.user.username}?`,
|
||||||
|
text: 'They cannot sign in and their API keys stop working. Their acknowledgements and timeline entries stay.',
|
||||||
|
confirmLabel: 'Disable',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
await act(() => api.setUserDisabled(userID, next));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- teams -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// An administrator passes every team-owner check without being in the team,
|
||||||
|
// which is what lets them repair a team whose owner has left. So this card
|
||||||
|
// edits, rather than reporting what somebody else would have to do.
|
||||||
|
//
|
||||||
|
// It is the one place membership can be changed from the person's side: the
|
||||||
|
// Team tab asks "who is in this team", and answering "which teams is this
|
||||||
|
// person in" there means visiting each team in turn.
|
||||||
|
function teamsCard() {
|
||||||
|
const rows = data.teams.map((t) =>
|
||||||
|
h('tr', {},
|
||||||
|
// Not a link: the Team tab always shows the viewer's own team, so
|
||||||
|
// sending them there from somebody else's membership would be a lie.
|
||||||
|
h('td', {}, h('strong', { text: t.name })),
|
||||||
|
h('td', { class: 'muted small', text: t.role }),
|
||||||
|
h('td', { class: 'row-actions' },
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button',
|
||||||
|
text: t.role === 'owner' ? 'Make member' : 'Make owner',
|
||||||
|
onclick: () => act(() =>
|
||||||
|
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
|
||||||
|
}),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
// The server refuses the last owner with a 409, which act() shows.
|
||||||
|
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
const inTeam = new Set(data.teams.map((t) => t.id));
|
||||||
|
const candidates = (data.allTeams || []).filter((t) => !inTeam.has(t.id));
|
||||||
|
const pick = h('select', {},
|
||||||
|
...candidates.map((t) => h('option', { value: String(t.id), text: t.name })));
|
||||||
|
const role = h('select', {},
|
||||||
|
h('option', { value: 'member', text: 'member' }),
|
||||||
|
h('option', { value: 'owner', text: 'owner' }));
|
||||||
|
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Teams' }),
|
||||||
|
data.teams.length === 0 && h('p', { class: 'muted small' },
|
||||||
|
'In no team. They can sign in, but there is no queue for them to work ',
|
||||||
|
'and nothing to page them about.'),
|
||||||
|
data.teams.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||||
|
candidates.length > 0 && form,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- account ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function accountCard() {
|
||||||
|
const u = data.user;
|
||||||
|
const self = u.id === myID();
|
||||||
|
|
||||||
|
const pw = h('input', {
|
||||||
|
type: 'password', name: 'password', autocomplete: 'new-password',
|
||||||
|
minlength: '10', required: true, placeholder: 'At least 10 characters',
|
||||||
|
});
|
||||||
|
const form = h('form', { class: 'inline-form' }, pw,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Set password' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
// No current password: that check is for changing your own, and an
|
||||||
|
// administrator setting somebody else's does not know it by design.
|
||||||
|
await api.setPassword(userID, pw.value);
|
||||||
|
pw.value = '';
|
||||||
|
toast(`Password set for ${u.username}. Their other sessions are signed out.`);
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Account' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Setting a password here is how somebody gets their first one, or a new ',
|
||||||
|
'one after forgetting it. It signs them out everywhere else. They change ',
|
||||||
|
'it themselves under Account afterwards.'),
|
||||||
|
self ? h('p', { class: 'muted small' },
|
||||||
|
'Change your own password under Account, where the current one is asked for.')
|
||||||
|
: form,
|
||||||
|
h('h3', { text: 'Delete' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Deleting erases their acknowledgements and timeline entries — incidents ',
|
||||||
|
'they handled stop saying who did. Disabling keeps the history and is ',
|
||||||
|
'almost always what is meant.'),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn btn-danger', type: 'button', text: `Delete ${u.username}`,
|
||||||
|
disabled: self,
|
||||||
|
title: self ? 'You cannot delete your own account' : '',
|
||||||
|
onclick: deleteUser,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser() {
|
||||||
|
if (!(await confirm({
|
||||||
|
title: `Delete ${data.user.username}?`,
|
||||||
|
text: 'Their API keys go with them, and their name comes off every incident they acknowledged. This cannot be undone.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
await api.deleteUser(userID);
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('User deleted.');
|
||||||
|
navigate('/admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plumbing --------------------------------------------------------------
|
||||||
|
|
||||||
|
// act runs a write and reloads. Errors are shown rather than thrown away: the
|
||||||
|
// 409 from the last-owner or last-administrator guard is the server explaining
|
||||||
|
// itself, and the reader needs to see it.
|
||||||
|
async function act(fn) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
@@ -67,6 +67,15 @@ export const setPassword = (userID, password, currentPassword) =>
|
|||||||
// users
|
// users
|
||||||
export const users = () => call('GET', '/users');
|
export const users = () => call('GET', '/users');
|
||||||
|
|
||||||
|
// What one person is in. /teams answers "what am I in" and cannot be asked
|
||||||
|
// about anybody else, which is what the admin page's per-user view needs.
|
||||||
|
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
|
||||||
|
|
||||||
|
// Where this user's pages go. An empty topic clears it, which the server
|
||||||
|
// treats as "no topic of their own" rather than an error.
|
||||||
|
export const setNotifyTarget = (id, ntfyTopic) =>
|
||||||
|
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
|
||||||
|
|
||||||
// incidents
|
// incidents
|
||||||
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
||||||
export const incident = (id) => call('GET', `/incidents/${id}`);
|
export const incident = (id) => call('GET', `/incidents/${id}`);
|
||||||
@@ -139,6 +148,7 @@ export const setUserAdmin = (id, isAdmin) =>
|
|||||||
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
|
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
|
||||||
export const setUserDisabled = (id, disabled) =>
|
export const setUserDisabled = (id, disabled) =>
|
||||||
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
|
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
|
||||||
|
export const deleteUser = (id) => call('DELETE', `/users/${id}`);
|
||||||
export const schedule = (teamID, from, to) =>
|
export const schedule = (teamID, from, to) =>
|
||||||
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
||||||
|
|
||||||
|
|||||||
@@ -11,27 +11,47 @@ import * as alerts from './alerts.js';
|
|||||||
import * as account from './account.js';
|
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';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
// One route per section; /incidents/{id} is the queue with a detail open.
|
// One route per section; /incidents/{id} is the queue with a detail open, and
|
||||||
|
// /admin/users/{id} is a section of its own rather than a mode of the Admin
|
||||||
|
// tab, because it replaces the page rather than opening beside it.
|
||||||
const SECTIONS = {
|
const SECTIONS = {
|
||||||
queue: { title: 'Queue', view: queue },
|
queue: { title: 'Queue', view: queue },
|
||||||
oncall: { title: 'On-call', view: oncall },
|
oncall: { title: 'On-call', view: oncall },
|
||||||
alerts: { title: 'Alerts', view: alerts },
|
alerts: { title: 'Alerts', view: alerts },
|
||||||
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' },
|
||||||
more: { title: 'Account', view: account },
|
more: { title: 'Account', view: account },
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseRoute(pathname) {
|
function parseRoute(pathname) {
|
||||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||||
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+)\/?$/);
|
||||||
|
if (u) return { section: 'adminuser', user: Number(u[1]) };
|
||||||
const name = pathname.replace(/^\/|\/$/g, '');
|
const name = pathname.replace(/^\/|\/$/g, '');
|
||||||
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
|
// 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.
|
||||||
|
const t = admin.TABS.find((x) => x.path === `/${name}`);
|
||||||
|
if (t) return { section: 'admin', tab: t.tab };
|
||||||
|
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'more') return { section: name };
|
||||||
return { section: 'queue', incident: null };
|
return { section: 'queue', incident: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the top bar and the document title call this route. Admin's sub-sections
|
||||||
|
// are pages in their own right, so they say which one rather than "Admin" four
|
||||||
|
// times; the overview keeps the tab's own name.
|
||||||
|
function title(r) {
|
||||||
|
const t = r.section === 'admin' && r.tab
|
||||||
|
? admin.TABS.find((x) => x.tab === r.tab)
|
||||||
|
: null;
|
||||||
|
return t ? t.label : SECTIONS[r.section].title;
|
||||||
|
}
|
||||||
|
|
||||||
let route = parseRoute(location.pathname);
|
let route = parseRoute(location.pathname);
|
||||||
// How many in-app navigations deep we are, so Back can use the browser's
|
// How many in-app navigations deep we are, so Back can use the browser's
|
||||||
// history when there is somewhere to go back to, and the queue otherwise.
|
// history when there is somewhere to go back to, and the queue otherwise.
|
||||||
@@ -64,13 +84,16 @@ function render() {
|
|||||||
route = parseRoute(location.pathname);
|
route = parseRoute(location.pathname);
|
||||||
const app = $('app');
|
const app = $('app');
|
||||||
|
|
||||||
for (const [name, s] of Object.entries(SECTIONS)) {
|
for (const name of Object.keys(SECTIONS)) {
|
||||||
const el = $(`view-${name}`);
|
const el = $(`view-${name}`);
|
||||||
el.hidden = name !== route.section;
|
el.hidden = name !== route.section;
|
||||||
if (name === route.section) $('topbar-title').textContent = s.title;
|
if (name === route.section) $('topbar-title').textContent = title(route);
|
||||||
}
|
}
|
||||||
|
// A section may light up somebody else's tab: /admin/users/{id} is still the
|
||||||
|
// Admin tab as far as the nav is concerned, since there is no tab of its own.
|
||||||
|
const current = SECTIONS[route.section].nav || route.section;
|
||||||
for (const link of document.querySelectorAll('.nav-link')) {
|
for (const link of document.querySelectorAll('.nav-link')) {
|
||||||
if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page');
|
if (link.dataset.section === current) link.setAttribute('aria-current', 'page');
|
||||||
else link.removeAttribute('aria-current');
|
else link.removeAttribute('aria-current');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +108,14 @@ function render() {
|
|||||||
incident.show(route.incident);
|
incident.show(route.incident);
|
||||||
} else {
|
} else {
|
||||||
incident.show(null);
|
incident.show(null);
|
||||||
SECTIONS[route.section].view.show();
|
SECTIONS[route.section].view.show(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
||||||
else if (prev.section !== route.section) window.scrollTo(0, 0);
|
// A changed tab counts as a changed page: stepping from a long user list to
|
||||||
|
// the settings should not land you halfway down them.
|
||||||
|
else if (prev.section !== route.section || prev.tab !== route.tab) window.scrollTo(0, 0);
|
||||||
|
|
||||||
updateTitle();
|
updateTitle();
|
||||||
}
|
}
|
||||||
@@ -129,7 +154,7 @@ function updateBadges() {
|
|||||||
|
|
||||||
function updateTitle() {
|
function updateTitle() {
|
||||||
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
||||||
const section = SECTIONS[route.section].title;
|
const section = title(route);
|
||||||
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
|
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
|
||||||
document.title = triggered ? `(${triggered}) ${base}` : base;
|
document.title = triggered ? `(${triggered}) ${base}` : base;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user