console: "New account" becomes a screen of its own — a text box in the header is not where you name a project
Creating an account was an inline form in the site header: a placeholder, no label, no room to say what an account even is, and its only error path was a flash bounced onto whichever account you happened to be viewing. It is now GET /accounts/new, linked from the switcher — a normal gated page (session required, CSRF on the POST) with room to explain that an account is a project or a team, that names are free text and deliberately not unique, and that you become its first owner. An empty name now redirects back to the screen you were on rather than off to some other account. Two details the page is careful about. It takes no account parameter of any kind, so there is nothing here that could be pointed at an account you are not a member of. And it fills its header chrome with memberAccount — currentAccount's first two cases split out WITHOUT the mint — so merely looking at the page can neither create an account nor move your MRU pointer; only landing does that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+187
−14
internal/console/server.go+68 −14internal/console/server_account_test.go+95 −0internal/console/templates/layout.html+6 −0internal/console/templates/newaccount.html+18 −0
diff --git a/internal/console/server.go b/internal/console/server.go| index 3f5a6be0c..1c36c274f 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -150,6 +150,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite) |
| s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite) |
| s.mux.HandleFunc("GET /invite/{token}", s.handleAcceptInvite) |
| + s.mux.HandleFunc("GET /accounts/new", s.handleNewAccountForm) |
| s.mux.HandleFunc("POST /accounts/new", s.handleNewAccount) |
| // The pre-accounts URLs, kept as redirects so old links and bookmarks |
| // still land somewhere sensible rather than 404ing. |
| @@ -341,31 +342,50 @@ func (s *Server) redirectToCurrentAccount(w http.ResponseWriter, r *http.Request |
| // if still a member — an account you were removed from must not strand you), |
| // first membership, or mint. |
| func (s *Server) currentAccount(ctx context.Context, addr string) (string, error) { |
| - if id, err := s.store.LastAccount(addr); err != nil { |
| + a, err := s.memberAccount(addr) |
| + if err != nil { |
| + return "", err |
| + } |
| + if a != nil { |
| + return a.Sqid, nil |
| + } |
| + m, err := s.mintAccount(ctx, addr, addr) |
| + if err != nil { |
| return "", err |
| + } |
| + return m.Sqid, nil |
| +} |
| + |
| +// memberAccount is currentAccount's first two cases WITHOUT the mint: the MRU |
| +// account if the viewer is still a member of it, else their first membership |
| +// by creation order, else nil. Split out because pages that merely want the |
| +// header's account context — /accounts/new — must not create an account as a |
| +// side effect of being looked at. Only landing mints. |
| +func (s *Server) memberAccount(addr string) (*Account, error) { |
| + if id, err := s.store.LastAccount(addr); err != nil { |
| + return nil, err |
| } else if id != 0 { |
| if role, err := s.store.Role(id, addr); err != nil { |
| - return "", err |
| + return nil, err |
| } else if role != "" { |
| - if a, err := s.store.AccountByID(id); err != nil { |
| - return "", err |
| - } else if a != nil { |
| - return a.Sqid, nil |
| + a, err := s.store.AccountByID(id) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if a != nil { |
| + a.Role = role |
| + return a, nil |
| } |
| } |
| } |
| accts, err := s.store.AccountsFor(addr) |
| if err != nil { |
| - return "", err |
| + return nil, err |
| } |
| if len(accts) > 0 { |
| - return accts[0].Sqid, nil |
| + return &accts[0], nil |
| } |
| - a, err := s.mintAccount(ctx, addr, addr) |
| - if err != nil { |
| - return "", err |
| - } |
| - return a.Sqid, nil |
| + return nil, nil |
| } |
| // mintAccount creates an account owned by addr: the next free id allocated in |
| @@ -476,6 +496,38 @@ func (s *Server) handleLegacyApp(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/a/"+url.PathEscape(sqid)+"/apps/"+url.PathEscape(r.PathValue("app")), http.StatusFound) |
| } |
| +// handleNewAccountForm is the "New account" screen: its own gated page at its |
| +// own URL, linked from the switcher, rather than a text box wedged into the |
| +// header. It takes no account parameter of any kind — there is nothing here |
| +// to point at another account, so it cannot become a way to probe one — and |
| +// it deliberately does NOT record an MRU: you are not "in" an account here, |
| +// and moving the pointer would send the next sign-in somewhere never chosen. |
| +func (s *Server) handleNewAccountForm(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.session(r) |
| + if !ok { |
| + http.Redirect(w, r, "/", http.StatusFound) |
| + return |
| + } |
| + p := page{Title: "New account", Address: addr, CSRF: raw} |
| + p.Flash, p.FlashErr = s.takeFlash(w, r) |
| + if accts, err := s.store.AccountsFor(addr); err != nil { |
| + log.Printf("console: switcher: %v", err) |
| + } else { |
| + p.Accounts = accts |
| + } |
| + // The header chrome wants an account to name in the switcher's trigger. |
| + // memberAccount, not currentAccount: looking at this page must never mint. |
| + // A user with no memberships at all simply gets the wordmark header. |
| + if a, err := s.memberAccount(addr); err != nil { |
| + log.Printf("console: new-account chrome: %v", err) |
| + } else if a != nil { |
| + p.Account = a |
| + p.Role = a.Role |
| + p.Base = "/a/" + url.PathEscape(a.Sqid) |
| + } |
| + s.render(w, http.StatusOK, "newaccount", p) |
| +} |
| + |
| // handleNewAccount creates an account for any signed-in user. |
| func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) { |
| addr, raw, ok := s.session(r) |
| @@ -489,8 +541,10 @@ func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) { |
| } |
| name := strings.TrimSpace(r.FormValue("name")) |
| if name == "" || len(name) > 64 { |
| + // Back to the screen the mistake was made on, not off to some other |
| + // account: the form is a page now, and a page can show its own error. |
| s.setFlash(w, "err", "An account needs a name (up to 64 characters).") |
| - s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther) |
| + http.Redirect(w, r, "/accounts/new", http.StatusSeeOther) |
| return |
| } |
| a, err := s.mintAccount(r.Context(), name, addr) |
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go| index bbe0dc851..f0969ea6c 100644 |
| --- a/internal/console/server_account_test.go |
| +++ b/internal/console/server_account_test.go |
| @@ -310,6 +310,101 @@ func TestSignedOutHeaderKeepsTheWordmark(t *testing.T) { |
| } |
| } |
| +// "New account" is a screen of its own at its own URL: gated on a session, |
| +// CSRF on its POST, and creating from it lands you in the new account. |
| +func TestNewAccountScreen(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + |
| + // Signed out, it is not a page you can see. |
| + if st, loc, _ := get(t, newBrowser(t), fx.ts.URL+"/accounts/new"); st != http.StatusFound || loc != "/" { |
| + t.Fatalf("signed-out /accounts/new = %d -> %q, want 302 -> /", st, loc) |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + st, _, body := get(t, c, fx.ts.URL+"/accounts/new") |
| + if st != http.StatusOK { |
| + t.Fatalf("/accounts/new = %d, want 200", st) |
| + } |
| + if !strings.Contains(body, `action="/accounts/new"`) || !strings.Contains(body, `name="csrf"`) { |
| + t.Fatalf("the new-account screen has no CSRF-bearing form:\n%s", body) |
| + } |
| + // It is a page, not a header ornament: the chrome is still there. |
| + if !strings.Contains(header(t, body), "<details class=\"switcher\"") { |
| + t.Errorf("the new-account screen lost the header switcher:\n%s", body) |
| + } |
| + |
| + // The POST still refuses a forged CSRF token. |
| + if st, _, _ := postForm(t, c, fx.ts.URL+"/accounts/new", |
| + url.Values{"csrf": {"wrong"}, "name": {"Nope"}}); st != http.StatusForbidden { |
| + t.Errorf("new account with a bad csrf = %d, want 403", st) |
| + } |
| + |
| + // Creating from the screen lands you in the account it made. |
| + csrf := sessionToken(t, fx, c) |
| + st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", |
| + url.Values{"csrf": {csrf}, "name": {"Woodstar"}}) |
| + if st != http.StatusSeeOther { |
| + t.Fatalf("create = %d -> %q, want 303", st, loc) |
| + } |
| + accts, _ := fx.st.AccountsFor("paul@keymail.dev") |
| + var made *Account |
| + for i := range accts { |
| + if accts[i].Name == "Woodstar" { |
| + made = &accts[i] |
| + } |
| + } |
| + if made == nil { |
| + t.Fatalf("AccountsFor = %+v, want a Woodstar", accts) |
| + } |
| + if loc != "/a/"+made.Sqid+"/" { |
| + t.Fatalf("landed at %q, want /a/%s/", loc, made.Sqid) |
| + } |
| + // And the account you landed in is the one the header now names. |
| + _, _, body = get(t, c, fx.ts.URL+loc) |
| + if h := header(t, body); !strings.Contains(h, "Woodstar") { |
| + t.Errorf("the header does not name the account just created:\n%s", h) |
| + } |
| +} |
| + |
| +// A nameless submission is a mistake to correct on the screen you are on, |
| +// not a bounce back to some other account. |
| +func TestNewAccountScreenRejectsAnEmptyNameInPlace(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {" "}}) |
| + if st != http.StatusSeeOther || loc != "/accounts/new" { |
| + t.Fatalf("empty name = %d -> %q, want 303 -> /accounts/new", st, loc) |
| + } |
| + if _, _, body := get(t, c, fx.ts.URL+"/accounts/new"); !strings.Contains(body, "needs a name") { |
| + t.Errorf("the screen does not say why it refused:\n%s", body) |
| + } |
| +} |
| + |
| +// Visiting an account page updates the MRU; visiting /accounts/new does not |
| +// — it is not an account you are "in", and moving the MRU there would send |
| +// the next sign-in somewhere the user never chose. |
| +func TestNewAccountScreenLeavesTheMRUAlone(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + |
| + id := testAccountID() |
| + sqid := accountSqid(id) |
| + if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK { |
| + t.Fatalf("second account home = %d", st) |
| + } |
| + if st, _, _ := get(t, c, fx.ts.URL+"/accounts/new"); st != http.StatusOK { |
| + t.Fatalf("/accounts/new = %d", st) |
| + } |
| + if st, loc, _ := get(t, c, fx.ts.URL+"/"); st != http.StatusFound || loc != "/a/"+sqid+"/" { |
| + t.Fatalf("landing = %d -> %q, want the MRU account /a/%s/", st, loc, sqid) |
| + } |
| +} |
| + |
| // The audit trail is per (account, app), not per app name. Two accounts can |
| // both own a "blog"; sharing one activity stream would show each account the |
| // other's actions — including the other account's member addresses, which is |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index b6954b769..f0a27ebf2 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -95,6 +95,12 @@ |
| p { max-width: 40rem; margin: 0 0 1rem; } |
| .lede { font-size: 1.15rem; color: var(--muted); } |
| .small { font-size: 0.9rem; color: var(--muted); } |
| + /* Present to a screen reader, absent to the eye — for labels the visual |
| + design carries in placement or placeholder text alone. */ |
| + .visually-hidden { |
| + position: absolute; width: 1px; height: 1px; overflow: hidden; |
| + clip-path: inset(50%); white-space: nowrap; |
| + } |
| .error { color: var(--bad); } |
| .banner { |
| background: var(--accent-soft); border-radius: 0.5rem; |
diff --git a/internal/console/templates/newaccount.html b/internal/console/templates/newaccount.html| new file mode 100644 |
| index 000000000..8bcfe1d3a |
| --- /dev/null |
| +++ b/internal/console/templates/newaccount.html |
| @@ -0,0 +1,18 @@ |
| +{{define "newaccount"}}{{template "top" .}} |
| +<h1>New account</h1> |
| +<p class="lede">An account is a project or a team: it owns apps, has members, |
| +and deploys independently of your other accounts.</p> |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +<form class="signin" method="post" action="/accounts/new"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label class="visually-hidden" for="account-name">Account name</label> |
| + <input id="account-name" type="text" name="name" placeholder="Kass" autofocus> |
| + <button type="submit">Create account</button> |
| +</form> |
| +<p class="small">Names are free text, up to 64 characters, and deliberately |
| +not unique — two accounts may both be called “Blog”. Identity is the short |
| +id in the URL, so there is nothing to squat. You will be its first owner, |
| +and you can invite teammates from the account's page.</p> |
| +{{if .Account}}<p class="small"><a href="{{.Base}}/">Back to {{.Account.Name}}</a></p>{{end}} |
| +{{template "bottom" .}}{{end}} |