diff --git a/README.md b/README.md index 24d991d..9787731 100644 --- a/README.md +++ b/README.md @@ -155,12 +155,25 @@ somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all g ## Configuration +Two kinds of setting, split by who changes them and how often. + +**Where the server is plugged in** stays in the environment: the listen address, +the database DSN, the ntfy URL and token, the public URL. They are needed before +the database is open, and two of them are credentials. + +**How the server behaves** lives in the database and is edited by an +administrator in the web UI or through `PUT /api/admin/settings`, taking effect +on the next sweep rather than at the next restart. The variables below marked +**seed** are the value each of those starts from: written once, on first start, +and never overwritten afterwards — a redeploy cannot put a chart's default back +over an administrator's edit. + | Variable | Default | Description | |---|---|---| | `TERDUT_ADDR` | `:8080` | TCP address to listen on | | `TERDUT_DB_DSN` | — | **Required.** Postgres connection string, e.g. `postgres://terdut:secret@localhost:5432/terdut?sslmode=require` | -| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | How long a resolved alert or incident stays in the default list before being auto-archived | -| `TERDUT_STALE_AFTER` | `6h` | How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** | +| `TERDUT_ARCHIVE_AFTER` | `168h` (7d) | **seed.** How long a resolved alert or incident stays in the default list before being auto-archived | +| `TERDUT_STALE_AFTER` | `6h` | **seed.** How long a firing alert may go without a refreshing webhook before it is treated as resolved — **must exceed your Alertmanager `repeat_interval`** | | `TERDUT_DEADMAN_MATCHERS` | `alertname=Watchdog` | The **default** matchers a team starts with — switches are per team now, and this seeds teams that have no configuration of their own. `;` separates matchers, `,` the label conditions within one, `=` is exact equality. Every matcher must name an `alertname` | | `TERDUT_DEADMAN_TIMEOUT` | `15m` | How long a heartbeat may go unheard before its switch is declared dead — **must be shorter than the `repeat_interval` of the route carrying it**. `0` disables dead man's switch handling | | `TERDUT_DEADMAN_SEVERITY` | `critical` | Severity a dead man's switch incident opens at | @@ -168,7 +181,7 @@ somewhere to exec. The sidecar, the PVC and the `backupSidecar` values are all g | `TERDUT_NTFY_TOKEN` | — | Bearer token for an access-controlled ntfy | | `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call | | `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` | -| `TERDUT_NOTIFY_REPEAT` | `15m` | How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats | +| `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats | Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default. @@ -522,11 +535,20 @@ on anybody's. | `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 | | `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}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator | | `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it | | `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions | | `POST` | `/api/users/{id}/api-keys` | self or admin | Issue API key `{"name"}` — key shown once | | `DELETE` | `/api/users/{id}/api-keys/{keyID}` | self or admin | Revoke API key | +### Administration + +| Method | Path | Who | Description | +|---|---|---|---| +| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" | +| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials | +| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`. `400` for an unknown key or a value outside its bounds | + ### Alert ingestion Alerts arrive on a team's integration key. The key is both the credential and the @@ -548,6 +570,7 @@ and was removed in v0.13.0 once senders had moved onto keys. |---|---|---|---| | `GET` | `/api/teams` | any | The caller's own teams, each with their role | | `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner | +| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken | | `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents | | `GET` | `/api/teams/{teamID}/members` | member | Who is in the team | | `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` | diff --git a/cmd/terdut/main.go b/cmd/terdut/main.go index 947475b..064fda8 100644 --- a/cmd/terdut/main.go +++ b/cmd/terdut/main.go @@ -45,7 +45,13 @@ func main() { log.Fatalf("seed dead man's switch defaults: %v", err) } - router := api.NewRouter(database, notify) + // The behaviour knobs move into the database on first start, after which an + // administrator owns them and a redeploy leaves them alone. + if err := api.SeedSettings(context.Background(), database, cfg); err != nil { + log.Fatalf("seed settings: %v", err) + } + + router := api.NewRouter(database, notify, cfg) srv := &http.Server{ Addr: cfg.Addr, diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 038218f..0943e06 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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"}) diff --git a/internal/api/archiver.go b/internal/api/archiver.go index 705b4b9..606ec2c 100644 --- a/internal/api/archiver.go +++ b/internal/api/archiver.go @@ -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) diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 5989bf4..27b9c58 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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 + `"}` diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 0e983b9..64a7b59 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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 diff --git a/internal/api/notifier.go b/internal/api/notifier.go index c341183..c955263 100644 --- a/internal/api/notifier.go +++ b/internal/api/notifier.go @@ -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 diff --git a/internal/api/router.go b/internal/api/router.go index 48e072b..3dd8fb3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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)) diff --git a/internal/api/settings.go b/internal/api/settings.go new file mode 100644 index 0000000..fee8b36 --- /dev/null +++ b/internal/api/settings.go @@ -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) + } +} diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go new file mode 100644 index 0000000..e8b6fd6 --- /dev/null +++ b/internal/api/settings_test.go @@ -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) + } +} diff --git a/internal/api/testdb_test.go b/internal/api/testdb_test.go index 647add3..33d6294 100644 --- a/internal/api/testdb_test.go +++ b/internal/api/testdb_test.go @@ -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" diff --git a/internal/api/users.go b/internal/api/users.go index ab28337..10caffb 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -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 } diff --git a/internal/db/migrations/005_settings.sql b/internal/db/migrations/005_settings.sql new file mode 100644 index 0000000..c9e060c --- /dev/null +++ b/internal/db/migrations/005_settings.sql @@ -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; diff --git a/internal/models/user.go b/internal/models/user.go index 7985ba8..c75ef95 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -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 diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 17567ca..c9bdd6e 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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; } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index ef269b4..bbc1d3e 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -59,6 +59,13 @@ Alerts + + Account @@ -80,6 +87,7 @@ + diff --git a/internal/web/static/js/admin.js b/internal/web/static/js/admin.js new file mode 100644 index 0000000..beeb97a --- /dev/null +++ b/internal/web/static/js/admin.js @@ -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)), + ); +} diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index 150e3f1..d9cb09f 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -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 } }); diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 5415278..bc6121c 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -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();