Compare commits

...

5 Commits

Author SHA1 Message Date
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
14 changed files with 1266 additions and 43 deletions
+25 -1
View File
@@ -550,6 +550,25 @@ granting the flag itself. Everybody else works incidents — acknowledging,
assigning, snoozing, resolving, noting — and manages their own account and
nobody else's. An API key carries exactly the rights of the user it belongs to.
**Getting an account.** The first one comes from `/api/bootstrap`. After that
it depends on `signup_mode`, an administrator setting:
- `invite_only` (the default) — a team owner mints a link with
`POST /api/teams/{teamID}/invites`, and the person who opens it picks a
username and password and lands in that team with the role the link carries.
Links are single-use unless told otherwise, expire after seven days, and can
be revoked before that.
- `open` — anybody who can reach the server can create an account, and must
name a team, which they then own.
Invites are **links, not email**: this server has no SMTP, and adding it to send
one message would be a subsystem to run, secure and monitor. Send the link
however you already talk to the person.
A domain-restricted third mode was considered and dropped: with no email there
is nothing to verify an address against, so it would only check the domain of a
string somebody typed.
The first user, from `/api/bootstrap`, is an administrator. Users created
afterwards are not, until an administrator says so. An install always keeps at
least one: the last administrator can be neither deleted nor demoted, and
@@ -584,6 +603,8 @@ on anybody's.
| Method | Path | Who | Description |
|---|---|---|---|
| `GET` | `/api/signup` | — | Whether sign-up is open, and whether `?invite=` is usable. No session needed: the caller has no account yet |
| `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only |
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
@@ -601,7 +622,7 @@ on anybody's.
|---|---|---|---|
| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" |
| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials |
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`. `400` for an unknown key or a value outside its bounds |
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds |
### Alert ingestion
@@ -632,6 +653,9 @@ and was removed in v0.13.0 once senders had moved onto keys.
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.14.0
appVersion: "v0.14.0"
version: 0.15.0
appVersion: "v0.15.0"
+42 -22
View File
@@ -139,6 +139,34 @@ func hashPassword(pw string) (string, error) {
return string(h), err
}
// startSession mints a session and sets the cookie. Shared by login and
// sign-up: somebody who has just chosen a password is signed in, rather than
// being sent to a form to type the same credential again.
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
raw, tokenHash, err := randomToken()
if err != nil {
return err
}
now := time.Now()
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
return nil
}
// handleLogin exchanges a username and password for a session cookie.
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -182,29 +210,10 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
}
limiter.clear(userKey)
raw, tokenHash, err := randomToken()
if err != nil {
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
now := time.Now()
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
user, err := fetchUser(r.Context(), db, userID)
if err != nil {
@@ -243,6 +252,11 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
type meResponse struct {
User any `json:"user"`
HasPassword bool `json:"has_password"`
// OnboardingDismissed is whether this person has put the first-run
// checklist away. Per user rather than per browser: somebody who finishes
// setting up on a laptop should not be nagged again on their phone.
OnboardingDismissed bool `json:"onboarding_dismissed"`
}
// handleMe says who the caller is. The web UI calls it on load to decide
@@ -256,9 +270,15 @@ func handleMe(db *sql.DB) http.HandlerFunc {
return
}
var hash sql.NullString
var dismissed *int64
db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash)
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid})
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
caller.ID).Scan(&hash, &dismissed)
respond(w, http.StatusOK, meResponse{
User: user,
HasPassword: hash.Valid,
OnboardingDismissed: dismissed != nil,
})
}
}
+23 -1
View File
@@ -15,6 +15,12 @@ import (
// notify disables notifications. Dead man's switches are per team and read from
// the database, so nothing about them is wired in here.
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
// One limiter each, both process-wide for the life of the router: login
// counts failed passwords, sign-up counts account creation, and mixing the
// two would let a burst of sign-ups lock somebody out of logging in.
loginLimit := newLoginLimiter()
signupLimiter := newLoginLimiter()
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -39,9 +45,16 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// JSON 404 every unknown /api path gets.
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
// Signing up. Both are unauthenticated by necessity: the caller has no
// account yet. The info endpoint says whether the door is open and whether
// an invite link is good, so the form can say so before somebody picks a
// password.
r.Get("/api/signup", handleSignupInfo(db))
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
// Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key.
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL))
r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
// All other /api routes require a valid API key.
@@ -49,6 +62,10 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Use(AuthMiddleware(db))
r.Get("/api/me", handleMe(db))
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
// Proves the topic works, which is the only part of "notifications are
// set up" that the person holding the phone can confirm.
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
// Readable by anyone signed in: the queue's assignment control and the
// on-call schedule both need to name people.
@@ -109,6 +126,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
// Invite links into this team.
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
// A team's escalation ladder: who is paged when nobody answers.
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
+52 -14
View File
@@ -90,6 +90,16 @@ func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
type settingsResponse struct {
Editable map[string]settingValue `json:"editable"`
FromEnv map[string]string `json:"from_env"`
// Choices are settings that are a word from a fixed list rather than a
// duration. One so far: who may create an account.
Choices map[string]choiceValue `json:"choices"`
}
type choiceValue struct {
Value string `json:"value"`
Options []string `json:"options"`
Description string `json:"description"`
}
type settingValue struct {
@@ -104,6 +114,14 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := settingsResponse{
Editable: map[string]settingValue{},
Choices: map[string]choiceValue{
SettingSignupMode: {
Value: signupMode(r.Context(), db),
Options: []string{SignupInviteOnly, SignupOpen},
Description: "who may create an account: invite_only means a link from a team owner, " +
"open means anybody who can reach this server",
},
},
FromEnv: map[string]string{
// Never the ntfy token or the DSN: both are credentials, and an
// admin page that renders them turns a browser tab into a place
@@ -137,7 +155,7 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
// sit in the table looking like configuration and doing nothing.
func handleSetSettings(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req map[string]int64
var req map[string]any
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
@@ -147,17 +165,37 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
return
}
for key, secs := range req {
b, known := settingBounds[key]
if !known {
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
return
}
d := time.Duration(secs) * time.Second
if d < b.min || d > b.max {
respond(w, http.StatusBadRequest, errResp(
key+" must be between "+b.min.String()+" and "+b.max.String()))
return
// Validate everything before writing anything: a request that sets two
// settings and gets one wrong should change neither.
values := map[string]string{}
for key, raw := range req {
switch key {
case SettingSignupMode:
mode, _ := raw.(string)
if mode != SignupOpen && mode != SignupInviteOnly {
respond(w, http.StatusBadRequest,
errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
return
}
values[key] = mode
default:
b, known := settingBounds[key]
if !known {
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
return
}
secs, ok := raw.(float64) // JSON numbers decode as float64
if !ok {
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
return
}
d := time.Duration(int64(secs)) * time.Second
if d < b.min || d > b.max {
respond(w, http.StatusBadRequest, errResp(
key+" must be between "+b.min.String()+" and "+b.max.String()))
return
}
values[key] = strconv.FormatInt(int64(secs), 10)
}
}
@@ -168,13 +206,13 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
}
defer tx.Rollback() //nolint:errcheck
for key, secs := range req {
for key, value := range values {
if _, err := tx.ExecContext(r.Context(), `
INSERT INTO settings (key, value, updated_at)
VALUES ($1, $2, `+nowEpoch+`)
ON CONFLICT (key) DO UPDATE SET
value = excluded.value, updated_at = excluded.updated_at`,
key, strconv.FormatInt(secs, 10)); err != nil {
key, value); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
+489
View File
@@ -0,0 +1,489 @@
package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// SettingSignupMode says who may create an account. It lives in the settings
// table with the other behaviour settings, so an administrator changes it in
// the admin page rather than in a chart.
//
// Two modes, not three. A domain-restricted mode was considered and dropped:
// with no email in this server there is nothing to verify an address against,
// so it would check the domain of a string somebody typed — a speed bump
// dressed as a control.
const (
SettingSignupMode = "signup_mode"
SignupInviteOnly = "invite_only"
SignupOpen = "open"
)
// defaultSignupMode is invite-only. An install that gets a public hostname
// before anybody has thought about sign-up should not be collecting accounts
// from the internet by default.
const defaultSignupMode = SignupInviteOnly
// inviteTTL is how long a new invite link lives. Long enough to send it and be
// read tomorrow, short enough that a link in an old chat log stops working.
const inviteTTL = 7 * 24 * time.Hour
// signupMode reads the current mode, falling back to invite-only for a missing
// or unrecognised value: the failure mode of a typo in this setting should be
// the closed door, not the open one.
func signupMode(ctx context.Context, db *sql.DB) string {
var raw string
if err := db.QueryRowContext(ctx,
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
return defaultSignupMode
}
if raw != SignupOpen && raw != SignupInviteOnly {
return defaultSignupMode
}
return raw
}
// handleSignupInfo tells the sign-up page what it may offer, without requiring
// a session: whether open sign-up is on, and whether the invite in the URL is
// any good. A bad invite is better reported before somebody picks a password.
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := map[string]any{"mode": signupMode(r.Context(), db)}
if token := r.URL.Query().Get("invite"); token != "" {
inv, err := loadInvite(r.Context(), db, token)
switch {
case err == nil:
out["invite_valid"] = true
out["invite_team"] = inv.teamName
default:
// Deliberately one answer for expired, revoked, used up and
// never existed. Telling a stranger which it was tells them
// something about links they do not hold.
out["invite_valid"] = false
}
}
respond(w, http.StatusOK, out)
}
}
type invite struct {
id int64
teamID int64
teamName string
role string
}
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
// it exists, has not been revoked, has not expired and has uses left.
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
var inv invite
err := q.QueryRowContext(ctx, `
SELECT i.id, i.team_id, t.name, i.role
FROM invites i
JOIN teams t ON t.id = i.team_id
WHERE i.token_hash = $1
AND i.revoked_at IS NULL
AND i.expires_at > `+nowEpoch+`
AND i.uses < i.max_uses`, hashToken(token)).
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
if errors.Is(err, sql.ErrNoRows) {
return invite{}, errInviteUnusable
}
return inv, err
}
var errInviteUnusable = errors.New("invite is not usable")
// handleSignup creates an account, and puts it somewhere.
//
// Rate-limited on the same limiter as login, by address: sign-up is the other
// unauthenticated endpoint that writes, and an open install without this is a
// way to fill somebody's user table.
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addr := clientAddr(r)
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
TeamName string `json:"team_name"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Username = strings.TrimSpace(req.Username)
req.Email = strings.TrimSpace(req.Email)
req.TeamName = strings.TrimSpace(req.TeamName)
if req.Username == "" || req.Email == "" {
respond(w, http.StatusBadRequest, errResp("username and email are required"))
return
}
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
mode := signupMode(r.Context(), db)
var inv invite
hasInvite := false
if req.Invite != "" {
var err error
inv, err = loadInvite(r.Context(), db, req.Invite)
if err != nil {
limiter.fail("signup:" + addr)
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return
}
hasInvite = true
}
if !hasInvite && mode != SignupOpen {
// No invite and the door is shut. Not 404: the endpoint exists and
// saying so is how somebody knows to ask for a link.
respond(w, http.StatusForbidden,
errResp("sign-up is invite-only on this server"))
return
}
if !hasInvite && req.TeamName == "" {
// Open sign-up with no team would create an account that sees an
// empty queue and can be paged by nobody.
respond(w, http.StatusBadRequest, errResp("team_name is required"))
return
}
hash, err := hashPassword(req.Password)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
var userID int64
var invitedVia *int64
if hasInvite {
invitedVia = &inv.id
}
if err := tx.QueryRowContext(r.Context(), `
INSERT INTO users (username, email, password_hash, invited_via)
VALUES ($1, $2, $3, $4) RETURNING id`,
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("username or email already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
teamID, role := inv.teamID, inv.role
if !hasInvite {
// Open sign-up makes a team, and its creator owns it.
if err := tx.QueryRowContext(r.Context(),
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
role = models.RoleOwner
}
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
teamID, userID, role); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if hasInvite {
// Counted inside the transaction, so two people redeeming the last
// use of a link at once cannot both get in.
res, err := tx.ExecContext(r.Context(),
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return
}
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
// Signed in immediately: the alternative is a form that says "now go
// and log in", which is the same credential typed twice.
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, _ := fetchUser(r.Context(), db, userID)
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
}
}
// maxSignupsPerAddr is looser than the login limit: several people joining from
// one office share an address, and the thing being limited is account creation
// rather than password guessing.
const maxSignupsPerAddr = 10
// ---------------------------------------------------------------------------
// Invites
// ---------------------------------------------------------------------------
type inviteJSON struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int64 `json:"max_uses"`
Uses int64 `json:"uses"`
Revoked bool `json:"revoked"`
// URL is the whole link, returned once when the invite is created. Like an
// integration key, only its hash is stored.
URL string `json:"url,omitempty"`
}
func handleListInvites(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
rows, err := db.QueryContext(r.Context(), `
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
FROM invites
WHERE team_id = $1
ORDER BY id DESC`, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
out := []inviteJSON{}
for rows.Next() {
var i inviteJSON
var created, expires int64
var revoked *int64
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
&i.MaxUses, &i.Uses, &revoked); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
i.Revoked = revoked != nil
out = append(out, i)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, out)
}
}
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
// a team's configuration: deciding who joins is configuring the team.
func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
var req struct {
Role string `json:"role"`
MaxUses int64 `json:"max_uses"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Role == "" {
req.Role = models.RoleMember
}
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
return
}
if req.MaxUses == 0 {
req.MaxUses = 1
}
if req.MaxUses < 1 || req.MaxUses > 100 {
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
return
}
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
caller, _ := userFromContext(r.Context())
expires := time.Now().Add(inviteTTL)
var out inviteJSON
var created, expiresAt int64
if err := db.QueryRowContext(r.Context(), `
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
hash, teamID, req.Role, caller.ID, expires.Unix(), req.MaxUses).
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
out.CreatedAt = time.Unix(created, 0).UTC()
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
respond(w, http.StatusCreated, out)
}
}
// handleRevokeInvite stops a link working without waiting for it to expire.
func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
return
}
res, err := db.ExecContext(r.Context(),
"UPDATE invites SET revoked_at = "+nowEpoch+
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// ---------------------------------------------------------------------------
// Onboarding
// ---------------------------------------------------------------------------
// handleTestNotification publishes one push to the caller's own topic.
//
// The point of the first-run checklist's notification step is not that a topic
// string has been typed but that a phone buzzes, and only the person holding it
// can tell whether it did. Published directly rather than through the outbox:
// the outbox row requires an incident, and this deliberately belongs to no
// incident.
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.BaseURL == "" {
respond(w, http.StatusServiceUnavailable,
errResp("this server has no ntfy configured, so it can send nothing"))
return
}
caller, _ := userFromContext(r.Context())
var topic *string
if err := db.QueryRowContext(r.Context(),
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if topic == nil || *topic == "" {
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
return
}
if err := publish(r.Context(), cfg, ntfyMessage{
Topic: *topic,
Title: "terdut test",
Message: "If this arrived, your notifications work.",
Tags: []string{"white_check_mark"},
}); err != nil {
// The failure is the useful part here: a wrong topic, a token the
// ntfy server rejects, or an ntfy that is down all look the same
// from the phone, which is silence.
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDismissOnboarding hides the first-run checklist, or brings it back.
// Stored per user rather than in the browser: somebody who finishes setting up
// on a laptop should not be nagged again on their phone.
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Dismissed *bool `json:"dismissed"`
}
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
return
}
caller, _ := userFromContext(r.Context())
var err error
if *req.Dismissed {
_, err = db.ExecContext(r.Context(),
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
} else {
_, err = db.ExecContext(r.Context(),
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+270
View File
@@ -0,0 +1,270 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/cookiejar"
"testing"
)
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
// and returns the response and a client holding whatever cookie came back.
func signup(t *testing.T, s *ts, body map[string]any) (*http.Response, *http.Client) {
t.Helper()
data, _ := json.Marshal(body)
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/signup", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("signup: %v", err)
}
return resp, client
}
// invite mints a link into the default team and returns its raw token.
func invite(t *testing.T, s *ts, role string, maxUses int64) string {
t.Helper()
var out struct {
URL string `json:"url"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/invites",
map[string]any{"role": role, "max_uses": maxUses}), &out)
if out.URL == "" {
t.Fatal("no invite URL returned")
}
// ...?invite=<token>
i := len(out.URL) - 1
for ; i >= 0 && out.URL[i] != '='; i-- {
}
return out.URL[i+1:]
}
func setSignupMode(t *testing.T, s *ts, mode string) {
t.Helper()
resp := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": mode})
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("set signup mode: %d", resp.StatusCode)
}
}
// The default is the closed door. An install that gets a public hostname before
// anybody has thought about sign-up should not be collecting accounts.
func TestSignup_InviteOnlyByDefault(t *testing.T) {
s := newTS(t)
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
if info["mode"] != "invite_only" {
t.Errorf("default sign-up mode is %v, want invite_only", info["mode"])
}
resp, _ := signup(t, s, map[string]any{
"username": "stranger", "email": "s@test.com", "password": "correct-horse-battery",
})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("sign-up without an invite: expected 403, got %d", resp.StatusCode)
}
}
// An invite carries the team and the role, so redeeming one lands somewhere
// usable rather than in an account that sees an empty queue.
func TestSignup_InviteCreatesAMemberOfThatTeam(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 1)
// The form checks the link before asking for a password.
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
if info["invite_valid"] != true {
t.Fatalf("a fresh invite should be valid: %v", info)
}
if info["invite_team"] != "Default" {
t.Errorf("the form should name the team: %v", info["invite_team"])
}
resp, client := signup(t, s, map[string]any{
"username": "newcomer", "email": "n@test.com",
"password": "correct-horse-battery", "invite": token,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("redeeming an invite: %d", resp.StatusCode)
}
var me struct {
User struct {
ID int64 `json:"id"`
IsAdmin bool `json:"is_admin"`
} `json:"user"`
}
decode(t, resp, &me)
if me.User.IsAdmin {
t.Error("somebody who signs up must not be an administrator")
}
// Signed in already: the cookie came back with the response.
got, err := client.Get(s.URL + "/api/teams")
if err != nil {
t.Fatal(err)
}
teams := list(t, got)
if len(teams) != 1 || teams[0]["name"] != "Default" || teams[0]["role"] != "member" {
t.Errorf("expected membership of Default as member, got %v", teams)
}
}
// A single-use link is single-use, and the check is inside the transaction so
// two people redeeming the last use at once cannot both get in.
func TestSignup_InviteCannotBeUsedTwice(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 1)
first, _ := signup(t, s, map[string]any{
"username": "first", "email": "f@test.com",
"password": "correct-horse-battery", "invite": token,
})
first.Body.Close()
if first.StatusCode != http.StatusCreated {
t.Fatalf("first redemption: %d", first.StatusCode)
}
second, _ := signup(t, s, map[string]any{
"username": "second", "email": "s@test.com",
"password": "correct-horse-battery", "invite": token,
})
second.Body.Close()
if second.StatusCode != http.StatusForbidden {
t.Errorf("second redemption: expected 403, got %d", second.StatusCode)
}
// And the link reports itself unusable before anybody types a password.
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
if info["invite_valid"] != false {
t.Error("a used-up invite should report itself invalid")
}
}
// Revoking stops a link without waiting for it to expire.
func TestSignup_RevokedInviteStopsWorking(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 5)
invites := list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/invites", nil))
if len(invites) != 1 {
t.Fatalf("expected one invite, got %d", len(invites))
}
id := int64(invites[0]["id"].(float64))
resp := s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/invites/"+id64(id), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("revoke: %d", resp.StatusCode)
}
used, _ := signup(t, s, map[string]any{
"username": "late", "email": "l@test.com",
"password": "correct-horse-battery", "invite": token,
})
used.Body.Close()
if used.StatusCode != http.StatusForbidden {
t.Errorf("a revoked invite: expected 403, got %d", used.StatusCode)
}
}
// Open sign-up makes a team, because an account in no team sees an empty queue
// and can be paged by nobody.
func TestSignup_OpenModeMakesATeam(t *testing.T) {
s := newTS(t)
setSignupMode(t, s, "open")
missing, _ := signup(t, s, map[string]any{
"username": "solo", "email": "s@test.com", "password": "correct-horse-battery",
})
missing.Body.Close()
if missing.StatusCode != http.StatusBadRequest {
t.Errorf("open sign-up with no team name: expected 400, got %d", missing.StatusCode)
}
resp, client := signup(t, s, map[string]any{
"username": "solo", "email": "s@test.com",
"password": "correct-horse-battery", "team_name": "Solo",
})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("open sign-up: %d", resp.StatusCode)
}
got, err := client.Get(s.URL + "/api/teams")
if err != nil {
t.Fatal(err)
}
teams := list(t, got)
if len(teams) != 1 || teams[0]["name"] != "Solo" || teams[0]["role"] != "owner" {
t.Errorf("the creator should own their new team, got %v", teams)
}
}
// Switching the mode is an administrator's decision, and it takes effect at
// once rather than at the next restart.
func TestSignup_ModeIsAnAdminSetting(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "plain")
resp := call(http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "open"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a member changing the mode: expected 403, got %d", resp.StatusCode)
}
bad := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "everybody"})
bad.Body.Close()
if bad.StatusCode != http.StatusBadRequest {
t.Errorf("an unknown mode: expected 400, got %d", bad.StatusCode)
}
setSignupMode(t, s, "open")
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
if info["mode"] != "open" {
t.Errorf("the change should be visible at once, got %v", info["mode"])
}
}
// Minting a link is configuring the team, so it is an owner's job.
func TestSignup_InvitesAreOwnerOnly(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "plain")
resp := call(http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": "member"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a member minting an invite: expected 403, got %d", resp.StatusCode)
}
}
// A password still has to be a password, and a taken username is still taken.
func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 5)
short, _ := signup(t, s, map[string]any{
"username": "shorty", "email": "sh@test.com", "password": "abc", "invite": token,
})
short.Body.Close()
if short.StatusCode != http.StatusBadRequest {
t.Errorf("a short password: expected 400, got %d", short.StatusCode)
}
taken, _ := signup(t, s, map[string]any{
"username": "admin", "email": "other@test.com",
"password": "correct-horse-battery", "invite": token,
})
taken.Body.Close()
if taken.StatusCode != http.StatusConflict {
t.Errorf("an existing username: expected 409, got %d", taken.StatusCode)
}
}
@@ -0,0 +1,49 @@
-- Self-service sign-up, and the invite links that make it useful.
--
-- Until now the only way to get an account was for somebody who already had one
-- to create it, and the login page told people to "ask an admin". That is a
-- workable arrangement for one operator and an impossible one for a team.
--
-- An invite is a link, not an email: this server has no SMTP and adding it to
-- send one message would be a new subsystem to run, secure and monitor. The
-- person inviting sends the link however they already talk to the person they
-- are inviting.
CREATE TABLE invites (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
-- SHA-256 of the raw token, like api_keys, the integration keys and the
-- acknowledgement tokens. A leaked database hands nobody an account.
token_hash TEXT NOT NULL UNIQUE,
-- Which team the invitee lands in, and as what. An invite always names a
-- team: an account in no team sees an empty queue and can be paged by
-- nobody, which is not a state to invite somebody into.
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
-- Invites expire. A link that works forever is a credential nobody
-- remembers issuing, sitting in a chat log.
expires_at BIGINT NOT NULL,
-- Single-use by default: max_uses 1. A team onboarding six people at once
-- can raise it rather than minting six links.
max_uses BIGINT NOT NULL DEFAULT 1 CHECK (max_uses > 0 AND max_uses <= 100),
uses BIGINT NOT NULL DEFAULT 0,
-- Revoked by hand, separately from expiry, so "this link is no longer
-- wanted" and "this link timed out" stay distinguishable in the listing.
revoked_at BIGINT
);
CREATE INDEX invites_team_idx ON invites(team_id);
-- Who redeemed which invite. Kept after the invite is gone — the answer to "how
-- did this account get here" should outlive the link that made it.
ALTER TABLE users ADD COLUMN invited_via BIGINT REFERENCES invites(id) ON DELETE SET NULL;
-- Where a person is in the first-run checklist, so it can be resumed and
-- dismissed rather than nagging forever. One row per user, created on demand.
ALTER TABLE users ADD COLUMN onboarding_dismissed_at BIGINT;
+20
View File
@@ -687,3 +687,23 @@ kbd {
border-radius: 6px; padding: 8px; font-size: 12px;
}
.key-url code { word-break: break-all; }
/* --- onboarding checklist ------------------------------------------------
Sits above the queue until it is finished or hidden. Deliberately plain:
it is a list of things to do, not a celebration. */
.onboarding { border-left: 3px solid var(--accent); }
.onboarding-head { display: flex; align-items: center; gap: 10px; }
.onboarding-head h2 { flex: 1; margin: 0; }
.checklist { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.checklist .step { display: flex; gap: 10px; align-items: flex-start; }
.checklist .step p { margin: 2px 0 0; }
.step-mark {
flex: none; width: 20px; height: 20px; border-radius: 50%;
border: 1px solid var(--border-strong); color: var(--accent);
display: flex; align-items: center; justify-content: center; font-size: 13px;
}
.step.done .step-mark { border-color: var(--accent); }
.step.done > div > strong { color: var(--muted); text-decoration: line-through; }
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
+31
View File
@@ -37,6 +37,37 @@
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
<p class="login-hint">No password yet? Ask an admin to set one, or run
<code>PUT /api/users/{id}/password</code> with your API key.</p>
<p class="login-hint" id="signup-link" hidden>
No account? <a href="/signup">Create one</a>.</p>
</form>
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
what the server allows: an invite link, or open sign-up. -->
<form id="signup-form" class="login-card" autocomplete="on" hidden>
<div class="login-brand">
<img src="/icon.svg" alt="" width="40" height="40">
<h1>terdut</h1>
</div>
<p class="signup-intro" id="signup-intro"></p>
<label>
<span>Username</span>
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label>
<span>Email</span>
<input name="email" type="email" autocomplete="email" required>
</label>
<label>
<span>Password</span>
<input name="password" type="password" autocomplete="new-password" minlength="10" required>
</label>
<label id="signup-team-label" hidden>
<span>Team name</span>
<input name="team_name" autocomplete="off">
</label>
<p class="form-error" role="alert" hidden></p>
<button class="btn btn-primary btn-block" type="submit">Create account</button>
<p class="login-hint">Already have one? <a href="/">Sign in</a>.</p>
</form>
</main>
+14
View File
@@ -87,6 +87,20 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
// schedule
// Sign-up, both halves unauthenticated: the caller has no account yet.
export const signupInfo = (invite) =>
call('GET', '/signup', { query: invite ? { invite } : {} });
export const signup = (body) => call('POST', '/signup', { body });
export const invites = (id) => call('GET', `/teams/${id}/invites`);
export const createInvite = (id, role, maxUses) =>
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
export const testNotification = () => call('POST', '/me/notify/test');
export const dismissOnboarding = (dismissed) =>
call('PUT', '/me/onboarding', { body: { dismissed } });
export const teams = () => call('GET', '/teams');
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
+93
View File
@@ -142,6 +142,14 @@ async function boot() {
document.addEventListener('click', interceptLinks);
document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup);
// /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') {
$('boot').hidden = true;
await showSignup();
return;
}
try {
state.me = await api.me();
@@ -161,6 +169,84 @@ function showBootError(err) {
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
}
// The sign-up screen. Reached at /signup, with an optional ?invite= that the
// server has already judged — the form says whether the link is good before
// somebody picks a password, rather than after.
async function showSignup() {
poll.stop();
ui.closeSheet(null);
reset();
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('login-form').hidden = true;
$('signup-form').hidden = false;
const invite = new URLSearchParams(location.search).get('invite');
const intro = $('signup-intro');
const form = $('signup-form');
const teamLabel = $('signup-team-label');
form.querySelector('.form-error').hidden = true;
let info;
try {
info = await api.signupInfo(invite);
} catch (err) {
intro.textContent = err.message;
return;
}
if (invite && info.invite_valid) {
intro.textContent = `You have been invited to ${info.invite_team}.`;
teamLabel.hidden = true;
form.team_name.required = false;
} else if (invite) {
// One answer for expired, revoked, used up and never existed, matching the
// server: which it was is not a stranger's business.
intro.textContent = 'That invite link is not usable. Ask whoever sent it for a new one.';
form.querySelector('button[type=submit]').disabled = true;
} else if (info.mode === 'open') {
intro.textContent = 'Create an account and a team to put your alerts in.';
teamLabel.hidden = false;
form.team_name.required = true;
} else {
intro.textContent = 'Sign-up on this server is invite-only. Ask a team owner for a link.';
form.querySelector('button[type=submit]').disabled = true;
}
form.username.focus();
}
async function onSignup(e) {
e.preventDefault();
const form = e.currentTarget;
const err = form.querySelector('.form-error');
const btn = form.querySelector('button[type=submit]');
err.hidden = true;
btn.disabled = true;
try {
state.me = await api.signup({
username: form.username.value.trim(),
email: form.email.value.trim(),
password: form.password.value,
invite: new URLSearchParams(location.search).get('invite') || undefined,
team_name: form.team_name.value.trim() || undefined,
});
form.password.value = '';
// Signing up signs you in, so go straight to the queue rather than to a
// login form asking for the credential just chosen.
history.replaceState({ depth: 0 }, '', '/');
route = parseRoute('/');
await loadTeams();
$('nav-admin').hidden = !state.me?.user?.is_admin;
showApp();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
btn.disabled = false;
}
}
function showLogin() {
poll.stop();
ui.closeSheet(null);
@@ -168,8 +254,15 @@ function showLogin() {
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('signup-form').hidden = true;
$('login-form').hidden = false;
const form = $('login-form');
form.querySelector('.form-error').hidden = true;
// Only offer the door that is open. Somebody without an invite on an
// invite-only server should be told, not sent to a form that refuses them.
api.signupInfo().then((info) => {
$('signup-link').hidden = info.mode !== 'open';
}).catch(() => {});
form.password.value = '';
(form.username.value ? form.password : form.username).focus();
}
+147
View File
@@ -0,0 +1,147 @@
// The first-run checklist: the four things a new install or a new person has
// to do before an alert reaches a phone.
//
// It is computed from what the server already knows rather than from stored
// progress — a topic is set or it is not, an integration exists or it does not
// — so it cannot claim a step is done when it is not, and it comes back by
// itself if somebody deletes their integration a month later.
//
// Dismissal is the one piece of state, kept per user so finishing on a laptop
// does not leave the phone nagging.
import * as api from './api.js';
import { h, clear, spinner } from './ui.js';
import { state, currentTeam } from './state.js';
import { navigate } from './app.js';
import { isoDate } from './format.js';
let steps = null;
let error = null;
let busy = false;
let testResult = null;
// done() is deliberately a question about the world, not a flag: each step asks
// the data whether it happened.
export async function load() {
const team = currentTeam();
if (!team) {
steps = null;
return;
}
try {
const [schedule, integrations, alerts] = await Promise.all([
api.schedule(team.id, isoDate(new Date()), isoDate(new Date())),
api.integrations(team.id),
api.alerts({ limit: 1 }),
]);
steps = [
{
id: 'topic',
title: 'Set where your pages go',
text: 'An ntfy topic on your account. Without one, incidents assigned to you page the team’s fallback topic instead of your phone.',
done: Boolean(state.me?.user?.ntfy_topic),
action: { label: 'Account', go: '/more' },
},
{
id: 'rota',
title: 'Put somebody on call',
text: 'An incident opens assigned to whoever the rota says is on call today. With an empty rota it opens unassigned.',
done: (schedule || []).length > 0,
action: { label: 'Team', go: '/team' },
},
{
id: 'integration',
title: 'Create an alert source',
text: 'Alerts arrive on an integration key, which says which team they belong to. Nothing can reach this team without one.',
done: (integrations || []).length > 0,
action: { label: 'Team', go: '/team' },
},
{
id: 'alert',
title: 'Send a test alert',
text: 'Post to the integration URL and watch it appear in the queue. Until one arrives, none of the above is proven.',
done: (alerts || []).length > 0,
action: { label: 'How', go: '/team' },
},
];
error = null;
} catch (err) {
error = err.message;
}
}
// visible reports whether there is anything worth showing: something undone,
// and not dismissed.
export function visible() {
if (!steps || state.me?.onboarding_dismissed) return false;
return steps.some((s) => !s.done);
}
export function card() {
if (!visible()) return null;
const remaining = steps.filter((s) => !s.done).length;
return h('div', { class: 'card onboarding' },
h('div', { class: 'onboarding-head' },
h('h2', { text: 'Finish setting up' }),
h('span', { class: 'muted small', text: `${remaining} left` }),
h('button', {
class: 'btn-sm', type: 'button', text: 'Hide',
title: 'Hide this checklist for good',
onclick: async () => {
try {
await api.dismissOnboarding(true);
if (state.me) state.me.onboarding_dismissed = true;
} catch (err) {
error = err.message;
}
rerender();
},
})),
error && h('p', { class: 'load-error', text: error }),
h('ol', { class: 'checklist' }, ...steps.map(stepRow)),
testResult && h('p', { class: testResult.ok ? 'muted small' : 'load-error', text: testResult.text }),
);
}
function stepRow(step) {
return h('li', { class: step.done ? 'step done' : 'step' },
h('span', { class: 'step-mark', text: step.done ? '✓' : '' }),
h('div', {},
h('strong', { text: step.title }),
h('p', { class: 'muted small', text: step.text }),
!step.done && h('div', { class: 'step-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: step.action.label,
onclick: () => navigate(step.action.go),
}),
// The topic step is the only one this page can finish by itself, and
// the only proof that matters is a phone buzzing.
step.id === 'topic' && state.me?.user?.ntfy_topic && h('button', {
class: 'btn-sm', type: 'button', text: 'Send a test push',
disabled: busy,
onclick: sendTest,
}),
)),
);
}
async function sendTest() {
busy = true;
try {
await api.testNotification();
testResult = { ok: true, text: 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.' };
} catch (err) {
testResult = { ok: false, text: err.message };
} finally {
busy = false;
}
rerender();
}
// The queue owns the card's place on the page, so ask it to redraw rather than
// reaching into its list.
let rerender = () => {};
export function onRerender(fn) {
rerender = fn;
}
+9 -3
View File
@@ -4,6 +4,7 @@ import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
@@ -25,6 +26,8 @@ const EMPTY = {
archived: ['Nothing archived', ''],
};
onboarding.onRerender(() => renderList());
let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading
@@ -89,6 +92,7 @@ export async function refresh({ fresh = false } = {}) {
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter;
const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return;
items = result;
error = null;
@@ -153,20 +157,22 @@ function renderChips() {
function renderList() {
const el = document.getElementById('queue-list');
const checklist = onboarding.card();
if (error && !items) {
clear(el, h('div', { class: 'load-error', text: error }));
clear(el, checklist, h('div', { class: 'load-error', text: error }));
return;
}
if (!items) {
clear(el, spinner());
clear(el, checklist, spinner());
return;
}
if (!items.length) {
const [title, text] = EMPTY[filter];
clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
return;
}
clear(el,
checklist,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map((inc, i) => row(inc, i)),
);