carlos / platform Public

console: redesign polish pass — full suite green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 27e2b661dc4266753005ae10d42cfdf8a0c503bd parent 21e0713
6 files changed, +34 −14
  • docs/superpowers/specs/2026-08-06-console-redesign-design.md +1 −1
  • internal/console/authcli.go +1 −1
  • internal/console/server_test.go +27 −10
  • internal/console/templates/app_activity.html +1 −0
  • internal/console/templates/app_new.html +3 −1
  • internal/console/templates/app_releases.html +1 −1
diff --git a/docs/superpowers/specs/2026-08-06-console-redesign-design.md b/docs/superpowers/specs/2026-08-06-console-redesign-design.md
index a96b85c9a..a9ecf131d 100644
--- a/docs/superpowers/specs/2026-08-06-console-redesign-design.md
+++ b/docs/superpowers/specs/2026-08-06-console-redesign-design.md
@@ -101,7 +101,7 @@ LAST).
| `/a/{sqid}/apps/{app}/activity` | App · Activity | audit table, cap 10 → 50 |
| `/a/{sqid}/apps/{app}/settings` | App · Settings | app identity + **Domains & TLS** (Heroku precedent; the tab has real content today) |
| `/settings` | Your settings | command-line access (cli_tokens content moves here) |
-| `/auth/cli/tokens` | 302 → `/settings` | Keymail notification bodies and cli_approve links keep working |
+| `/auth/cli/tokens` | 303 → `/settings` | Keymail notification bodies and cli_approve links keep working |
Existing legacy redirects (`/apps`, `/apps/{app}`) stay. No new legacy
redirects needed — the old GET URLs (`/a/{sqid}/`, `/a/{sqid}/apps/{app}`)
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 042725d5c..bc1f4e2f4 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -699,7 +699,7 @@ func (s *Server) handleCLITokens(w http.ResponseWriter, r *http.Request) {
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your terminals. Please try again.")
return
}
- p := cliPage{page: page{Title: "Command-line access", Address: addr, CSRF: raw},
+ p := cliPage{page: page{Title: "Your settings", Address: addr, CSRF: raw},
Tokens: toks, Console: s.cfg.Origin, Days: int(CLITokenTTL / (24 * time.Hour))}
p.Flash, p.FlashErr = s.takeFlash(w, r)
s.renderCLI(w, http.StatusOK, "settings", p)
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 9b83d292b..00a93b3d9 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "slices"
"strings"
"testing"
"time"
@@ -530,6 +531,7 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
t.Fatalf("overview missing %q", want)
}
}
+ assertNonMember404(t, fx, "/apps/kass")
}
func TestReleasesTab(t *testing.T) {
@@ -547,9 +549,11 @@ func TestReleasesTab(t *testing.T) {
t.Fatalf("releases tab missing %q", want)
}
}
- // Every mutation form on the tab (promote, rollback, new canary, retire
- // canary) carries a back=releases hidden field, so its redirect returns
- // here rather than to the overview — exactly 4, one per form.
+ // Every mutation form on the tab carries a back=releases hidden field, so
+ // its redirect returns here rather than to the overview. The fixture's
+ // count of 4 is 2 promote forms (one per version, v1 and v2) + 1 create-
+ // canary form + 1 retire-canary form (canary/x) — the rollback form
+ // contributes 0 because fixture stable is v1 with no history yet.
if n := strings.Count(body, `name="back" value="releases"`); n != 4 {
t.Fatalf(`releases tab has %d back=releases fields, want exactly 4`, n)
}
@@ -718,7 +722,7 @@ func TestAppNamedNewIsRefused(t *testing.T) {
if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
t.Fatalf("claiming 'new' = %d -> %q, want 303 back to the form", status, loc)
}
- if apps, _ := fx.st.AppsIn(OpsAccountID); strings.Contains(strings.Join(apps, " "), "new") {
+ if apps, _ := fx.st.AppsIn(OpsAccountID); slices.Contains(apps, "new") {
t.Fatal("reserved name 'new' was claimed")
}
}
@@ -1152,6 +1156,14 @@ func TestActivityTab(t *testing.T) {
assertNonMember404(t, fx, "/apps/kass/activity")
}
+// NOTE: Assemble groups routes by app NAME with no account filter (view.go),
+// so two accounts that happen to own an app of the same name share routes
+// here — rival-account hosts render in this tab's Domains table, and the
+// same leak reaches the overview tab's ladder. PRE-EXISTING, gated (the app
+// page's session → membership → HasAppIn 404 runs before Assemble, so this
+// is not a non-member leak), and neither introduced nor fixed by this task —
+// fixing it there would also decide what the half-migrated AccountMismatch
+// banner shows, a separate call. Source fix: view.go:294-308.
func TestAppSettingsTab(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
@@ -1759,6 +1771,13 @@ func TestAppPageSurvivesMetricsFailure(t *testing.T) {
if !strings.Contains(body, `aria-current="page">Instances</a>`) {
t.Fatalf("instances tab did not render its normal chrome: %s", body)
}
+ // Content, not just chrome: with the metrics read failing closed
+ // (appResourceMetrics returns nil) and the fixture route unreachable
+ // (Instances.Reporting false), the tab's real nil-metrics empty state
+ // is what a reader sees — not a partially-rendered page.
+ if !strings.Contains(body, "Nothing to show yet") {
+ t.Fatalf("instances tab did not render its nil-metrics empty state: %s", body)
+ }
}
// failingListStore fails exactly the List the metrics sweep starts with,
@@ -1823,12 +1842,10 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
}
// And the other account's host is not in the Resources section at all.
//
- // NOTE: the assertion is scoped to Resources on purpose. Assemble groups
- // routes by app NAME with no account filter (view.go), so rival's host
- // already renders in this page's Domains/ladder tables — a PRE-EXISTING
- // cross-account leak this task neither introduces nor fixes (fixing it
- // there would also decide what the half-migrated AccountMismatch banner
- // shows, which is a separate call). See the task report.
+ // NOTE: the assertion is scoped to Resources on purpose — this is the
+ // /instances tab, which renders neither Domains nor the ladder, so
+ // rival's host has nowhere else on THIS page to leak. It still leaks
+ // on the settings and overview tabs; see the NOTE above TestAppSettingsTab.
if res := resourcesSection(t, body); strings.Contains(res, "rival.example.com") {
t.Fatalf("Resources section leaked another account's host:\n%s", res)
}
diff --git a/internal/console/templates/app_activity.html b/internal/console/templates/app_activity.html
index 814d9e917..10c8ea993 100644
--- a/internal/console/templates/app_activity.html
+++ b/internal/console/templates/app_activity.html
@@ -3,6 +3,7 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
+<h2>Activity</h2>
{{if .Events}}
<div class="tablewrap">
<table>
diff --git a/internal/console/templates/app_new.html b/internal/console/templates/app_new.html
index f558958d2..f3814a228 100644
--- a/internal/console/templates/app_new.html
+++ b/internal/console/templates/app_new.html
@@ -8,7 +8,9 @@
</div>
<form class="signin" method="post" action="{{.Base}}/apps/new">
<input type="hidden" name="csrf" value="{{.CSRF}}">
- <input type="text" name="name" placeholder="my-app" autofocus>
+ <label class="visually-hidden" for="app-name">App name</label>
+ <input id="app-name" type="text" name="name" placeholder="my-app" autofocus
+ required pattern="[a-z][a-z0-9-]{0,31}" maxlength="32">
<button type="submit">Create app</button>
</form>
<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — unique within this account, not across the platform.</p>
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 3050813df..b9eeb9e10 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -31,7 +31,7 @@
</table>
</div>
<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p>
-{{else}}<p class="small">No channels yet.</p>{{end}}
+{{else}}<p class="small">No channels yet — ship a release, then promote it below to create the first one.</p>{{end}}
<h2>Canaries</h2>
{{if .Detail.Canaries}}