carlos / platform Public

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
Paul Campbell 386018be67529c208ec0d984fd87ff3a95445772 parent 885f0fc
9 files changed, +114 −9
  • internal/console/resolve.go +25 −1
  • internal/console/resolve_test.go +31 −0
  • internal/console/server.go +16 −1
  • internal/console/server_account_test.go +29 −0
  • internal/console/server_test.go +5 −3
  • internal/console/store.go +4 −0
  • internal/console/templates/apps.html +1 −1
  • internal/console/templates/layout.html +2 −2
  • internal/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}}