carlos / platform Public

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>
Paul Campbell 096ccca98159fa5676788c9d97a90a0224eaee60 parent 228f516
5 files changed, +80 −14
  • internal/console/server.go +21 −0
  • internal/console/server_account_test.go +4 −1
  • internal/console/server_test.go +23 −0
  • internal/console/templates/account_settings.html +32 −0
  • internal/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}}