feat(console): links and landings prefer the account name; forms stay on sqid paths
preferredSeg picks an account's name for console-GENERATED links (landing redirect, switcher, nav) when the name is slug-shaped and unique among the viewer's own accounts, falling back to the sqid otherwise. Forms and redirects-after-POST are untouched and keep posting to page.Base (sqid) — prettiness is for links, not for the exact target a mutation names. Updated TestReturningSignInViaConfirmationLink: the fixture's only account for paul is named "ops" (slug-shaped, unique for him), so its landing redirect now goes to /a/ops/ instead of /a/bab/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
9 files changed,
+114
−9
internal/console/resolve.go+25 −1internal/console/resolve_test.go+31 −0internal/console/server.go+16 −1internal/console/server_account_test.go+29 −0internal/console/server_test.go+5 −3internal/console/store.go+4 −0internal/console/templates/apps.html+1 −1internal/console/templates/layout.html+2 −2internal/console/templates/newaccount.html+1 −1
diff --git a/internal/console/resolve.go b/internal/console/resolve.go| index ca3451c33..55201399a 100644 |
| --- a/internal/console/resolve.go |
| +++ b/internal/console/resolve.go |
| @@ -1,6 +1,10 @@ |
| package console |
| -import "strings" |
| +import ( |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| +) |
| // matchAccounts resolves one path/flag segment against the accounts the |
| // VIEWER belongs to, matching it as a sqid or a name — symmetrically, with |
| @@ -21,3 +25,23 @@ func matchAccounts(accts []Account, seg string) []*Account { |
| } |
| return out |
| } |
| + |
| +// preferredSeg is the path segment the console GENERATES for an account: |
| +// its name, when the name is slug-shaped and resolves uniquely among the |
| +// viewer's own accounts; the sqid otherwise. Links and redirects use it — |
| +// forms always post to the sqid path (page.Base), because a mutation must |
| +// name its target exactly and prettiness is for links. |
| +func preferredSeg(accts []Account, a *Account) string { |
| + if !domains.ValidAccountName(a.Name) { |
| + return a.Sqid |
| + } |
| + for i := range accts { |
| + if accts[i].ID == a.ID { |
| + continue |
| + } |
| + if strings.EqualFold(accts[i].Name, a.Name) || accts[i].Sqid == a.Name { |
| + return a.Sqid |
| + } |
| + } |
| + return a.Name |
| +} |
diff --git a/internal/console/resolve_test.go b/internal/console/resolve_test.go| index 1e0a3c35f..e1310f995 100644 |
| --- a/internal/console/resolve_test.go |
| +++ b/internal/console/resolve_test.go |
| @@ -59,3 +59,34 @@ func TestMatchAccountsKeepsRole(t *testing.T) { |
| t.Errorf("matchAccounts dropped the role: %+v", got) |
| } |
| } |
| + |
| +func TestPreferredSeg(t *testing.T) { |
| + accts := []Account{ |
| + {ID: 1, Sqid: "bab", Name: "ops"}, |
| + {ID: 2, Sqid: "bah", Name: "amadan"}, |
| + {ID: 3, Sqid: "bad", Name: "amadan"}, |
| + {ID: 4, Sqid: "baf", Name: "paul@keymail.dev"}, |
| + {ID: 5, Sqid: "bag", Name: "bah"}, // collides with #2's sqid |
| + } |
| + cases := []struct { |
| + id int64 |
| + want string |
| + }{ |
| + {1, "ops"}, // unique slug-shaped name → the name |
| + {2, "bah"}, // name shared with #3 → sqid |
| + {3, "bad"}, // same |
| + {4, "baf"}, // address-named (auto-mint) → sqid |
| + {5, "bag"}, // name equals #2's sqid → sqid |
| + } |
| + for _, c := range cases { |
| + var a *Account |
| + for i := range accts { |
| + if accts[i].ID == c.id { |
| + a = &accts[i] |
| + } |
| + } |
| + if got := preferredSeg(accts, a); got != c.want { |
| + t.Errorf("preferredSeg(#%d) = %q, want %q", c.id, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 593a720c2..e96dea40b 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -304,6 +304,7 @@ type page struct { |
| Account *Account // the account being viewed |
| Role string // the viewer's role in it: owner | member |
| Base string // "/a/<sqid>" — what every form on the page posts under |
| + Nav string // "/a/<name-or-sqid>" — what LINKS use; forms keep posting to Base |
| Accounts []Account // the switcher: every account the viewer belongs to |
| Members []Account // account home: memberships (Name carries the address) |
| Invites []Invite // account home, owners only: pending invitations |
| @@ -441,7 +442,16 @@ func (s *Server) redirectToCurrentAccount(w http.ResponseWriter, r *http.Request |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not open your account. Please try again.") |
| return |
| } |
| - http.Redirect(w, r, "/a/"+url.PathEscape(sqid)+"/", code) |
| + seg := sqid |
| + if accts, err := s.store.AccountsFor(addr); err == nil { |
| + for i := range accts { |
| + if accts[i].Sqid == sqid { |
| + seg = preferredSeg(accts, &accts[i]) |
| + break |
| + } |
| + } |
| + } |
| + http.Redirect(w, r, "/a/"+url.PathEscape(seg)+"/", code) |
| } |
| // currentAccount resolves the three landing cases in order: MRU history (only |
| @@ -645,6 +655,7 @@ func (s *Server) handleNewAccountForm(w http.ResponseWriter, r *http.Request) { |
| p.Account = a |
| p.Role = a.Role |
| p.Base = "/a/" + url.PathEscape(a.Sqid) |
| + p.Nav = "/a/" + url.PathEscape(preferredSeg(p.Accounts, a)) |
| } |
| s.render(w, http.StatusOK, "newaccount", p) |
| } |
| @@ -1110,6 +1121,10 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, addr, raw s |
| if len(p.Accounts) == 0 { |
| p.Accounts = []Account{*acct} |
| } |
| + for i := range p.Accounts { |
| + p.Accounts[i].Seg = preferredSeg(p.Accounts, &p.Accounts[i]) |
| + } |
| + p.Nav = "/a/" + url.PathEscape(preferredSeg(p.Accounts, acct)) |
| return p |
| } |
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go| index 52560ac6d..7f33e1960 100644 |
| --- a/internal/console/server_account_test.go |
| +++ b/internal/console/server_account_test.go |
| @@ -85,6 +85,35 @@ func TestLandingRedirectsToLastAccount(t *testing.T) { |
| } |
| } |
| +// Landing prefers the readable URL: a viewer whose account has a unique |
| +// slug-shaped name lands on /a/<name>/, and the page's nav links use it, |
| +// while its forms keep posting to the sqid path. |
| +func TestLandingPrefersNameSegment(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.setUser("solo@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-solo") |
| + id := testAccountID() |
| + sqid := accountSqid(id) |
| + if err := fx.st.CreateAccount(id, sqid, "amadan", "solo@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK { |
| + t.Fatal("visit to set MRU") |
| + } |
| + st, loc, _ := get(t, c, fx.ts.URL+"/") |
| + if st != http.StatusFound || loc != "/a/amadan/" { |
| + t.Fatalf("landing = %d -> %q, want 302 -> /a/amadan/", st, loc) |
| + } |
| + _, _, body := get(t, c, fx.ts.URL+"/a/amadan/") |
| + if !strings.Contains(body, `href="/a/amadan/"`) { |
| + t.Errorf("nav links should use the name segment") |
| + } |
| + if !strings.Contains(body, `action="/a/`+sqid+`/`) { |
| + t.Errorf("form actions must stay on the sqid path") |
| + } |
| +} |
| + |
| // A user with no memberships at all gets a personal account minted on the |
| // spot — with a bucket manifest, so every box materializes the same identity. |
| // A name the viewer cannot resolve — someone ELSE's account — answers with |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 74da16298..a2d5efe5a 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -298,9 +298,11 @@ func TestReturningSignInViaConfirmationLink(t *testing.T) { |
| t.Fatalf("no confirmation link in sealed body: %q", sent.Body) |
| } |
| - // The link mints a session — once. |
| - if status, loc, _ := get(t, c, link); status != http.StatusSeeOther || loc != fx.path("/") { |
| - t.Fatalf("confirm = %d -> %q, want 303 -> /apps", status, loc) |
| + // The link mints a session — once. Landing prefers the readable segment: |
| + // the fixture's only account for paul is named "ops", slug-shaped and |
| + // unique for him, so preferredSeg picks the name over fx.sqid ("bab"). |
| + if status, loc, _ := get(t, c, link); status != http.StatusSeeOther || loc != "/a/ops/" { |
| + t.Fatalf("confirm = %d -> %q, want 303 -> /a/ops/", status, loc) |
| } |
| _, _, body = get(t, c, fx.u("/")) |
| if !strings.Contains(body, "kass") { |
diff --git a/internal/console/store.go b/internal/console/store.go| index 301e154e0..7c96ae37e 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -510,6 +510,10 @@ type Account struct { |
| Name string |
| Role string |
| CreatedAt time.Time |
| + |
| + // Seg is the path segment the console generates for this account in the |
| + // viewer's chrome (preferredSeg) — display state, never stored. |
| + Seg string |
| } |
| // Invite is one pending invitation. |
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html| index ba7f6fd52..3f6c4017e 100644 |
| --- a/internal/console/templates/apps.html |
| +++ b/internal/console/templates/apps.html |
| @@ -9,7 +9,7 @@ |
| <tr><th>App</th><th>Hosts</th><th>Instances</th><th>Status</th><th>Stable</th><th>Canary</th></tr> |
| {{range .Apps}} |
| <tr> |
| - <td><a href="{{$.Base}}/apps/{{.App}}">{{.App}}</a></td> |
| + <td><a href="{{$.Nav}}/apps/{{.App}}">{{.App}}</a></td> |
| <td>{{.Hosts}}</td> |
| <td>{{.Instances}}</td> |
| <td>{{if .Statuses}}{{.Statuses}}{{else}}—{{end}}</td> |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index cf142d4d1..f0a7974eb 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -226,7 +226,7 @@ |
| {{range .Accounts}}<li>{{if eq .Sqid $.Account.Sqid}} |
| <span class="current" aria-current="true"><span class="mark">{{icon "check"}}</span>{{.Name}}</span> |
| {{else}} |
| - <a href="/a/{{.Sqid}}/"><span class="mark">{{icon "check"}}</span>{{.Name}}</a> |
| + <a href="/a/{{.Seg}}/"><span class="mark">{{icon "check"}}</span>{{.Name}}</a> |
| {{end}}</li> |
| {{end}} |
| </ul> |
| @@ -238,7 +238,7 @@ |
| <div class="brand"><a href="/">CARLOS console</a></div> |
| {{end}} |
| {{if .Address}}<nav> |
| - {{if .Account}}<a href="{{.Base}}/">Apps</a>{{end}} |
| + {{if .Account}}<a href="{{.Nav}}/">Apps</a>{{end}} |
| <span class="addr">{{.Address}}</span> |
| <form method="post" action="/auth/signout"> |
| <input type="hidden" name="csrf" value="{{.CSRF}}"> |
diff --git a/internal/console/templates/newaccount.html b/internal/console/templates/newaccount.html| index 8bcfe1d3a..211541b7c 100644 |
| --- a/internal/console/templates/newaccount.html |
| +++ b/internal/console/templates/newaccount.html |
| @@ -14,5 +14,5 @@ and deploys independently of your other accounts.</p> |
| 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}} |
| +{{if .Account}}<p class="small"><a href="{{.Nav}}/">Back to {{.Account.Name}}</a></p>{{end}} |
| {{template "bottom" .}}{{end}} |