Add the sign-up page and the first-run checklist
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
This commit is contained in:
+13
-2
@@ -252,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
|
||||
@@ -265,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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,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.
|
||||
|
||||
@@ -410,3 +410,80 @@ func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user