Compare commits

...

10 Commits

Author SHA1 Message Date
Niklas Ye 828cf87656 Set the chart's placeholder version to 0.16.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m33s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 59s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway. release.yaml passes --version and --app-version
from the git tag when it packages, so neither line decides anything about
what is published; they exist to be read by somebody looking at the tree
before the tag does. A tree heading for v0.16.0 that says 0.15.1 tells that
reader something false.

Its own commit, like 8869ac8, 9376105 and 4e8c52c before it, so the feature
commit's diff stays the feature.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 12:55:58 +02:00
Niklas Ye ac9af8e4f5 Manage a person's account and teams from one page
The Admin tab could make somebody an administrator and disable them, and
nothing else. Setting a first password, deleting an account and seeing
which teams a person is in all meant curl, and the last one meant opening
each team in turn — the Team tab answers "who is in this team", which is
the wrong way round when the question is about a person.

A name in the user list now opens /admin/users/{id}: their email and when
they joined, where their notifications go, the administrator and disabled
flags, the teams they are in with their role in each, a password field
for a first or forgotten one, and deletion. A section of its own rather
than an expanding row, because memberships and the account actions
together are more than a table row can hold and still be read on a phone.

Adding somebody mints an invite link into a chosen team rather than
creating a bare account. POST /api/users makes a user with no password
and no team, who can sign in nowhere and would see nothing if they did;
the invite machinery from #7 already solves both, and the password is
chosen by the person it belongs to instead of passing through an
administrator.

One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is
always about the caller and cannot be asked about anybody else. It 404s
for a user who does not exist, so the page can tell "in no teams" from
"no such person" — an empty list is a real answer and needed to stay one.

No authorisation changed, and the interesting part is why it did not.
requireTeamOwner has accepted the administrator flag since a4fbd60, with
the reason in its own comment: somebody has to be able to repair a team
whose owner has left. It guards nine call sites, so an administrator has
always been able to configure any team on this server — while #1's
decision table and this README both said an admin "is not implicitly in
every team", full stop. The code was right and the prose was wrong in the
safe-sounding direction, which is the worse way round to have it.

So the documentation moved to meet the code. The Teams table marks owner
as owner-or-admin, and the Authentication section states the two
directions separately: an administrator configures any team, and reads
none, because callerTeamIDs is built from real memberships only. Joining
a team to see its queue is a membership change and shows as one.

TestAdmin_ConfiguresATeamTheyAreNotIn pins both halves — the admin
renames, invites, adds and removes on a team they are not in, then sees
zero of its incidents. Nothing tested this from v0.12.0 to here, which is
why four releases of prose could contradict it quietly.

The UI has not been opened in a browser. Its wiring is checked — every
cross-module import resolves, every api.* call exists, every CSS class
has a rule, and the deep link serves index.html — but nobody has clicked
through it, least of all at phone width.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 12:55:42 +02:00
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
18 changed files with 1914 additions and 62 deletions
+55 -7
View File
@@ -51,7 +51,7 @@ archive), who is on call, the alert feed, and changing your own password. It is
built for a phone first. On a phone it has a bottom tab bar and a sticky action built for a phone first. On a phone it has a bottom tab bar and a sticky action
bar, it follows the system's dark mode, and it can be added to the home screen. bar, it follows the system's dark mode, and it can be added to the home screen.
From 900px wide it switches to a sidebar with the queue and the incident side by From 900px wide it switches to a sidebar with the queue and the incident side by
side. Schedule editing, statistics and user management remain in side. Statistics remain in
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now. [terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
You sign in with a username and password. Users have no password until one is You sign in with a username and password. Users have no password until one is
@@ -93,7 +93,19 @@ between them at the top.
The **Admin** tab appears only for a system administrator, and holds what The **Admin** tab appears only for a system administrator, and holds what
belongs to the whole server rather than to one team: every team, every user, and belongs to the whole server rather than to one team: every team, every user, and
the settings that used to be environment variables. the settings that used to be environment variables. Adding somebody is minting
them an invite link into a team, rather than creating a bare account: the person
who accepts it picks their own password, so one never passes through an
administrator, and the link carries the team, so they land somewhere with a
queue in it.
A name in that list opens **that person's page**, at `/admin/users/{id}`: their
email and when they joined, where their notifications go, whether they are an
administrator, whether the account is disabled, the teams they are in with their
role in each, a password field for a first or forgotten one, and deletion. It is
the one place membership is edited from the person's side — the Team tab answers
"who is in this team", and answering "which teams is this person in" there means
visiting each team in turn.
### Docker ### Docker
@@ -550,6 +562,25 @@ granting the flag itself. Everybody else works incidents — acknowledging,
assigning, snoozing, resolving, noting — and manages their own account and 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. 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 The first user, from `/api/bootstrap`, is an administrator. Users created
afterwards are not, until an administrator says so. An install always keeps at 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 least one: the last administrator can be neither deleted nor demoted, and
@@ -560,10 +591,16 @@ Endpoints that require the flag answer `403` with
**Teams** are the unit of tenancy, and are a separate axis from the administrator **Teams** are the unit of tenancy, and are a separate axis from the administrator
flag. A team owns its incidents, alerts, schedule and integrations, and a user flag. A team owns its incidents, alerts, schedule and integrations, and a user
sees exactly the teams they belong to — an administrator is not implicitly in sees exactly the teams they belong to. Within a team an **owner** configures it
every team, because administration is about accounts, not about reading other (schedule, integrations, membership) and a **member** works its incidents.
people's incidents. Within a team an **owner** configures it (schedule,
integrations, membership) and a **member** works its incidents. An administrator crosses that line in one direction only. They **configure any
team** without being in it — every owner-only endpoint accepts the flag, because
otherwise a team whose last owner left could never be repaired. They do **not
read any team**: the queue, the alerts and the incidents are filtered by real
membership, so an administrator sees a team's work only by joining it, which is
a membership change and shows up as one. Administration is about accounts and
the shape of a team, not about reading other people's incidents.
Anything belonging to a team you are not in answers `404`, not `403`: whether an Anything belonging to a team you are not in answers `404`, not `403`: whether an
incident exists is itself something only its team should learn. incident exists is itself something only its team should learn.
@@ -584,8 +621,11 @@ on anybody's.
| Method | Path | Who | Description | | 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 | | `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people | | `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable |
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator | | `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator | | `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator | | `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
@@ -601,7 +641,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/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 | | `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 ### Alert ingestion
@@ -620,6 +660,11 @@ and was removed in v0.13.0 once senders had moved onto keys.
### Teams ### Teams
**owner** below means an owner of that team *or* a system administrator, who
passes every one of these without being a member — see
[Authentication](#authentication). **member** means membership and nothing else: an
administrator who is not in the team gets the same `404` as anybody else.
| Method | Path | Who | Description | | Method | Path | Who | Description |
|---|---|---|---| |---|---|---|---|
| `GET` | `/api/teams` | any | The caller's own teams, each with their role | | `GET` | `/api/teams` | any | The caller's own teams, each with their role |
@@ -632,6 +677,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 | | `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 | | `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 | | `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 | | `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 | | `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}` | | `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: # appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is # image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing. # metadata and drives nothing.
version: 0.14.0 version: 0.16.0
appVersion: "v0.14.0" appVersion: "v0.16.0"
+119
View File
@@ -237,6 +237,125 @@ func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
} }
} }
// An administrator passes every team-owner check without being in the team,
// which is what lets them repair a team whose owner has left. It has been true
// since teams landed and nothing pinned it, so a later reading of the epic's
// "an admin is not implicitly in every team" could quietly take it away.
//
// The line it draws: configuring a team, yes; reading what the team owns, no.
// The queue below is the half that stays shut.
func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) {
s := newTS(t)
// A team the admin is deliberately not a member of. It is created by
// somebody else, so the admin's only claim on it is the flag.
_, call := member(t, s, "founder")
var team struct {
ID int64 `json:"id"`
}
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
if team.ID == 0 {
t.Fatal("no team was created")
}
var mine []struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine)
for _, m := range mine {
if m.ID == team.ID {
t.Fatalf("the admin should not be a member of team %d", team.ID)
}
}
path := "/api/teams/" + id64(team.ID)
for _, c := range []struct {
name string
method string
path string
body any
want int
}{
{"rename it", http.MethodPut, path,
map[string]string{"name": "theirs, renamed"}, http.StatusNoContent},
{"mint an invite", http.MethodPost, path + "/invites",
map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated},
{"add a member", http.MethodPost, path + "/members",
map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent},
{"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent},
} {
resp := s.req(t, c.method, c.path, c.body)
resp.Body.Close()
if resp.StatusCode != c.want {
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
}
}
// The other half of the rule. An incident in that team is not the admin's
// to read, because administration is about accounts — and the last case
// above has just taken the admin back out of the membership.
var integration struct {
Key string `json:"key"`
}
decode(t, call(http.MethodPost, path+"/integrations",
map[string]string{"name": "theirs alertmanager"}), &integration)
postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull")
var incidents []struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents)
if len(incidents) != 0 {
t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents))
}
}
// The admin page's per-user view asks what somebody is in. Self or admin, like
// the rest of the per-user endpoints.
func TestUserTeams_SelfOrAdmin(t *testing.T) {
s := newTS(t)
memberID, call := member(t, s, "joiner")
path := "/api/users/" + id64(memberID) + "/teams"
// member() puts them in the default team, so both readings agree on one.
for _, c := range []struct {
name string
do func() *http.Response
}{
{"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }},
{"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }},
} {
var teams []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
}
decode(t, c.do(), &teams)
if len(teams) != 1 {
t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams))
}
if teams[0].Role != "member" {
t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role)
}
}
// Somebody else's is not theirs to read.
otherID, _ := member(t, s, "nosy")
resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode)
}
// A user who does not exist is a 404 rather than an empty list, which is
// how the page tells "no teams" from "no such person".
resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("a missing user: expected 404, got %d", resp.StatusCode)
}
}
// The flag has to reach the client, or the web UI cannot decide what to show. // The flag has to reach the client, or the web UI cannot decide what to show.
func TestAdmin_MeReportsTheFlag(t *testing.T) { func TestAdmin_MeReportsTheFlag(t *testing.T) {
s := newTS(t) s := newTS(t)
+42 -22
View File
@@ -139,6 +139,34 @@ func hashPassword(pw string) (string, error) {
return string(h), err 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. // handleLogin exchanges a username and password for a session cookie.
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc { func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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) limiter.clear(userKey)
raw, tokenHash, err := randomToken() if err := startSession(w, r, db, userID, publicURL); err != nil {
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return 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) user, err := fetchUser(r.Context(), db, userID)
if err != nil { if err != nil {
@@ -243,6 +252,11 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
type meResponse struct { type meResponse struct {
User any `json:"user"` User any `json:"user"`
HasPassword bool `json:"has_password"` 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 // 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 return
} }
var hash sql.NullString var hash sql.NullString
var dismissed *int64
db.QueryRowContext(r.Context(), db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash) "SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid}) caller.ID).Scan(&hash, &dismissed)
respond(w, http.StatusOK, meResponse{
User: user,
HasPassword: hash.Valid,
OnboardingDismissed: dismissed != nil,
})
} }
} }
+24 -1
View File
@@ -15,6 +15,12 @@ import (
// notify disables notifications. Dead man's switches are per team and read from // notify disables notifications. Dead man's switches are per team and read from
// the database, so nothing about them is wired in here. // the database, so nothing about them is wired in here.
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler { 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 := chi.NewRouter()
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) 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. // JSON 404 every unknown /api path gets.
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify)) 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, // Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key. // 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)) r.Post("/api/logout", handleLogout(db, notify.PublicURL))
// All other /api routes require a valid API key. // 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.Use(AuthMiddleware(db))
r.Get("/api/me", handleMe(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 // Readable by anyone signed in: the queue's assignment control and the
// on-call schedule both need to name people. // on-call schedule both need to name people.
@@ -57,6 +74,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// Your own account, or anybody's if you are an admin. The handlers call // Your own account, or anybody's if you are an admin. The handlers call
// requireSelfOrAdmin rather than sitting behind AdminOnly, because // requireSelfOrAdmin rather than sitting behind AdminOnly, because
// which rule applies depends on the {id} in the path. // which rule applies depends on the {id} in the path.
r.Get("/api/users/{id}/teams", handleUserTeams(db))
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db)) r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
r.Put("/api/users/{id}/password", handleSetPassword(db)) r.Put("/api/users/{id}/password", handleSetPassword(db))
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db)) r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
@@ -109,6 +127,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db)) r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(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. // A team's escalation ladder: who is paged when nobody answers.
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db)) r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(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 { type settingsResponse struct {
Editable map[string]settingValue `json:"editable"` Editable map[string]settingValue `json:"editable"`
FromEnv map[string]string `json:"from_env"` 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 { 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) { return func(w http.ResponseWriter, r *http.Request) {
out := settingsResponse{ out := settingsResponse{
Editable: map[string]settingValue{}, 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{ FromEnv: map[string]string{
// Never the ntfy token or the DSN: both are credentials, and an // Never the ntfy token or the DSN: both are credentials, and an
// admin page that renders them turns a browser tab into a place // 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. // sit in the table looking like configuration and doing nothing.
func handleSetSettings(db *sql.DB) http.HandlerFunc { func handleSetSettings(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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 { if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body")) respond(w, http.StatusBadRequest, errResp("invalid request body"))
return return
@@ -147,17 +165,37 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
return return
} }
for key, secs := range req { // Validate everything before writing anything: a request that sets two
b, known := settingBounds[key] // settings and gets one wrong should change neither.
if !known { values := map[string]string{}
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key)) for key, raw := range req {
return switch key {
} case SettingSignupMode:
d := time.Duration(secs) * time.Second mode, _ := raw.(string)
if d < b.min || d > b.max { if mode != SignupOpen && mode != SignupInviteOnly {
respond(w, http.StatusBadRequest, errResp( respond(w, http.StatusBadRequest,
key+" must be between "+b.min.String()+" and "+b.max.String())) errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
return 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 defer tx.Rollback() //nolint:errcheck
for key, secs := range req { for key, value := range values {
if _, err := tx.ExecContext(r.Context(), ` if _, err := tx.ExecContext(r.Context(), `
INSERT INTO settings (key, value, updated_at) INSERT INTO settings (key, value, updated_at)
VALUES ($1, $2, `+nowEpoch+`) VALUES ($1, $2, `+nowEpoch+`)
ON CONFLICT (key) DO UPDATE SET ON CONFLICT (key) DO UPDATE SET
value = excluded.value, updated_at = excluded.updated_at`, 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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return 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)
}
}
+62
View File
@@ -51,6 +51,68 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
} }
} }
// handleUserTeams lists one user's teams, for the admin page's per-user view:
// "what is this person in", which /api/teams cannot answer because it is always
// about the caller.
//
// Self or admin, matching the other per-user endpoints. It says which teams
// somebody belongs to and in what role — not anything those teams own, so it
// stays on the accounts side of the line the administrator flag draws.
func handleUserTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
// A user with no teams and a user who does not exist both list nothing,
// so the existence check is what tells them apart.
var exists bool
if err := db.QueryRowContext(r.Context(),
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if !exists {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
rows, err := db.QueryContext(r.Context(), `
SELECT t.id, t.name, t.created_at, m.role
FROM teams t
JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = $1
ORDER BY t.name`, id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
teams := []models.Team{}
for rows.Next() {
var t models.Team
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
t.CreatedAt = time.Unix(created, 0).UTC()
teams = append(teams, t)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, teams)
}
}
// handleCreateTeam creates a team and makes its creator the first owner. A team // handleCreateTeam creates a team and makes its creator the first owner. A team
// with no owner would need an administrator to repair before anybody could use // with no owner would need an administrator to repair before anybody could use
// it, so the two happen in one transaction. // it, so the two happen in one transaction.
@@ -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;
+80 -2
View File
@@ -199,7 +199,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
/* Bottom tab bar on phones. */ /* Bottom tab bar on phones. */
.nav { .nav {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
display: grid; grid-template-columns: repeat(4, 1fr); /* One column per link, however many there are. This was repeat(4, 1fr) when
there were four tabs; Team and Admin arriving pushed six items into four
columns, which on a phone is how they stopped fitting. Auto columns mean
the next tab cannot break the row either — and Admin is only rendered for
an administrator, so the count genuinely varies between viewers. */
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
height: calc(var(--tabbar-h) + var(--safe-bottom)); height: calc(var(--tabbar-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom); padding-bottom: var(--safe-bottom);
background: color-mix(in srgb, var(--surface) 92%, transparent); background: color-mix(in srgb, var(--surface) 92%, transparent);
@@ -212,8 +217,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
position: relative; position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600; color: var(--faint); font-size: 11px; font-weight: 600;
/* min-width lets a column shrink below its label's natural width, which is
what stops six tabs widening the bar past the screen. */
min-width: 0; padding: 0 2px;
} }
.nav-link svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .nav-label {
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
the widest label, "On-call", is about 38px at this size, so nothing has to
be hidden. The ellipsis above is the backstop if a future tab is named
something longer. */
@media (max-width: 420px) {
.nav-link { font-size: 10px; gap: 1px; }
.nav-link svg { width: 21px; height: 21px; }
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
}
.nav-link[aria-current="page"] { color: var(--accent); } .nav-link[aria-current="page"] { color: var(--accent); }
.nav-badge { .nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px); position: absolute; top: 6px; left: calc(50% + 6px);
@@ -658,6 +680,42 @@ kbd {
.admin-settings button[type="submit"] { margin-top: 12px; } .admin-settings button[type="submit"] { margin-top: 12px; }
.small { font-size: 13px; } .small { font-size: 13px; }
/* The name in the user list is the way to that person's page. */
.user-link { color: var(--text); font-weight: 650; text-decoration: none; }
.user-link:hover { color: var(--accent); text-decoration: underline; }
.invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; }
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
/* The link is shown once and never stored, so it has to be selectable and
wrap rather than scroll off the side of a phone. */
.invite-out { margin-top: 12px; font-size: 13px; }
.invite-link {
display: block; margin-top: 6px; padding: 8px; border-radius: var(--radius-sm);
background: var(--surface-2); font-family: var(--mono); font-size: 12px;
word-break: break-all; user-select: all;
}
/* --- one user ------------------------------------------------------------ */
.back-link {
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
color: var(--muted); font-size: 14px; text-decoration: none;
}
.back-link:hover { color: var(--text); }
.back-link svg { width: 18px; height: 18px; }
.user-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.user-head h2 { margin: 0; }
.user-facts {
display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px;
margin: 12px 0 0; font-size: 14px;
}
.user-facts dt { color: var(--muted); }
.user-facts dd { margin: 0; overflow-wrap: anywhere; }
.row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.admin-table .row-actions { margin-top: 0; gap: 6px; }
/* --- team settings ------------------------------------------------------- /* --- team settings -------------------------------------------------------
Forms with a label above each control, rather than the queue's rows of Forms with a label above each control, rather than the queue's rows of
links. The escalation ladder is the only nested structure in the app, so it links. The escalation ladder is the only nested structure in the app, so it
@@ -687,3 +745,23 @@ kbd {
border-radius: 6px; padding: 8px; font-size: 12px; border-radius: 6px; padding: 8px; font-size: 12px;
} }
.key-url code { word-break: break-all; } .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); }
+40 -6
View File
@@ -37,6 +37,37 @@
<button class="btn btn-primary btn-block" type="submit">Sign in</button> <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 <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> <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> </form>
</main> </main>
@@ -46,31 +77,31 @@
<img src="/icon.svg" alt="" width="28" height="28"> <img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span> <span>terdut</span>
</a> </a>
<a class="nav-link" href="/" data-section="queue"> <a class="nav-link" href="/" data-section="queue" aria-label="Queue">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="nav-label">Queue</span> <span class="nav-label">Queue</span>
<span class="nav-badge" data-badge hidden></span> <span class="nav-badge" data-badge hidden></span>
</a> </a>
<a class="nav-link" href="/oncall" data-section="oncall"> <a class="nav-link" href="/oncall" data-section="oncall" aria-label="On-call">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
<span class="nav-label">On-call</span> <span class="nav-label">On-call</span>
</a> </a>
<a class="nav-link" href="/alerts" data-section="alerts"> <a class="nav-link" href="/alerts" data-section="alerts" aria-label="Alerts">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
<span class="nav-label">Alerts</span> <span class="nav-label">Alerts</span>
</a> </a>
<a class="nav-link" href="/team" data-section="team"> <a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span> <span class="nav-label">Team</span>
</a> </a>
<!-- Hidden unless the signed-in user is a system administrator; app.js <!-- Hidden unless the signed-in user is a system administrator; app.js
unhides it once /api/me says so. The server refuses every admin unhides it once /api/me says so. The server refuses every admin
endpoint regardless, so this is a courtesy and not a gate. --> endpoint regardless, so this is a courtesy and not a gate. -->
<a class="nav-link" href="/admin" data-section="admin" id="nav-admin" hidden> <a class="nav-link" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
<span class="nav-label">Admin</span> <span class="nav-label">Admin</span>
</a> </a>
<a class="nav-link" href="/more" data-section="more"> <a class="nav-link" href="/more" data-section="more" aria-label="Account">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
<span class="nav-label">Account</span> <span class="nav-label">Account</span>
</a> </a>
@@ -93,6 +124,9 @@
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section> <section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
<section id="view-team" class="view view-page" data-view="team" hidden></section> <section id="view-team" class="view view-page" data-view="team" hidden></section>
<section id="view-admin" class="view view-page" data-view="admin" hidden></section> <section id="view-admin" class="view view-page" data-view="admin" hidden></section>
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
list, and a section of its own so a deep link survives a reload. -->
<section id="view-adminuser" class="view view-page" data-view="adminuser" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section> <section id="view-more" class="view view-page" data-view="more" hidden></section>
</div> </div>
+56 -2
View File
@@ -155,7 +155,9 @@ function usersCard() {
const self = u.id === myID(); const self = u.id === myID();
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' }, return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
h('td', {}, h('td', {},
h('strong', { text: u.username }), // The name is the way in: everything about one person lives on their
// own page, and this table stays a list rather than becoming a form.
h('a', { class: 'user-link', href: `/admin/users/${u.id}`, text: u.username }),
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }), u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })), self && h('span', { class: 'you', text: 'you' })),
h('td', { class: 'muted', text: u.email }), h('td', { class: 'muted', text: u.email }),
@@ -184,7 +186,8 @@ function usersCard() {
h('h2', { text: 'Users' }), h('h2', { text: 'Users' }),
h('p', { class: 'muted small' }, h('p', { class: 'muted small' },
'Disabling an account stops it signing in and stops its API keys, and keeps ', 'Disabling an account stops it signing in and stops its API keys, and keeps ',
'its acknowledgements and timeline entries. Deleting a user erases those.'), 'its acknowledgements and timeline entries. Deleting a user erases those. ',
'Open a name for their teams, their password and the rest.'),
h('table', { class: 'admin-table' }, h('table', { class: 'admin-table' },
h('thead', {}, h('tr', {}, h('thead', {}, h('tr', {},
h('th', { text: 'User' }), h('th', { text: 'User' }),
@@ -192,6 +195,57 @@ function usersCard() {
h('th', { text: '' }), h('th', { text: '' }),
h('th', { text: '' }))), h('th', { text: '' }))),
h('tbody', {}, rows)), h('tbody', {}, rows)),
inviteForm(),
);
}
// Adding a person is minting them an invite, not creating a row. The account
// is created by whoever accepts it, so they pick their own password and it
// never passes through an administrator — and the link carries the team, which
// a bare POST /api/users cannot, leaving an account with nothing to work on.
//
// Minting for a team the administrator is not in is allowed: the flag passes
// every team-owner check, so a server administrator can staff any team. The
// link shows up in that team's own invite list, where an owner can revoke it.
function inviteForm() {
const team = h('select', {},
...data.teams.map((t) => h('option', { value: String(t.id), text: t.name })));
const role = h('select', {},
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const out = h('p', { class: 'invite-out', hidden: true });
const form = h('form', { class: 'inline-form' }, team, role,
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
busy = true;
try {
const inv = await api.createInvite(Number(team.value), role.value, 1);
// Shown once and never stored, so it is put on the page to be copied
// rather than toasted away after three seconds.
clear(out, h('strong', { text: 'Send them this link. It is shown once.' }),
h('code', { class: 'invite-link', text: inv.url }));
out.hidden = false;
error = null;
} catch (err) {
error = err.message;
render();
return;
} finally {
busy = false;
}
});
return h('div', { class: 'invite-block' },
h('h3', { text: 'Add someone' }),
h('p', { class: 'muted small' },
'An invite link puts them in a team and lets them choose their own ',
'password. It lasts a week and can be used once.'),
data.teams.length > 0 ? form
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
out,
); );
} }
+294
View File
@@ -0,0 +1,294 @@
// One person, at /admin/users/{id}: what they are, what they are in, and the
// levers an administrator has over the account.
//
// A section of its own rather than an expanding row in the Admin tab's table,
// because memberships and the account actions together are more than a row can
// hold and still be read on a phone.
//
// Like the Admin tab, this hides nothing the server would allow and shows
// nothing it would refuse: every write here is an endpoint that answers 403
// without the flag, so the view is a description of the rules rather than an
// enforcement of them.
import * as api from './api.js';
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
import { state, myID } from './state.js';
import { navigate } from './app.js';
import { when } from './format.js';
const view = () => document.getElementById('view-adminuser');
let userID = null;
let data = null; // { user, teams, allTeams }
let error = null;
let busy = false;
export function show(route) {
const next = route && route.user != null ? route.user : null;
if (next !== userID) {
userID = next;
data = null;
error = null;
}
if (!data) clear(view(), spinner());
refresh();
}
export async function refresh() {
if (userID == null || !state.me?.user?.is_admin) {
render();
return;
}
try {
// The user comes from the list rather than a show endpoint: there is no
// GET /api/users/{id}, and adding one for a row the list already carries
// would be a second way to say the same thing.
const [users, teams, allTeams] = await Promise.all([
api.users(),
api.userTeams(userID),
api.adminTeams(),
]);
const user = users.find((u) => u.id === userID) || null;
data = { user, teams, allTeams };
error = null;
} catch (err) {
error = err.message;
}
render();
}
function render() {
const el = view();
if (!state.me?.user?.is_admin) {
clear(el, backLink(), h('div', { class: 'card' },
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
return;
}
if (!data) {
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
if (!data.user) {
clear(el, backLink(), h('div', { class: 'card' },
h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' })));
return;
}
clear(el,
backLink(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
identityCard(),
teamsCard(),
accountCard(),
);
}
function backLink() {
return h('a', { class: 'back-link', href: '/admin' }, icon('chevronLeft'), h('span', { text: 'Admin' }));
}
// --- identity --------------------------------------------------------------
function identityCard() {
const u = data.user;
const self = u.id === myID();
return h('div', { class: 'card' },
h('div', { class: 'user-head' },
h('h2', { text: u.username }),
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })),
h('dl', { class: 'user-facts' },
fact('Email', u.email),
fact('Joined', when(u.created_at)),
fact('Notifications', u.ntfy_topic ? `ntfy: ${u.ntfy_topic}` : 'None of their own'),
u.disabled_at && fact('Disabled', when(u.disabled_at)),
),
// Both of these refuse your own account, and the last administrator's. An
// enabled button that always fails is worse than no button.
h('div', { class: 'row-actions' },
!self && h('button', {
class: 'btn', type: 'button',
text: u.is_admin ? 'Revoke admin' : 'Make admin',
onclick: () => setAdmin(!u.is_admin),
}),
!self && h('button', {
class: 'btn', type: 'button',
text: u.disabled_at ? 'Enable account' : 'Disable account',
onclick: () => setDisabled(!u.disabled_at),
}),
),
self && h('p', { class: 'muted small' },
'You cannot change your own administrator flag or disable yourself — ',
'that is how an install ends up with nobody who can administer it.'),
);
}
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
async function setAdmin(next) {
if (next && !(await confirm({
title: `Make ${data.user.username} an administrator?`,
text: 'They will be able to manage every account, configure any team, and grant this to others.',
confirmLabel: 'Make admin',
}))) return;
await act(() => api.setUserAdmin(userID, next));
}
async function setDisabled(next) {
if (next && !(await confirm({
title: `Disable ${data.user.username}?`,
text: 'They cannot sign in and their API keys stop working. Their acknowledgements and timeline entries stay.',
confirmLabel: 'Disable',
danger: true,
}))) return;
await act(() => api.setUserDisabled(userID, next));
}
// --- teams -----------------------------------------------------------------
// An administrator passes every team-owner check without being in the team,
// which is what lets them repair a team whose owner has left. So this card
// edits, rather than reporting what somebody else would have to do.
//
// It is the one place membership can be changed from the person's side: the
// Team tab asks "who is in this team", and answering "which teams is this
// person in" there means visiting each team in turn.
function teamsCard() {
const rows = data.teams.map((t) =>
h('tr', {},
// Not a link: the Team tab always shows the viewer's own team, so
// sending them there from somebody else's membership would be a lie.
h('td', {}, h('strong', { text: t.name })),
h('td', { class: 'muted small', text: t.role }),
h('td', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button',
text: t.role === 'owner' ? 'Make member' : 'Make owner',
onclick: () => act(() =>
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
// The server refuses the last owner with a 409, which act() shows.
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
}),
),
));
const inTeam = new Set(data.teams.map((t) => t.id));
const candidates = (data.allTeams || []).filter((t) => !inTeam.has(t.id));
const pick = h('select', {},
...candidates.map((t) => h('option', { value: String(t.id), text: t.name })));
const role = h('select', {},
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
});
return h('div', { class: 'card' },
h('h2', { text: 'Teams' }),
data.teams.length === 0 && h('p', { class: 'muted small' },
'In no team. They can sign in, but there is no queue for them to work ',
'and nothing to page them about.'),
data.teams.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
candidates.length > 0 && form,
);
}
// --- account ---------------------------------------------------------------
function accountCard() {
const u = data.user;
const self = u.id === myID();
const pw = h('input', {
type: 'password', name: 'password', autocomplete: 'new-password',
minlength: '10', required: true, placeholder: 'At least 10 characters',
});
const form = h('form', { class: 'inline-form' }, pw,
h('button', { class: 'btn', type: 'submit', text: 'Set password' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
busy = true;
try {
// No current password: that check is for changing your own, and an
// administrator setting somebody else's does not know it by design.
await api.setPassword(userID, pw.value);
pw.value = '';
toast(`Password set for ${u.username}. Their other sessions are signed out.`);
error = null;
} catch (err) {
error = err.message;
} finally {
busy = false;
}
await refresh();
});
return h('div', { class: 'card' },
h('h2', { text: 'Account' }),
h('p', { class: 'muted small' },
'Setting a password here is how somebody gets their first one, or a new ',
'one after forgetting it. It signs them out everywhere else. They change ',
'it themselves under Account afterwards.'),
self ? h('p', { class: 'muted small' },
'Change your own password under Account, where the current one is asked for.')
: form,
h('h3', { text: 'Delete' }),
h('p', { class: 'muted small' },
'Deleting erases their acknowledgements and timeline entries — incidents ',
'they handled stop saying who did. Disabling keeps the history and is ',
'almost always what is meant.'),
h('button', {
class: 'btn btn-danger', type: 'button', text: `Delete ${u.username}`,
disabled: self,
title: self ? 'You cannot delete your own account' : '',
onclick: deleteUser,
}),
);
}
async function deleteUser() {
if (!(await confirm({
title: `Delete ${data.user.username}?`,
text: 'Their API keys go with them, and their name comes off every incident they acknowledged. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
}))) return;
try {
await api.deleteUser(userID);
} catch (err) {
error = err.message;
render();
return;
}
toast('User deleted.');
navigate('/admin');
}
// --- plumbing --------------------------------------------------------------
// act runs a write and reloads. Errors are shown rather than thrown away: the
// 409 from the last-owner or last-administrator guard is the server explaining
// itself, and the reader needs to see it.
async function act(fn) {
if (busy) return;
busy = true;
try {
await fn();
error = null;
} catch (err) {
error = err.message;
} finally {
busy = false;
}
await refresh();
}
+19
View File
@@ -67,6 +67,10 @@ export const setPassword = (userID, password, currentPassword) =>
// users // users
export const users = () => call('GET', '/users'); export const users = () => call('GET', '/users');
// What one person is in. /teams answers "what am I in" and cannot be asked
// about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
// incidents // incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts }); export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const incident = (id) => call('GET', `/incidents/${id}`); export const incident = (id) => call('GET', `/incidents/${id}`);
@@ -87,6 +91,20 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts }); export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
// schedule // 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 teams = () => call('GET', '/teams');
export const createTeam = (name) => call('POST', '/teams', { body: { name } }); export const createTeam = (name) => call('POST', '/teams', { body: { name } });
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } }); export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
@@ -125,6 +143,7 @@ export const setUserAdmin = (id, isAdmin) =>
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } }); call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
export const setUserDisabled = (id, disabled) => export const setUserDisabled = (id, disabled) =>
call('PUT', `/users/${id}/disabled`, { body: { disabled } }); call('PUT', `/users/${id}/disabled`, { body: { disabled } });
export const deleteUser = (id) => call('DELETE', `/users/${id}`);
export const schedule = (teamID, from, to) => export const schedule = (teamID, from, to) =>
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } }); call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
+105 -3
View File
@@ -11,22 +11,28 @@ import * as alerts from './alerts.js';
import * as account from './account.js'; import * as account from './account.js';
import * as team from './team.js'; import * as team from './team.js';
import * as admin from './admin.js'; import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
// One route per section; /incidents/{id} is the queue with a detail open. // One route per section; /incidents/{id} is the queue with a detail open, and
// /admin/users/{id} is a section of its own rather than a mode of the Admin
// tab, because it replaces the page rather than opening beside it.
const SECTIONS = { const SECTIONS = {
queue: { title: 'Queue', view: queue }, queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall }, oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts }, alerts: { title: 'Alerts', view: alerts },
team: { title: 'Team', view: team }, team: { title: 'Team', view: team },
admin: { title: 'Admin', view: admin }, admin: { title: 'Admin', view: admin },
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
more: { title: 'Account', view: account }, more: { title: 'Account', view: account },
}; };
function parseRoute(pathname) { function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/); const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) }; if (m) return { section: 'queue', incident: Number(m[1]) };
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
if (u) return { section: 'adminuser', user: Number(u[1]) };
const name = pathname.replace(/^\/|\/$/g, ''); const name = pathname.replace(/^\/|\/$/g, '');
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name }; if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
return { section: 'queue', incident: null }; return { section: 'queue', incident: null };
@@ -69,8 +75,11 @@ function render() {
el.hidden = name !== route.section; el.hidden = name !== route.section;
if (name === route.section) $('topbar-title').textContent = s.title; if (name === route.section) $('topbar-title').textContent = s.title;
} }
// A section may light up somebody else's tab: /admin/users/{id} is still the
// Admin tab as far as the nav is concerned, since there is no tab of its own.
const current = SECTIONS[route.section].nav || route.section;
for (const link of document.querySelectorAll('.nav-link')) { for (const link of document.querySelectorAll('.nav-link')) {
if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page'); if (link.dataset.section === current) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current'); else link.removeAttribute('aria-current');
} }
@@ -85,7 +94,7 @@ function render() {
incident.show(route.incident); incident.show(route.incident);
} else { } else {
incident.show(null); incident.show(null);
SECTIONS[route.section].view.show(); SECTIONS[route.section].view.show(route);
} }
if (detailOpen && !wasOpen) window.scrollTo(0, 0); if (detailOpen && !wasOpen) window.scrollTo(0, 0);
@@ -142,6 +151,14 @@ async function boot() {
document.addEventListener('click', interceptLinks); document.addEventListener('click', interceptLinks);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin); $('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 { try {
state.me = await api.me(); state.me = await api.me();
@@ -161,6 +178,84 @@ function showBootError(err) {
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' })); $('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() { function showLogin() {
poll.stop(); poll.stop();
ui.closeSheet(null); ui.closeSheet(null);
@@ -168,8 +263,15 @@ function showLogin() {
$('boot').hidden = true; $('boot').hidden = true;
$('app').hidden = true; $('app').hidden = true;
$('login').hidden = false; $('login').hidden = false;
$('signup-form').hidden = true;
$('login-form').hidden = false;
const form = $('login-form'); const form = $('login-form');
form.querySelector('.form-error').hidden = true; 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.password.value = '';
(form.username.value ? form.password : form.username).focus(); (form.username.value ? form.password : form.username).focus();
} }
+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 { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js'; import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js'; import { state, myID } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js'; import { navigate } from './app.js';
// The same filters as the TUI's `f` cycle, plus archived ones to get back to. // 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', ''], archived: ['Nothing archived', ''],
}; };
onboarding.onRerender(() => renderList());
let filter = loadFilter(); let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading 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 query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter; const cached = filter === 'open' && !fresh && !teamFilter;
const result = cached ? state.open : await api.incidents(query); const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return; if (requested !== filter) return;
items = result; items = result;
error = null; error = null;
@@ -153,20 +157,22 @@ function renderChips() {
function renderList() { function renderList() {
const el = document.getElementById('queue-list'); const el = document.getElementById('queue-list');
const checklist = onboarding.card();
if (error && !items) { if (error && !items) {
clear(el, h('div', { class: 'load-error', text: error })); clear(el, checklist, h('div', { class: 'load-error', text: error }));
return; return;
} }
if (!items) { if (!items) {
clear(el, spinner()); clear(el, checklist, spinner());
return; return;
} }
if (!items.length) { if (!items.length) {
const [title, text] = EMPTY[filter]; 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; return;
} }
clear(el, clear(el,
checklist,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map((inc, i) => row(inc, i)), items.map((inc, i) => row(inc, i)),
); );