Add self-service sign-up and invite links
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
This commit is contained in:
+19
-1
@@ -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.
|
||||
@@ -109,6 +122,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))
|
||||
|
||||
Reference in New Issue
Block a user