Add an admin page, and move the behaviour settings into the database
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m1s

Closes #5. Three of the server's tunables were environment variables,
which meant changing how long an incident waits before being paged again
required editing a chart, merging it and waiting for a reconcile. They
are behaviour rather than infrastructure, and the difference is who needs
to change them and how often.

The split is by who owns the value. What stays in the environment is
where the server is plugged in: the listen address, the DSN, the ntfy URL
and token, the public URL. Those are needed before the database is open
and two of them are credentials -- the settings endpoint reports that
ntfy is configured and that a token is set, and never what either is.

What moves is how it behaves: the notify repeat interval, the stale
window and the archive window. The environment variable becomes the seed
rather than the setting, written once on first start and never
overwritten, so a redeploy cannot put a chart's default back over an
administrator's edit -- the rule the per-team dead man's switches already
follow. The loops read the current value per tick, so a change at 02:00
is obeyed at 02:00.

Key/value rather than a column per knob: #6 and #7 will both add
settings, and a table shaped one-column-per-setting needs a migration for
each. The cost is that values are text and the accessor has to say what
type it wanted, which settings.go does in one place. Unknown keys are
refused rather than stored -- a typo that wrote notify_repeat_second
would otherwise sit in the table looking like configuration and doing
nothing -- and each value has bounds loose enough to catch a slipped
decimal point without having an opinion about anybody's rota.

Disabling an account is new, and is not deleting one. Deleting a user
nulls acknowledged_by and assigned_to, which quietly rewrites who did
what during an incident months after the fact. A disabled user cannot
authenticate by either credential, loses their sessions immediately, and
stays the name on every acknowledgement they made. The check is part of
the lookup in serveAs rather than a test afterwards, so there is no path
where the row is loaded and the flag is then forgotten.

The page itself is a fourth tab, shown only to an administrator and only
as a courtesy: every endpoint under it is refused with 403 regardless, so
somebody who types /admin gets an explanation rather than a blank screen.
It lists teams with their size and open-incident count, users with their
flags, and the settings with their bounds -- plus the environment half,
read-only, so somebody hunting for the ntfy URL learns where it lives
instead of concluding the server has none.

Delete is disabled rather than offered-and-refused for a team with open
incidents, and neither admin action is offered on your own account, since
the server refuses both.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
Niklas Ye
2026-09-20 18:23:46 +02:00
parent 303e7a3365
commit b0a02c010b
19 changed files with 1110 additions and 15 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
}
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, cfg))
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
+8
View File
@@ -19,6 +19,10 @@ const (
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
// immediate pass so a restart reconciles state right away.
// archiveAfter and staleAfter are the values the server started with. They are
// the fallback, not the setting: each pass reads the current value from the
// settings table, so an administrator's change takes effect on the next tick
// instead of at the next restart.
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
@@ -45,6 +49,10 @@ func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter tim
// staleness rules would otherwise resolve it as 'expiry' long before that.
// Exported so tests can drive a pass without waiting on the ticker.
func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
settings := NewSettings(db)
staleAfter = settings.Duration(ctx, SettingStaleAfter, staleAfter)
archiveAfter = settings.Duration(ctx, SettingArchiveAfter, archiveAfter)
heartbeats := sweepDeadman(ctx, db, notify)
expireStale(ctx, db, staleAfter, heartbeats)
resolveSettledIncidents(ctx, db)
+1 -1
View File
@@ -301,7 +301,7 @@ func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
func TestBootstrap_WithPassword(t *testing.T) {
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}))
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig()))
t.Cleanup(srv.Close)
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
+5 -1
View File
@@ -144,8 +144,12 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.DB, userID, sessionID int64) {
var u models.User
var createdUnix int64
// disabled_at IS NULL is part of the lookup rather than a check afterwards:
// a disabled account is one that cannot authenticate, by either credential,
// and the way to be sure of that is for there to be no path where the row
// is loaded and the flag is then forgotten.
if err := db.QueryRowContext(r.Context(),
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1", userID,
"SELECT id, username, email, created_at, is_admin FROM users WHERE id = $1 AND disabled_at IS NULL", userID,
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix, &u.IsAdmin); err != nil {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
+6 -2
View File
@@ -134,7 +134,11 @@ func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
// queued, so an ntfy outage produces a retry backlog rather than a reminder
// backlog that all lands at once when it comes back.
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
if cfg.RepeatEvery <= 0 {
// cfg.RepeatEvery is what the server started with; the settings table is
// what it runs on. Read per tick, so an administrator lengthening the
// interval at 02:00 is obeyed at 02:00 and not at the next restart.
repeat := NewSettings(db).Duration(ctx, SettingNotifyRepeat, cfg.RepeatEvery)
if repeat <= 0 {
return
}
now := time.Now()
@@ -156,7 +160,7 @@ func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
AND i.archived_at IS NULL
AND i.status = 'triggered'
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)`,
now.Add(-cfg.RepeatEvery).Unix(), now.Unix())
now.Add(-repeat).Unix(), now.Unix())
if err != nil {
log.Printf("notifier: find reminders: %v", err)
return
+10 -1
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"net/http"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -13,7 +14,7 @@ import (
// the only handler that has to decide where a new incident's page goes; a zero
// notify disables notifications. Dead man's switches are per team and read from
// the database, so nothing about them is wired in here.
func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -70,6 +71,13 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
r.Post("/api/users", handleCreateUser(db))
r.Delete("/api/users/{id}", handleDeleteUser(db))
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
r.Put("/api/users/{id}/disabled", handleSetUserDisabled(db))
// What exists on this server, and how it behaves. /api/teams
// answers "what am I in"; this one answers "what is there".
r.Get("/api/admin/teams", handleAdminListTeams(db))
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
r.Put("/api/admin/settings", handleSetSettings(db))
})
// Alerts are read-only: they are Alertmanager's record, not a work
@@ -95,6 +103,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig) http.Handler {
// Teams. A user sees the teams they belong to; an owner configures one.
r.Get("/api/teams", handleListTeams(db))
r.Post("/api/teams", handleCreateTeam(db))
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
+346
View File
@@ -0,0 +1,346 @@
package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"github.com/go-chi/chi/v5"
)
// The settings an administrator can change at runtime. Each is behaviour rather
// than infrastructure: what the server does, not where it is plugged in.
//
// The values are seconds, stored as text. A duration string would be friendlier
// to read in psql and worse everywhere else — it can be stored unparseable, and
// then the question is what a background loop should do at 02:00 with a
// tuning knob it cannot understand.
const (
SettingNotifyRepeat = "notify_repeat_seconds"
SettingStaleAfter = "stale_after_seconds"
SettingArchiveAfter = "archive_after_seconds"
)
// settingBounds keeps an edit from producing a server that cannot work. The
// ceilings are loose — they exist to catch a slipped decimal point, not to have
// an opinion about anybody's rota.
var settingBounds = map[string]struct {
min, max time.Duration
label string
}{
SettingNotifyRepeat: {0, 24 * time.Hour, "how long an incident may sit unacknowledged before it is paged again; 0 disables reminders"},
SettingStaleAfter: {5 * time.Minute, 30 * 24 * time.Hour, "how long a firing alert may go without a refreshing webhook before the sweeper resolves it"},
SettingArchiveAfter: {time.Minute, 365 * 24 * time.Hour, "how long a resolved alert or incident stays in the default list"},
}
// Settings reads the runtime configuration. It holds no cache: the readers are
// two background loops that tick every 30 seconds and 15 minutes, and handlers
// that run once per request, so a query each time costs nothing measurable and
// means an administrator's change takes effect on the next tick rather than at
// the next restart.
type Settings struct{ db *sql.DB }
// NewSettings returns a reader over db.
func NewSettings(db *sql.DB) *Settings { return &Settings{db: db} }
// Duration reads one setting, falling back to def when the row is missing or
// unreadable. A tuning knob is never worth failing a sweep over: the fallback
// is the value the server started with.
func (s *Settings) Duration(ctx context.Context, key string, def time.Duration) time.Duration {
var raw string
err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = $1", key).Scan(&raw)
if err != nil {
return def
}
secs, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return def
}
return time.Duration(secs) * time.Second
}
// SeedSettings writes each key from the server's environment configuration,
// once. Never overwrites: after the first start the database owns these, and a
// redeploy must not put a chart's default back over an administrator's edit —
// the same rule as the per-team dead man's switches.
func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
seeds := map[string]time.Duration{
SettingNotifyRepeat: cfg.NotifyRepeat,
SettingStaleAfter: cfg.StaleAfter,
SettingArchiveAfter: cfg.ArchiveAfter,
}
for key, d := range seeds {
if _, err := db.ExecContext(ctx, `
INSERT INTO settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING`,
key, strconv.FormatInt(int64(d.Seconds()), 10)); err != nil {
return err
}
}
return nil
}
// settingsResponse is what the admin page renders. The environment half is
// included and marked read-only, so somebody looking for the ntfy URL finds out
// where it lives rather than concluding the server does not have one.
type settingsResponse struct {
Editable map[string]settingValue `json:"editable"`
FromEnv map[string]string `json:"from_env"`
}
type settingValue struct {
Seconds int64 `json:"seconds"`
Description string `json:"description"`
MinSeconds int64 `json:"min_seconds"`
MaxSeconds int64 `json:"max_seconds"`
}
func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
settings := NewSettings(db)
return func(w http.ResponseWriter, r *http.Request) {
out := settingsResponse{
Editable: map[string]settingValue{},
FromEnv: map[string]string{
// Never the ntfy token or the DSN: both are credentials, and an
// admin page that renders them turns a browser tab into a place
// they leak from.
"ntfy_url": cfg.NtfyURL,
"ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
"ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
"public_url": cfg.PublicURL,
"listen_address": cfg.Addr,
},
}
for key, b := range settingBounds {
def := map[string]time.Duration{
SettingNotifyRepeat: cfg.NotifyRepeat,
SettingStaleAfter: cfg.StaleAfter,
SettingArchiveAfter: cfg.ArchiveAfter,
}[key]
out.Editable[key] = settingValue{
Seconds: int64(settings.Duration(r.Context(), key, def).Seconds()),
Description: b.label,
MinSeconds: int64(b.min.Seconds()),
MaxSeconds: int64(b.max.Seconds()),
}
}
respond(w, http.StatusOK, out)
}
}
// handleSetSettings changes one or more settings. Unknown keys are refused
// rather than stored: a typo that writes notify_repeat_second would otherwise
// sit in the table looking like configuration and doing nothing.
func handleSetSettings(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req map[string]int64
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if len(req) == 0 {
respond(w, http.StatusBadRequest, errResp("no settings given"))
return
}
for key, secs := range req {
b, known := settingBounds[key]
if !known {
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
return
}
d := time.Duration(secs) * time.Second
if d < b.min || d > b.max {
respond(w, http.StatusBadRequest, errResp(
key+" must be between "+b.min.String()+" and "+b.max.String()))
return
}
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
for key, secs := range req {
if _, err := tx.ExecContext(r.Context(), `
INSERT INTO settings (key, value, updated_at)
VALUES ($1, $2, `+nowEpoch+`)
ON CONFLICT (key) DO UPDATE SET
value = excluded.value, updated_at = excluded.updated_at`,
key, strconv.FormatInt(secs, 10)); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleAdminListTeams lists every team on the server, with its size. The
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
// which only an administrator may ask.
func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(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
ORDER BY t.name`)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
type adminTeam struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Members int64 `json:"members"`
OpenIncidents int64 `json:"open_incidents"`
}
teams := []adminTeam{}
for rows.Next() {
var t adminTeam
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); 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)
}
}
// handleRenameTeam renames a team. An owner's job, and an administrator's when
// a team has nobody left to do it.
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil || req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
res, err := db.ExecContext(r.Context(),
"UPDATE teams SET name = $1 WHERE id = $2", req.Name, teamID)
if err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleSetUserDisabled takes an account out of use, or puts it back.
//
// Not a delete: the person's acknowledgements, assignments and timeline entries
// stay attached to them. Deleting a user nulls those columns, which rewrites
// what happened during an incident months after the fact.
func handleSetUserDisabled(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
}
var req struct {
Disabled *bool `json:"disabled"`
}
if err := decodeJSON(r, &req); err != nil || req.Disabled == nil {
respond(w, http.StatusBadRequest, errResp("disabled is required"))
return
}
if *req.Disabled {
caller, _ := userFromContext(r.Context())
if caller.ID == id {
respond(w, http.StatusConflict, errResp("cannot disable your own account"))
return
}
last, err := isLastAdmin(r.Context(), db, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if last {
respond(w, http.StatusConflict, errResp("cannot disable the last administrator"))
return
}
}
var res sql.Result
if *req.Disabled {
res, err = db.ExecContext(r.Context(),
"UPDATE users SET disabled_at = "+nowEpoch+" WHERE id = $1 AND disabled_at IS NULL", id)
} else {
res, err = db.ExecContext(r.Context(),
"UPDATE users SET disabled_at = NULL WHERE id = $1", id)
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
// Either no such user, or already in the state asked for. The
// second is not a failure, so check which before answering.
var exists int
if err := db.QueryRowContext(r.Context(),
"SELECT 1 FROM users WHERE id = $1", id).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
}
// Signing back in is the only way to use a re-enabled account, and a
// disabled one must not keep a live session.
if *req.Disabled {
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", id) //nolint:errcheck
}
user, err := fetchUser(r.Context(), db, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, user)
}
}
+275
View File
@@ -0,0 +1,275 @@
package api_test
import (
"net/http"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// The settings an administrator can change, and the ones they cannot.
func TestSettings_EditableAndReadOnly(t *testing.T) {
s := newTS(t)
var got struct {
Editable map[string]struct {
Seconds int64 `json:"seconds"`
Description string `json:"description"`
MinSeconds int64 `json:"min_seconds"`
MaxSeconds int64 `json:"max_seconds"`
} `json:"editable"`
FromEnv map[string]string `json:"from_env"`
}
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
// Seeded from the environment the server started with, not from zero.
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 900 {
t.Errorf("notify_repeat_seconds seeded as %d, want 900", v)
}
if v := got.Editable["stale_after_seconds"].Seconds; v != 21600 {
t.Errorf("stale_after_seconds seeded as %d, want 21600", v)
}
if got.Editable["archive_after_seconds"].Description == "" {
t.Error("a setting without a description is a number nobody can act on")
}
// The environment half is visible so somebody can see where it lives, but
// never the credentials themselves.
if _, ok := got.FromEnv["public_url"]; !ok {
t.Error("public_url should be reported as environment-configured")
}
for _, leak := range []string{"ntfy_token", "dsn", "database_dsn", "password"} {
if v, ok := got.FromEnv[leak]; ok {
t.Errorf("%s must not be in the settings response (got %q)", leak, v)
}
}
}
// Changing a setting takes effect on the next tick, without a restart. This is
// the whole point of moving them out of the environment.
func TestSettings_ChangeTakesEffectOnTheNextSweep(t *testing.T) {
s := newTS(t)
// An alert whose last webhook was two hours ago. Under the seeded
// stale_after of six hours the sweeper leaves it alone.
postWebhook(t, s, []map[string]any{
amAlert("fp-settings", "Stale", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = $2",
time.Now().Add(-2*time.Hour).Unix(), "fp-settings")
sweep(t, s, noArchive)
if status, _, _ := s.alertRow(t, "fp-settings"); status != "firing" {
t.Fatalf("before the change the alert should still be firing, got %q", status)
}
// Shorten it to an hour. Nothing restarts.
resp := s.req(t, http.MethodPut, "/api/admin/settings",
map[string]int64{"stale_after_seconds": 3600})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("change setting: %d", resp.StatusCode)
}
sweep(t, s, noArchive)
status, source, _ := s.alertRow(t, "fp-settings")
if status != "resolved" {
t.Errorf("after the change the alert should have expired, got %q", status)
}
if source == nil || *source != "expiry" {
t.Errorf("expected resolution_source expiry, got %v", source)
}
}
// A typo must not look like configuration, and a slipped decimal point must not
// produce a server that sweeps every second.
func TestSettings_RejectsUnknownKeysAndSillyValues(t *testing.T) {
s := newTS(t)
for _, c := range []struct {
name string
body map[string]int64
}{
{"unknown key", map[string]int64{"notify_repeat_second": 60}},
{"below the floor", map[string]int64{"stale_after_seconds": 30}},
{"above the ceiling", map[string]int64{"archive_after_seconds": 400 * 24 * 3600}},
{"nothing at all", map[string]int64{}},
} {
resp := s.req(t, http.MethodPut, "/api/admin/settings", c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: expected 400, got %d", c.name, resp.StatusCode)
}
}
}
// Settings are the server's behaviour, so only an administrator may change
// them — or see where the rest of the configuration comes from.
func TestSettings_AreAdminOnly(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "member")
for _, c := range []struct {
method string
body any
}{
{http.MethodGet, nil},
{http.MethodPut, map[string]int64{"notify_repeat_seconds": 60}},
} {
resp := call(c.method, "/api/admin/settings", c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s /api/admin/settings: expected 403, got %d", c.method, resp.StatusCode)
}
}
resp := call(http.MethodGet, "/api/admin/teams", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("GET /api/admin/teams: expected 403, got %d", resp.StatusCode)
}
}
// An administrator sees every team, including ones they are not in — which is
// exactly what /api/teams must not show them.
func TestSettings_AdminSeesEveryTeam(t *testing.T) {
s := newTS(t)
newTeam(t, s, "red")
newTeam(t, s, "blue")
all := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
if len(all) != 3 { // Default, red, blue
t.Fatalf("admin should see all 3 teams, saw %d", len(all))
}
for _, team := range all {
if _, ok := team["members"]; !ok {
t.Error("the admin listing should say how big each team is")
}
}
// The admin created them, so they own them — but they are not a member of
// a team somebody else makes, and /api/teams still answers "what am I in".
mine := list(t, s.req(t, http.MethodGet, "/api/teams", nil))
if len(mine) != 3 {
t.Errorf("the creator is an owner of what they created, saw %d", len(mine))
}
}
// Disabling is not deleting: the account stops working and the history stays.
func TestSettings_DisablingAnAccountKeepsItsHistory(t *testing.T) {
s := newTS(t)
memberID, call := member(t, s, "leaver")
// They acknowledge an incident, so there is history to preserve.
postWebhook(t, s, []map[string]any{
amAlert("fp-leaver", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
resp := call(http.MethodPost, "/api/incidents/1/acknowledge", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("acknowledge: %d", resp.StatusCode)
}
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
map[string]bool{"disabled": true})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("disable: %d", resp.StatusCode)
}
// Their API key stops working.
resp = call(http.MethodGet, "/api/incidents", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a disabled user's key: expected 401, got %d", resp.StatusCode)
}
// The acknowledgement still names them.
var incident map[string]any
decode(t, s.req(t, http.MethodGet, "/api/incidents/1", nil), &incident)
if incident["acknowledged_by"] != "leaver" {
t.Errorf("the acknowledgement should still name leaver, got %v", incident["acknowledged_by"])
}
if incident["status"] != "acknowledged" {
t.Errorf("the incident should still be acknowledged, got %v", incident["status"])
}
// And re-enabling gives the account back.
resp = s.req(t, http.MethodPut, "/api/users/"+id64(memberID)+"/disabled",
map[string]bool{"disabled": false})
resp.Body.Close()
resp = call(http.MethodGet, "/api/incidents", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("after re-enabling: expected 200, got %d", resp.StatusCode)
}
}
// The same two guards as deleting and demoting: an install must keep somebody
// who can administer it.
func TestSettings_CannotDisableYourselfOrTheLastAdmin(t *testing.T) {
s := newTS(t)
resp := s.req(t, http.MethodPut, "/api/users/1/disabled", map[string]bool{"disabled": true})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("disabling yourself: expected 409, got %d", resp.StatusCode)
}
}
// Renaming a team is an owner's job, and the name stays unique.
func TestSettings_TeamRename(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "red")
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Platform"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("rename: %d", resp.StatusCode)
}
teams := list(t, s.req(t, http.MethodGet, "/api/admin/teams", nil))
found := false
for _, x := range teams {
if x["name"] == "Platform" {
found = true
}
}
if !found {
t.Error("the renamed team should be listed under its new name")
}
// Taking a name that exists is a conflict, not a silent second team with
// the same label.
resp = s.req(t, http.MethodPut, "/api/teams/"+id64(team.id), map[string]string{"name": "Default"})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("renaming onto an existing name: expected 409, got %d", resp.StatusCode)
}
}
// The seed runs once. A redeploy must not put the chart's default back over an
// administrator's edit — the rule the dead man's switches already follow.
func TestSettings_SeedDoesNotOverwrite(t *testing.T) {
s := newTS(t)
resp := s.req(t, http.MethodPut, "/api/admin/settings",
map[string]int64{"notify_repeat_seconds": 60})
resp.Body.Close()
// A second start, with the environment still saying 15 minutes.
if err := api.SeedSettings(t.Context(), s.db, testConfig()); err != nil {
t.Fatalf("re-seed: %v", err)
}
var got struct {
Editable map[string]struct {
Seconds int64 `json:"seconds"`
} `json:"editable"`
}
decode(t, s.req(t, http.MethodGet, "/api/admin/settings", nil), &got)
if v := got.Editable["notify_repeat_seconds"].Seconds; v != 60 {
t.Errorf("the edit should survive a restart, got %d", v)
}
}
+15
View File
@@ -7,7 +7,9 @@ import (
"os"
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/db"
)
@@ -30,6 +32,19 @@ import (
// tests nothing is worse than one that does not run.
const testDSNEnv = "TERDUT_TEST_DSN"
// testConfig is the environment half of the server's configuration, which the
// admin settings page renders read-only and SeedSettings seeds the editable
// half from. The durations match the defaults config.Load would produce, so a
// test that never touches the settings table behaves as a fresh install does.
func testConfig() config.Config {
return config.Config{
Addr: ":8080",
ArchiveAfter: 7 * 24 * time.Hour,
StaleAfter: 6 * time.Hour,
NotifyRepeat: 15 * time.Minute,
}
}
// defaultTeam is the team migration 003 creates and the bootstrap user owns, as
// a path segment. Every test that does not say otherwise works inside it.
const defaultTeam = "1"
+8 -4
View File
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users ORDER BY id")
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id")
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -107,11 +107,13 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var u models.User
var ts int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin); err != nil {
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
u.CreatedAt = time.Unix(ts, 0).UTC()
u.DisabledAt = unixPtr(disabled)
users = append(users, u)
}
respond(w, http.StatusOK, users)
@@ -325,13 +327,15 @@ func randomToken() (raw, hash string, err error) {
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var u models.User
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic, is_admin FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin)
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
if err != nil {
return u, err
}
u.CreatedAt = time.Unix(ts, 0).UTC()
u.DisabledAt = unixPtr(disabled)
return u, nil
}
+35
View File
@@ -0,0 +1,35 @@
-- Settings that an administrator can change without a redeploy, and the flag
-- that takes an account out of use without deleting it.
--
-- Three of the server's tunables were environment variables, which meant
-- changing how long an incident waits before it is paged again required editing
-- a chart, merging it, and waiting for a reconcile. They are behaviour, not
-- infrastructure, and the difference is who needs to change them and how often.
--
-- What stays in the environment: the ntfy URL and token, the database DSN, the
-- listen address and the public URL. Those are where the server is plugged in
-- rather than how it behaves, they are needed before the database is open, and
-- two of them are credentials.
--
-- Key/value rather than a column per setting. A settings table with one row and
-- a column per knob needs a migration for every new knob, and #6 and #7 will
-- both add some. The cost is that values are text and the accessor has to say
-- what type it wanted; settings.go does that in one place.
--
-- No rows are seeded here: a migration cannot read the environment. The server
-- inserts each key from its own configuration at startup, once, so an install
-- that upgrades keeps exactly the behaviour it had. See SeedSettings.
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
-- Disabling an account rather than deleting it: the person has left, or the
-- credential is suspect, and their incidents, acknowledgements and timeline
-- entries must stay exactly where they are. Deleting a user nulls their
-- acknowledged_by and assigned_to, which quietly rewrites history.
--
-- A disabled user cannot sign in and their API keys stop working, but they are
-- still a name the timeline can show and still a member of their teams.
ALTER TABLE users ADD COLUMN disabled_at BIGINT;
+5
View File
@@ -13,6 +13,11 @@ type User struct {
// fallback topic instead.
NtfyTopic *string `json:"ntfy_topic,omitempty"`
// DisabledAt is when the account was taken out of use, or nil. A disabled
// user cannot authenticate by either credential, and keeps their name on
// every acknowledgement and timeline entry they made.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// IsAdmin is the system administrator flag: managing users and API keys.
// Not omitempty — a client has to be able to tell "false" from "this server
// is too old to have the field", and the web UI decides what to show from
+27
View File
@@ -630,3 +630,30 @@ kbd {
.toast, .app.detail-open ~ .toast { bottom: 24px; }
.only-desktop { display: block; }
}
/* --- admin ---------------------------------------------------------------
The admin page is three tables of things you act on, so it needs table
styling the rest of the app never did: the queue is a list of links and the
account page is a form. */
.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.admin-table th {
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
}
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
/* A disabled account stays readable — it is still the name on old
acknowledgements — but should not look like a working one. */
.disabled-row td { opacity: 0.55; }
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
.inline-form { display: flex; gap: 8px; margin-top: 12px; }
.inline-form input { flex: 1; min-width: 0; }
.admin-settings .setting-value { width: 5.5em; margin-right: 6px; }
.admin-settings .setting-unit { max-width: 8em; }
.admin-settings button[type="submit"] { margin-top: 12px; }
.small { font-size: 13px; }
+8
View File
@@ -59,6 +59,13 @@
<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>
</a>
<!-- Hidden unless the signed-in user is a system administrator; app.js
unhides it once /api/me says so. The server refuses every admin
endpoint regardless, so this is a courtesy and not a gate. -->
<a class="nav-link" href="/admin" data-section="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>
<span class="nav-label">Admin</span>
</a>
<a class="nav-link" href="/more" data-section="more">
<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>
@@ -80,6 +87,7 @@
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section>
</div>
+308
View File
@@ -0,0 +1,308 @@
// Administration: the teams on this server, the people who can sign in, and
// the settings that change how the server behaves.
//
// 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
// permission — so this view simply says so rather than pretending to be a
// gate.
import * as api from './api.js';
import { h, clear, spinner, confirm } from './ui.js';
import { state, myID } from './state.js';
const view = () => document.getElementById('view-admin');
let data = null; // { teams, users, settings }
let error = null;
let busy = false;
export function show() {
if (!data) clear(view(), spinner());
refresh();
}
export async function refresh() {
if (!state.me?.user?.is_admin) {
data = null;
render();
return;
}
try {
const [teams, users, settings] = await Promise.all([
api.adminTeams(),
api.users(),
api.adminSettings(),
]);
data = { teams, users, settings };
error = null;
} catch (err) {
error = err.message;
}
render();
}
function render() {
if (!state.me?.user?.is_admin) {
clear(view(), h('div', { class: 'card' },
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
return;
}
if (!data) {
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
clear(view(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
teamsCard(),
usersCard(),
settingsCard(),
);
}
// --- teams -----------------------------------------------------------------
function teamsCard() {
const rows = data.teams.map((t) =>
h('tr', {},
h('td', {}, h('strong', { text: t.name })),
h('td', { class: 'num', text: String(t.members) }),
h('td', { class: 'num', text: String(t.open_incidents) }),
h('td', {},
h('button', {
class: 'btn-sm',
type: 'button',
text: 'Rename',
onclick: () => renameTeam(t),
}),
// A team with open incidents cannot be deleted, and saying so before
// the click is kinder than a 409 afterwards.
h('button', {
class: 'btn-sm danger',
type: 'button',
text: 'Delete',
disabled: t.open_incidents > 0,
title: t.open_incidents > 0 ? 'Resolve its open incidents first' : '',
onclick: () => deleteTeam(t),
}),
),
));
return h('div', { class: 'card' },
h('h2', { text: 'Teams' }),
h('table', { class: 'admin-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Name' }),
h('th', { class: 'num', text: 'Members' }),
h('th', { class: 'num', text: 'Open' }),
h('th', { text: '' }))),
h('tbody', {}, rows)),
newTeamForm(),
);
}
function newTeamForm() {
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
const form = h('form', { class: 'inline-form' }, name,
h('button', { class: 'btn', type: 'submit', text: 'Create' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
busy = true;
try {
await api.createTeam(name.value.trim());
name.value = '';
await refresh();
} catch (err) {
error = err.message;
render();
} finally {
busy = false;
}
});
return form;
}
async function renameTeam(team) {
const next = window.prompt(`Rename ${team.name} to:`, team.name);
if (!next || next === team.name) return;
try {
await api.renameTeam(team.id, next);
} catch (err) {
error = err.message;
}
refresh();
}
async function deleteTeam(team) {
if (!(await confirm({
title: `Delete ${team.name}?`,
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
}))) return;
try {
await api.deleteTeam(team.id);
} catch (err) {
error = err.message;
}
refresh();
}
// --- users -----------------------------------------------------------------
function usersCard() {
const rows = data.users.map((u) => {
const self = u.id === myID();
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
h('td', {},
h('strong', { text: u.username }),
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })),
h('td', { class: 'muted', text: u.email }),
h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null),
h('td', {},
// Neither action is offered for your own account: the server refuses
// both, and an enabled-looking button that always fails is worse than
// no button.
!self && h('button', {
class: 'btn-sm',
type: 'button',
text: u.is_admin ? 'Revoke admin' : 'Make admin',
onclick: () => setAdmin(u, !u.is_admin),
}),
!self && h('button', {
class: 'btn-sm danger',
type: 'button',
text: u.disabled_at ? 'Enable' : 'Disable',
onclick: () => setDisabled(u, !u.disabled_at),
}),
),
);
});
return h('div', { class: 'card' },
h('h2', { text: 'Users' }),
h('p', { class: 'muted small' },
'Disabling an account stops it signing in and stops its API keys, and keeps ',
'its acknowledgements and timeline entries. Deleting a user erases those.'),
h('table', { class: 'admin-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'User' }),
h('th', { text: 'Email' }),
h('th', { text: '' }),
h('th', { text: '' }))),
h('tbody', {}, rows)),
);
}
async function setAdmin(user, next) {
if (next && !(await confirm({
title: `Make ${user.username} an administrator?`,
text: 'They will be able to create and delete users, and grant this to others.',
confirmLabel: 'Make admin',
}))) return;
try {
await api.setUserAdmin(user.id, next);
} catch (err) {
error = err.message;
}
refresh();
}
async function setDisabled(user, next) {
if (next && !(await confirm({
title: `Disable ${user.username}?`,
text: 'They cannot sign in and their API keys stop working. Their history stays.',
confirmLabel: 'Disable',
danger: true,
}))) return;
try {
await api.setUserDisabled(user.id, next);
} catch (err) {
error = err.message;
}
refresh();
}
// --- settings --------------------------------------------------------------
// Seconds are what the API speaks; people think in minutes and hours. The two
// are converted here rather than in the server, which should keep exactly one
// unit.
const UNITS = [
{ label: 'minutes', seconds: 60 },
{ label: 'hours', seconds: 3600 },
{ label: 'days', seconds: 86400 },
];
function bestUnit(seconds) {
for (const u of [...UNITS].reverse()) {
if (seconds > 0 && seconds % u.seconds === 0) return u;
}
return UNITS[0];
}
function settingsCard() {
const editable = data.settings.editable || {};
const inputs = new Map();
const rows = Object.entries(editable).map(([key, s]) => {
const unit = bestUnit(s.seconds);
const value = h('input', {
type: 'number',
min: '0',
value: String(Math.round(s.seconds / unit.seconds)),
class: 'setting-value',
});
const select = h('select', { class: 'setting-unit' },
...UNITS.map((u) => h('option', {
value: String(u.seconds),
text: u.label,
selected: u.seconds === unit.seconds,
})));
inputs.set(key, () => Number(value.value) * Number(select.value));
return h('tr', {},
h('td', {}, h('strong', { text: key.replace(/_seconds$/, '').replace(/_/g, ' ') })),
h('td', { class: 'muted small', text: s.description }),
h('td', {}, value, select),
);
});
const form = h('form', { class: 'admin-settings' },
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
h('button', { class: 'btn', type: 'submit', text: 'Save settings' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
busy = true;
const body = {};
for (const [key, read] of inputs) body[key] = read();
try {
await api.setAdminSettings(body);
await refresh();
} catch (err) {
error = err.message;
render();
} finally {
busy = false;
}
});
const env = Object.entries(data.settings.from_env || {}).map(([k, v]) =>
h('tr', {},
h('td', {}, h('code', { text: k })),
h('td', { class: 'muted', text: v === '' ? '(unset)' : v })));
return h('div', { class: 'card' },
h('h2', { text: 'Settings' }),
h('p', { class: 'muted small', text: 'Saved changes take effect on the next sweep — no restart.' }),
form,
h('h3', { text: 'From the environment' }),
h('p', { class: 'muted small' },
'Where the server is plugged in, rather than how it behaves. These are set ',
'in the deployment and are read-only here. Credentials are never shown.'),
h('table', { class: 'admin-table' }, h('tbody', {}, env)),
);
}
+13
View File
@@ -88,6 +88,19 @@ export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts }
// schedule
export const teams = () => call('GET', '/teams');
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
export const deleteTeam = (id) => call('DELETE', `/teams/${id}`);
// Administration. Every one of these is refused with 403 for anybody without
// the flag, so the UI hides the section rather than guarding it.
export const adminTeams = () => call('GET', '/admin/teams');
export const adminSettings = () => call('GET', '/admin/settings');
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
export const setUserAdmin = (id, isAdmin) =>
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
export const setUserDisabled = (id, disabled) =>
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
export const schedule = (teamID, from, to) =>
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
+6 -1
View File
@@ -9,6 +9,7 @@ import * as incident from './incident.js';
import * as oncall from './oncall.js';
import * as alerts from './alerts.js';
import * as account from './account.js';
import * as admin from './admin.js';
const $ = (id) => document.getElementById(id);
@@ -17,6 +18,7 @@ const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
admin: { title: 'Admin', view: admin },
more: { title: 'Account', view: account },
};
@@ -24,7 +26,7 @@ function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) };
const name = pathname.replace(/^\/|\/$/g, '');
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
if (name === 'oncall' || name === 'alerts' || name === 'admin' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
@@ -142,6 +144,9 @@ async function boot() {
try {
state.me = await api.me();
await loadTeams();
// The Admin tab exists only for an administrator. Somebody who types /admin
// anyway gets the view's own "ask an administrator" card, not a blank page.
$('nav-admin').hidden = !state.me?.user?.is_admin;
showApp();
} catch (err) {
if (err.status === 401) showLogin();