Compare commits

...

11 Commits

Author SHA1 Message Date
Niklas Ye 8869ac864f Set the chart's placeholder version to 0.15.1
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 10s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 22s
Release / image (push) Successful in 56s
Release / scan-image (push) Successful in 3s
Cosmetic, as in 4c85e76 and 041e159. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.15.1 that still says 0.15.0
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-21 11:23:56 +02:00
niklas 0677e74cf8 Merge pull request 'Let the tab bar fit however many tabs there are' (#21) from compact-nav into main
CI / chart (push) Successful in 1s
CI / test (push) Successful in 6s
CI / security (push) Successful in 12s
Reviewed-on: #21
2026-09-21 09:23:31 +00:00
Niklas Ye 56b8191a78 Let the tab bar fit however many tabs there are
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 2m33s
The bottom tab bar was grid-template-columns: repeat(4, 1fr), written when
there were four tabs. Team and Admin arrived in the last two releases and
nothing updated that number, so six items were being laid into four
columns -- which on a phone is the reported symptom, tabs that do not fit
the width.

grid-auto-flow: column with grid-auto-columns: 1fr makes the count follow
the markup instead. That also handles a case a fixed number cannot: Admin
is only rendered for an administrator, so the tab count genuinely differs
between two people looking at the same install.

Then the compactness. Each link gets min-width: 0 so a column may shrink
below its label's natural width, and the label itself ellipsises rather
than widening the bar. Under 420px the font drops to 10px, the icons to
21px and the badge shrinks to match.

No icon-only breakpoint. The arithmetic says the labels fit: six tabs on
a 320px phone give about 53px each, and the widest label, "On-call", is
about 38px at 10px. A media query that never fires is dead code, and the
ellipsis is the backstop if a future tab is named something longer.

The links gained aria-labels regardless. The icons are aria-hidden, so
the visible text was the accessible name, and it should not be the only
one.

The desktop sidebar is unaffected: it overrides display, padding and
font-size itself, so none of the phone rules reach it.

Not verified on a phone -- I cannot open a browser here, so this is the
cause identified from the CSS and the widths worked out on paper.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 11:18:30 +02:00
Niklas Ye 93761056eb Set the chart's placeholder version to 0.15.0
CI / chart (push) Successful in 4s
CI / test (push) Successful in 12s
CI / security (push) Successful in 17s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 24s
Cosmetic, as in 4c85e76 and 041e159. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.15.0 that still says 0.14.0
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-21 10:55:02 +02:00
niklas a92da7dcc0 Merge pull request 'Add the sign-up page and the first-run checklist' (#20) from onboarding-ui into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Reviewed-on: #20
2026-09-21 08:54:29 +00:00
Niklas Ye b39aac36b7 Add the sign-up page and the first-run checklist
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m28s
Second half of #7. The API could create accounts from invite links since
the last change; this is the part somebody can actually use.

/signup is the one route that works without a session. It asks the server
what it may offer before showing anything: an invite link that is good
names the team it leads to, a link that is not says so before somebody
picks a password rather than after, and an invite-only server with no
link says that instead of presenting a form it will refuse. The login
card only offers "create one" when sign-up is open, so the door nobody
can walk through is not advertised.

Signing up signs you in and lands on the queue, because the alternative
is a form saying "now go and log in" about the credential just chosen.

The checklist is the other half. Four things have to be true before an
alert reaches a phone -- a notification topic, somebody on the rota, an
alert source, and an alert that has actually arrived -- and on a fresh
install none of them are. It sits above the queue until they are.

It is computed from the data rather than from stored progress: a topic is
set or it is not, an integration exists or it does not. That means it
cannot claim a step is done when it is not, and it comes back by itself
if somebody deletes their integration a month later. The only stored
state is the dismissal, which is per user and not per browser --
finishing on a laptop should not leave the phone nagging.

The topic step is the only one the checklist can finish itself, and the
only proof that counts is a phone buzzing, so there is a test push.
POST /api/me/notify/test publishes directly rather than through the
outbox, which requires an incident this deliberately does not have. Its
failure is the useful part: a wrong topic, a rejected token and an ntfy
that is down all look identical from the phone, which is silence, so the
error comes back to the browser instead.

Verified against a live server with a real ntfy stand-in, the whole path:
an owner mints an invite, the sign-up page reports it valid and names the
team, the invitee signs up and is signed in as a member of that team, the
checklist's four questions answer correctly on a fresh install, a test
push is refused with no topic and delivered with one -- "PAGED
terdut-owner | terdut test" -- and the dismissal survives a reload.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 10:47:15 +02:00
niklas 19f168ab7e Merge pull request 'Add self-service sign-up and invite links' (#19) from signup-invites into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Reviewed-on: #19
2026-09-21 08:42:07 +00:00
Niklas Ye d827ceedff Add self-service sign-up and invite links
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 14s
CI / test (pull_request) Successful in 2m30s
First half of #7. Until now the only way to get an account was for
somebody who already had one to create it, and the login page told people
to "ask an admin" -- workable for one operator, impossible for a team.

Two modes, chosen by an administrator in the settings table: invite_only,
which is the default, and open. A third domain-restricted mode was
considered and dropped, because with no email in this server there is
nothing to verify an address against and it would only check the domain
of a string somebody typed.

The default is the closed door. An install that gets a public hostname
before anybody has thought about sign-up should not be collecting
accounts from the internet, and the failure mode of a typo in the setting
is invite_only rather than open.

An invite is a link, not an email. Adding SMTP to send one message would
be a subsystem to run, secure and monitor; the person inviting sends the
link however they already talk to the person they are inviting. A link
carries the team and the role, because an account in no team sees an
empty queue and can be paged by nobody -- that is not a state to invite
somebody into. Links are single-use by default, expire after seven days,
and can be revoked before that: a link that works forever is a credential
nobody remembers issuing, sitting in a chat log.

The uses counter is incremented inside the sign-up transaction and
guarded by `uses < max_uses`, so two people redeeming the last use at
once cannot both get in.

GET /api/signup reports the mode and whether a link is usable, so the
form can say "this link has expired" before somebody picks a password
rather than after. It gives one answer for expired, revoked, used up and
never existed: telling a stranger which it was tells them something about
links they do not hold.

Sign-up signs you in. The alternative is a form that says "now go and log
in", which is the same credential typed twice. login and signup now share
startSession rather than each minting a cookie.

Rate-limited per address on its own limiter, not login's: a burst of
sign-ups must not lock somebody out of logging in.

The settings table grew a second shape for this. It held only durations;
signup_mode is a word from a fixed list, so the admin endpoint now
validates everything before writing anything -- a request that sets two
settings and gets one wrong changes neither.

Still to come in #7: the sign-up and invite-redemption pages, the
first-run checklist, and the in-app integration instructions. The schema
carries onboarding_dismissed_at for the checklist already.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 09:06:48 +02:00
Niklas Ye 4e8c52c28c Set the chart's placeholder version to 0.14.0
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 12s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 53s
Release / scan-image (push) Successful in 2s
Cosmetic, as in 4c85e76 and 041e159. `make helm-package` passes --version
and --app-version from the tag, so neither field decides anything about
what release.yaml publishes.

Done anyway because a tree heading for v0.14.0 that still says 0.13.0
tells a reader something false. appVersion keeps the v, per
APPVERSION_PREFIX in .release.conf.
2026-09-20 21:29:05 +02:00
niklas fb927aa67b Merge pull request 'Put a team's own settings in the web UI' (#18) from team-settings-ui into main
CI / test (push) Successful in 5s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Reviewed-on: #18
2026-09-20 19:26:22 +00:00
Niklas Ye d728af53b1 Put a team's own settings in the web UI
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m19s
Closes #17. Everything a team owner configures was API-only: escalation,
integrations, dead man's switches, membership, and the rota -- which the
on-call view still described as the TUI's job, and the TUI has been broken
against this server since teams landed. Setting up the feature this whole
line of work exists for meant using curl.

A Team tab now holds all of it, one team at a time, with a picker for
somebody in more than one. An owner edits; a member sees the same page
without the controls, because the server refuses their writes anyway --
hiding a button is a courtesy to the reader, not the thing enforcing
anything.

The escalation editor holds a draft and sends the whole ladder, because
the API replaces it wholesale: the levels are an order, and patching one
rung leaves the numbering of the others undecided. Adding a level
defaults to five minutes and the rota, which is the shape almost every
ladder starts as.

An integration key is returned exactly once, so creating one opens a
panel that says so, shows the URL large with a copy button, and renders
the Alertmanager receiver snippet with the URL already in it -- the next
thing anybody does with that key is paste it into a config. The panel
stays until it is dismissed rather than disappearing on the next
re-render.

The incident view gains where an incident is on the ladder and when the
next page is due, which is the question somebody looking at an
unacknowledged incident actually has. The API carries it: the incident
payload now includes escalation_level and escalation_due_at, the latter
computed in the incident SELECT by joining the level's timeout, so a list
costs no extra queries.

Verified against a live server by making every call the page makes,
including the writes: the six reads the Team tab issues, a two-level
ladder saved and read back, an integration created and its key returned
once, three days of rota assigned, switches set, and an incident showing
level 1 with a due time five minutes out.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 21:22:24 +02:00
18 changed files with 1850 additions and 52 deletions
+35 -1
View File
@@ -85,6 +85,16 @@ How a browser stays signed in:
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
the web UI (`/incidents/{id}`).
A **Team** tab holds everything a team owns: the on-call rota, the escalation
ladder, the alert sources with their keys, the dead man's switches and the
membership. An owner edits it; a member sees the same page read-only, because
the server refuses their writes anyway. Somebody in more than one team picks
between them at the top.
The **Admin** tab appears only for a system administrator, and holds what
belongs to the whole server rather than to one team: every team, every user, and
the settings that used to be environment variables.
### Docker
```bash
@@ -540,6 +550,25 @@ granting the flag itself. Everybody else works incidents — acknowledging,
assigning, snoozing, resolving, noting — and manages their own account and
nobody else's. An API key carries exactly the rights of the user it belongs to.
**Getting an account.** The first one comes from `/api/bootstrap`. After that
it depends on `signup_mode`, an administrator setting:
- `invite_only` (the default) — a team owner mints a link with
`POST /api/teams/{teamID}/invites`, and the person who opens it picks a
username and password and lands in that team with the role the link carries.
Links are single-use unless told otherwise, expire after seven days, and can
be revoked before that.
- `open` — anybody who can reach the server can create an account, and must
name a team, which they then own.
Invites are **links, not email**: this server has no SMTP, and adding it to send
one message would be a subsystem to run, secure and monitor. Send the link
however you already talk to the person.
A domain-restricted third mode was considered and dropped: with no email there
is nothing to verify an address against, so it would only check the domain of a
string somebody typed.
The first user, from `/api/bootstrap`, is an administrator. Users created
afterwards are not, until an administrator says so. An install always keeps at
least one: the last administrator can be neither deleted nor demoted, and
@@ -574,6 +603,8 @@ on anybody's.
| Method | Path | Who | Description |
|---|---|---|---|
| `GET` | `/api/signup` | — | Whether sign-up is open, and whether `?invite=` is usable. No session needed: the caller has no account yet |
| `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only |
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
@@ -591,7 +622,7 @@ on anybody's.
|---|---|---|---|
| `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 |
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds |
### Alert ingestion
@@ -622,6 +653,9 @@ and was removed in v0.13.0 once senders had moved onto keys.
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.13.0
appVersion: "v0.13.0"
version: 0.15.1
appVersion: "v0.15.1"
+42 -22
View File
@@ -139,6 +139,34 @@ func hashPassword(pw string) (string, error) {
return string(h), err
}
// startSession mints a session and sets the cookie. Shared by login and
// sign-up: somebody who has just chosen a password is signed in, rather than
// being sent to a form to type the same credential again.
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
raw, tokenHash, err := randomToken()
if err != nil {
return err
}
now := time.Now()
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
return nil
}
// handleLogin exchanges a username and password for a session cookie.
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -182,29 +210,10 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
}
limiter.clear(userKey)
raw, tokenHash, err := randomToken()
if err != nil {
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
now := time.Now()
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
user, err := fetchUser(r.Context(), db, userID)
if err != nil {
@@ -243,6 +252,11 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
type meResponse struct {
User any `json:"user"`
HasPassword bool `json:"has_password"`
// OnboardingDismissed is whether this person has put the first-run
// checklist away. Per user rather than per browser: somebody who finishes
// setting up on a laptop should not be nagged again on their phone.
OnboardingDismissed bool `json:"onboarding_dismissed"`
}
// handleMe says who the caller is. The web UI calls it on load to decide
@@ -256,9 +270,15 @@ func handleMe(db *sql.DB) http.HandlerFunc {
return
}
var hash sql.NullString
var dismissed *int64
db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash)
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid})
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
caller.ID).Scan(&hash, &dismissed)
respond(w, http.StatusOK, meResponse{
User: user,
HasPassword: hash.Valid,
OnboardingDismissed: dismissed != nil,
})
}
}
+10 -1
View File
@@ -52,6 +52,13 @@ type querier interface {
const incidentSelectFrom = `
SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity,
i.escalation_level,
-- When this level runs out. Computed here rather than in Go because
-- the timeout lives beside the level in the policy, and one join is
-- cheaper than a second query per incident in a list.
(SELECT i.escalation_level_at + el.timeout_seconds
FROM escalation_levels el
WHERE el.team_id = i.team_id AND el.position = i.escalation_level),
i.triggered_at,
i.acknowledged_by, i.acknowledged_at, ack.username,
i.assigned_to, asg.username, i.snoozed_until,
@@ -65,10 +72,11 @@ func scanIncident(s scanner) (models.Incident, error) {
var i models.Incident
var groupLabelsJSON string
var triggeredAt int64
var ackAt, snoozedUntil, resolvedAt, archivedAt *int64
var ackAt, snoozedUntil, resolvedAt, archivedAt, escalationDue *int64
if err := s.Scan(
&i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
&i.EscalationLevel, &escalationDue,
&triggeredAt,
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
@@ -83,6 +91,7 @@ func scanIncident(s scanner) (models.Incident, error) {
i.SnoozedUntil = unixPtr(snoozedUntil)
i.ResolvedAt = unixPtr(resolvedAt)
i.ArchivedAt = unixPtr(archivedAt)
i.EscalationDueAt = unixPtr(escalationDue)
return i, nil
}
+23 -1
View File
@@ -15,6 +15,12 @@ import (
// 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, cfg config.Config) http.Handler {
// One limiter each, both process-wide for the life of the router: login
// counts failed passwords, sign-up counts account creation, and mixing the
// two would let a burst of sign-ups lock somebody out of logging in.
loginLimit := newLoginLimiter()
signupLimiter := newLoginLimiter()
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -39,9 +45,16 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// JSON 404 every unknown /api path gets.
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
// Signing up. Both are unauthenticated by necessity: the caller has no
// account yet. The info endpoint says whether the door is open and whether
// an invite link is good, so the form can say so before somebody picks a
// password.
r.Get("/api/signup", handleSignupInfo(db))
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
// Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key.
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL))
r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
// All other /api routes require a valid API key.
@@ -49,6 +62,10 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Use(AuthMiddleware(db))
r.Get("/api/me", handleMe(db))
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
// Proves the topic works, which is the only part of "notifications are
// set up" that the person holding the phone can confirm.
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
// Readable by anyone signed in: the queue's assignment control and the
// on-call schedule both need to name people.
@@ -109,6 +126,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
// Invite links into this team.
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
// A team's escalation ladder: who is paged when nobody answers.
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
+52 -14
View File
@@ -90,6 +90,16 @@ func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
type settingsResponse struct {
Editable map[string]settingValue `json:"editable"`
FromEnv map[string]string `json:"from_env"`
// Choices are settings that are a word from a fixed list rather than a
// duration. One so far: who may create an account.
Choices map[string]choiceValue `json:"choices"`
}
type choiceValue struct {
Value string `json:"value"`
Options []string `json:"options"`
Description string `json:"description"`
}
type settingValue struct {
@@ -104,6 +114,14 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := settingsResponse{
Editable: map[string]settingValue{},
Choices: map[string]choiceValue{
SettingSignupMode: {
Value: signupMode(r.Context(), db),
Options: []string{SignupInviteOnly, SignupOpen},
Description: "who may create an account: invite_only means a link from a team owner, " +
"open means anybody who can reach this server",
},
},
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
@@ -137,7 +155,7 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
// 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
var req map[string]any
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
@@ -147,17 +165,37 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
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
// Validate everything before writing anything: a request that sets two
// settings and gets one wrong should change neither.
values := map[string]string{}
for key, raw := range req {
switch key {
case SettingSignupMode:
mode, _ := raw.(string)
if mode != SignupOpen && mode != SignupInviteOnly {
respond(w, http.StatusBadRequest,
errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
return
}
values[key] = mode
default:
b, known := settingBounds[key]
if !known {
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
return
}
secs, ok := raw.(float64) // JSON numbers decode as float64
if !ok {
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
return
}
d := time.Duration(int64(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
}
values[key] = strconv.FormatInt(int64(secs), 10)
}
}
@@ -168,13 +206,13 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
}
defer tx.Rollback() //nolint:errcheck
for key, secs := range req {
for key, value := range values {
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 {
key, value); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
+489
View File
@@ -0,0 +1,489 @@
package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// SettingSignupMode says who may create an account. It lives in the settings
// table with the other behaviour settings, so an administrator changes it in
// the admin page rather than in a chart.
//
// Two modes, not three. A domain-restricted mode was considered and dropped:
// with no email in this server there is nothing to verify an address against,
// so it would check the domain of a string somebody typed — a speed bump
// dressed as a control.
const (
SettingSignupMode = "signup_mode"
SignupInviteOnly = "invite_only"
SignupOpen = "open"
)
// defaultSignupMode is invite-only. An install that gets a public hostname
// before anybody has thought about sign-up should not be collecting accounts
// from the internet by default.
const defaultSignupMode = SignupInviteOnly
// inviteTTL is how long a new invite link lives. Long enough to send it and be
// read tomorrow, short enough that a link in an old chat log stops working.
const inviteTTL = 7 * 24 * time.Hour
// signupMode reads the current mode, falling back to invite-only for a missing
// or unrecognised value: the failure mode of a typo in this setting should be
// the closed door, not the open one.
func signupMode(ctx context.Context, db *sql.DB) string {
var raw string
if err := db.QueryRowContext(ctx,
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
return defaultSignupMode
}
if raw != SignupOpen && raw != SignupInviteOnly {
return defaultSignupMode
}
return raw
}
// handleSignupInfo tells the sign-up page what it may offer, without requiring
// a session: whether open sign-up is on, and whether the invite in the URL is
// any good. A bad invite is better reported before somebody picks a password.
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := map[string]any{"mode": signupMode(r.Context(), db)}
if token := r.URL.Query().Get("invite"); token != "" {
inv, err := loadInvite(r.Context(), db, token)
switch {
case err == nil:
out["invite_valid"] = true
out["invite_team"] = inv.teamName
default:
// Deliberately one answer for expired, revoked, used up and
// never existed. Telling a stranger which it was tells them
// something about links they do not hold.
out["invite_valid"] = false
}
}
respond(w, http.StatusOK, out)
}
}
type invite struct {
id int64
teamID int64
teamName string
role string
}
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
// it exists, has not been revoked, has not expired and has uses left.
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
var inv invite
err := q.QueryRowContext(ctx, `
SELECT i.id, i.team_id, t.name, i.role
FROM invites i
JOIN teams t ON t.id = i.team_id
WHERE i.token_hash = $1
AND i.revoked_at IS NULL
AND i.expires_at > `+nowEpoch+`
AND i.uses < i.max_uses`, hashToken(token)).
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
if errors.Is(err, sql.ErrNoRows) {
return invite{}, errInviteUnusable
}
return inv, err
}
var errInviteUnusable = errors.New("invite is not usable")
// handleSignup creates an account, and puts it somewhere.
//
// Rate-limited on the same limiter as login, by address: sign-up is the other
// unauthenticated endpoint that writes, and an open install without this is a
// way to fill somebody's user table.
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addr := clientAddr(r)
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
TeamName string `json:"team_name"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Username = strings.TrimSpace(req.Username)
req.Email = strings.TrimSpace(req.Email)
req.TeamName = strings.TrimSpace(req.TeamName)
if req.Username == "" || req.Email == "" {
respond(w, http.StatusBadRequest, errResp("username and email are required"))
return
}
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
mode := signupMode(r.Context(), db)
var inv invite
hasInvite := false
if req.Invite != "" {
var err error
inv, err = loadInvite(r.Context(), db, req.Invite)
if err != nil {
limiter.fail("signup:" + addr)
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return
}
hasInvite = true
}
if !hasInvite && mode != SignupOpen {
// No invite and the door is shut. Not 404: the endpoint exists and
// saying so is how somebody knows to ask for a link.
respond(w, http.StatusForbidden,
errResp("sign-up is invite-only on this server"))
return
}
if !hasInvite && req.TeamName == "" {
// Open sign-up with no team would create an account that sees an
// empty queue and can be paged by nobody.
respond(w, http.StatusBadRequest, errResp("team_name is required"))
return
}
hash, err := hashPassword(req.Password)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
var userID int64
var invitedVia *int64
if hasInvite {
invitedVia = &inv.id
}
if err := tx.QueryRowContext(r.Context(), `
INSERT INTO users (username, email, password_hash, invited_via)
VALUES ($1, $2, $3, $4) RETURNING id`,
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("username or email already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
teamID, role := inv.teamID, inv.role
if !hasInvite {
// Open sign-up makes a team, and its creator owns it.
if err := tx.QueryRowContext(r.Context(),
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); 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
}
role = models.RoleOwner
}
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
teamID, userID, role); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if hasInvite {
// Counted inside the transaction, so two people redeeming the last
// use of a link at once cannot both get in.
res, err := tx.ExecContext(r.Context(),
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return
}
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
// Signed in immediately: the alternative is a form that says "now go
// and log in", which is the same credential typed twice.
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, _ := fetchUser(r.Context(), db, userID)
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
}
}
// maxSignupsPerAddr is looser than the login limit: several people joining from
// one office share an address, and the thing being limited is account creation
// rather than password guessing.
const maxSignupsPerAddr = 10
// ---------------------------------------------------------------------------
// Invites
// ---------------------------------------------------------------------------
type inviteJSON struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int64 `json:"max_uses"`
Uses int64 `json:"uses"`
Revoked bool `json:"revoked"`
// URL is the whole link, returned once when the invite is created. Like an
// integration key, only its hash is stored.
URL string `json:"url,omitempty"`
}
func handleListInvites(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
}
rows, err := db.QueryContext(r.Context(), `
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
FROM invites
WHERE team_id = $1
ORDER BY id DESC`, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
out := []inviteJSON{}
for rows.Next() {
var i inviteJSON
var created, expires int64
var revoked *int64
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
&i.MaxUses, &i.Uses, &revoked); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
i.Revoked = revoked != nil
out = append(out, i)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, out)
}
}
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
// a team's configuration: deciding who joins is configuring the team.
func handleCreateInvite(db *sql.DB, publicURL string) 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 {
Role string `json:"role"`
MaxUses int64 `json:"max_uses"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Role == "" {
req.Role = models.RoleMember
}
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
return
}
if req.MaxUses == 0 {
req.MaxUses = 1
}
if req.MaxUses < 1 || req.MaxUses > 100 {
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
return
}
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
caller, _ := userFromContext(r.Context())
expires := time.Now().Add(inviteTTL)
var out inviteJSON
var created, expiresAt int64
if err := db.QueryRowContext(r.Context(), `
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
hash, teamID, req.Role, caller.ID, expires.Unix(), req.MaxUses).
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
out.CreatedAt = time.Unix(created, 0).UTC()
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
respond(w, http.StatusCreated, out)
}
}
// handleRevokeInvite stops a link working without waiting for it to expire.
func handleRevokeInvite(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
}
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
return
}
res, err := db.ExecContext(r.Context(),
"UPDATE invites SET revoked_at = "+nowEpoch+
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
if err != nil {
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)
}
}
// ---------------------------------------------------------------------------
// Onboarding
// ---------------------------------------------------------------------------
// handleTestNotification publishes one push to the caller's own topic.
//
// The point of the first-run checklist's notification step is not that a topic
// string has been typed but that a phone buzzes, and only the person holding it
// can tell whether it did. Published directly rather than through the outbox:
// the outbox row requires an incident, and this deliberately belongs to no
// incident.
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.BaseURL == "" {
respond(w, http.StatusServiceUnavailable,
errResp("this server has no ntfy configured, so it can send nothing"))
return
}
caller, _ := userFromContext(r.Context())
var topic *string
if err := db.QueryRowContext(r.Context(),
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if topic == nil || *topic == "" {
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
return
}
if err := publish(r.Context(), cfg, ntfyMessage{
Topic: *topic,
Title: "terdut test",
Message: "If this arrived, your notifications work.",
Tags: []string{"white_check_mark"},
}); err != nil {
// The failure is the useful part here: a wrong topic, a token the
// ntfy server rejects, or an ntfy that is down all look the same
// from the phone, which is silence.
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDismissOnboarding hides the first-run checklist, or brings it back.
// Stored per user rather than in the browser: somebody who finishes setting up
// on a laptop should not be nagged again on their phone.
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Dismissed *bool `json:"dismissed"`
}
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
return
}
caller, _ := userFromContext(r.Context())
var err error
if *req.Dismissed {
_, err = db.ExecContext(r.Context(),
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
} else {
_, err = db.ExecContext(r.Context(),
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+270
View File
@@ -0,0 +1,270 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/cookiejar"
"testing"
)
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
// and returns the response and a client holding whatever cookie came back.
func signup(t *testing.T, s *ts, body map[string]any) (*http.Response, *http.Client) {
t.Helper()
data, _ := json.Marshal(body)
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/signup", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("signup: %v", err)
}
return resp, client
}
// invite mints a link into the default team and returns its raw token.
func invite(t *testing.T, s *ts, role string, maxUses int64) string {
t.Helper()
var out struct {
URL string `json:"url"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/invites",
map[string]any{"role": role, "max_uses": maxUses}), &out)
if out.URL == "" {
t.Fatal("no invite URL returned")
}
// ...?invite=<token>
i := len(out.URL) - 1
for ; i >= 0 && out.URL[i] != '='; i-- {
}
return out.URL[i+1:]
}
func setSignupMode(t *testing.T, s *ts, mode string) {
t.Helper()
resp := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": mode})
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("set signup mode: %d", resp.StatusCode)
}
}
// The default is the closed door. An install that gets a public hostname before
// anybody has thought about sign-up should not be collecting accounts.
func TestSignup_InviteOnlyByDefault(t *testing.T) {
s := newTS(t)
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
if info["mode"] != "invite_only" {
t.Errorf("default sign-up mode is %v, want invite_only", info["mode"])
}
resp, _ := signup(t, s, map[string]any{
"username": "stranger", "email": "s@test.com", "password": "correct-horse-battery",
})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("sign-up without an invite: expected 403, got %d", resp.StatusCode)
}
}
// An invite carries the team and the role, so redeeming one lands somewhere
// usable rather than in an account that sees an empty queue.
func TestSignup_InviteCreatesAMemberOfThatTeam(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 1)
// The form checks the link before asking for a password.
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
if info["invite_valid"] != true {
t.Fatalf("a fresh invite should be valid: %v", info)
}
if info["invite_team"] != "Default" {
t.Errorf("the form should name the team: %v", info["invite_team"])
}
resp, client := signup(t, s, map[string]any{
"username": "newcomer", "email": "n@test.com",
"password": "correct-horse-battery", "invite": token,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("redeeming an invite: %d", resp.StatusCode)
}
var me struct {
User struct {
ID int64 `json:"id"`
IsAdmin bool `json:"is_admin"`
} `json:"user"`
}
decode(t, resp, &me)
if me.User.IsAdmin {
t.Error("somebody who signs up must not be an administrator")
}
// Signed in already: the cookie came back with the response.
got, err := client.Get(s.URL + "/api/teams")
if err != nil {
t.Fatal(err)
}
teams := list(t, got)
if len(teams) != 1 || teams[0]["name"] != "Default" || teams[0]["role"] != "member" {
t.Errorf("expected membership of Default as member, got %v", teams)
}
}
// A single-use link is single-use, and the check is inside the transaction so
// two people redeeming the last use at once cannot both get in.
func TestSignup_InviteCannotBeUsedTwice(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 1)
first, _ := signup(t, s, map[string]any{
"username": "first", "email": "f@test.com",
"password": "correct-horse-battery", "invite": token,
})
first.Body.Close()
if first.StatusCode != http.StatusCreated {
t.Fatalf("first redemption: %d", first.StatusCode)
}
second, _ := signup(t, s, map[string]any{
"username": "second", "email": "s@test.com",
"password": "correct-horse-battery", "invite": token,
})
second.Body.Close()
if second.StatusCode != http.StatusForbidden {
t.Errorf("second redemption: expected 403, got %d", second.StatusCode)
}
// And the link reports itself unusable before anybody types a password.
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
if info["invite_valid"] != false {
t.Error("a used-up invite should report itself invalid")
}
}
// Revoking stops a link without waiting for it to expire.
func TestSignup_RevokedInviteStopsWorking(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 5)
invites := list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/invites", nil))
if len(invites) != 1 {
t.Fatalf("expected one invite, got %d", len(invites))
}
id := int64(invites[0]["id"].(float64))
resp := s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/invites/"+id64(id), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("revoke: %d", resp.StatusCode)
}
used, _ := signup(t, s, map[string]any{
"username": "late", "email": "l@test.com",
"password": "correct-horse-battery", "invite": token,
})
used.Body.Close()
if used.StatusCode != http.StatusForbidden {
t.Errorf("a revoked invite: expected 403, got %d", used.StatusCode)
}
}
// Open sign-up makes a team, because an account in no team sees an empty queue
// and can be paged by nobody.
func TestSignup_OpenModeMakesATeam(t *testing.T) {
s := newTS(t)
setSignupMode(t, s, "open")
missing, _ := signup(t, s, map[string]any{
"username": "solo", "email": "s@test.com", "password": "correct-horse-battery",
})
missing.Body.Close()
if missing.StatusCode != http.StatusBadRequest {
t.Errorf("open sign-up with no team name: expected 400, got %d", missing.StatusCode)
}
resp, client := signup(t, s, map[string]any{
"username": "solo", "email": "s@test.com",
"password": "correct-horse-battery", "team_name": "Solo",
})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("open sign-up: %d", resp.StatusCode)
}
got, err := client.Get(s.URL + "/api/teams")
if err != nil {
t.Fatal(err)
}
teams := list(t, got)
if len(teams) != 1 || teams[0]["name"] != "Solo" || teams[0]["role"] != "owner" {
t.Errorf("the creator should own their new team, got %v", teams)
}
}
// Switching the mode is an administrator's decision, and it takes effect at
// once rather than at the next restart.
func TestSignup_ModeIsAnAdminSetting(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "plain")
resp := call(http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "open"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a member changing the mode: expected 403, got %d", resp.StatusCode)
}
bad := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "everybody"})
bad.Body.Close()
if bad.StatusCode != http.StatusBadRequest {
t.Errorf("an unknown mode: expected 400, got %d", bad.StatusCode)
}
setSignupMode(t, s, "open")
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
if info["mode"] != "open" {
t.Errorf("the change should be visible at once, got %v", info["mode"])
}
}
// Minting a link is configuring the team, so it is an owner's job.
func TestSignup_InvitesAreOwnerOnly(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "plain")
resp := call(http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": "member"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a member minting an invite: expected 403, got %d", resp.StatusCode)
}
}
// A password still has to be a password, and a taken username is still taken.
func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 5)
short, _ := signup(t, s, map[string]any{
"username": "shorty", "email": "sh@test.com", "password": "abc", "invite": token,
})
short.Body.Close()
if short.StatusCode != http.StatusBadRequest {
t.Errorf("a short password: expected 400, got %d", short.StatusCode)
}
taken, _ := signup(t, s, map[string]any{
"username": "admin", "email": "other@test.com",
"password": "correct-horse-battery", "invite": token,
})
taken.Body.Close()
if taken.StatusCode != http.StatusConflict {
t.Errorf("an existing username: expected 409, got %d", taken.StatusCode)
}
}
@@ -0,0 +1,49 @@
-- Self-service sign-up, and the invite links that make it useful.
--
-- Until now the only way to get an account was for somebody who already had one
-- to create it, and the login page told people to "ask an admin". That is a
-- workable arrangement for one operator and an impossible one for a team.
--
-- An invite is a link, not an email: this server has no SMTP and adding it to
-- send one message would be a new subsystem to run, secure and monitor. The
-- person inviting sends the link however they already talk to the person they
-- are inviting.
CREATE TABLE invites (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
-- SHA-256 of the raw token, like api_keys, the integration keys and the
-- acknowledgement tokens. A leaked database hands nobody an account.
token_hash TEXT NOT NULL UNIQUE,
-- Which team the invitee lands in, and as what. An invite always names a
-- team: an account in no team sees an empty queue and can be paged by
-- nobody, which is not a state to invite somebody into.
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
-- Invites expire. A link that works forever is a credential nobody
-- remembers issuing, sitting in a chat log.
expires_at BIGINT NOT NULL,
-- Single-use by default: max_uses 1. A team onboarding six people at once
-- can raise it rather than minting six links.
max_uses BIGINT NOT NULL DEFAULT 1 CHECK (max_uses > 0 AND max_uses <= 100),
uses BIGINT NOT NULL DEFAULT 0,
-- Revoked by hand, separately from expiry, so "this link is no longer
-- wanted" and "this link timed out" stay distinguishable in the listing.
revoked_at BIGINT
);
CREATE INDEX invites_team_idx ON invites(team_id);
-- Who redeemed which invite. Kept after the invite is gone — the answer to "how
-- did this account get here" should outlive the link that made it.
ALTER TABLE users ADD COLUMN invited_via BIGINT REFERENCES invites(id) ON DELETE SET NULL;
-- Where a person is in the first-run checklist, so it can be resumed and
-- dismissed rather than nagging forever. One row per user, created on demand.
ALTER TABLE users ADD COLUMN onboarding_dismissed_at BIGINT;
+7
View File
@@ -11,6 +11,13 @@ import "time"
// the webhook and the sweeper may flip to "resolved" once every member alert has
// stopped firing.
type Incident struct {
// EscalationLevel is which rung of its team's ladder this incident is on,
// 0 for none — either the team has no ladder, or somebody has answered.
// EscalationDueAt is when the current level runs out, so a client can say
// how long is left rather than only what already happened.
EscalationLevel int64 `json:"escalation_level"`
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
// TeamID is the team that owns this incident, fixed when it opens: an
// incident never moves between teams. TeamName rides along so the combined
// queue can badge each row without a second request.
+74 -2
View File
@@ -199,7 +199,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
/* Bottom tab bar on phones. */
.nav {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
display: grid; grid-template-columns: repeat(4, 1fr);
/* One column per link, however many there are. This was repeat(4, 1fr) when
there were four tabs; Team and Admin arriving pushed six items into four
columns, which on a phone is how they stopped fitting. Auto columns mean
the next tab cannot break the row either — and Admin is only rendered for
an administrator, so the count genuinely varies between viewers. */
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
height: calc(var(--tabbar-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
background: color-mix(in srgb, var(--surface) 92%, transparent);
@@ -212,8 +217,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600;
/* min-width lets a column shrink below its label's natural width, which is
what stops six tabs widening the bar past the screen. */
min-width: 0; padding: 0 2px;
}
.nav-link svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.nav-label {
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
the widest label, "On-call", is about 38px at this size, so nothing has to
be hidden. The ellipsis above is the backstop if a future tab is named
something longer. */
@media (max-width: 420px) {
.nav-link { font-size: 10px; gap: 1px; }
.nav-link svg { width: 21px; height: 21px; }
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
}
.nav-link[aria-current="page"] { color: var(--accent); }
.nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px);
@@ -657,3 +679,53 @@ kbd {
.admin-settings .setting-unit { max-width: 8em; }
.admin-settings button[type="submit"] { margin-top: 12px; }
.small { font-size: 13px; }
/* --- team settings -------------------------------------------------------
Forms with a label above each control, rather than the queue's rows of
links. The escalation ladder is the only nested structure in the app, so it
gets a little indentation to make the levels read as an order. */
.stacked-form { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; align-items: flex-start; }
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
.stacked-form label.checkbox { gap: 8px; }
.stacked-form input.wide { min-width: min(420px, 100%); }
.team-picker { margin-top: 8px; max-width: 100%; }
.ladder-level {
border-left: 3px solid var(--border-strong);
padding: 8px 0 8px 12px; margin: 12px 0;
}
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
/* An integration key is shown exactly once, so it should look like something
to act on rather than another row of text. */
.key-panel {
margin-top: 12px; padding: 12px;
border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft);
}
.key-panel pre {
overflow-x: auto; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: 8px; font-size: 12px;
}
.key-url code { word-break: break-all; }
/* --- onboarding checklist ------------------------------------------------
Sits above the queue until it is finished or hidden. Deliberately plain:
it is a list of things to do, not a celebration. */
.onboarding { border-left: 3px solid var(--accent); }
.onboarding-head { display: flex; align-items: center; gap: 10px; }
.onboarding-head h2 { flex: 1; margin: 0; }
.checklist { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.checklist .step { display: flex; gap: 10px; align-items: flex-start; }
.checklist .step p { margin: 2px 0 0; }
.step-mark {
flex: none; width: 20px; height: 20px; border-radius: 50%;
border: 1px solid var(--border-strong); color: var(--accent);
display: flex; align-items: center; justify-content: center; font-size: 13px;
}
.step.done .step-mark { border-color: var(--accent); }
.step.done > div > strong { color: var(--muted); text-decoration: line-through; }
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
+41 -5
View File
@@ -37,6 +37,37 @@
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
<p class="login-hint">No password yet? Ask an admin to set one, or run
<code>PUT /api/users/{id}/password</code> with your API key.</p>
<p class="login-hint" id="signup-link" hidden>
No account? <a href="/signup">Create one</a>.</p>
</form>
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
what the server allows: an invite link, or open sign-up. -->
<form id="signup-form" class="login-card" autocomplete="on" hidden>
<div class="login-brand">
<img src="/icon.svg" alt="" width="40" height="40">
<h1>terdut</h1>
</div>
<p class="signup-intro" id="signup-intro"></p>
<label>
<span>Username</span>
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label>
<span>Email</span>
<input name="email" type="email" autocomplete="email" required>
</label>
<label>
<span>Password</span>
<input name="password" type="password" autocomplete="new-password" minlength="10" required>
</label>
<label id="signup-team-label" hidden>
<span>Team name</span>
<input name="team_name" autocomplete="off">
</label>
<p class="form-error" role="alert" hidden></p>
<button class="btn btn-primary btn-block" type="submit">Create account</button>
<p class="login-hint">Already have one? <a href="/">Sign in</a>.</p>
</form>
</main>
@@ -46,27 +77,31 @@
<img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span>
</a>
<a class="nav-link" href="/" data-section="queue">
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="nav-label">Queue</span>
<span class="nav-badge" data-badge hidden></span>
</a>
<a class="nav-link" href="/oncall" data-section="oncall">
<a class="nav-link" href="/oncall" data-section="oncall" aria-label="On-call">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
<span class="nav-label">On-call</span>
</a>
<a class="nav-link" href="/alerts" data-section="alerts">
<a class="nav-link" href="/alerts" data-section="alerts" aria-label="Alerts">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
<span class="nav-label">Alerts</span>
</a>
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</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>
<a class="nav-link" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
<span class="nav-label">Admin</span>
</a>
<a class="nav-link" href="/more" data-section="more">
<a class="nav-link" href="/more" data-section="more" aria-label="Account">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
<span class="nav-label">Account</span>
</a>
@@ -87,6 +122,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-team" class="view view-page" data-view="team" hidden></section>
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section>
</div>
+38
View File
@@ -87,11 +87,49 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
// schedule
// Sign-up, both halves unauthenticated: the caller has no account yet.
export const signupInfo = (invite) =>
call('GET', '/signup', { query: invite ? { invite } : {} });
export const signup = (body) => call('POST', '/signup', { body });
export const invites = (id) => call('GET', `/teams/${id}/invites`);
export const createInvite = (id, role, maxUses) =>
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
export const testNotification = () => call('POST', '/me/notify/test');
export const dismissOnboarding = (dismissed) =>
call('PUT', '/me/onboarding', { body: { dismissed } });
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}`);
// A team's own settings. Every write is owner-only and every read is
// member-only; the server answers 403 and 404 respectively, so the UI shows
// what the role allows rather than guarding it.
export const teamMembers = (id) => call('GET', `/teams/${id}/members`);
export const addTeamMember = (id, userID, role) =>
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
export const createIntegration = (id, name) =>
call('POST', `/teams/${id}/integrations`, { body: { name } });
export const deleteIntegration = (id, integrationID) =>
call('DELETE', `/teams/${id}/integrations/${integrationID}`);
export const deadman = (id) => call('GET', `/teams/${id}/deadman`);
export const setDeadman = (id, body) => call('PUT', `/teams/${id}/deadman`, { body });
export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
export const assignSchedule = (id, userID, dates, replace = false) =>
call('POST', `/teams/${id}/schedule`, { body: { user_id: userID, dates, replace } });
export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/schedule/${entryID}`);
// 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');
+96 -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 team from './team.js';
import * as admin from './admin.js';
const $ = (id) => document.getElementById(id);
@@ -18,6 +19,7 @@ const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
team: { title: 'Team', view: team },
admin: { title: 'Admin', view: admin },
more: { title: 'Account', view: account },
};
@@ -26,7 +28,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 === 'admin' || name === 'more') return { section: name };
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
@@ -140,6 +142,14 @@ async function boot() {
document.addEventListener('click', interceptLinks);
document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup);
// /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') {
$('boot').hidden = true;
await showSignup();
return;
}
try {
state.me = await api.me();
@@ -159,6 +169,84 @@ function showBootError(err) {
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
}
// The sign-up screen. Reached at /signup, with an optional ?invite= that the
// server has already judged — the form says whether the link is good before
// somebody picks a password, rather than after.
async function showSignup() {
poll.stop();
ui.closeSheet(null);
reset();
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('login-form').hidden = true;
$('signup-form').hidden = false;
const invite = new URLSearchParams(location.search).get('invite');
const intro = $('signup-intro');
const form = $('signup-form');
const teamLabel = $('signup-team-label');
form.querySelector('.form-error').hidden = true;
let info;
try {
info = await api.signupInfo(invite);
} catch (err) {
intro.textContent = err.message;
return;
}
if (invite && info.invite_valid) {
intro.textContent = `You have been invited to ${info.invite_team}.`;
teamLabel.hidden = true;
form.team_name.required = false;
} else if (invite) {
// One answer for expired, revoked, used up and never existed, matching the
// server: which it was is not a stranger's business.
intro.textContent = 'That invite link is not usable. Ask whoever sent it for a new one.';
form.querySelector('button[type=submit]').disabled = true;
} else if (info.mode === 'open') {
intro.textContent = 'Create an account and a team to put your alerts in.';
teamLabel.hidden = false;
form.team_name.required = true;
} else {
intro.textContent = 'Sign-up on this server is invite-only. Ask a team owner for a link.';
form.querySelector('button[type=submit]').disabled = true;
}
form.username.focus();
}
async function onSignup(e) {
e.preventDefault();
const form = e.currentTarget;
const err = form.querySelector('.form-error');
const btn = form.querySelector('button[type=submit]');
err.hidden = true;
btn.disabled = true;
try {
state.me = await api.signup({
username: form.username.value.trim(),
email: form.email.value.trim(),
password: form.password.value,
invite: new URLSearchParams(location.search).get('invite') || undefined,
team_name: form.team_name.value.trim() || undefined,
});
form.password.value = '';
// Signing up signs you in, so go straight to the queue rather than to a
// login form asking for the credential just chosen.
history.replaceState({ depth: 0 }, '', '/');
route = parseRoute('/');
await loadTeams();
$('nav-admin').hidden = !state.me?.user?.is_admin;
showApp();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
btn.disabled = false;
}
}
function showLogin() {
poll.stop();
ui.closeSheet(null);
@@ -166,8 +254,15 @@ function showLogin() {
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('signup-form').hidden = true;
$('login-form').hidden = false;
const form = $('login-form');
form.querySelector('.form-error').hidden = true;
// Only offer the door that is open. Somebody without an invite on an
// invite-only server should be told, not sent to a form that refuses them.
api.signupInfo().then((info) => {
$('signup-link').hidden = info.mode !== 'open';
}).catch(() => {});
form.password.value = '';
(form.username.value ? form.password : form.username).focus();
}
+13
View File
@@ -95,6 +95,15 @@ function statusBadges() {
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
}
// Where it is on the ladder, while it is still climbing. The queue shows
// what happened; this says what happens next, which is the question somebody
// looking at an unacknowledged incident actually has.
if (inc.escalation_level > 0) {
const left = inc.escalation_due_at && isFuture(inc.escalation_due_at)
? ` · next in ${until(inc.escalation_due_at)}`
: ' · next page due';
out.push(badge(`Escalating · level ${inc.escalation_level}${left}`, 'st-triggered'));
}
if (inc.archived_at) out.push(badge('Archived', 'plain'));
return out;
}
@@ -115,6 +124,10 @@ function facts() {
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
add('Snoozed until', when(inc.snoozed_until));
}
if (inc.escalation_level > 0 && inc.escalation_due_at) {
add('Escalates next', when(inc.escalation_due_at),
h('span', { class: 'sub', text: ` · level ${inc.escalation_level}` }));
}
if (inc.resolved_at) {
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
+147
View File
@@ -0,0 +1,147 @@
// The first-run checklist: the four things a new install or a new person has
// to do before an alert reaches a phone.
//
// It is computed from what the server already knows rather than from stored
// progress — a topic is set or it is not, an integration exists or it does not
// — so it cannot claim a step is done when it is not, and it comes back by
// itself if somebody deletes their integration a month later.
//
// Dismissal is the one piece of state, kept per user so finishing on a laptop
// does not leave the phone nagging.
import * as api from './api.js';
import { h, clear, spinner } from './ui.js';
import { state, currentTeam } from './state.js';
import { navigate } from './app.js';
import { isoDate } from './format.js';
let steps = null;
let error = null;
let busy = false;
let testResult = null;
// done() is deliberately a question about the world, not a flag: each step asks
// the data whether it happened.
export async function load() {
const team = currentTeam();
if (!team) {
steps = null;
return;
}
try {
const [schedule, integrations, alerts] = await Promise.all([
api.schedule(team.id, isoDate(new Date()), isoDate(new Date())),
api.integrations(team.id),
api.alerts({ limit: 1 }),
]);
steps = [
{
id: 'topic',
title: 'Set where your pages go',
text: 'An ntfy topic on your account. Without one, incidents assigned to you page the team’s fallback topic instead of your phone.',
done: Boolean(state.me?.user?.ntfy_topic),
action: { label: 'Account', go: '/more' },
},
{
id: 'rota',
title: 'Put somebody on call',
text: 'An incident opens assigned to whoever the rota says is on call today. With an empty rota it opens unassigned.',
done: (schedule || []).length > 0,
action: { label: 'Team', go: '/team' },
},
{
id: 'integration',
title: 'Create an alert source',
text: 'Alerts arrive on an integration key, which says which team they belong to. Nothing can reach this team without one.',
done: (integrations || []).length > 0,
action: { label: 'Team', go: '/team' },
},
{
id: 'alert',
title: 'Send a test alert',
text: 'Post to the integration URL and watch it appear in the queue. Until one arrives, none of the above is proven.',
done: (alerts || []).length > 0,
action: { label: 'How', go: '/team' },
},
];
error = null;
} catch (err) {
error = err.message;
}
}
// visible reports whether there is anything worth showing: something undone,
// and not dismissed.
export function visible() {
if (!steps || state.me?.onboarding_dismissed) return false;
return steps.some((s) => !s.done);
}
export function card() {
if (!visible()) return null;
const remaining = steps.filter((s) => !s.done).length;
return h('div', { class: 'card onboarding' },
h('div', { class: 'onboarding-head' },
h('h2', { text: 'Finish setting up' }),
h('span', { class: 'muted small', text: `${remaining} left` }),
h('button', {
class: 'btn-sm', type: 'button', text: 'Hide',
title: 'Hide this checklist for good',
onclick: async () => {
try {
await api.dismissOnboarding(true);
if (state.me) state.me.onboarding_dismissed = true;
} catch (err) {
error = err.message;
}
rerender();
},
})),
error && h('p', { class: 'load-error', text: error }),
h('ol', { class: 'checklist' }, ...steps.map(stepRow)),
testResult && h('p', { class: testResult.ok ? 'muted small' : 'load-error', text: testResult.text }),
);
}
function stepRow(step) {
return h('li', { class: step.done ? 'step done' : 'step' },
h('span', { class: 'step-mark', text: step.done ? '✓' : '' }),
h('div', {},
h('strong', { text: step.title }),
h('p', { class: 'muted small', text: step.text }),
!step.done && h('div', { class: 'step-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: step.action.label,
onclick: () => navigate(step.action.go),
}),
// The topic step is the only one this page can finish by itself, and
// the only proof that matters is a phone buzzing.
step.id === 'topic' && state.me?.user?.ntfy_topic && h('button', {
class: 'btn-sm', type: 'button', text: 'Send a test push',
disabled: busy,
onclick: sendTest,
}),
)),
);
}
async function sendTest() {
busy = true;
try {
await api.testNotification();
testResult = { ok: true, text: 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.' };
} catch (err) {
testResult = { ok: false, text: err.message };
} finally {
busy = false;
}
rerender();
}
// The queue owns the card's place on the page, so ask it to redraw rather than
// reaching into its list.
let rerender = () => {};
export function onRerender(fn) {
rerender = fn;
}
+9 -3
View File
@@ -4,6 +4,7 @@ import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
@@ -25,6 +26,8 @@ const EMPTY = {
archived: ['Nothing archived', ''],
};
onboarding.onRerender(() => renderList());
let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading
@@ -89,6 +92,7 @@ export async function refresh({ fresh = false } = {}) {
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter;
const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return;
items = result;
error = null;
@@ -153,20 +157,22 @@ function renderChips() {
function renderList() {
const el = document.getElementById('queue-list');
const checklist = onboarding.card();
if (error && !items) {
clear(el, h('div', { class: 'load-error', text: error }));
clear(el, checklist, h('div', { class: 'load-error', text: error }));
return;
}
if (!items) {
clear(el, spinner());
clear(el, checklist, spinner());
return;
}
if (!items.length) {
const [title, text] = EMPTY[filter];
clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
return;
}
clear(el,
checklist,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map((inc, i) => row(inc, i)),
);
+453
View File
@@ -0,0 +1,453 @@
// Team settings: the rota, who is in the team, where its alerts come from,
// what it escalates through, and which of its alerts are heartbeats.
//
// Everything here was API-only until now, which meant a team owner had to use
// curl to set up escalation — the feature this whole line of work exists for.
//
// The server decides what a role may do: an owner's edits succeed, a member's
// are refused with 403, and a non-member gets 404 for the lot. This view hides
// the controls a member cannot use, because a form that always fails is worse
// than no form, but it is not the thing enforcing anything.
import * as api from './api.js';
import { h, clear, spinner, confirm } from './ui.js';
import { state, currentTeam, users as allUsers } from './state.js';
import { isoDate, addDays } from './format.js';
const view = () => document.getElementById('view-team');
let teamID = null;
let data = null; // { team, members, integrations, escalation, deadman, schedule, users }
let error = null;
let freshKey = null; // an integration key, shown once, until the view is left
export function show() {
if (!data) clear(view(), spinner());
refresh();
}
function selectedTeam() {
const teams = state.teams || [];
return teams.find((t) => t.id === teamID) || currentTeam();
}
export async function refresh() {
const team = selectedTeam();
if (!team) {
data = null;
render();
return;
}
teamID = team.id;
try {
// A member may read all of this; only the writes are owner-only.
const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([
api.teamMembers(team.id),
api.integrations(team.id),
api.escalation(team.id),
api.deadman(team.id),
api.schedule(team.id, isoDate(new Date()), isoDate(addDays(new Date(), 30))),
allUsers(),
]);
data = { team, members, integrations, escalation, deadman, schedule, users };
error = null;
} catch (err) {
error = err.message;
}
render();
}
function isOwner() {
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
}
function render() {
if (!data) {
clear(view(), error
? h('div', { class: 'load-error', text: error })
: h('div', { class: 'card' }, h('p', { class: 'muted', text: 'You are not in a team yet.' })));
return;
}
clear(view(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
teamPicker(),
!isOwner() && h('div', { class: 'card' },
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
scheduleCard(),
escalationCard(),
integrationsCard(),
deadmanCard(),
membersCard(),
);
}
// Only shown to somebody in more than one team, like the queue's filter chips.
function teamPicker() {
if ((state.teams || []).length < 2) {
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
}
const select = h('select', { class: 'team-picker' },
...state.teams.map((t) => h('option', {
value: String(t.id), text: t.name, selected: t.id === teamID,
})));
select.addEventListener('change', () => {
teamID = Number(select.value);
data = null;
freshKey = null;
show();
});
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
}
// --- schedule --------------------------------------------------------------
// The rota is one person per UTC day. The on-call page shows it; this is where
// it is set, which until now was the TUI's job and the TUI cannot do it any
// more.
function scheduleCard() {
const rows = (data.schedule || []).map((e) =>
h('tr', {},
h('td', { text: e.date }),
h('td', {}, h('strong', { text: e.username })),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Clear',
onclick: () => act(() => api.unassignSchedule(teamID, e.id)),
})),
));
return h('div', { class: 'card' },
h('h2', { text: 'On-call rota' }),
h('p', { class: 'muted small', text: 'One person per UTC day, for the next 30 days.' }),
rows.length
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
: h('p', { class: 'muted', text: 'Nobody is scheduled.' }),
isOwner() && assignForm(),
);
}
function assignForm() {
const who = memberSelect();
const from = h('input', { type: 'date', required: true, value: isoDate(new Date()) });
const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' });
const replace = h('input', { type: 'checkbox' });
const form = h('form', { class: 'stacked-form' },
h('label', {}, 'Who ', who),
h('label', {}, 'From ', from),
h('label', {}, 'Days ', days),
// Taking a day somebody else holds has to be asked for, the same rule the
// API enforces: a plain assignment that silently moved a shift would move
// who gets paged without telling either of them.
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
const start = new Date(from.value + 'T00:00:00Z');
const dates = [];
for (let i = 0; i < Number(days.value || 1); i++) dates.push(isoDate(addDays(start, i)));
act(() => api.assignSchedule(teamID, Number(who.value), dates, replace.checked));
});
return form;
}
function memberSelect(selected) {
return h('select', {},
...(data.members || []).map((m) => h('option', {
value: String(m.user_id), text: m.username, selected: m.user_id === selected,
})));
}
// --- escalation ------------------------------------------------------------
// The ladder is edited as a whole and sent as a whole, because the API replaces
// it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided.
let draft = null;
function escalationCard() {
const esc = data.escalation;
if (!draft) {
draft = {
repeat_count: esc.repeat_count || 0,
fallback_topic: esc.fallback_topic || '',
levels: (esc.levels || []).map((l) => ({
timeout_seconds: l.timeout_seconds,
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
})),
};
}
const body = [];
if (!draft.levels.length) {
body.push(h('p', { class: 'muted' },
'No ladder. An unacknowledged incident re-pages the same person every ',
'reminder interval and nobody else is woken.'));
}
draft.levels.forEach((level, i) => {
body.push(h('div', { class: 'ladder-level' },
h('div', { class: 'ladder-head' },
h('strong', { text: `Level ${i + 1}` }),
isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => { draft.levels.splice(i, 1); render(); },
})),
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
level.timeout_seconds = secs;
}), ' before the next level'),
h('div', { class: 'ladder-targets' },
...level.targets.map((t, ti) => targetRow(level, t, ti)),
isOwner() && h('button', {
class: 'btn-sm', type: 'button', text: '+ target',
onclick: () => { level.targets.push({ kind: 'oncall' }); render(); },
})),
));
});
if (isOwner()) {
body.push(h('button', {
class: 'btn-sm', type: 'button', text: '+ level',
onclick: () => {
draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] });
render();
},
}));
const repeat = h('input', {
type: 'number', min: '0', max: '10', class: 'setting-value',
value: String(draft.repeat_count),
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
});
const fallback = h('input', {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; },
});
body.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
body.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
body.push(h('button', {
class: 'btn', type: 'button', text: 'Save ladder',
onclick: () => act(() => api.setEscalation(teamID, draft), { resetDraft: true }),
}));
}
return h('div', { class: 'card' },
h('h2', { text: 'Escalation' }),
h('p', { class: 'muted small' },
'When a level’s wait passes and nobody has acknowledged, the next level is ',
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
...body,
);
}
function targetRow(level, target, index) {
const kind = h('select', {},
h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }),
h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' }));
kind.addEventListener('change', () => {
target.kind = kind.value;
target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined;
render();
});
const who = target.kind === 'user'
? memberSelect(target.user_id)
: null;
if (who) {
who.addEventListener('change', () => { target.user_id = Number(who.value); });
}
return h('div', { class: 'target-row' }, kind, who,
isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: '×',
title: 'Remove this target',
onclick: () => { level.targets.splice(index, 1); render(); },
}));
}
function minutesInput(seconds, onChange) {
const input = h('input', {
type: 'number', min: '1', class: 'setting-value',
value: String(Math.max(1, Math.round(seconds / 60))),
oninput: (e) => onChange(Number(e.target.value) * 60),
});
return h('span', {}, input, ' minutes');
}
// --- integrations ----------------------------------------------------------
function integrationsCard() {
const rows = (data.integrations || []).map((i) =>
h('tr', {},
h('td', {}, h('strong', { text: i.name })),
h('td', { class: 'muted small', text: i.kind }),
h('td', { class: 'muted small', text: i.last_used_at ? 'in use' : 'never used' }),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Revoke',
onclick: async () => {
if (!(await confirm({
title: `Revoke ${i.name}?`,
text: 'Anything posting with this key stops delivering immediately.',
confirmLabel: 'Revoke',
danger: true,
}))) return;
act(() => api.deleteIntegration(teamID, i.id));
},
})),
));
return h('div', { class: 'card' },
h('h2', { text: 'Alert sources' }),
h('p', { class: 'muted small' },
'Alerts arrive on an integration key, which says both that the sender may ',
'post and which team the alerts belong to.'),
rows.length
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
freshKey && newKeyPanel(),
isOwner() && !freshKey && newIntegrationForm(),
);
}
// The key is returned exactly once. Say so, show it large, and give the
// Alertmanager snippet with it already in place — the next thing anybody does
// with it is paste it into a config.
function newKeyPanel() {
const url = freshKey.url || `${location.origin}/api/integrations/${freshKey.key}/alertmanager`;
const snippet = `receivers:
- name: terdut
webhook_configs:
- url: ${url}
send_resolved: true`;
return h('div', { class: 'key-panel' },
h('strong', { text: 'Copy this now — it is not shown again.' }),
h('pre', { class: 'key-url' }, h('code', { text: url })),
h('button', {
class: 'btn-sm', type: 'button', text: 'Copy URL',
onclick: () => navigator.clipboard?.writeText(url),
}),
h('p', { class: 'muted small', text: 'Alertmanager receiver:' }),
h('pre', {}, h('code', { text: snippet })),
h('button', {
class: 'btn-sm', type: 'button', text: 'Done',
onclick: () => { freshKey = null; render(); },
}),
);
}
function newIntegrationForm() {
const name = h('input', { type: 'text', placeholder: 'prod alertmanager', required: true });
const form = h('form', { class: 'inline-form' }, name,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
freshKey = await api.createIntegration(teamID, name.value.trim());
await refresh();
} catch (err) {
error = err.message;
render();
}
});
return form;
}
// --- dead man's switches ---------------------------------------------------
function deadmanCard() {
const d = data.deadman || {};
const matchers = h('input', {
type: 'text', value: d.matchers || '', placeholder: 'alertname=Watchdog',
class: 'wide',
});
const timeout = h('input', {
type: 'number', min: '0', class: 'setting-value',
value: String(Math.round((d.timeout_seconds || 0) / 60)),
});
const severity = h('select', {},
...['critical', 'error', 'warning', 'info'].map((s) =>
h('option', { value: s, text: s, selected: (d.severity || 'critical') === s })));
const form = h('form', { class: 'stacked-form' },
h('label', {}, 'Heartbeat alerts ', matchers),
h('label', {}, 'Declare dead after ', timeout, ' minutes of silence'),
h('label', {}, 'Open the incident at severity ', severity),
h('button', { class: 'btn', type: 'submit', text: 'Save switches' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.setDeadman(teamID, {
matchers: matchers.value.trim(),
timeout_seconds: Number(timeout.value) * 60,
severity: severity.value,
}));
});
return h('div', { class: 'card' },
h('h2', { text: 'Dead man’s switches' }),
h('p', { class: 'muted small' },
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
'quiet for longer than the timeout opens an incident. ',
h('code', { text: 'alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat' }),
' — semicolons separate switches, commas separate conditions, and every ',
'switch must name an alertname. Leave empty to watch nothing.'),
isOwner() ? form : h('p', { class: 'muted', text: d.matchers || 'Nothing watched.' }),
);
}
// --- members ---------------------------------------------------------------
function membersCard() {
const rows = (data.members || []).map((m) =>
h('tr', {},
h('td', {}, h('strong', { text: m.username })),
h('td', { class: 'muted small', text: m.role }),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm', type: 'button',
text: m.role === 'owner' ? 'Make member' : 'Make owner',
onclick: () => act(() =>
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
}), isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
})),
));
const inTeam = new Set((data.members || []).map((m) => m.user_id));
const candidates = (data.users || []).filter((u) => !inTeam.has(u.id) && !u.disabled_at);
const pick = h('select', {},
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
const role = h('select', {},
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
});
return h('div', { class: 'card' },
h('h2', { text: 'Members' }),
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
isOwner() && candidates.length > 0 && form,
);
}
// --- plumbing --------------------------------------------------------------
// act runs a write and reloads. Errors are shown rather than thrown away: a
// 409 from the last-owner guard or the schedule's conflict rule is the server
// explaining itself, and the reader needs to see it.
async function act(fn, { resetDraft = false } = {}) {
try {
await fn();
error = null;
if (resetDraft) draft = null;
} catch (err) {
error = err.message;
}
if (!resetDraft) draft = null;
await refresh();
}