console: account settings is its own page
Identity plus the danger zone; delete keeps its owner gate, zero-apps rule, type-the-name confirm, and step-up, all at the same POST path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+80
−14
internal/console/server.go+21 −0internal/console/server_account_test.go+4 −1internal/console/server_test.go+23 −0internal/console/templates/account_settings.html+32 −0internal/console/templates/apps.html+0 −13
diff --git a/internal/console/server.go b/internal/console/server.go| index 9b9cc91ef..fed6113c8 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -253,6 +253,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // account-by-session, so a link a teammate opens lands where you meant. |
| s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome) |
| s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam) |
| + s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings) |
| // Go 1.22's mux prefers this literal over apps/{app} below — exactly why |
| // "new" must be a reserved app name (claimApp): an app so named could |
| // never be viewed. |
| @@ -346,6 +347,7 @@ type page struct { |
| Accounts []Account // the switcher: every account the viewer belongs to |
| Members []Account // team page: memberships (Name carries the address) |
| Invites []Invite // team page, owners only: pending invitations |
| + HasApps bool // account settings: an account with apps cannot be deleted |
| // The Trash (ownership.go): the account's deleted-but-restorable apps on |
| // /a/{sqid}/trash, and the count the account home links with. Zero hides |
| @@ -1187,6 +1189,25 @@ func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "team", p) |
| } |
| +// handleAccountSettings is the account itself — identity, and its end. |
| +// Deletion stays owner-only and zero-apps-only; the POST is unchanged. |
| +func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + p := s.accountPage(w, r, addr, raw, acct, role) |
| + p.Title = "Account settings" |
| + p.ActiveNav = "settings" |
| + apps, err := s.store.AppsIn(acct.ID) |
| + if err != nil { |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| + return |
| + } |
| + p.HasApps = len(apps) > 0 |
| + s.render(w, http.StatusOK, "account_settings", p) |
| +} |
| + |
| // accountPage fills the chrome every account-scoped page shares — the |
| // switcher, the base URL its forms post to, the viewer's role — and records |
| // the MRU, so signing in lands you back on the account you were last in. |
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go| index 46be4103e..e67ca3e0c 100644 |
| --- a/internal/console/server_account_test.go |
| +++ b/internal/console/server_account_test.go |
| @@ -109,7 +109,10 @@ func TestLandingPrefersNameSegment(t *testing.T) { |
| if !strings.Contains(body, `href="/a/amadan/"`) { |
| t.Errorf("nav links should use the name segment") |
| } |
| - if !strings.Contains(body, `action="/a/`+sqid+`/`) { |
| + // The account is empty, so its delete form (the only sqid-posting form |
| + // left, now that settings owns it) lives on /settings, not the home page. |
| + _, _, settings := get(t, c, fx.ts.URL+"/a/amadan/settings") |
| + if !strings.Contains(settings, `action="/a/`+sqid+`/`) { |
| t.Errorf("form actions must stay on the sqid path") |
| } |
| } |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index b4ff5c4b4..bef7da6e3 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -564,6 +564,29 @@ func TestTeamPage(t *testing.T) { |
| } |
| } |
| +func TestAccountSettingsPage(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/settings")) |
| + if status != 200 { |
| + t.Fatalf("GET /settings = %d", status) |
| + } |
| + for _, want := range []string{"Account settings", "<h2>Identity</h2>", "Your role"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("settings page missing %q", want) |
| + } |
| + } |
| + // The fixture account has apps (kass), so no delete form may render — |
| + // but the section explains itself instead of vanishing silently. |
| + if strings.Contains(body, `action="`+fx.path("/delete")+`"`) { |
| + t.Fatal("delete form rendered for an account with apps") |
| + } |
| + if !strings.Contains(body, "can't be deleted") { |
| + t.Fatal("missing the why-not-deletable explanation") |
| + } |
| + assertNonMember404(t, fx, "/settings") |
| +} |
| + |
| func TestAppNamedNewIsRefused(t *testing.T) { |
| fx := newConsoleFixture(t) |
| c := signInPaul(t, fx) |
diff --git a/internal/console/templates/account_settings.html b/internal/console/templates/account_settings.html| new file mode 100644 |
| index 000000000..aaf4431f7 |
| --- /dev/null |
| +++ b/internal/console/templates/account_settings.html |
| @@ -0,0 +1,32 @@ |
| +{{define "account_settings"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +<div class="pagehead"> |
| + <p class="eyebrow">Account · {{.Account.Name}}</p> |
| + <h1>Account settings</h1> |
| + <p class="lede">The account itself — what it's called, and how it ends.</p> |
| +</div> |
| +<h2>Identity</h2> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Name</th><th>ID</th><th>Your role</th></tr> |
| + <tr><td>{{.Account.Name}}</td><td><code>{{.Account.Sqid}}</code></td><td>{{.Role}}</td></tr> |
| +</table> |
| +</div> |
| +{{if .IsOwner}} |
| +<h2>Delete this account</h2> |
| +{{if .HasApps}} |
| +<p class="small">This account has apps, so it can't be deleted. Delete or move the apps first; |
| + an account with nothing in it can be retired from this page.</p> |
| +{{else}} |
| +<p class="small">An account with no apps can be retired. Type <strong>{{.Account.Name}}</strong> to |
| + confirm, then confirm with your passkey. Members and pending invitations go with it; nothing |
| + outside this account is touched.</p> |
| +<form class="signin" method="post" action="{{.Base}}/delete"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <input type="text" name="confirm" placeholder="{{.Account.Name}}"> |
| + <button type="submit">Delete account</button> |
| +</form> |
| +{{end}} |
| +{{end}} |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html| index 4a1dca1ed..ed3a97f5c 100644 |
| --- a/internal/console/templates/apps.html |
| +++ b/internal/console/templates/apps.html |
| @@ -27,17 +27,4 @@ |
| restorable until the platform destroys them.</p>{{end}} |
| <p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p> |
| -{{if .IsOwner}} |
| -{{if not .Apps}} |
| -<h2>Delete this account</h2> |
| -<p class="small">An account with no apps can be retired. Type <strong>{{.Account.Name}}</strong> to |
| - confirm, then confirm with your passkey. Members and pending invitations go with it; nothing |
| - outside this account is touched.</p> |
| -<form class="signin" method="post" action="{{.Base}}/delete"> |
| - <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| - <input type="text" name="confirm" placeholder="{{.Account.Name}}"> |
| - <button type="submit">Delete account</button> |
| -</form> |
| -{{end}} |
| -{{end}} |
| {{template "bottom" .}}{{end}} |