carlos / platform Public

Merge pull request #68 from carlosframework/worktree-console-redesign

console: carloku reskin + Heroku-style IA — sections, tabs, and a settings page of one's own
Paul Campbell b918b56f083dd648deff7bc61aae1f0757698ac9 parent dd87bf6parent b3eb702
24 files changed, +2582 −329
  • docs/superpowers/plans/2026-08-06-console-redesign.md +1342 −0
  • docs/superpowers/specs/2026-08-06-console-redesign-design.md +184 −0
  • internal/console/apicli.go +6 −0
  • internal/console/apicli_test.go +7 −0
  • internal/console/authcli.go +8 −5
  • internal/console/authcli_test.go +33 −4
  • internal/console/ownership.go +8 −7
  • internal/console/server.go +210 −58
  • internal/console/server_account_test.go +46 −5
  • internal/console/server_test.go +393 −54
  • internal/console/templates/account_settings.html +32 −0
  • internal/console/templates/app_activity.html +18 −0
  • internal/console/templates/app_instances.html +59 −0
  • internal/console/templates/app_new.html +17 −0
  • internal/console/templates/app_overview.html +25 −0
  • internal/console/templates/{app.html => app_releases.html} +9 −88
  • internal/console/templates/app_settings.html +32 −0
  • internal/console/templates/appnav.html +13 −0
  • internal/console/templates/apps.html +1 −74
  • internal/console/templates/cli_approve.html +2 −2
  • internal/console/templates/home.html +6 −2
  • internal/console/templates/layout.html +57 −25
  • internal/console/templates/{cli_tokens.html => settings.html} +10 −5
  • internal/console/templates/team.html +64 −0
diff --git a/docs/superpowers/plans/2026-08-06-console-redesign.md b/docs/superpowers/plans/2026-08-06-console-redesign.md
new file mode 100644
index 000000000..ba7f9d291
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-06-console-redesign.md
@@ -0,0 +1,1342 @@
+# Console Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Reskin the CARLOS console in the carloku.com design language and split its two overloaded pages into a Heroku-style IA: apps / team / settings sections per account, five tabs per app, a create-app page, and a personal settings page.
+
+**Architecture:** Go `html/template`, all CSS inline in `internal/console/templates/layout.html`, zero JS added, zero off-origin resources. GET pages split; **no POST path moves** (step-up stash/resume re-enters the same path). Tabs are built additively (Tasks 8–11) while the old one-page app view keeps working; the old page retires in Task 12 — **every commit keeps the suite green.** Every new account-scoped GET uses the existing `account()`/`owner()`/`accountApp()` gates so non-members keep getting byte-identical 404s.
+
+**Tech Stack:** Go 1.22 `http.ServeMux` method+pattern routing, `html/template`, vendored Lucide SVG icons (`icons.go`), stdlib-only tests via `httptest` fixtures in `internal/console/server_test.go`.
+
+**Spec:** `docs/superpowers/specs/2026-08-06-console-redesign-design.md`
+
+**Delegation (CLAUDE.md policy):** implement tasks with **Sonnet** using this plan as the brief; every task touching handlers/gates (3–13) gets an **Opus review**; name the model explicitly on every dispatch.
+
+## Global Constraints
+
+- Build/test loop, run from the repo root of THIS worktree:
+ `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1`
+ (may need the sandbox disabled — "read-only file system" from the toolchain is the tell).
+ Final gate: `go build ./... && go vet ./... && go test ./... -count=1` all clean; `gofmt -l` clean on touched files.
+- `icons_test.go` invariants are law: no `<link>` of any kind (no favicon, no webfont), no `<script src`, no off-origin anything, no text-glyph arrows/carets/checks in header chrome (`▾ ✓ × →` etc. — use `{{icon "slug"}}`), the 🤖 factor-X footer keeps its emoji.
+- Gate order in every POST handler: session → membership/ownership (byte-identical 404) → CSRF (`subtle.ConstantTimeCompare`) → `requireFreshAuth` LAST. New GET handlers: gate first, render after; never leak account existence.
+- Links use `{{.Nav}}` (name-or-sqid), forms post under `{{.Base}}` (sqid). Never mix.
+- Templates: a page is `{{define "x"}}{{template "top" .}} … {{template "bottom" .}}{{end}}`. A new page needs: `templates/<name>.html`, any new fields on `page` (server.go:312), a handler, one `mux.HandleFunc` line in `NewServer` (server.go:238-275).
+- Where a test snippet below guesses a fixture accessor wrong, follow the idiom of the named existing test — never weaken what the snippet asserts.
+- Commit after every task. Messages end with
+ `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
+
+---
+
+### Task 1: Design tokens + typography reskin (CSS only)
+
+**Files:**
+- Modify: `internal/console/templates/layout.html:7-213` (the `<style>` block only)
+
+**Interfaces:**
+- Produces: CSS classes `.pagehead`, `.eyebrow`, `.tabs`, `button.linkish.danger`, `a.addr`, and CSS vars `--sans`, `--mono`, `--warn`, `--accent-strong` — used by every later task. No template markup changes in this task.
+
+- [ ] **Step 1: Run the console suite to record a green baseline**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1`
+Expected: PASS (this task must not change it — no test asserts the serif stack or any color).
+
+- [ ] **Step 2: Replace the token block** (`layout.html:8-19`) with the carloku palette:
+
+```css
+ :root {
+ --bg: #ffffff; --ink: #201d29; --muted: #6f6480;
+ --accent: #5b3e96; --accent-strong: #4c3380;
+ --accent-soft: rgba(91, 62, 150, 0.09);
+ --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f;
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #16141c; --ink: #f0eef6; --muted: #a99ebd;
+ --accent: #b394f2; --accent-strong: #c9b2f7;
+ --accent-soft: rgba(179, 148, 242, 0.12);
+ --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a;
+ }
+ }
+```
+
+- [ ] **Step 3: Retype the page.** Precise edits, all inside the `<style>` block:
+
+1. `body` (`:21-25`): `font: 400 1rem/1.6 var(--sans);` (replaces the Charter/Georgia serif line; keep the rest of the rule).
+2. `code` (`:28`) and `pre` (`:32`): `font-family: var(--mono);`.
+3. `select, input[type=text]` (`:38`): add `font-family: var(--sans);` and change `border-radius` to `0.4rem`.
+4. The six `font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;` lines: **delete** from `header nav` (`:56`), `.switcher` (`:65`), `button, .btn` (`:197`), `footer` (`:210`) — redundant now the body is sans; **replace with** `font-family: var(--mono);` in `.rung-name` (`:171`) and `th` (`:189`) — mono is the working typeface for column headers and rung labels.
+5. `.tile h2` (`:131`): add `font-family: var(--mono);`.
+6. `.chip` (`:139`): add `font-family: var(--mono);` and change `font-weight` to `500`.
+7. Widths: `header` (`:49`), `main` (`:110`), `footer` (`:208`): `max-width: 64rem;`.
+8. `button, .btn` (`:196-202`): `border-radius: 0.4rem;`, then add after `button.linkish` (`:203-206`):
+
+```css
+ button:not(.linkish):hover, .btn:hover { background: var(--accent-strong); }
+ button.linkish.danger { color: var(--bad); }
+```
+
+- [ ] **Step 4: Add the new component CSS** (append at the end of the `<style>` block):
+
+```css
+ /* Page anatomy: every page opens with a mono eyebrow naming the context,
+ an h1, and one human sentence saying what the page is for. */
+ .pagehead { margin: 0 0 1.75rem; }
+ .eyebrow {
+ font-family: var(--mono); font-size: 0.75rem; letter-spacing: 0.08em;
+ text-transform: uppercase; color: var(--muted); margin: 0 0 0.3rem;
+ }
+ .pagehead h1 { margin: 0 0 0.35rem; }
+ .pagehead .lede { margin: 0; font-size: 1.05rem; }
+ /* App tab bar (Heroku-style), and the account nav's active marker.
+ aria-current carries the state; the accent underline draws it. */
+ .tabs {
+ display: flex; flex-wrap: wrap; gap: 1.25rem; margin: 0 0 2rem;
+ border-bottom: 1px solid var(--rule);
+ font-family: var(--mono); font-size: 0.85rem;
+ }
+ .tabs a {
+ color: var(--muted); text-decoration: none;
+ padding: 0 0 0.55rem; border-bottom: 2px solid transparent; margin-bottom: -1px;
+ }
+ .tabs a:hover { color: var(--ink); }
+ .tabs a[aria-current] { color: var(--ink); border-bottom-color: var(--accent); }
+ header nav a[aria-current] { color: var(--ink); font-weight: 600; text-decoration: none; }
+ header nav a.addr { color: var(--muted); text-decoration: none; }
+ header nav a.addr:hover { text-decoration: underline; }
+```
+
+- [ ] **Step 5: Re-run the console suite**
+
+Run: the Step 1 command. Expected: PASS unchanged. If a test fails it was asserting a style detail — fix the assertion to the new value, never revert the design.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/console/templates/layout.html
+git commit -m "console: adopt the carloku design tokens and retype the page
+
+Sans body, mono as the working typeface, purple accent, 64rem measure.
+Defines --warn (was referenced but undefined) and the .pagehead/.tabs
+vocabulary the IA split builds on."
+```
+
+---
+
+### Task 2: Signed-out home hero
+
+**Files:**
+- Modify: `internal/console/templates/home.html`
+- Test: `internal/console/server_test.go` (whichever tests assert home copy — grep `"The CARLOS platform console"`)
+
+- [ ] **Step 1: Rewrite `home.html`** (keep every `{{…}}` action that exists today — Error, the signin form):
+
+```html
+{{define "home"}}{{template "top" .}}
+<div class="pagehead">
+ <p class="eyebrow">🤖 CARLOS console</p>
+ <h1>Ship it. Promote it. Sleep<span style="color: var(--accent)">.</span></h1>
+ <p class="lede">Your apps, their live routes, and their release channels —
+ signed releases in, immutable bytes out.</p>
+</div>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form class="signin" method="get" action="/auth/signin">
+ <button type="submit">Sign in with Keymail</button>
+</form>
+<p class="small">One button, whether it's your first time or your five-hundredth.
+First visits approve the console once in your Keymail — that single approval
+is the whole sign-up. After that, the same button is a one-click sign-in.
+No passwords, ever; revoke the console from Keymail's connected apps at any
+time.</p>
+{{template "bottom" .}}{{end}}
+```
+
+The eyebrow's 🤖 is content (the carloku wordmark line), not header chrome, so `TestSwitcherChromeIsInlineSVGNotTextGlyphs` (header-scoped) is unaffected; an inline `style` attribute breaks no icons_test invariant (those ban `<link>`, `src=`, `url(http…)`).
+
+- [ ] **Step 2: Run the console suite; update any test asserting the old h1**
+
+Run: the console suite. Expected: tests asserting `The CARLOS platform console` fail; change those wants to `Ship it. Promote it. Sleep`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/console/templates/home.html internal/console/*_test.go
+git commit -m "console: carloku hero on the signed-out home"
+```
+
+---
+
+### Task 3: Create-app page (`GET /a/{sqid}/apps/new`)
+
+**Files:**
+- Create: `internal/console/templates/app_new.html`
+- Modify: `internal/console/server.go` (route, `handleNewAppForm`, `handleNewApp` error-redirect, `claimApp` reserved name), `internal/console/templates/apps.html`
+- Test: `internal/console/server_test.go`
+
+**Interfaces:**
+- Consumes: `account()` (server.go:601), `accountPage()` (server.go:1174).
+- Produces: `page.ActiveNav string` field; `assertNonMember404(t, fx, sub)` test helper — every later task's 404-parity step uses it.
+
+- [ ] **Step 1: Write the failing tests** (append to `server_test.go`):
+
+```go
+// assertNonMember404 signs bob (no membership in the fixture account) in and
+// asserts the account-scoped path answers him byte-identically to an account
+// that does not exist at all.
+func assertNonMember404(t *testing.T, fx *consoleFixture, sub string) {
+ t.Helper()
+ fx.setUser("bob@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-nm-"+t.Name())
+ s1, _, b1 := get(t, c, fx.u(sub))
+ s2, _, b2 := get(t, c, fx.ts.URL+"/a/zzzzzzzz"+sub)
+ if s1 != http.StatusNotFound || s2 != http.StatusNotFound || b1 != b2 {
+ t.Fatalf("non-member leak on %s: member-URL=%d unknown-URL=%d identical=%v", sub, s1, s2, b1 == b2)
+ }
+}
+
+func TestCreateAppPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/new"))
+ if status != 200 {
+ t.Fatalf("GET /apps/new = %d", status)
+ }
+ for _, want := range []string{
+ "Create an app", `action="` + fx.path("/apps/new") + `"`,
+ "lowercase letters, digits, and dashes",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("create-app page missing %q", want)
+ }
+ }
+ // The account home now links here instead of embedding the form.
+ _, _, home := get(t, c, fx.u("/"))
+ if !strings.Contains(home, `/apps/new"`) {
+ t.Fatal("account home does not link to /apps/new")
+ }
+ if strings.Contains(home, `action="`+fx.path("/apps/new")+`"`) {
+ t.Fatal("account home still embeds the create form")
+ }
+ assertNonMember404(t, fx, "/apps/new")
+}
+
+func TestAppNamedNewIsRefused(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"new"}})
+ 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") {
+ t.Fatal("reserved name 'new' was claimed")
+ }
+}
+```
+
+(`fx.u(sub)` and `fx.path(sub)` build the fixture account's URL/path — the `TestCreateApp` idiom at server_test.go:409.)
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `… go test ./internal/console/ -run 'CreateAppPage|AppNamedNew' -count=1`
+Expected: FAIL — `GET /apps/new = 404` (route missing).
+
+- [ ] **Step 3: Implement.**
+
+1. `page` struct (server.go:312, with the chrome fields around :334): add
+
+```go
+ ActiveNav string // account nav section this page sits in: "apps" | "team" | "settings"
+```
+
+2. Route (with the other `/a/{sqid}` GETs, server.go:254):
+
+```go
+ s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm)
+```
+
+Go 1.22's mux prefers the literal `apps/new` over `apps/{app}` — which is exactly why the name must be reserved (next point): an app named `new` could never be viewed.
+
+3. `claimApp` (server.go:1354): read its invalid-name branch first, then add a reserved-name check beside it, returning the same outcome shape the invalid-name path returns, with the message `That name is reserved.` The check is `name == "new"`, placed before any store write.
+
+4. Handler (next to `handleNewApp`, server.go:1421):
+
+```go
+// handleNewAppForm is the create-app page; the claim itself stays on
+// POST /a/{sqid}/apps/new (handleNewApp).
+func (s *Server) handleNewAppForm(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 = "Create an app"
+ p.ActiveNav = "apps"
+ s.render(w, http.StatusOK, "app_new", p)
+}
+```
+
+5. `handleNewApp` (server.go:1421): every failure path that today redirects to the account root (`303 → base+"/"`) redirects to `base+"/apps/new"` instead. The success path (303 → the new app's page) is unchanged.
+
+6. `templates/app_new.html`:
+
+```html
+{{define "app_new"}}{{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>Create an app</h1>
+ <p class="lede">Claim a name here; ship a release to it from your terminal.</p>
+</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>
+ <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>
+{{template "bottom" .}}{{end}}
+```
+
+7. `templates/apps.html`: delete the `<h2>Create an app</h2>` block (`:28-34`, heading + form + naming note) and add in its place:
+
+```html
+<p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p>
+```
+
+- [ ] **Step 4: Run the new tests, then the whole console suite**
+
+Expected: the two new tests PASS. `TestCreateApp` (server_test.go:409) fails on its taken-name legs — it asserts `303 → fx.path("/")` and re-reads `/` for the "That name is taken." flash; update those to `fx.path("/apps/new")` and read the flash from `fx.u("/apps/new")`. `TestEmptyStateWelcomesNewUser` (:376) asserts `action="…/apps/new"` on the home body — change that want to `href` + keep "Create app".
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/templates/ internal/console/server.go internal/console/server_test.go
+git commit -m "console: create-app is its own page
+
+GET /a/{sqid}/apps/new; the claim POST stays put. 'new' becomes a
+reserved app name because the literal route shadows apps/{app}."
+```
+
+---
+
+### Task 4: Team page (`GET /a/{sqid}/team`)
+
+**Files:**
+- Create: `internal/console/templates/team.html`
+- Modify: `internal/console/server.go` (route, `handleTeam`, redirect targets in `handleInvite`/`handleRevokeInvite`, slim `handleAccountHome`), `internal/console/ownership.go` (redirect targets in `handleRemoveMember`/`handleTransferOwnership`), `internal/console/templates/apps.html`
+- Test: `internal/console/server_test.go`, plus whichever tests drive invite/remove/transfer flows (grep `invite\|members/` across `internal/console/*_test.go`)
+
+**Interfaces:**
+- Consumes: `account()`, `accountPage()`, `assertNonMember404` (Task 3), `page.ActiveNav`.
+- Produces: the `/team` page every invite/member POST redirects back to.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestTeamPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/team"))
+ if status != 200 {
+ t.Fatalf("GET /team = %d", status)
+ }
+ for _, want := range []string{
+ "Team", "people who can see and ship",
+ `action="` + fx.path("/invite") + `"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("team page missing %q", want)
+ }
+ }
+ // The account home no longer carries the member machinery.
+ _, _, home := get(t, c, fx.u("/"))
+ for _, gone := range []string{"Invite a teammate", "Hand over ownership"} {
+ if strings.Contains(home, gone) {
+ t.Fatalf("account home still shows %q", gone)
+ }
+ }
+ assertNonMember404(t, fx, "/team")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails** — expected: `GET /team = 404`.
+
+- [ ] **Step 3: Implement.**
+
+1. Route: `s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam)`.
+2. Handler (next to `handleAccountHome`, server.go:1121):
+
+```go
+// handleTeam lists the account's members and, for owners, the invite
+// machinery. The mutations it hosts all POST to their existing paths.
+func (s *Server) handleTeam(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 = "Team"
+ p.ActiveNav = "team"
+ if members, err := s.store.Members(acct.ID); err != nil {
+ log.Printf("console: members: %v", err)
+ } else {
+ p.Members = members
+ }
+ if role == RoleOwner {
+ if invites, err := s.store.PendingInvites(acct.ID); err != nil {
+ log.Printf("console: pending invites: %v", err)
+ } else {
+ p.Invites = invites
+ }
+ }
+ s.render(w, http.StatusOK, "team", p)
+}
+```
+
+3. `templates/team.html` — the Members/invites machinery from `apps.html:36-90`, wrapped in the page anatomy; destructive buttons gain `danger`:
+
+```html
+{{define "team"}}{{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>Team</h1>
+ <p class="lede">The people who can see and ship this account's apps.</p>
+</div>
+<div class="tablewrap">
+<table>
+ <tr><th>Address</th><th>Role</th>{{if .IsOwner}}<th></th>{{end}}</tr>
+ {{range .Members}}<tr>
+ <td>{{.Name}}</td>
+ <td>{{.Role}}</td>
+ {{if $.IsOwner}}<td>
+ {{if ne .Name $.Address}}
+ <form method="post" action="{{$.Base}}/members/transfer">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="address" value="{{.Name}}">
+ <button class="linkish" type="submit">Hand over ownership</button>
+ </form>
+ <form method="post" action="{{$.Base}}/members/remove">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="address" value="{{.Name}}">
+ <button class="linkish danger" type="submit">Remove</button>
+ </form>
+ {{end}}
+ </td>{{end}}
+ </tr>{{end}}
+</table>
+</div>
+{{if .IsOwner}}
+<p class="small">Removing a member, handing over ownership and deleting the account each ask
+ you to confirm with your passkey — a signed-in browser on its own isn't enough.</p>
+<h2>Invite a teammate</h2>
+<form class="signin" method="post" action="{{.Base}}/invite">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="address" placeholder="them@keymail.dev">
+ <button type="submit">Send invitation</button>
+</form>
+<p class="small">The invitation arrives in their Keymail Apps inbox — the platform never emails anyone. It works once and expires in 7 days.</p>
+{{if .Invites}}
+<div class="tablewrap">
+<table>
+ <tr><th>Invited</th><th>By</th><th>Expires</th><th></th></tr>
+ {{range .Invites}}
+ <tr>
+ <td>{{.Address}}</td>
+ <td>{{.InvitedBy}}</td>
+ <td>{{fmtTime .ExpiresAt}}</td>
+ <td>
+ <form method="post" action="{{$.Base}}/invite/revoke">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="token" value="{{.Token}}">
+ <button class="linkish danger" type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{end}}
+{{end}}
+{{template "bottom" .}}{{end}}
+```
+
+4. `templates/apps.html`: delete `:36-90` (the `<h2>Members</h2>` heading through the pending-invites table close). Leave the delete-account block — it moves in Task 5.
+5. `handleAccountHome` (server.go:1121): delete the members load (`:1163-1167`) and the owner-invites load (`:1156-1162`).
+6. Redirect targets: where these four handlers `http.Redirect` back to the account root after `setFlash`, point them at `…/team` instead — `handleInvite` (server.go:751), `handleRevokeInvite` (:804), `handleRemoveMember` (ownership.go:33), `handleTransferOwnership` (ownership.go:58). **Only the redirect URL changes; gates, CSRF, and step-up calls do not move.** (Transfer demotes you to member; `/team` renders for members, so the landing is safe. Remove removes *someone else* — you stay a member.)
+
+- [ ] **Step 4: Run the full console suite; update redirect assertions**
+
+Expected: tests driving invite/remove/transfer assert `303 → fx.path("/")` — update to `fx.path("/team")`, and flash re-reads of `/` become `/team`. `stepup_test.go`'s ordering tests (`TestStepUpIsUnskippable`, `TestOwnershipActionsBehindStepUp`, `TestCSRFStillOutranksStepUp`) must pass — if one fails on anything but a landing URL, the implementation reordered a gate: fix the code, not the test.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: team is its own page
+
+Members, invites, transfer and remove live at /a/{sqid}/team; their
+POSTs stay at their existing paths and redirect back to the page."
+```
+
+---
+
+### Task 5: Account settings page (`GET /a/{sqid}/settings`)
+
+**Files:**
+- Create: `internal/console/templates/account_settings.html`
+- Modify: `internal/console/server.go` (route, `handleAccountSettings`, `page.HasApps`), `internal/console/templates/apps.html`
+- Test: `internal/console/server_test.go`, `internal/console/deletion_test.go` (delete-account flow)
+
+**Interfaces:**
+- Consumes: `account()`, `accountPage()`, `assertNonMember404`.
+- Produces: `page.HasApps bool`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+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")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails** — expected: 404.
+
+- [ ] **Step 3: Implement.**
+
+1. `page` struct: add `HasApps bool // account settings: an account with apps cannot be deleted`.
+2. Route: `s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings)`.
+3. Handler:
+
+```go
+// 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)
+}
+```
+
+4. `templates/account_settings.html`:
+
+```html
+{{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}}
+```
+
+Display note: `apps.html` today shows the empty-account delete form to **every** member; the POST is owner-gated, so a member submitting it gets a 404. Showing a member a form that will refuse them is exactly the un-human behavior this redesign removes — hence the `{{if .IsOwner}}` wrap. The template gate is display only; `handleDeleteAccount`'s `owner()` gate (ownership.go:86) remains the enforcement.
+
+5. `templates/apps.html`: delete the `{{if not .Apps}}…Delete this account…{{end}}` block (`:91-101`).
+
+- [ ] **Step 4: Run the full console suite; update delete-flow tests**
+
+Expected: `deletion_test.go`'s browser-driven account-delete tests fetch `/` for the form — point those fetches at `/settings`. POST path, confirm-name check, and step-up assertions stay identical.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "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."
+```
+
+---
+
+### Task 6: Account nav (Apps · Team · Settings) + address → personal settings link
+
+**Files:**
+- Modify: `internal/console/templates/layout.html:240-247`, `internal/console/server.go` (`ActiveNav` in `handleAccountHome` and `handleApp`), `internal/console/ownership.go` (`ActiveNav` in `handleTrash`)
+- Test: `internal/console/server_account_test.go`
+
+- [ ] **Step 1: Write the failing test** (append to server_account_test.go; derive the account-home path the way `TestEmptyStateWelcomesNewUser` does — the Location of `GET /`):
+
+```go
+func TestAccountNavHasSections(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, home, _ := get(t, c, fx.ts.URL+"/")
+ nav := strings.TrimSuffix(home, "/")
+ _, _, body := get(t, c, fx.ts.URL+home)
+ h := header(t, body)
+ for _, want := range []string{
+ `href="` + nav + `/"`, `href="` + nav + `/team"`, `href="` + nav + `/settings"`,
+ `href="/settings"`, // the address is the door to personal settings
+ } {
+ if !strings.Contains(h, want) {
+ t.Fatalf("header missing %q in:\n%s", want, h)
+ }
+ }
+ if strings.Count(h, `aria-current="page"`) != 1 {
+ t.Fatalf("want exactly one active nav section on the apps page, got:\n%s", h)
+ }
+ _, _, team := get(t, c, fx.ts.URL+nav+"/team")
+ if !strings.Contains(header(t, team), `/team" aria-current="page"`) {
+ t.Fatal("team page does not mark Team active")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.** The nav block in `layout.html` (`:240-247`) becomes:
+
+```html
+ {{if .Address}}<nav>
+ {{if .Account}}
+ <a href="{{.Nav}}/"{{if eq .ActiveNav "apps"}} aria-current="page"{{end}}>Apps</a>
+ <a href="{{.Nav}}/team"{{if eq .ActiveNav "team"}} aria-current="page"{{end}}>Team</a>
+ <a href="{{.Nav}}/settings"{{if eq .ActiveNav "settings"}} aria-current="page"{{end}}>Settings</a>
+ {{end}}
+ <a class="addr" href="/settings" title="Your settings">{{.Address}}</a>
+ <form method="post" action="/auth/signout">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button class="linkish" type="submit">Sign out</button>
+ </form>
+ </nav>{{end}}
+```
+
+(`/settings` 404s until Task 13 registers it — same PR, noted in the commit message.)
+
+Set `ActiveNav = "apps"` in `handleAccountHome` (server.go:1126, after `accountPage`), `handleApp` (:1240), and `handleTrash` (ownership.go:169, where it builds its page).
+
+- [ ] **Step 4: Run the console suite; update the pinned header tests**
+
+Expected: `TestHeaderSwitcherIsADropdownNamingTheCurrentAccount` (:220), `TestSingleAccountHeaderIsCoherent` (:263), `TestKeylessStillRendersTheSwitcher` (:286) pin the old single-link nav / addr-as-text — update to the three links and the addr link. `TestSignedOutHeaderKeepsTheWordmark` (:301) must pass **unmodified**.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: account nav gains Team and Settings; the address opens your settings
+
+/settings itself lands with the personal-settings page later in this branch."
+```
+
+---
+
+### Task 7: `appPage` helper + app tab bar (old app page keeps working)
+
+**Files:**
+- Create: `internal/console/templates/appnav.html`
+- Modify: `internal/console/server.go` (`appPage` helper, `handleApp` prologue, `page.ActiveTab`/`page.AppName`), `internal/console/templates/app.html`
+- Test: `internal/console/server_test.go`
+
+**Interfaces:**
+- Consumes: `accountApp()` (server.go:1207), `ensureAccountManifest`, `Assemble` — currently called inline in `handleApp` (server.go:1225-1242).
+- Produces:
+ - `func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (page, *Account, bool)` — gates, assembles the single-app view into `p.Detail`, fills chrome, sets `p.AppName`, `p.ActiveTab = tab`, `p.ActiveNav = "apps"`, `p.Title = app`. Tasks 8–12 call it.
+ - `page.ActiveTab string`, `page.AppName string`.
+ - Template partial `appnav` reading `.Account`, `.Nav`, `.AppName`, `.ActiveTab` — eyebrow + h1 + tab bar; the tab pages of Tasks 8–11 all open with it.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestAppTabBar(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, home, _ := get(t, c, fx.ts.URL+"/")
+ base := strings.TrimSuffix(home, "/") + "/apps/kass"
+ _, _, body := get(t, c, fx.ts.URL+base)
+ for _, want := range []string{
+ `class="tabs"`,
+ `href="` + base + `"`, `href="` + base + `/releases"`,
+ `href="` + base + `/instances"`, `href="` + base + `/activity"`,
+ `href="` + base + `/settings"`,
+ `aria-current="page">Overview</a>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("app page missing %q", want)
+ }
+ }
+}
+```
+
+(The fixture's app is `kass` — see `TestCreateApp`'s taken-name list. If the account-home redirect idiom differs, copy `TestEmptyStateWelcomesNewUser`.)
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. `page` struct additions:
+
+```go
+ ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings"
+ AppName string // app pages: the {app} segment, for the tab bar and eyebrow
+```
+
+2. `appPage` — extract `handleApp`'s prologue (server.go:1226-1242) **verbatim**, then set the new chrome:
+
+```go
+// appPage gates an app page, assembles its platform view, and fills the
+// chrome every app tab shares. Callers add their own tab's data on top.
+func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (page, *Account, bool) {
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
+ if !ok {
+ return page{}, nil, false
+ }
+ // The account manifest is re-attempted on view: minting writes it, but a
+ // bucket hiccup there is deliberately non-fatal, and this is where it gets
+ // another chance. Best-effort — a page view never fails on it.
+ s.ensureAccountManifest(r.Context(), acct)
+ views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.cfg.EdgeIPs)
+ if err != nil || len(views) != 1 {
+ log.Printf("console: assemble %s: %v", app, err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
+ return page{}, nil, false
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = app
+ p.AppName = app
+ p.ActiveTab = tab
+ p.ActiveNav = "apps"
+ p.Detail = &views[0]
+ return p, acct, true
+}
+```
+
+3. `handleApp` (server.go:1225): replace its gate+manifest+Assemble prologue with `p, acct, ok := s.appPage(w, r, "overview")`; the rest of its loads (Versions, Instances, metrics sweep, Events) stay for now, assigning into `p`.
+
+4. `templates/appnav.html`:
+
+```html
+{{define "appnav"}}
+<div class="pagehead">
+ <p class="eyebrow">App · {{.Account.Name}}</p>
+ <h1>{{.AppName}}</h1>
+</div>
+<nav class="tabs" aria-label="App sections">
+ <a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Releases</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Instances</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a>
+</nav>
+{{end}}
+```
+
+5. `templates/app.html:2`: replace `<h1>{{.Detail.App}}</h1>` with `{{template "appnav" .}}`, moving the two flash lines (`:3-4`) **above** it (flashes render first on app pages — the promote flows land here).
+
+(The four tab URLs 404 until Tasks 8–11 land — same PR; the suite has no test clicking them yet.)
+
+- [ ] **Step 4: Run the console suite**
+
+Expected: new test PASSES; existing app-page tests keep passing (the page still renders every section). If a ladder test pins `<h1>` adjacency, update it to the appnav markup.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: app pages get their tab bar and shared appPage chrome
+
+Tabs land page by page next; the one-page view keeps serving until the
+last tab exists."
+```
+
+---
+
+### Task 8: App Releases tab
+
+**Files:**
+- Create: `internal/console/templates/app_releases.html`
+- Modify: `internal/console/server.go` (route, `handleAppReleases`; `back` field in `handlePromote`/`handleRollback`/`handleNewCanary`/`handleRetireCanary`)
+- Test: `internal/console/server_test.go`
+
+**Interfaces:**
+- Consumes: `appPage()` (Task 7), `Versions()` (as called at server.go:1243).
+- Produces: the `back` form-field convention — optional hidden `back=releases`; the four promote-family handlers redirect to `…/apps/{app}/releases` when `back` is exactly the literal `"releases"`, else to the app page as today. The field survives step-up stash/resume (the stash keeps all fields but `csrf`, stepup.go:285-295). Never echo `back` into a URL — the allowlist comparison is the whole parser.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestReleasesTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if status != 200 {
+ t.Fatalf("GET releases tab = %d", status)
+ }
+ for _, want := range []string{
+ `aria-current="page">Releases</a>`,
+ "<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>",
+ `name="back" value="releases"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("releases tab missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass/releases")
+}
+```
+
+Also extend ONE promote-family flow test (e.g. `TestRollbackFlow`, server_test.go:549) with a `back` leg: repeat its happy-path POST with `"back": {"releases"}` added to the `url.Values` and assert the redirect Location ends `/apps/kass/releases`; keep the original leg asserting the app-page redirect (the default must stay covered).
+
+- [ ] **Step 2: Run to verify both fail.**
+
+- [ ] **Step 3: Implement.**
+
+1. Route: `s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)`.
+2. Handler:
+
+```go
+// handleAppReleases is the deploy surface: every shipped release, the
+// channels they promote onto, and the canaries riding ahead.
+func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "releases")
+ if !ok {
+ return
+ }
+ versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
+ if err != nil {
+ log.Printf("console: versions %s: %v", p.AppName, err)
+ p.FlashErr = "Could not list this app's releases."
+ }
+ p.Versions = versions
+ s.render(w, http.StatusOK, "app_releases", p)
+}
+```
+
+3. `templates/app_releases.html`: flashes, `{{template "appnav" .}}`, then `app.html`'s Channels (`:89-116`), Canaries (`:118-156`) and Releases (`:158-193`) sections copied **verbatim** (the originals stay in `app.html` until Task 12), with exactly three changes in the copy:
+ - every `<form method="post"` in these sections gains `<input type="hidden" name="back" value="releases">`;
+ - `Roll back` and `Retire` buttons become `class="linkish danger"`;
+ - nothing else — the empty-releases `carlos ship` guidance block (`:188-191`) comes along unchanged.
+4. `back` handling in `handlePromote` (server.go:1458), `handleRollback` (:1591), `handleNewCanary` (:1796), `handleRetireCanary` (:1868) — each computes redirects to the app page today; wrap that destination:
+
+```go
+ dest := base + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+```
+
+using each handler's actual base/app variable names, applied to **every** redirect in the handler (success and flash-error alike) so a releases-page submit always lands back on the releases page. `handleProduction`/`handleFastTrack` keep their current redirects — their forms live in the ladder.
+
+- [ ] **Step 4: Run the console suite** — expected: all green (old page untouched, new legs pass).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: releases tab — channels, canaries, releases, promote flows
+
+Forms carry back=releases (allowlisted literal, never echoed) so a
+promote made on the tab lands back on the tab; ladder flows still land
+on the app page."
+```
+
+---
+
+### Task 9: App Instances tab
+
+**Files:**
+- Create: `internal/console/templates/app_instances.html`
+- Modify: `internal/console/server.go` (route, `handleAppInstances`, extract `appResourceMetrics` helper; `handleApp` calls the helper)
+- Test: `internal/console/server_test.go`
+
+**Interfaces:**
+- Consumes: `appPage()`, `s.instances.Report`, `s.OwnerAppMetrics`.
+- Produces: `func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics` — the tenant-bounded metrics sweep, shared by `handleApp` (until Task 12) and `handleAppInstances`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestInstancesTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
+ if status != 200 {
+ t.Fatalf("GET instances tab = %d", status)
+ }
+ if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ t.Fatal("tab not active")
+ }
+ assertNonMember404(t, fx, "/apps/kass/instances")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. Extract the metrics sweep from `handleApp` (server.go:1249-1281) into a helper, moving the **entire tenant-boundary comment block (server.go:1250-1264) with it, word for word** — it documents why the account comparison is not redundant with the gate:
+
+```go
+func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics {
+ // [the verbatim comment block from server.go:1250-1264 lives here]
+ hosts := make([]string, 0, len(view.Routes))
+ hib := make(map[string]bool, len(view.Routes))
+ for _, rt := range view.Routes {
+ if rt.Account != acct.Sqid {
+ continue
+ }
+ hosts = append(hosts, rt.Host)
+ hib[rt.Host] = rt.Hibernate
+ }
+ am, err := s.OwnerAppMetrics(ctx, hosts)
+ if err != nil {
+ log.Printf("console: app metrics %s: %v", view.App, err)
+ return nil
+ }
+ for i := range am {
+ am[i].Hibernate = hib[am[i].Host]
+ }
+ return am
+}
+```
+
+`handleApp`'s inline sweep becomes `p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)`.
+
+2. Route: `s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)`.
+3. Handler:
+
+```go
+// handleAppInstances is what the app is actually running: its self-reported
+// instances, and each one's CPU/memory on the box it runs on.
+func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "instances")
+ if !ok {
+ return
+ }
+ p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
+ p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
+ s.render(w, http.StatusOK, "app_instances", p)
+}
+```
+
+4. `templates/app_instances.html`: flashes + `{{template "appnav" .}}`, then `app.html`'s Instances (`:41-59`) and Resources (`:61-87`) sections copied verbatim, plus an empty state after both:
+
+```html
+{{if and (or (not .Instances) (not .Instances.Reporting)) (not .AppMetrics)}}
+<p class="small">Nothing to show yet — instances appear when the app serves
+<code>/.carlos/instances</code>, and resource readings when it runs on a
+reporting box.</p>
+{{end}}
+```
+
+- [ ] **Step 4: Run the console suite** — expected: green. `TestAppPageOwnerMetricsBoundary` (:1210) still drives the old app page, which now goes through the extracted helper — it passing is the proof the extraction preserved the boundary.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: instances tab; the tenant-bounded metrics sweep becomes a helper
+
+Boundary comment and behavior move intact — the old page and the new
+tab share the one implementation."
+```
+
+---
+
+### Task 10: App Activity tab
+
+**Files:**
+- Create: `internal/console/templates/app_activity.html`
+- Modify: `internal/console/server.go` (route, `handleAppActivity`, `activityCap`)
+- Test: `internal/console/server_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestActivityTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/activity"))
+ if status != 200 {
+ t.Fatalf("GET activity tab = %d", status)
+ }
+ for _, want := range []string{`aria-current="page">Activity</a>`, "CLI promotions don"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("activity tab missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass/activity")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. Next to `eventsCap` (server.go:1291): `const activityCap = 50 // the activity tab has the room the overview strip never did`.
+2. Route: `s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)`.
+3. Handler:
+
+```go
+// handleAppActivity is the console's own audit trail for this app.
+func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "activity")
+ if !ok {
+ return
+ }
+ if events, err := s.store.Events(acct.ID, p.AppName, activityCap); err != nil {
+ log.Printf("console: events %s: %v", p.AppName, err)
+ } else {
+ p.Events = events
+ }
+ s.render(w, http.StatusOK, "app_activity", p)
+}
+```
+
+4. `templates/app_activity.html`: flashes + `{{template "appnav" .}}`, then the activity table from `app.html:195-206` with the `{{if .Events}}` gate inverted into an empty state:
+
+```html
+{{if .Events}}
+<div class="tablewrap">
+<table>
+ <tr><th>When</th><th>Who</th><th>What</th></tr>
+ {{range .Events}}
+ <tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Address}}</td><td>{{.Detail}}</td></tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">Nothing yet — promotes, rollbacks and canaries made in this console will show here.</p>{{end}}
+<p class="small">Actions taken through this console only — CLI promotions don't appear here.</p>
+```
+
+- [ ] **Step 4: Run the console suite** — expected: green (`eventsCap` and the old page untouched).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: activity tab — the audit trail gets a page and a 50-row cap"
+```
+
+---
+
+### Task 11: App Settings tab (identity + domains)
+
+**Files:**
+- Create: `internal/console/templates/app_settings.html`
+- Modify: `internal/console/server.go` (route, `handleAppSettings`)
+- Test: `internal/console/server_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestAppSettingsTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ if status != 200 {
+ t.Fatalf("GET app settings = %d", status)
+ }
+ for _, want := range []string{
+ `aria-current="page">Settings</a>`, "<h2>Identity</h2>", "Domains &amp; TLS",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("app settings missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass/settings")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. Route: `s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)`.
+2. Handler:
+
+```go
+// handleAppSettings is the app's own record: identity, and its domains —
+// the two things about an app that aren't a deploy.
+func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
+ p, _, ok := s.appPage(w, r, "settings")
+ if !ok {
+ return
+ }
+ s.render(w, http.StatusOK, "app_settings", p)
+}
+```
+
+3. `templates/app_settings.html`: flashes + `{{template "appnav" .}}`, then:
+
+```html
+<h2>Identity</h2>
+<div class="tablewrap">
+<table>
+ <tr><th>App</th><th>Account</th></tr>
+ <tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr>
+</table>
+</div>
+<h2>Domains &amp; TLS</h2>
+```
+
+followed by the body of `app.html`'s Domains section (`:22-39` — the table/empty-state, not its `<h2>` line) copied verbatim.
+
+- [ ] **Step 4: Run the console suite** — expected: green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: app settings tab — identity, domains and TLS (Heroku's answer to where domains live)"
+```
+
+---
+
+### Task 12: Slim the Overview; retire `app.html`
+
+**Files:**
+- Create: `internal/console/templates/app_overview.html`
+- Delete: `internal/console/templates/app.html`
+- Modify: `internal/console/server.go` (`handleApp` slims, `eventsCap` deleted)
+- Test: `internal/console/server_test.go` (the displaced app-page tests)
+
+**Interfaces:**
+- Consumes: everything Tasks 7–11 built. After this task, `handleApp` loads: `appPage` + `Versions` (the ladder's promote select) + `s.instances.Report` (the summary line). `AppMetrics` and `Events` loads leave `handleApp` for good.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ for _, gone := range []string{"<h2>Channels</h2>", "<h2>Releases</h2>", "<h2>Recent activity</h2>", "<h2>Domains</h2>", "<h2>Resources</h2>"} {
+ if strings.Contains(body, gone) {
+ t.Fatalf("overview still renders %q", gone)
+ }
+ }
+ for _, want := range []string{`class="ladder"`, "domain", "release"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("overview missing %q", want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails** (the old page still renders everything).
+
+- [ ] **Step 3: Implement.**
+
+1. `templates/app_overview.html`:
+
+```html
+{{define "app_overview"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+{{if .Detail.AccountMismatch}}
+<p class="banner error">
+ <strong>This app is not deploying.</strong> Its hosts read releases from
+ account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
+ <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
+ nothing. Its bucket objects were moved without the console's own record
+ following. On the console's box, run:
+ <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code>
+ <br><span class="small">If it is the routes that are in the wrong account,
+ move those back instead — whichever of the two is right, they have to
+ agree.</span>
+</p>
+{{end}}
+{{template "ladder" .}}
+<p class="small">
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">{{len .Detail.Domains}} domain{{if ne (len .Detail.Domains) 1}}s{{end}}</a>
+ {{if and .Instances .Instances.Reporting}} · <a href="{{.Nav}}/apps/{{.AppName}}/instances">{{len .Instances.Instances}} instance{{if ne (len .Instances.Instances) 1}}s{{end}} reporting</a>{{end}}
+ · <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{len .Versions}} release{{if ne (len .Versions) 1}}s{{end}}</a>
+</p>
+{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
+{{template "bottom" .}}{{end}}
+```
+
+2. `handleApp`: delete the `AppMetrics` helper call and the `Events` load; keep `Versions` and `Instances`; render `"app_overview"`.
+3. Delete `templates/app.html` and the now-unused `const eventsCap` (server.go:1291). Verify nothing references either: `grep -rn '"app"' internal/console/*.go` (render calls) and `grep -rn eventsCap internal/console/`.
+
+- [ ] **Step 4: Run the console suite; move the displaced tests to their tabs**
+
+Expected failures, each fixed by pointing the body-fetch at the section's new home (assertions themselves stay as strong as they were):
+- `TestAppPageRendersDomains` (:1007) → `…/apps/kass/settings`
+- `TestAppPageInstances` (:770), `TestAppPageOwnerMetricsBoundary` (:1210) → `…/instances` (the boundary test's assertions must pass **unmodified apart from the URL** — it is the tenant-boundary regression test)
+- `TestRecentActivity` (:796) → `…/activity` (a 10-cap assertion becomes 50)
+- `TestPromoteFlow` (:462), `TestRollbackFlow` (:549), `TestCanaryFlow` (:599), `TestRetireCanary` (:687) — where they scrape/assert the forms' page, fetch `…/releases`; their POSTs and default-redirect legs are unchanged
+- `TestCreateApp` (:409) — post-claim it asserts the ship-guidance copy on the app page; that copy now lives on the releases tab: fetch `…/apps/zephyr/releases` for those wants, and assert the Overview answer (`0 releases`) on the app page itself
+- `ladder_test.go` — drives the app URL; the ladder partial is untouched, so only assertions about surrounding page furniture (if any) change.
+
+- [ ] **Step 5: Full console suite green, then commit**
+
+```bash
+git add -A internal/console/ && git commit -m "console: the one-page app era ends
+
+Overview is the ladder plus a summary line; every section lives on its
+tab, each loading only its own data."
+```
+
+---
+
+### Task 13: Personal settings (`GET /settings`)
+
+**Files:**
+- Modify: `internal/console/authcli.go` (routes, revoke redirect, notification link), rename `internal/console/templates/cli_tokens.html` → `templates/settings.html`, `internal/console/templates/cli_approve.html` (two hrefs)
+- Test: `internal/console/authcli_test.go`
+
+- [ ] **Step 1: Write the failing test** (authcli_test.go; read how its existing tests fetch `/auth/cli/tokens` and match the client/redirect idiom):
+
+```go
+func TestPersonalSettingsPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.ts.URL+"/settings")
+ if status != 200 {
+ t.Fatalf("GET /settings = %d", status)
+ }
+ for _, want := range []string{"Your settings", "Command-line access"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/settings missing %q", want)
+ }
+ }
+ // The old URL keeps working — Keymail notification bodies link to it.
+ status, loc, _ := get(t, c, fx.ts.URL+"/auth/cli/tokens")
+ if status != http.StatusSeeOther || loc != "/settings" {
+ t.Fatalf("/auth/cli/tokens = %d -> %q, want 303 -> /settings", status, loc)
+ }
+ // Signed out, the personal page must not render.
+ a := newBrowser(t)
+ status, loc, _ = get(t, a, fx.ts.URL+"/settings")
+ if status != http.StatusFound || loc != "/" {
+ t.Fatalf("signed-out /settings = %d -> %q, want 302 -> /", status, loc)
+ }
+}
+```
+
+(Signed-out behavior must match what `GET /auth/cli/tokens` does today — read `handleCLITokens` first; if it differs from 302 `/`, assert what it actually does. The page's protection level must not decrease.)
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. In `registerCLIRoutes` (authcli.go:204-212), replace the `GET /auth/cli/tokens` registration with:
+
+```go
+ s.mux.HandleFunc("GET /settings", s.handleCLITokens)
+ s.mux.HandleFunc("GET /auth/cli/tokens", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
+ })
+```
+
+2. `handleCLITokenRevoke` (authcli.go): its post-revoke redirect to `/auth/cli/tokens` becomes `/settings`.
+3. Rename `templates/cli_tokens.html` → `templates/settings.html`, its define to `settings`, and the `renderCLI(w, …, "cli_tokens", …)` call in `handleCLITokens` to `"settings"`. Reshape the page top (everything below the lede — tokens table, empty state, the `carlos auth login` guidance — stays, with Revoke gaining `class="linkish danger"`):
+
+```html
+{{define "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">{{.Address}}</p>
+ <h1>Your settings</h1>
+ <p class="lede">Yours across every account — not any one account's.</p>
+</div>
+<h2>Command-line access</h2>
+<p class="small">Every terminal that can act as {{.Address}}.</p>
+```
+
+4. `cli_approve.html:16` and `:52`: hrefs `/auth/cli/tokens` → `/settings`. The Keymail notification body (grep `auth/cli/tokens` in authcli.go, ~:442) → `/settings` too, so new mail links straight; old mail rides the redirect.
+
+- [ ] **Step 4: Run the console suite** — update authcli tests asserting the old URL in rendered bodies (`TestCLIApprovalPageNeverApprovesFromALink` and the token-page tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A internal/console/ && git commit -m "console: your settings at /settings; the cli tokens page moves in
+
+/auth/cli/tokens 303s there, so Keymail notification links keep working."
+```
+
+---
+
+### Task 14: Full verification + template sweep + copy pass
+
+**Files:**
+- Modify: only what the checks below force.
+
+- [ ] **Step 1: The whole gate, from the worktree root**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... &&
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... &&
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1
+```
+
+Expected: every package passes. Then `gofmt -l internal/console/ cmd/console/` — empty output (the three pre-existing unformatted files elsewhere stay untouched).
+
+- [ ] **Step 2: Template sanity sweep** — the two classic split bugs:
+
+```bash
+grep -n 'href="{{.Base}}' internal/console/templates/*.html # must be empty: links use .Nav
+grep -n 'action="{{.Nav}}' internal/console/templates/*.html # must be empty: forms use .Base
+grep -rn '"app"' internal/console/*.go # no render call for the retired template
+```
+
+Fix any hit.
+
+- [ ] **Step 3: Human pass.** Read every page template top-to-bottom once, checking each has: an eyebrow, an h1, a lede (or a deliberate reason not to), an empty state that says what will fill the page, and destructive buttons in `danger`. Fix in place; keep copy in the console's voice (plain sentences, no exclamation marks, the reader is told why, not just what).
+
+- [ ] **Step 4: Commit anything Steps 1–3 changed**
+
+```bash
+git add -A && git commit -m "console: redesign polish pass — full suite green"
+```
+
+- [ ] **Step 5: Opus review checklist** (per CLAUDE.md, this branch is production-touching):
+gate order in every new handler; 404 parity on every new GET (`assertNonMember404` coverage); the `back` allowlist never echoes input; the metrics tenant boundary moved verbatim; no icons_test invariant loosened; every form posts to `.Base` and every link walks `.Nav`.
diff --git a/docs/superpowers/specs/2026-08-06-console-redesign-design.md b/docs/superpowers/specs/2026-08-06-console-redesign-design.md
new file mode 100644
index 000000000..a9ecf131d
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-06-console-redesign-design.md
@@ -0,0 +1,184 @@
+# Console redesign: carloku-informed reskin + information architecture
+
+**Date:** 2026-08-06
+**Status:** approved direction (tabs / domains-under-settings / one branch — Paul, 2026-08-06)
+
+## Why
+
+The console reads like a modified blog theme: serif body text (Charter/Georgia),
+one "Apps" nav link, and two overloaded pages — `apps.html` is account home +
+team + create-app + account settings in one scroll, and `app.html` is a 203-line
+everything-page. carloku.com established a design language (purple accent,
+system sans + ui-monospace, hairline rules, light/dark) that the console should
+inherit, reshaped for a Heroku/Render/Fly-class console.
+
+## Non-goals
+
+- No JavaScript beyond the existing switcher script; no webfonts, no favicon,
+ no off-origin subresources (`icons_test.go` invariants stand unchanged).
+- No new features: no env/config UI, no app rename/delete, no fleet nav link
+ (`/fleet` stays deliberately unlinked and unprobeable).
+- No POST path moves. Step-up stash/resume re-enters the same path and the
+ gate-order tests pin behavior; forms move to new pages, their actions do not.
+- No API changes (`/api/cli/*`, `/auth/cli/*` JSON endpoints untouched).
+
+## Visual direction
+
+Adopt the carloku tokens wholesale in `layout.html`:
+
+```css
+:root {
+ --bg: #ffffff; --ink: #201d29; --muted: #6f6480;
+ --accent: #5b3e96; --accent-strong: #4c3380;
+ --accent-soft: rgba(91, 62, 150, 0.09);
+ --rule: #eae7f0;
+ --bad: #b3403a; /* keep, re-tuned against the new palette */
+ --warn: #a6790f; /* DEFINE it — today referenced but undefined */
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #16141c; --ink: #f0eef6; --muted: #a99ebd;
+ --accent: #b394f2; --accent-strong: #c9b2f7;
+ --accent-soft: rgba(179, 148, 242, 0.12);
+ --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a;
+ }
+}
+```
+
+Dark `--bad`/`--warn` values may be tuned during build for contrast (AA on
+`--bg`); everything else is carloku-verbatim.
+
+**Type.** Body becomes `var(--sans)` (~1rem/1.6). `var(--mono)` is the working
+console typeface: page eyebrows, tab labels, chips, identifiers (release IDs,
+channels, hosts, sqids, addresses), table cells that carry machine truth.
+Headings sans semibold. The serif stack is deleted.
+
+**Page anatomy.** Every page opens with a header block:
+
+- mono eyebrow naming the context (`ACCOUNT · AMADAN`, `APP · KEYMAIL-RELAY`,
+ `YOUR SETTINGS`),
+- an `h1`,
+- a one-line `.lede` in `--muted` saying what the page is for, in plain human
+ words ("People who can see and ship this account's apps.").
+
+Content max-width widens 56rem → 64rem (consoles need table room); prose
+elements keep a ~40rem reading measure. Buttons take the carloku primary style
+(accent bg, `--bg` text, 0.4rem radius, 600 weight; hover `--accent-strong`).
+Destructive buttons stay `--bad`-outlined. Guidance rows (e.g. the domains DNS
+hint) may use carloku's numbered-circle affordance.
+
+**Navigation.**
+
+- Top bar (account context): switcher · **Apps · Team · Settings** links,
+ right side: address + personal menu (`Your settings`, sign out) + the
+ existing signout form. Active section gets an accent underline. Signed-out
+ header keeps the `CARLOS console` wordmark (test-pinned).
+- App pages add a horizontal tab bar under the app header: **Overview ·
+ Releases · Instances · Activity · Settings**. Active tab = accent
+ underline (2px). Pure CSS, `flex-wrap` for narrow screens; no media queries
+ required.
+
+**Home (signed-out)** gets the carloku hero treatment: mono eyebrow, big h1
+with accent-colored full stop, lede, primary "Sign in with Keymail" button.
+
+## Information architecture
+
+GET pages split; POST endpoints keep their exact paths and their exact gate
+order (session → account/owner byte-identical 404 → CSRF → `requireFreshAuth`
+LAST).
+
+| Route (GET) | Page | Content |
+|---|---|---|
+| `/a/{sqid}/` | Apps | apps table only + "Create app" button (link) |
+| `/a/{sqid}/apps/new` | Create app | name form; POST already lives at this path |
+| `/a/{sqid}/team` | Team | members table, transfer/remove, invite form, pending invites |
+| `/a/{sqid}/settings` | Account settings | account identity (name, sqid) + danger zone: delete (zero-apps rule and type-the-name intact) |
+| `/a/{sqid}/apps/{app}` | App · Overview | release ladder + live summary (domains OK/instances running chips linking to their tabs) |
+| `/a/{sqid}/apps/{app}/releases` | App · Releases | releases table + promote forms, channels + rollback, canaries + create/retire |
+| `/a/{sqid}/apps/{app}/instances` | App · Instances | instances table + per-host resource sparkline tiles |
+| `/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` | 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}`)
+remain real pages, just slimmer.
+
+**Overview summary chips** are derived from data already loaded for the app
+page today (domains, instances); no new store queries beyond what each split
+page needs. Each split page loads only its own data — the app page today
+loads everything for every visit; after the split, each handler fetches its
+tab's slice.
+
+## Code structure
+
+**Templates.** `apps.html` splits into `apps.html`, `app_new.html`,
+`team.html`, `account_settings.html`. `app.html` splits into
+`app_overview.html`, `app_releases.html`, `app_instances.html`,
+`app_activity.html`, `app_settings.html`. New shared partials in
+`layout.html` or their own file: `pagehead` (eyebrow/h1/lede), `appnav`
+(tab bar). `ladder.html` unchanged (its markup is heavily test-pinned;
+it gets restyled by CSS only). `cli_tokens.html` becomes `settings.html`
+(personal). All CSS stays in the single `layout.html` `<style>` block.
+
+**Handlers** (`internal/console/`):
+
+- `handleAccountHome` slims to apps-list only.
+- New: `handleNewAppForm` (GET `/a/{sqid}/apps/new`), `handleTeam`,
+ `handleAccountSettings`, `handleSettings` (personal), plus app tab handlers
+ `handleAppReleases`, `handleAppInstances`, `handleAppActivity`,
+ `handleAppSettings`. `handleApp` becomes the Overview.
+- All account-scoped GETs call the existing `account()` / `owner()` /
+ `accountApp()` gates — non-members get byte-identical 404s on every new
+ route. Personal `/settings` is session-gated only (like `/auth/cli/tokens`
+ today): signed-out gets 302 `/`.
+- `accountPage()` gains an `ActiveNav` field; a small `appPage()` helper
+ wraps `accountApp()` + common app-header data + `ActiveTab`.
+- `page` struct gains `ActiveNav`, `ActiveTab`, `Eyebrow`, `Lede` (or
+ equivalent — final shape at implementation).
+- Route registrations grouped and commented in `NewServer`.
+
+**Icons.** New Lucide icons vendored into `icons.go` as needed (e.g.
+`settings`, `users`, `activity`, `globe`) following the existing const+map
+pattern; `TestTemplateIconSlugsAllExist` keeps them honest.
+
+## Error handling
+
+Unchanged in kind: `renderMessage` pages, cookie flashes, redirect-then-render
+for every POST. The `message` template gets the same page-anatomy restyle.
+404-parity: every new account-scoped GET must answer signed-out with 302 `/`
+and non-member with byte-identical `http.NotFound` — asserted per-route in
+tests.
+
+## Testing
+
+- Update the ~110 markup assertions across `server_test.go`,
+ `server_account_test.go`, `ladder_test.go` (CSS-only changes should leave
+ most ladder assertions alone), `authcli_test.go`, `stepup_test.go`
+ (paths unchanged — expect near-zero churn), `icons_test.go` (invariants
+ unchanged; new icons registered).
+- New tests: each new GET route renders for members / 404s byte-identically
+ for non-members / redirects signed-out; tab bar marks the active tab;
+ `/auth/cli/tokens` redirects to `/settings`; create-app GET page renders
+ and its POST still round-trips; team actions still work from `/team`;
+ delete-account still gated behind zero apps + type-the-name + step-up from
+ `/settings`.
+- `go build ./... && go vet ./... && go test ./... -count=1` clean before
+ merge; `gofmt -l` clean on touched files.
+
+## Risks
+
+- **PR #63 (customer fleets)** touches `internal/console/server.go` — expect a
+ route-table rebase whichever lands second. No template overlap.
+- The markup-pinned test suite is the bulk of the work; budget accordingly.
+- Deploy: console is a long-running unit — promote → adopt → **restart
+ console** on both boxes, per standing rule.
+
+## Rollout
+
+One branch, one PR. Screenshots (light + dark) in the PR body. After merge:
+ship → promote → adopt → restart consoles (flagship + tito). Website batch
+untouched (console is not the marketing site).
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 7afd4bb13..101bccba3 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -170,6 +170,8 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
case claimInvalid:
jsonError(w, http.StatusBadRequest,
"invalid app name — lowercase letters, digits, and dashes, starting with a letter")
+ case claimReserved:
+ jsonError(w, http.StatusConflict, "that name is reserved")
case claimTaken:
jsonError(w, http.StatusConflict, "that name is taken")
case claimCheckFailed, claimCreateFailed:
@@ -184,6 +186,10 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
"account": acct.Sqid, "app": name,
"url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
})
+ default:
+ // Any future claimOutcome lands here rather than silently 200ing
+ // with an empty body — see the reserved-name finding this guards.
+ jsonError(w, http.StatusInternalServerError, "could not create the app — try again")
}
}
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 6efab0315..1f0407a8b 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -2043,6 +2043,13 @@ func TestAPICreateAppRefusalMatrix(t *testing.T) {
if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9lives"}, token); status != http.StatusBadRequest {
t.Fatalf("invalid name = %d %v, want 400", status, out)
}
+ // "new" is the literal name the console's own routes shadow — claimApp's
+ // claimReserved leg, refused before any store read/write.
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "new"}, token); status != http.StatusConflict {
+ t.Fatalf("reserved name = %d %v, want 409", status, out)
+ } else if !strings.Contains(str(t, out, "error"), "reserved") {
+ t.Fatalf("reserved name error = %v, want it to say reserved", out)
+ }
// kass is the fixture's already-claimed app.
if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "kass"}, token); status != http.StatusConflict {
t.Fatalf("claimed name = %d %v, want 409", status, out)
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 6826f0d50..bc1f4e2f4 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -205,7 +205,10 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("POST /auth/cli/poll", s.handleCLIPoll)
s.mux.HandleFunc("GET /auth/cli", s.handleCLIApprovePage)
s.mux.HandleFunc("POST /auth/cli/approve", s.handleCLIApprove)
- s.mux.HandleFunc("GET /auth/cli/tokens", s.handleCLITokens)
+ s.mux.HandleFunc("GET /settings", s.handleCLITokens)
+ s.mux.HandleFunc("GET /auth/cli/tokens", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
+ })
s.mux.HandleFunc("POST /auth/cli/tokens/revoke", s.handleCLITokenRevoke)
s.mux.HandleFunc("GET /auth/cli/whoami", s.handleCLIWhoami)
s.mux.HandleFunc("POST /auth/cli/revoke", s.handleCLIRevoke)
@@ -528,7 +531,7 @@ func (s *Server) notifyCLIIssued(address, rawToken string) {
fmt.Sprint(int(CLITokenTTL/(24*time.Hour)))+" days.\n\n"+
"If that was you running `carlos auth login`, nothing to do.\n\n"+
"If it was NOT you — if you clicked a link somebody sent you — revoke it now:\n\n"+
- s.cfg.Origin+"/auth/cli/tokens\n"); err != nil {
+ s.cfg.Origin+"/settings\n"); err != nil {
log.Printf("console: could not notify %s of a CLI token issue: %v", address, err)
}
}
@@ -696,10 +699,10 @@ 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, "cli_tokens", p)
+ s.renderCLI(w, http.StatusOK, "settings", p)
}
func (s *Server) handleCLITokenRevoke(w http.ResponseWriter, r *http.Request) {
@@ -726,7 +729,7 @@ func (s *Server) handleCLITokenRevoke(w http.ResponseWriter, r *http.Request) {
} else {
s.setFlash(w, "ok", "Revoked — that terminal can no longer act as you.")
}
- http.Redirect(w, r, "/auth/cli/tokens", http.StatusSeeOther)
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// handleCLIWhoami answers `carlos auth whoami`: who this token speaks as, and
diff --git a/internal/console/authcli_test.go b/internal/console/authcli_test.go
index c7df17447..248de3b53 100644
--- a/internal/console/authcli_test.go
+++ b/internal/console/authcli_test.go
@@ -161,7 +161,7 @@ func TestCLILoginEndToEnd(t *testing.T) {
}
// The owner can see it, and kill it, from the console.
- code, _, body = get(t, browser, fx.ts.URL+"/auth/cli/tokens")
+ code, _, body = get(t, browser, fx.ts.URL+"/settings")
if code != http.StatusOK || !strings.Contains(body, "paul@laptop") {
t.Fatalf("tokens page = %d, body:\n%s", code, body)
}
@@ -266,7 +266,7 @@ func TestCLIIssuanceNotifiesTheUser(t *testing.T) {
if found == "" {
t.Fatalf("no issuance notice in the sealed sends: %v", fx.fk.sentBodies())
}
- for _, want := range []string{"paul@laptop", "/auth/cli/tokens", "revoke"} {
+ for _, want := range []string{"paul@laptop", "/settings", "revoke"} {
if !strings.Contains(found, want) {
t.Fatalf("issuance notice missing %q:\n%s", want, found)
}
@@ -486,6 +486,35 @@ func cliToken(t *testing.T, fx *consoleFixture, address, oauthCode string) strin
return tok
}
+// TestPersonalSettingsPage: the CLI-tokens page becomes the personal settings
+// page at /settings. The old URL keeps working as a redirect — Keymail
+// notification bodies already mailed link to it — and a signed-out visitor
+// gets exactly the protection handleCLITokens has always given this page.
+func TestPersonalSettingsPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.ts.URL+"/settings")
+ if status != 200 {
+ t.Fatalf("GET /settings = %d", status)
+ }
+ for _, want := range []string{"Your settings", "Command-line access"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/settings missing %q", want)
+ }
+ }
+ // The old URL keeps working — Keymail notification bodies link to it.
+ status, loc, _ := get(t, c, fx.ts.URL+"/auth/cli/tokens")
+ if status != http.StatusSeeOther || loc != "/settings" {
+ t.Fatalf("/auth/cli/tokens = %d -> %q, want 303 -> /settings", status, loc)
+ }
+ // Signed out, the personal page must not render.
+ a := newBrowser(t)
+ status, loc, _ = get(t, a, fx.ts.URL+"/settings")
+ if status != http.StatusFound || loc != "/" {
+ t.Fatalf("signed-out /settings = %d -> %q, want 302 -> /", status, loc)
+ }
+}
+
// TestCLITokensPageRendersAppTokenHonestly: an app token never expires
// (expires_at 0), and the page must SAY so rather than rendering the Unix
// epoch — "expired in 1970" is a row a reader dismisses instead of
@@ -502,8 +531,8 @@ func TestCLITokensPageRendersAppTokenHonestly(t *testing.T) {
Kind: cliKindApp, AccountID: 7, App: "tito", CreatedAt: now},
}}
var buf bytes.Buffer
- if err := templates.ExecuteTemplate(&buf, "cli_tokens", p); err != nil {
- t.Fatalf("render cli_tokens: %v", err)
+ if err := templates.ExecuteTemplate(&buf, "settings", p); err != nil {
+ t.Fatalf("render settings: %v", err)
}
html := buf.String()
if strings.Contains(html, "1970") {
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index f88dbe2d3..e7ab12b33 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -49,7 +49,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
} else {
s.setFlash(w, "ok", target+" is no longer a member of this account.")
}
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/team", http.StatusSeeOther)
}
// handleTransferOwnership hands the account to another member and steps the
@@ -74,7 +74,7 @@ func (s *Server) handleTransferOwnership(w http.ResponseWriter, r *http.Request)
} else {
s.setFlash(w, "ok", target+" now owns this account. You are a member of it.")
}
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/team", http.StatusSeeOther)
}
// handleDeleteAccount retires an EMPTY account. Deliberately narrow: an account
@@ -97,7 +97,7 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
// cost a passkey touch.
if strings.TrimSpace(r.FormValue("confirm")) != acct.Name {
s.setFlash(w, "err", "Type the account's name exactly to delete it.")
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
return
}
if !s.requireFreshAuth(w, r, addr, raw) {
@@ -110,7 +110,7 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
// act on by dropping its registry row.
if acct.ID == OpsAccountID {
s.setFlash(w, "err", deletionAdvice(fmt.Errorf("console: ops")))
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
return
}
if n, err := s.store.AppCount(acct.ID); err != nil || n > 0 {
@@ -120,7 +120,7 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
} else {
s.setFlash(w, "err", deletionAdvice(fmt.Errorf("console: still holds %d app(s)", n)))
}
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
return
}
// The bucket first, and the deletion is abandoned if it will not take.
@@ -143,13 +143,13 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
s.setFlash(w, "err", "Could not retire this account in the deployment bucket, so nothing "+
"was deleted — an account deleted only here would come back on the next restart. "+
"Try again once the bucket is reachable.")
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
return
}
if err := s.store.DeleteAccount(acct.ID, time.Now()); err != nil {
log.Printf("console: delete account: %v", err)
s.setFlash(w, "err", deletionAdvice(err))
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther)
return
}
s.setFlash(w, "ok", "Deleted “"+acct.Name+"”.")
@@ -180,6 +180,7 @@ func (s *Server) handleTrash(w http.ResponseWriter, r *http.Request) {
}
p := s.accountPage(w, r, addr, raw, acct, role)
p.Title = "Trash"
+ p.ActiveNav = "apps"
p.Trashed = TrashedApps(r.Context(), s.cfg.Releases, acct.Sqid, rows)
s.render(w, http.StatusOK, "trash", p)
}
diff --git a/internal/console/server.go b/internal/console/server.go
index dbbb7a068..c31e827de 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -252,8 +252,18 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// Account-scoped and explicit: /a/<sqid>/apps/<app> rather than
// 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.
+ s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm)
s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/production", s.handleProduction)
@@ -316,6 +326,11 @@ type page struct {
Error string
Welcome bool // one-shot "account created" banner
+ ActiveNav string // account nav section this page sits in: "apps" | "team" | "settings"
+
+ ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings"
+ AppName string // app pages: the {app} segment, for the tab bar and eyebrow
+
SubmittedAddr string // interstitial: the address the user typed
KeymailURL string // interstitial: link to that address's keymail host
@@ -337,8 +352,9 @@ type page struct {
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
+ 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
@@ -759,7 +775,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther)
}
to := strings.ToLower(strings.TrimSpace(r.FormValue("address")))
if !ValidAddress(to) {
@@ -828,7 +844,7 @@ func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) {
break
}
}
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther)
}
// handleAcceptInvite consumes an invite link. It requires a session as the
@@ -1116,8 +1132,8 @@ func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) {
// ---- app pages --------------------------------------------------------------
-// handleAccountHome is the account's app list, its switcher, its members and
-// its pending invitations — the page /​ lands you on.
+// handleAccountHome is the account's app list and switcher — the page /
+// lands you on. Members and pending invitations live on /team (handleTeam).
func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
addr, raw, acct, role, ok := s.account(w, r)
if !ok {
@@ -1125,6 +1141,7 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
}
p := s.accountPage(w, r, addr, raw, acct, role)
p.Title = acct.Name
+ p.ActiveNav = "apps"
if c, err := r.Cookie(welcomeCookie); err == nil && c.Value == "1" {
p.Welcome = true
s.clearCookie(w, welcomeCookie)
@@ -1145,14 +1162,31 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
p.Apps = append(p.Apps, summarize(v))
}
}
- // The Trash link, and only when there is something in it. Best-effort like
- // the members and invites reads below: a failed count costs a link, never
- // the page.
+ // The Trash link, and only when there is something in it. Best-effort: a
+ // failed count costs a link, never the page.
if trashed, err := s.store.TrashedAppsIn(acct.ID); err != nil {
log.Printf("console: trashed apps: %v", err)
} else {
p.TrashN = len(trashed)
}
+ s.render(w, http.StatusOK, "apps", p)
+}
+
+// handleTeam lists the account's members and, for owners, the invite
+// machinery. The mutations it hosts all POST to their existing paths.
+func (s *Server) handleTeam(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 = "Team"
+ p.ActiveNav = "team"
+ if members, err := s.store.Members(acct.ID); err != nil {
+ log.Printf("console: members: %v", err)
+ } else {
+ p.Members = members
+ }
if role == RoleOwner {
if invites, err := s.store.PendingInvites(acct.ID); err != nil {
log.Printf("console: pending invites: %v", err)
@@ -1160,12 +1194,26 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
p.Invites = invites
}
}
- if members, err := s.store.Members(acct.ID); err != nil {
- log.Printf("console: members: %v", err)
- } else {
- p.Members = members
+ 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
}
- s.render(w, http.StatusOK, "apps", p)
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = "Account settings"
+ p.ActiveNav = "settings"
+ n, err := s.store.AppCount(acct.ID)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
+ return
+ }
+ p.HasApps = n > 0
+ s.render(w, http.StatusOK, "account_settings", p)
}
// accountPage fills the chrome every account-scoped page shares — the
@@ -1222,10 +1270,12 @@ func (s *Server) accountApp(w http.ResponseWriter, r *http.Request) (addr, raw s
return addr, raw, acct, role, app, true
}
-func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
+// appPage gates an app page, assembles its platform view, and fills the
+// chrome every app tab shares. Callers add their own tab's data on top.
+func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (page, *Account, bool) {
addr, raw, acct, role, app, ok := s.accountApp(w, r)
if !ok {
- return
+ return page{}, nil, false
}
// The account manifest is re-attempted on view: minting writes it, but a
// bucket hiccup there is deliberately non-fatal, and this is where it gets
@@ -1235,60 +1285,123 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
if err != nil || len(views) != 1 {
log.Printf("console: assemble %s: %v", app, err)
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
- return
+ return page{}, nil, false
}
p := s.accountPage(w, r, addr, raw, acct, role)
p.Title = app
+ p.AppName = app
+ p.ActiveTab = tab
+ p.ActiveNav = "apps"
p.Detail = &views[0]
+ return p, acct, true
+}
+
+// appResourceMetrics sweeps the app's own instances only. The allowlist is
+// built HERE, from the routes this account's app owns and nothing else —
+// that slice is the entire tenant boundary on the metrics read, so it must
+// never be widened to "all routes" or taken from the request. A failed
+// sweep costs the section, never the page.
+//
+// The account comparison is load-bearing and not redundant with the
+// accountApp gate above: Assemble groups routes by app NAME, and app
+// names are unique per ACCOUNT, not globally (Store.AppAccounts) — two
+// accounts may each own an app called "api", and both accounts' routes
+// arrive in this one AppView. Comparing each route's own account is what
+// keeps one tenant's cgroup readings off the other's page. It fails
+// closed: a route stamped with an account the console record disagrees
+// with (the half-migrated state AccountMismatch describes) yields no
+// metrics rather than someone else's.
+func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics {
+ hosts := make([]string, 0, len(view.Routes))
+ hib := make(map[string]bool, len(view.Routes))
+ for _, rt := range view.Routes {
+ if rt.Account != acct.Sqid {
+ continue
+ }
+ hosts = append(hosts, rt.Host)
+ hib[rt.Host] = rt.Hibernate
+ }
+ am, err := s.OwnerAppMetrics(ctx, hosts)
+ if err != nil {
+ log.Printf("console: app metrics %s: %v", view.App, err)
+ return nil
+ }
+ for i := range am {
+ am[i].Hibernate = hib[am[i].Host]
+ }
+ return am
+}
+
+func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "overview")
+ if !ok {
+ return
+ }
+ app := p.Detail.App
versions, err := Versions(s.cfg.Releases, acct.Sqid, app)
if err != nil {
log.Printf("console: versions %s: %v", app, err)
p.FlashErr = "Could not list this app's releases."
}
p.Versions = versions
- p.Instances = s.instances.Report(r.Context(), views[0].Routes)
- // Resources: the app's own instances only. The allowlist is built HERE,
- // from the routes this account's app owns and nothing else — that slice
- // is the entire tenant boundary on the metrics read, so it must never be
- // widened to "all routes" or taken from the request. A failed sweep
- // costs the section, never the page.
- //
- // The account comparison is load-bearing and not redundant with the
- // accountApp gate above: Assemble groups routes by app NAME, and app
- // names are unique per ACCOUNT, not globally (Store.AppAccounts) — two
- // accounts may each own an app called "api", and both accounts' routes
- // arrive in this one AppView. Comparing each route's own account is what
- // keeps one tenant's cgroup readings off the other's page. It fails
- // closed: a route stamped with an account the console record disagrees
- // with (the half-migrated state AccountMismatch describes) yields no
- // metrics rather than someone else's.
- hosts := make([]string, 0, len(views[0].Routes))
- hib := make(map[string]bool, len(views[0].Routes))
- for _, rt := range views[0].Routes {
- if rt.Account != acct.Sqid {
- continue
- }
- hosts = append(hosts, rt.Host)
- hib[rt.Host] = rt.Hibernate
+ p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
+ s.render(w, http.StatusOK, "app_overview", p)
+}
+
+// handleAppReleases is the deploy surface: every shipped release, the
+// channels they promote onto, and the canaries riding ahead.
+func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "releases")
+ if !ok {
+ return
}
- if am, err := s.OwnerAppMetrics(r.Context(), hosts); err != nil {
- log.Printf("console: app metrics %s: %v", app, err)
- } else {
- for i := range am {
- am[i].Hibernate = hib[am[i].Host]
- }
- p.AppMetrics = am
+ versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
+ if err != nil {
+ log.Printf("console: versions %s: %v", p.AppName, err)
+ p.FlashErr = "Could not list this app's releases."
+ }
+ p.Versions = versions
+ s.render(w, http.StatusOK, "app_releases", p)
+}
+
+// handleAppInstances is what the app is actually running: its self-reported
+// instances, and each one's CPU/memory on the box it runs on.
+func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "instances")
+ if !ok {
+ return
+ }
+ p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
+ p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
+ s.render(w, http.StatusOK, "app_instances", p)
+}
+
+// handleAppActivity is the console's own audit trail for this app.
+func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "activity")
+ if !ok {
+ return
}
- if events, err := s.store.Events(acct.ID, app, eventsCap); err != nil {
- log.Printf("console: events %s: %v", app, err)
+ if events, err := s.store.Events(acct.ID, p.AppName, activityCap); err != nil {
+ log.Printf("console: events %s: %v", p.AppName, err)
} else {
p.Events = events
}
- s.render(w, http.StatusOK, "app", p)
+ s.render(w, http.StatusOK, "app_activity", p)
+}
+
+// handleAppSettings is the app's own record: identity, and its domains —
+// the two things about an app that aren't a deploy.
+func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
+ p, _, ok := s.appPage(w, r, "settings")
+ if !ok {
+ return
+ }
+ s.render(w, http.StatusOK, "app_settings", p)
}
-// eventsCap bounds the app page's recent-activity list.
-const eventsCap = 10
+// activityCap bounds the activity tab's own listing.
+const activityCap = 50 // the activity tab has the room the overview strip never did
// audit best-effort-records an owner action: a failed audit line is logged,
// never surfaced, and never fails the action it records.
@@ -1333,6 +1446,7 @@ type claimOutcome int
const (
claimOK claimOutcome = iota
claimInvalid // not a ValidApp name
+ claimReserved // the literal name the console's own routes shadow (e.g. "new")
claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race
claimCheckFailed // a database lookup failed
claimStoreFailed // the release-store listing failed
@@ -1355,6 +1469,12 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr string)
if !release.ValidApp(name) {
return claimInvalid
}
+ // "new" is the literal path segment GET /a/{sqid}/apps/new resolves to —
+ // Go 1.22's mux prefers it over apps/{app}, so an app named "new" could
+ // never be viewed. Reserved before any store write, same as an invalid name.
+ if name == "new" {
+ return claimReserved
+ }
// Taken means taken IN THIS ACCOUNT — app names are unique per account,
// never globally, so another account's "blog" is not a conflict.
if has, err := s.store.HasAppIn(acct.ID, name); err != nil {
@@ -1413,6 +1533,19 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr string)
return claimOK
}
+// handleNewAppForm is the create-app page; the claim itself stays on
+// POST /a/{sqid}/apps/new (handleNewApp).
+func (s *Server) handleNewAppForm(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 = "Create an app"
+ p.ActiveNav = "apps"
+ s.render(w, http.StatusOK, "app_new", p)
+}
+
// handleNewApp is the browser door onto claimApp: the account page's New app
// form. Any member may claim — creation grants nothing promotion doesn't
// already gate — and unlike the CLI door it carries no rate limit: a session
@@ -1428,14 +1561,17 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
return
}
home := "/a/" + url.PathEscape(acct.Sqid) + "/"
+ formPage := home + "apps/new"
fail := func(msg string) {
s.setFlash(w, "err", msg)
- http.Redirect(w, r, home, http.StatusSeeOther)
+ http.Redirect(w, r, formPage, http.StatusSeeOther)
}
name := normalizeAppName(r.FormValue("name"))
switch s.claimApp(r.Context(), acct, name, addr) {
case claimInvalid:
fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")
+ case claimReserved:
+ fail("That name is reserved.")
case claimTaken:
fail("That name is taken.")
case claimCheckFailed:
@@ -1471,7 +1607,11 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+ http.Redirect(w, r, dest, http.StatusSeeOther)
}
channel := strings.TrimSpace(r.FormValue("new_channel"))
if channel == "" {
@@ -1604,7 +1744,11 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+ http.Redirect(w, r, dest, http.StatusSeeOther)
}
channel := strings.TrimSpace(r.FormValue("channel"))
if !release.ValidChannel(channel) {
@@ -1809,7 +1953,11 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+ http.Redirect(w, r, dest, http.StatusSeeOther)
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
if !canaryNameRe.MatchString(name) {
@@ -1881,7 +2029,11 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+ http.Redirect(w, r, dest, http.StatusSeeOther)
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
if !canaryNameRe.MatchString(name) {
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index a36eb87ae..1eae5aa5e 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")
}
}
@@ -311,7 +314,7 @@ func TestInviteAcceptCreatesMembership(t *testing.T) {
st, loc, _ := postForm(t, owner, fx.u("/invite"),
url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
- if st != http.StatusSeeOther || loc != fx.path("/") {
+ if st != http.StatusSeeOther || loc != fx.path("/team") {
t.Fatalf("invite = %d -> %q", st, loc)
}
pending, _ := fx.st.PendingInvites(OpsAccountID)
@@ -641,12 +644,12 @@ func TestAuditIsScopedToTheAccount(t *testing.T) {
// And through the page, which is where the leak would actually be seen.
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/activity"))
if !strings.Contains(body, "promoted v1 to stable") {
- t.Error("the account's own activity is missing from its app page")
+ t.Error("the account's own activity is missing from its activity tab")
}
if strings.Contains(body, "sam@keymail.dev") || strings.Contains(body, "promoted v9 to stable") {
- t.Errorf("another account's activity leaked onto this app page:\n%s", body)
+ t.Errorf("another account's activity leaked onto its activity tab:\n%s", body)
}
}
@@ -692,6 +695,17 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
if !strings.Contains(body, "console migrate-app -app kass -to "+sqid) {
t.Errorf("the warning does not name the command that fixes it:\n%s", body)
}
+
+ // The promote/rollback/canary forms live on the releases tab, not the
+ // overview — a deep link there must not promote blind. Same warning,
+ // same fix, on the page that actually hosts the mutations.
+ _, _, body = get(t, c, fx.u("/apps/kass/releases"))
+ if !strings.Contains(body, "not deploying") {
+ t.Errorf("no warning on the releases tab of a half-migrated app:\n%s", body)
+ }
+ if !strings.Contains(body, "console migrate-app -app kass -to "+sqid) {
+ t.Errorf("the releases tab warning does not name the command that fixes it:\n%s", body)
+ }
}
// Creating an account from the console: minted id, bucket manifest, owner
@@ -761,3 +775,30 @@ func TestSameAppNameInTwoAccounts(t *testing.T) {
}
}
}
+
+// The account nav grows Apps · Team · Settings, account-scoped and marking
+// whichever section the page sits in, and the signed-in address becomes the
+// door to personal settings (registered later this branch).
+func TestAccountNavHasSections(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, home, _ := get(t, c, fx.ts.URL+"/")
+ nav := strings.TrimSuffix(home, "/")
+ _, _, body := get(t, c, fx.ts.URL+home)
+ h := header(t, body)
+ for _, want := range []string{
+ `href="` + nav + `/"`, `href="` + nav + `/team"`, `href="` + nav + `/settings"`,
+ `href="/settings"`, // the address is the door to personal settings
+ } {
+ if !strings.Contains(h, want) {
+ t.Fatalf("header missing %q in:\n%s", want, h)
+ }
+ }
+ if strings.Count(h, `aria-current="page"`) != 1 {
+ t.Fatalf("want exactly one active nav section on the apps page, got:\n%s", h)
+ }
+ _, _, team := get(t, c, fx.ts.URL+nav+"/team")
+ if !strings.Contains(header(t, team), `/team" aria-current="page"`) {
+ t.Fatal("team page does not mark Team active")
+ }
+}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 29955b01a..00a93b3d9 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -5,6 +5,7 @@ import (
"crypto/ed25519"
"encoding/json"
"errors"
+ "fmt"
"io"
"net/http"
"net/http/cookiejar"
@@ -13,6 +14,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "slices"
"strings"
"testing"
"time"
@@ -242,21 +244,39 @@ func TestConsoleE2E(t *testing.T) {
t.Fatal(err)
}
- // Detail page: routes and channels, with the bake approximation labeled.
+ // Overview: the ladder, with channel names and versions on it.
status, _, body := get(t, c, fx.u("/apps/kass"))
if status != 200 {
t.Fatalf("/apps/kass = %d", status)
}
- for _, want := range []string{
- "Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom",
- "ready", "v1", "stable", "canary/x", "v2",
- "bakes until ~", "<td>live</td>", "approximation",
- } {
+ for _, want := range []string{"v1", "stable", "canary/x", "v2"} {
if !strings.Contains(body, want) {
t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
}
}
+ // Releases tab: the Channels table, with the bake approximation labeled.
+ status, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
+ if status != 200 {
+ t.Fatalf("/apps/kass/releases = %d", status)
+ }
+ for _, want := range []string{"canary/x", "v2", "bakes until ~", "<td>live</td>", "approximation"} {
+ if !strings.Contains(rbody, want) {
+ t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, rbody)
+ }
+ }
+
+ // Settings tab: the app's domains.
+ status, _, sbody := get(t, c, fx.u("/apps/kass/settings"))
+ if status != 200 {
+ t.Fatalf("/apps/kass/settings = %d", status)
+ }
+ for _, want := range []string{"Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom", "ready"} {
+ if !strings.Contains(sbody, want) {
+ t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, sbody)
+ }
+ }
+
// Apps not owned 404 exactly like apps that don't exist.
if status, _, _ := get(t, c, fx.u("/apps/otherapp")); status != http.StatusNotFound {
t.Fatalf("/apps/otherapp = %d, want 404", status)
@@ -387,7 +407,7 @@ func TestEmptyStateWelcomesNewUser(t *testing.T) {
}
for _, want := range []string{
"Account created", "Welcome to the CARLOS platform",
- "bob@keymail.dev", "Create app", `action="` + strings.TrimSuffix(home, "/") + `/apps/new"`,
+ "bob@keymail.dev", "Create app", `href="` + strings.TrimSuffix(home, "/") + `/apps/new"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("empty state missing %q in:\n%s", want, body)
@@ -420,14 +440,23 @@ func TestCreateApp(t *testing.T) {
if status != 200 {
t.Fatalf("/apps/zephyr = %d", status)
}
+ for _, want := range []string{"Created app zephyr", "0 releases"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body)
+ }
+ }
+ status, _, rbody := get(t, c, fx.u("/apps/zephyr/releases"))
+ if status != 200 {
+ t.Fatalf("/apps/zephyr/releases = %d", status)
+ }
for _, want := range []string{
- "Created app zephyr", "Nothing shipped yet",
+ "Nothing shipped yet",
"carlos ship -app zephyr -version $(git rev-parse --short HEAD) -target linux-arm64",
"carlos ship -app zephyr -kind static -version v1",
"promoted to channels from this page",
} {
- if !strings.Contains(body, want) {
- t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body)
+ if !strings.Contains(rbody, want) {
+ t.Fatalf("/apps/zephyr/releases missing %q in:\n%s", want, rbody)
}
}
apps, err := fx.st.AppsIn(OpsAccountID)
@@ -449,10 +478,10 @@ func TestCreateApp(t *testing.T) {
t.Fatal(err)
}
for _, name := range []string{"zephyr", "kass", "orphan", "cligranted"} {
- if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}}); status != http.StatusSeeOther || loc != fx.path("/") {
- t.Fatalf("claim %s = %d -> %q, want 303 -> the account home", name, status, loc)
+ if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}}); status != http.StatusSeeOther || loc != fx.path("/apps/new") {
+ t.Fatalf("claim %s = %d -> %q, want 303 -> the create form", name, status, loc)
}
- if _, _, body := get(t, c, fx.u("/")); !strings.Contains(body, "That name is taken.") {
+ if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "That name is taken.") {
t.Fatalf("claim %s: no taken flash", name)
}
}
@@ -461,7 +490,7 @@ func TestCreateApp(t *testing.T) {
if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"Bad Name!"}}); status != http.StatusSeeOther {
t.Fatalf("invalid name = %d, want redirect with flash", status)
}
- if _, _, body := get(t, c, fx.u("/")); !strings.Contains(body, "Invalid app name") {
+ if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "Invalid app name") {
t.Fatal("no invalid-name flash")
}
if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {"wrong"}, "name": {"fine-name"}}); status != http.StatusForbidden {
@@ -469,15 +498,244 @@ func TestCreateApp(t *testing.T) {
}
}
+func TestAppTabBar(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, home, _ := get(t, c, fx.ts.URL+"/")
+ base := strings.TrimSuffix(home, "/") + "/apps/kass"
+ _, _, body := get(t, c, fx.ts.URL+base)
+ for _, want := range []string{
+ `class="tabs"`,
+ `href="` + base + `"`, `href="` + base + `/releases"`,
+ `href="` + base + `/instances"`, `href="` + base + `/activity"`,
+ `href="` + base + `/settings"`,
+ `aria-current="page">Overview</a>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("app page missing %q", want)
+ }
+ }
+}
+
+func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ for _, gone := range []string{"<h2>Channels</h2>", "<h2>Releases</h2>", "<h2>Recent activity</h2>", "<h2>Domains</h2>", "<h2>Resources</h2>"} {
+ if strings.Contains(body, gone) {
+ t.Fatalf("overview still renders %q", gone)
+ }
+ }
+ for _, want := range []string{`class="ladder"`, "domain", "release"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("overview missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass")
+}
+
+func TestReleasesTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if status != 200 {
+ t.Fatalf("GET releases tab = %d", status)
+ }
+ for _, want := range []string{
+ `aria-current="page">Releases</a>`,
+ "<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("releases tab missing %q", want)
+ }
+ }
+ // 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)
+ }
+ assertNonMember404(t, fx, "/apps/kass/releases")
+}
+
+func TestInstancesTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
+ if status != 200 {
+ t.Fatalf("GET instances tab = %d", status)
+ }
+ if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ t.Fatal("tab not active")
+ }
+ assertNonMember404(t, fx, "/apps/kass/instances")
+}
+
+// assertNonMember404 signs bob (no membership in the fixture account) in and
+// asserts the account-scoped path answers him byte-identically to an account
+// that does not exist at all.
+func assertNonMember404(t *testing.T, fx *consoleFixture, sub string) {
+ t.Helper()
+ fx.setUser("bob@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-nm-"+t.Name())
+ s1, _, b1 := get(t, c, fx.u(sub))
+ s2, _, b2 := get(t, c, fx.ts.URL+"/a/zzzzzzzz"+sub)
+ if s1 != http.StatusNotFound || s2 != http.StatusNotFound || b1 != b2 {
+ t.Fatalf("non-member leak on %s: member-URL=%d unknown-URL=%d identical=%v", sub, s1, s2, b1 == b2)
+ }
+}
+
+func TestCreateAppPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/new"))
+ if status != 200 {
+ t.Fatalf("GET /apps/new = %d", status)
+ }
+ for _, want := range []string{
+ "Create an app", `action="` + fx.path("/apps/new") + `"`,
+ "lowercase letters, digits, and dashes",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("create-app page missing %q", want)
+ }
+ }
+ // The account home now links here instead of embedding the form.
+ _, _, home := get(t, c, fx.u("/"))
+ if !strings.Contains(home, `/apps/new"`) {
+ t.Fatal("account home does not link to /apps/new")
+ }
+ if strings.Contains(home, `action="`+fx.path("/apps/new")+`"`) {
+ t.Fatal("account home still embeds the create form")
+ }
+ assertNonMember404(t, fx, "/apps/new")
+}
+
+func TestTeamPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ tok, err := fx.st.CreateInvite(OpsAccountID, "sam@keymail.dev", "paul@keymail.dev", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/team"))
+ if status != 200 {
+ t.Fatalf("GET /team = %d", status)
+ }
+ for _, want := range []string{
+ "Team", "people who can see and ship",
+ `action="` + fx.path("/invite") + `"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("team page missing %q", want)
+ }
+ }
+ // The account home no longer carries the member machinery.
+ _, _, home := get(t, c, fx.u("/"))
+ for _, gone := range []string{"Invite a teammate", "Hand over ownership"} {
+ if strings.Contains(home, gone) {
+ t.Fatalf("account home still shows %q", gone)
+ }
+ }
+ assertNonMember404(t, fx, "/team")
+
+ // A non-owner member sees the roster and nothing an owner alone should
+ // hold: no invite form, no member-administration forms, and — the one
+ // that matters — not the pending invite's TOKEN. That token is a bearer
+ // capability (whoever holds it can accept the invite as its addressee),
+ // so PendingInvites must stay behind the role==RoleOwner branch, not just
+ // behind membership.
+ fx.setUser("kim@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-kim-team")
+ mStatus, _, mBody := get(t, member, fx.u("/team"))
+ if mStatus != 200 {
+ t.Fatalf("member GET /team = %d", mStatus)
+ }
+ if strings.Contains(mBody, tok) {
+ t.Fatal("member view leaked a pending invite's token")
+ }
+ for _, gone := range []string{"Invite a teammate", "members/remove", "members/transfer"} {
+ if strings.Contains(mBody, gone) {
+ t.Fatalf("member view of /team still shows %q", gone)
+ }
+ }
+}
+
+func TestAccountSettingsPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ 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")
+
+ // A non-owner member sees the Identity section and nothing of the danger
+ // zone at all — not the heading, not the form, not the explanation. The
+ // section is owner-only display, same as Team's member-administration
+ // forms; the account having apps here is incidental, not the reason.
+ fx.setUser("kim@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-kim-settings")
+ mStatus, _, mBody := get(t, member, fx.u("/settings"))
+ if mStatus != 200 {
+ t.Fatalf("member GET /settings = %d", mStatus)
+ }
+ if !strings.Contains(mBody, "<h2>Identity</h2>") {
+ t.Fatal("member view missing the Identity section")
+ }
+ for _, gone := range []string{"Delete this account", `action="` + fx.path("/delete") + `"`} {
+ if strings.Contains(mBody, gone) {
+ t.Fatalf("member view of /settings still shows %q", gone)
+ }
+ }
+}
+
+func TestAppNamedNewIsRefused(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"new"}})
+ 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); slices.Contains(apps, "new") {
+ t.Fatal("reserved name 'new' was claimed")
+ }
+}
+
func TestPromoteFlow(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- // The app page offers promote forms per shipped version.
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ // The releases tab offers promote forms per shipped version.
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, `action="/a/bab/apps/kass/promote"`) {
- t.Fatal("no promote form on the app page")
+ t.Fatal("no promote form on the releases tab")
}
if strings.Contains(body, "Promotions disabled") {
t.Fatal("keyed fixture rendered the disabled note")
@@ -569,7 +827,7 @@ func TestRollbackFlow(t *testing.T) {
// Fixture stable is v1 with no history: no rollback button for it (nor
// for the single-promote canary), and a forced POST politely refuses.
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if strings.Contains(body, `name="channel" value="stable"`) || strings.Contains(body, `name="channel" value="canary/x"`) {
t.Fatal("rollback button rendered for a channel with no history")
}
@@ -583,7 +841,7 @@ func TestRollbackFlow(t *testing.T) {
// primitive rather than promoted through the console, because this test is
// about rollback and the ladder has its own (TestLadderRefusesASkip).
seedChannel(t, fx, "kass", "stable", "v2")
- if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, `name="channel" value="stable"`) {
+ if _, _, body := get(t, c, fx.u("/apps/kass/releases")); !strings.Contains(body, `name="channel" value="stable"`) {
t.Fatal("no rollback button after stable gained history")
}
status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
@@ -598,6 +856,15 @@ func TestRollbackFlow(t *testing.T) {
if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Rolled back stable to v1.") {
t.Fatal("no rollback flash")
}
+
+ // Same happy path, but posted from the releases tab (back=releases): the
+ // redirect must land back on the tab, not the app page. stable is v1 with
+ // v2 in history after the rollback above, so a second rollback is valid.
+ status, loc, _ = postForm(t, c, fx.u("/apps/kass/rollback"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/releases") {
+ t.Fatalf("rollback with back=releases = %d -> %q, want %q", status, loc, fx.path("/apps/kass/releases"))
+ }
}
// addRoute upserts a registry route into the fixture's registry.
@@ -617,14 +884,14 @@ func TestCanaryFlow(t *testing.T) {
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- // The app page: create form, and the fixture canary listed hostless.
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ // The releases tab: create form, and the fixture canary listed hostless.
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
for _, want := range []string{
`action="/a/bab/apps/kass/canary"`, `action="/a/bab/apps/kass/canary/retire"`,
"canary/x", "no host yet", "Create canary",
} {
if !strings.Contains(body, want) {
- t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
+ t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body)
}
}
@@ -787,9 +1054,9 @@ func TestAppPageInstances(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
- // Before any reporting route exists: no Instances section — the page is
+ // Before any reporting route exists: no Instances section — the tab is
// exactly the route-level view.
- if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "<h2>Instances</h2>") {
+ if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Instances</h2>") {
t.Fatal("Instances section rendered with nothing reporting")
}
@@ -797,26 +1064,31 @@ func TestAppPageInstances(t *testing.T) {
app := newInstanceApp(t, kassReport)
fx.addRoute(t, registry.Route{Host: app.host(t), Kind: "instance",
Status: "ready", App: "kass"})
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/instances"))
for _, want := range []string{
"<h2>Instances</h2>", "reports 3 internal instances",
"riders-2026", "spring-camp", "trial-run",
"eu-west-1", "archived", "read-only since June",
} {
if !strings.Contains(body, want) {
- t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
+ t.Fatalf("/apps/kass/instances missing %q in:\n%s", want, body)
}
}
}
+// TestRecentActivity pins the activity tab against real, console-driven
+// events (not synthetic RecordEvent calls) — TestActivityTab only checks the
+// tab's chrome, so this is where seeded rows actually render. It also pins
+// activityCap=50: past the cap, the oldest rows fall off and the newest 50
+// (and no more) are what's on the page.
func TestRecentActivity(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- // Fresh app page: no activity yet.
- if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "Recent activity") {
- t.Fatal("activity section rendered before any action")
+ // Fresh activity tab: no activity yet.
+ if _, _, body := get(t, c, fx.u("/apps/kass/activity")); !strings.Contains(body, "Nothing yet") {
+ t.Fatal("activity tab did not show its empty state before any action")
}
// A promotion the ladder allows (a fresh build entering at edge), and a
@@ -832,9 +1104,9 @@ func TestRecentActivity(t *testing.T) {
postForm(t, c, fx.u("/apps/kass/canary/retire"),
url.Values{"csrf": {csrf}, "name": {"rc"}})
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/activity"))
for _, want := range []string{
- "Recent activity", "paul@keymail.dev",
+ "paul@keymail.dev",
"promoted v3 to edge", "rolled back stable to v1",
"created canary rc at v1", "retired canary rc",
"CLI promotions don",
@@ -847,6 +1119,66 @@ func TestRecentActivity(t *testing.T) {
if strings.Index(body, "retired canary rc") > strings.Index(body, "promoted v3 to edge") {
t.Fatal("activity is not newest-first")
}
+
+ // Push the account's own event count well past the cap. The 4 real
+ // actions above are now the oldest rows in the account, so this also
+ // exercises them falling off — not just synthetic filler.
+ for i := 0; i < 60; i++ {
+ if err := fx.st.RecordEvent(OpsAccountID, "kass", "paul@keymail.dev",
+ "marker", fmt.Sprintf("cap-marker-%03d", i), time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, _, capped := get(t, c, fx.u("/apps/kass/activity"))
+ if n := strings.Count(capped, "cap-marker-"); n != 50 {
+ t.Fatalf("activity tab rendered %d rows past the cap, want exactly 50 (activityCap)", n)
+ }
+ if !strings.Contains(capped, "cap-marker-059") {
+ t.Fatal("newest event fell off the capped activity tab")
+ }
+ if strings.Contains(capped, "cap-marker-000") || strings.Contains(capped, "promoted v3 to edge") {
+ t.Fatal("activity tab kept rows older than its 50-row cap should allow")
+ }
+}
+
+func TestActivityTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/activity"))
+ if status != 200 {
+ t.Fatalf("GET activity tab = %d", status)
+ }
+ for _, want := range []string{`aria-current="page">Activity</a>`, "CLI promotions don"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("activity tab missing %q", want)
+ }
+ }
+ 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)
+ status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ if status != 200 {
+ t.Fatalf("GET app settings = %d", status)
+ }
+ for _, want := range []string{
+ `aria-current="page">Settings</a>`, "<h2>Identity</h2>", "Domains &amp; TLS",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("app settings missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass/settings")
}
func TestPromotionsDisabledWithoutKey(t *testing.T) {
@@ -859,11 +1191,12 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
t.Fatal("keyless console is missing the disabled note")
}
// The Domains section is a read — keyless (read-only) mode still renders it.
- if !strings.Contains(body, "Domains") || !strings.Contains(body, "kass.carlosapp.dev") {
+ if _, _, sbody := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") {
t.Fatal("keyless console is missing the Domains section")
}
- if strings.Contains(body, `action="/a/bab/apps/kass/promote"`) || strings.Contains(body, `action="/a/bab/apps/kass/rollback"`) ||
- strings.Contains(body, `action="/a/bab/apps/kass/canary"`) || strings.Contains(body, `action="/a/bab/apps/kass/canary/retire"`) {
+ _, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
+ if strings.Contains(rbody, `action="/a/bab/apps/kass/promote"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/rollback"`) ||
+ strings.Contains(rbody, `action="/a/bab/apps/kass/canary"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/canary/retire"`) {
t.Fatal("keyless console rendered mutation forms")
}
if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"),
@@ -1039,7 +1372,7 @@ func TestAppPageRendersDomains(t *testing.T) {
fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance",
Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/settings"))
for _, want := range []string{
"Domains", "kass.bab.carlosapp.dev", "platform", "pointing",
"kass.carlosapp.dev", "custom", "—",
@@ -1047,7 +1380,7 @@ func TestAppPageRendersDomains(t *testing.T) {
"Checks run every few minutes.",
} {
if !strings.Contains(body, want) {
- t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
+ t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, body)
}
}
}
@@ -1397,9 +1730,9 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
}
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
if status != http.StatusOK {
- t.Fatalf("/apps/kass = %d, want 200: %s", status, body)
+ t.Fatalf("/apps/kass/instances = %d, want 200: %s", status, body)
}
for _, want := range []string{
"Resources", "owned.example.com", "11%", "1.0 KB",
@@ -1423,19 +1756,27 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
}
// TestAppPageSurvivesMetricsFailure: metrics are decoration. A bucket that
-// fails every read must still leave the owner a fully rendered app page.
+// fails every read must still leave the owner a fully rendered instances tab
+// — the only tab that reads metrics now that the overview doesn't.
func TestAppPageSurvivesMetricsFailure(t *testing.T) {
fx := newConsoleFixture(t)
fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance",
Status: "ready", App: "kass", Backing: registry.BackingUnit})
fx.srv.cfg.Releases = failingListStore{fx.rel}
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
if status != http.StatusOK {
- t.Fatalf("/apps/kass with a broken metrics read = %d, want 200: %s", status, body)
+ t.Fatalf("/apps/kass/instances with a broken metrics read = %d, want 200: %s", status, body)
+ }
+ if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ t.Fatalf("instances tab did not render its normal chrome: %s", body)
}
- if !strings.Contains(body, "Domains") {
- t.Fatalf("app page did not render its normal sections: %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)
}
}
@@ -1486,9 +1827,9 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
}
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
if status != http.StatusOK {
- t.Fatalf("/apps/kass = %d, want 200", status)
+ t.Fatalf("/apps/kass/instances = %d, want 200", status)
}
if !strings.Contains(body, "11%") {
t.Fatalf("own instance's metrics missing in:\n%s", body)
@@ -1501,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)
}
@@ -1519,7 +1858,7 @@ func resourcesSection(t *testing.T, body string) string {
if i < 0 {
t.Fatalf("no Resources section in:\n%s", body)
}
- j := strings.Index(body[i:], "<h2>Channels</h2>")
+ j := strings.Index(body[i:], "</main>")
if j < 0 {
t.Fatalf("Resources section never ended:\n%s", body[i:])
}
@@ -1550,9 +1889,9 @@ func TestAppPageOwnerMetricsStaleBoxIsNotCurrent(t *testing.T) {
}
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
if status != http.StatusOK {
- t.Fatalf("/apps/kass = %d, want 200", status)
+ t.Fatalf("/apps/kass/instances = %d, want 200", status)
}
if !strings.Contains(body, "asleep — no recent readings") {
t.Fatalf("stale box did not render as no-recent-readings:\n%s", resourcesSection(t, body))
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/app_activity.html b/internal/console/templates/app_activity.html
new file mode 100644
index 000000000..10c8ea993
--- /dev/null
+++ b/internal/console/templates/app_activity.html
@@ -0,0 +1,18 @@
+{{define "app_activity"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+
+<h2>Activity</h2>
+{{if .Events}}
+<div class="tablewrap">
+<table>
+ <tr><th>When</th><th>Who</th><th>What</th></tr>
+ {{range .Events}}
+ <tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Address}}</td><td>{{.Detail}}</td></tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">Nothing yet — promotes, rollbacks and canaries made in this console will show here.</p>{{end}}
+<p class="small">Actions taken through this console only — CLI promotions don't appear here.</p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html
new file mode 100644
index 000000000..5463f5085
--- /dev/null
+++ b/internal/console/templates/app_instances.html
@@ -0,0 +1,59 @@
+{{define "app_instances"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+
+{{if and .Instances .Instances.Reporting}}
+<h2>Instances</h2>
+<p class="small">This app reports {{len .Instances.Instances}} internal instance{{if ne (len .Instances.Instances) 1}}s{{end}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p>
+{{if .Instances.Instances}}
+<div class="tablewrap">
+<table>
+ <tr><th>Slug</th><th>Status</th><th>Region</th><th>Detail</th></tr>
+ {{range .Instances.Instances}}
+ <tr>
+ <td><code>{{.Slug}}</code></td>
+ <td>{{if .Status}}{{.Status}}{{else}}—{{end}}</td>
+ <td>{{if .Region}}{{.Region}}{{else}}—{{end}}</td>
+ <td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{end}}
+{{end}}
+
+{{if .AppMetrics}}
+<h2>Resources</h2>
+<p class="small">CPU and memory for each of this app&#39;s instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance&#39;s own peak, and the number beside it is the current figure.</p>
+{{range .AppMetrics}}
+{{if .Reporting}}
+<p class="small"><code>{{.Host}}</code></p>
+<section class="tiles" aria-label="{{.Host}} resources">
+ <div class="tile">
+ <h2>CPU</h2>
+ <p class="tile-value">{{pct .CPUFrac}}</p>
+ <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkCPU}}"/></svg>
+ </div>
+ <div class="tile">
+ <h2>Memory</h2>
+ <p class="tile-value">{{bytesOrDash .MemBytes}}</p>
+ <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkMem}}"/></svg>
+ </div>
+</section>
+{{else}}
+{{if .Hibernate}}
+<p class="small"><code>{{.Host}}</code> — asleep — no recent readings</p>
+{{else}}
+<p class="small"><code>{{.Host}}</code> — no per-instance readings</p>
+{{end}}
+{{end}}
+{{end}}
+{{end}}
+
+{{if and (or (not .Instances) (not .Instances.Reporting)) (not .AppMetrics)}}
+<p class="small">Nothing to show yet — instances appear when the app serves
+<code>/.carlos/instances</code>, and resource readings when it runs on a
+reporting box.</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_new.html b/internal/console/templates/app_new.html
new file mode 100644
index 000000000..02d232e8d
--- /dev/null
+++ b/internal/console/templates/app_new.html
@@ -0,0 +1,17 @@
+{{define "app_new"}}{{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>Create an app</h1>
+ <p class="lede">Claim a name here; ship a release to it from your terminal.</p>
+</div>
+<form class="signin" method="post" action="{{.Base}}/apps/new">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <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-Za-z][A-Za-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>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
new file mode 100644
index 000000000..c312c4804
--- /dev/null
+++ b/internal/console/templates/app_overview.html
@@ -0,0 +1,25 @@
+{{define "app_overview"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+{{if .Detail.AccountMismatch}}
+<p class="banner error">
+ <strong>This app is not deploying.</strong> Its hosts read releases from
+ account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
+ <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
+ nothing. Its bucket objects were moved without the console's own record
+ following. On the console's box, run:
+ <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code>
+ <br><span class="small">If it is the routes that are in the wrong account,
+ move those back instead — whichever of the two is right, they have to
+ agree.</span>
+</p>
+{{end}}
+{{template "ladder" .}}
+<p class="small">
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">{{len .Detail.Domains}} domain{{if ne (len .Detail.Domains) 1}}s{{end}}</a>
+ {{if and .Instances .Instances.Reporting}} · <a href="{{.Nav}}/apps/{{.AppName}}/instances">{{len .Instances.Instances}} instance{{if ne (len .Instances.Instances) 1}}s{{end}} reporting</a>{{end}}
+ · <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{len .Versions}} release{{if ne (len .Versions) 1}}s{{end}}</a>
+</p>
+{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app.html b/internal/console/templates/app_releases.html
similarity index 62%
rename from internal/console/templates/app.html
rename to internal/console/templates/app_releases.html
index 44a0cf899..27dd4c509 100644
--- a/internal/console/templates/app.html
+++ b/internal/console/templates/app_releases.html
@@ -1,7 +1,7 @@
-{{define "app"}}{{template "top" .}}
-<h1>{{.Detail.App}}</h1>
+{{define "app_releases"}}{{template "top" .}}
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
{{if .Detail.AccountMismatch}}
<p class="banner error">
<strong>This app is not deploying.</strong> Its hosts read releases from
@@ -16,76 +16,6 @@
</p>
{{end}}
-{{template "ladder" .}}
-
-<h2>Domains</h2>
-{{if .Detail.Domains}}
-<div class="tablewrap">
-<table>
- <tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr>
- {{range .Detail.Domains}}
- <tr>
- <td><code>{{.Domain}}</code></td>
- <td>{{.Type}}</td>
- <td>{{if .DNS}}{{.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td>
- <td>{{if .TLS}}{{.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td>
- </tr>
- {{if .Guidance}}
- <tr><td colspan="4" class="small">{{.Guidance}}</td></tr>
- {{end}}
- {{end}}
-</table>
-</div>
-{{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}}
-
-{{if and .Instances .Instances.Reporting}}
-<h2>Instances</h2>
-<p class="small">This app reports {{len .Instances.Instances}} internal instance{{if ne (len .Instances.Instances) 1}}s{{end}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p>
-{{if .Instances.Instances}}
-<div class="tablewrap">
-<table>
- <tr><th>Slug</th><th>Status</th><th>Region</th><th>Detail</th></tr>
- {{range .Instances.Instances}}
- <tr>
- <td><code>{{.Slug}}</code></td>
- <td>{{if .Status}}{{.Status}}{{else}}—{{end}}</td>
- <td>{{if .Region}}{{.Region}}{{else}}—{{end}}</td>
- <td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
- </tr>
- {{end}}
-</table>
-</div>
-{{end}}
-{{end}}
-
-{{if .AppMetrics}}
-<h2>Resources</h2>
-<p class="small">CPU and memory for each of this app&#39;s instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance&#39;s own peak, and the number beside it is the current figure.</p>
-{{range .AppMetrics}}
-{{if .Reporting}}
-<p class="small"><code>{{.Host}}</code></p>
-<section class="tiles" aria-label="{{.Host}} resources">
- <div class="tile">
- <h2>CPU</h2>
- <p class="tile-value">{{pct .CPUFrac}}</p>
- <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkCPU}}"/></svg>
- </div>
- <div class="tile">
- <h2>Memory</h2>
- <p class="tile-value">{{bytesOrDash .MemBytes}}</p>
- <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkMem}}"/></svg>
- </div>
-</section>
-{{else}}
-{{if .Hibernate}}
-<p class="small"><code>{{.Host}}</code> — asleep — no recent readings</p>
-{{else}}
-<p class="small"><code>{{.Host}}</code> — no per-instance readings</p>
-{{end}}
-{{end}}
-{{end}}
-{{end}}
-
<h2>Channels</h2>
{{if .Detail.Channels}}
<div class="tablewrap">
@@ -105,7 +35,8 @@
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/rollback">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="channel" value="{{.Name}}">
- <button type="submit" class="linkish">Roll back</button>
+ <input type="hidden" name="back" value="releases">
+ <button type="submit" class="linkish danger">Roll back</button>
</form>
{{end}}</td>
</tr>
@@ -113,7 +44,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}}
@@ -135,7 +66,8 @@
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/canary/retire">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="name" value="{{.Name}}">
- <button type="submit" class="linkish">Retire</button>
+ <input type="hidden" name="back" value="releases">
+ <button type="submit" class="linkish danger">Retire</button>
</form>
{{end}}</td>
</tr>
@@ -146,6 +78,7 @@
{{if and .CanPromote .Versions}}
<form class="signin" method="post" action="{{.Base}}/apps/{{.Detail.App}}/canary">
<input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="hidden" name="back" value="releases">
<input type="text" name="name" placeholder="rc-2">
<select name="version">
{{range .Versions}}{{if not .Err}}<option value="{{.Version}}">{{.Version}}</option>{{end}}{{end}}
@@ -173,6 +106,7 @@
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="version" value="{{.Version}}">
+ <input type="hidden" name="back" value="releases">
<select name="channel">
{{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
@@ -191,17 +125,4 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code
<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p>
{{end}}
{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
-
-{{if .Events}}
-<h2>Recent activity</h2>
-<div class="tablewrap">
-<table>
- <tr><th>When</th><th>Who</th><th>What</th></tr>
- {{range .Events}}
- <tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Address}}</td><td>{{.Detail}}</td></tr>
- {{end}}
-</table>
-</div>
-<p class="small">Actions taken through this console only — CLI promotions don't appear here.</p>
-{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
new file mode 100644
index 000000000..02017bcf5
--- /dev/null
+++ b/internal/console/templates/app_settings.html
@@ -0,0 +1,32 @@
+{{define "app_settings"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+
+<h2>Identity</h2>
+<div class="tablewrap">
+<table>
+ <tr><th>App</th><th>Account</th></tr>
+ <tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr>
+</table>
+</div>
+<h2>Domains &amp; TLS</h2>
+{{if .Detail.Domains}}
+<div class="tablewrap">
+<table>
+ <tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr>
+ {{range .Detail.Domains}}
+ <tr>
+ <td><code>{{.Domain}}</code></td>
+ <td>{{.Type}}</td>
+ <td>{{if .DNS}}{{.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td>
+ <td>{{if .TLS}}{{.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td>
+ </tr>
+ {{if .Guidance}}
+ <tr><td colspan="4" class="small">{{.Guidance}}</td></tr>
+ {{end}}
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html
new file mode 100644
index 000000000..4f3b07c7f
--- /dev/null
+++ b/internal/console/templates/appnav.html
@@ -0,0 +1,13 @@
+{{define "appnav"}}
+<div class="pagehead">
+ <p class="eyebrow">App · {{.Account.Name}}</p>
+ <h1>{{.AppName}}</h1>
+</div>
+<nav class="tabs" aria-label="App sections">
+ <a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Releases</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Instances</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a>
+</nav>
+{{end}}
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
index 748ae19e6..ed3a97f5c 100644
--- a/internal/console/templates/apps.html
+++ b/internal/console/templates/apps.html
@@ -25,79 +25,6 @@
{{end}}
{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps,
restorable until the platform destroys them.</p>{{end}}
-<h2>Create an app</h2>
-<form class="signin" method="post" action="{{.Base}}/apps/new">
- <input type="hidden" name="csrf" value="{{.CSRF}}">
- <input type="text" name="name" placeholder="my-app">
- <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>
+<p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p>
-<h2>Members</h2>
-<div class="tablewrap">
-<table>
- <tr><th>Address</th><th>Role</th>{{if .IsOwner}}<th></th>{{end}}</tr>
- {{range .Members}}<tr>
- <td>{{.Name}}</td>
- <td>{{.Role}}</td>
- {{if $.IsOwner}}<td>
- {{if ne .Name $.Address}}
- <form method="post" action="{{$.Base}}/members/transfer">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="address" value="{{.Name}}">
- <button class="linkish" type="submit">Hand over ownership</button>
- </form>
- <form method="post" action="{{$.Base}}/members/remove">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="address" value="{{.Name}}">
- <button class="linkish" type="submit">Remove</button>
- </form>
- {{end}}
- </td>{{end}}
- </tr>{{end}}
-</table>
-</div>
-{{if .IsOwner}}
-<p class="small">Removing a member, handing over ownership and deleting the account each ask
- you to confirm with your passkey — a signed-in browser on its own isn't enough.</p>
-<h2>Invite a teammate</h2>
-<form class="signin" method="post" action="{{.Base}}/invite">
- <input type="hidden" name="csrf" value="{{.CSRF}}">
- <input type="text" name="address" placeholder="them@keymail.dev">
- <button type="submit">Send invitation</button>
-</form>
-<p class="small">The invitation arrives in their Keymail Apps inbox — the platform never emails anyone. It works once and expires in 7 days.</p>
-{{if .Invites}}
-<div class="tablewrap">
-<table>
- <tr><th>Invited</th><th>By</th><th>Expires</th><th></th></tr>
- {{range .Invites}}
- <tr>
- <td>{{.Address}}</td>
- <td>{{.InvitedBy}}</td>
- <td>{{fmtTime .ExpiresAt}}</td>
- <td>
- <form method="post" action="{{$.Base}}/invite/revoke">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="token" value="{{.Token}}">
- <button class="linkish" type="submit">Revoke</button>
- </form>
- </td>
- </tr>
- {{end}}
-</table>
-</div>
-{{end}}
-{{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}}
diff --git a/internal/console/templates/cli_approve.html b/internal/console/templates/cli_approve.html
index 741918fa3..dae971dcc 100644
--- a/internal/console/templates/cli_approve.html
+++ b/internal/console/templates/cli_approve.html
@@ -13,7 +13,7 @@
</div>
<p>Approving gives that terminal your identity — every account you belong to —
for {{.Days}} days. You can revoke it at any time from
-<a href="/auth/cli/tokens">command-line access</a>, and a note goes to your
+<a href="/settings">command-line access</a>, and a note goes to your
Keymail inbox the moment it is issued.</p>
<form class="signin" method="post" action="/auth/cli/approve">
<input type="hidden" name="csrf" value="{{.CSRF}}">
@@ -49,7 +49,7 @@ which means it has to be on a screen in front of you.</p>
<p>Your terminal has the credential — it should say so within a couple of
seconds. It expires in {{.Days}} days, and a note is on its way to your Keymail
inbox.</p>
-<p><a href="/auth/cli/tokens">See every terminal signed in as you</a>, and
+<p><a href="/settings">See every terminal signed in as you</a>, and
revoke any you don't recognise.</p>
{{else}}
<p>Nothing was issued. That login attempt is dead and cannot be approved
diff --git a/internal/console/templates/home.html b/internal/console/templates/home.html
index b3b0fd8b1..56fc3ef42 100644
--- a/internal/console/templates/home.html
+++ b/internal/console/templates/home.html
@@ -1,6 +1,10 @@
{{define "home"}}{{template "top" .}}
-<h1>The CARLOS platform console</h1>
-<p class="lede">Your apps, their live routes, and their release channels.</p>
+<div class="pagehead">
+ <p class="eyebrow">🤖 CARLOS console</p>
+ <h1>Ship it. Promote it. Sleep<span style="color: var(--accent)">.</span></h1>
+ <p class="lede">Your apps, their live routes, and their release channels —
+ signed releases in, immutable bytes out.</p>
+</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form class="signin" method="get" action="/auth/signin">
<button type="submit">Sign in with Keymail</button>
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index f0a7974eb..21494314e 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -6,38 +6,42 @@
<title>{{.Title}} · CARLOS console</title>
<style>
:root {
- --bg: #ffffff; --ink: #1d2029; --muted: #64707f;
- --accent: #0f5e85; --accent-soft: rgba(15, 94, 133, 0.09);
- --rule: #e7e9f0; --bad: #b3403a;
+ --bg: #ffffff; --ink: #201d29; --muted: #6f6480;
+ --accent: #5b3e96; --accent-strong: #4c3380;
+ --accent-soft: rgba(91, 62, 150, 0.09);
+ --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f;
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
- --bg: #14151c; --ink: #eef0f6; --muted: #93a4ba;
- --accent: #5db8dd; --accent-soft: rgba(93, 184, 221, 0.12);
- --rule: #2a2e38; --bad: #e07b76;
+ --bg: #16141c; --ink: #f0eef6; --muted: #a99ebd;
+ --accent: #b394f2; --accent-strong: #c9b2f7;
+ --accent-soft: rgba(179, 148, 242, 0.12);
+ --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg); color: var(--ink);
- font: 400 1.0625rem/1.65 Charter, Georgia, "Times New Roman", serif;
+ font: 400 1rem/1.6 var(--sans);
-webkit-font-smoothing: antialiased; padding: 0 1.25rem;
}
a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
code {
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-family: var(--mono);
font-size: 0.88em; background: var(--accent-soft);
padding: 0.1em 0.35em; border-radius: 0.25rem;
}
pre {
background: var(--accent-soft); border-radius: 0.5rem;
padding: 0.9rem 1.1rem; margin: 0 0 1rem; max-width: 40rem;
- overflow-x: auto; font-size: 0.85rem; line-height: 1.6;
+ overflow-x: auto; font-family: var(--mono); font-size: 0.85rem; line-height: 1.6;
}
pre code { background: none; padding: 0; }
select, input[type=text] {
- font: inherit; font-size: 0.9rem; color: var(--ink); background: var(--bg);
- border: 1px solid var(--rule); border-radius: 0.5rem; padding: 0.3rem 0.5rem;
+ font: inherit; font-family: var(--sans); font-size: 0.9rem; color: var(--ink); background: var(--bg);
+ border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem;
}
td form { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin: 0; }
/* Interface icons: inline SVG from internal/console/icons.go — Lucide,
@@ -46,14 +50,14 @@
inherit its colour. */
.icon { width: 1em; height: 1em; flex: none; vertical-align: -0.125em; }
header {
- max-width: 56rem; margin: 0 auto; padding: 1.4rem 0;
+ max-width: 64rem; margin: 0 auto; padding: 1.4rem 0;
display: flex; align-items: baseline; gap: 1rem;
border-bottom: 1px solid var(--rule);
}
header .brand a { font-weight: 700; color: var(--ink); text-decoration: none; }
header nav {
margin-left: auto; display: flex; align-items: baseline; gap: 1rem;
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 0.9rem;
+ font-size: 0.9rem;
}
header .addr { color: var(--muted); }
/* The account switcher sits where the wordmark used to: a <details>
@@ -62,7 +66,6 @@
at the foot only adds Escape and click-away, which are enhancements. */
header .switcher {
position: relative;
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
}
header .switcher > summary {
display: inline-flex; align-items: baseline; gap: 0.4rem;
@@ -107,7 +110,7 @@
header .switcher-menu .new a .mark { visibility: visible; }
header .switcher-menu hr { border: 0; border-top: 1px solid var(--rule); margin: 0.35rem 0.2rem; }
header .switcher-menu .new a { color: var(--accent); }
- main { max-width: 56rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; }
+ main { max-width: 64rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; }
h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; }
h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; }
p { max-width: 40rem; margin: 0 0 1rem; }
@@ -129,7 +132,7 @@
.tiles { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0 0 1.5rem; }
.tile { flex: 1 1 10rem; background: var(--accent-soft); border-radius: 0.6rem; padding: 1rem 1.1rem; }
.tile h2 {
- margin: 0 0 0.35rem; font-size: 0.78rem; text-transform: uppercase;
+ margin: 0 0 0.35rem; font-family: var(--mono); font-size: 0.78rem; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
}
.tile-value { font-size: 1.6rem; font-weight: 600; margin: 0; }
@@ -137,7 +140,7 @@
/* .chip-stale is bordered-only, no fill — a "trust this less" flag, not
an outage marker, matching internal/status/assets/status.css. */
.chip {
- display: inline-block; font-size: 0.78rem; font-weight: 600;
+ display: inline-block; font-family: var(--mono); font-size: 0.78rem; font-weight: 500;
padding: 0.15rem 0.55rem; border-radius: 999px; margin: 0.15rem 0.3rem 0.15rem 0;
}
.chip-stale { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
@@ -168,7 +171,7 @@
}
.rung + .rung { margin-top: 0.4rem; }
.rung-name {
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-family: var(--mono);
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
font-weight: 600; color: var(--muted); margin: 0 0 0.3rem;
}
@@ -186,7 +189,7 @@
.step form { display: flex; align-items: baseline; gap: 0.5rem; margin: 0; }
.step > .icon { color: var(--muted); align-self: center; }
th {
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-family: var(--mono);
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--muted); text-align: left; font-weight: 600;
}
@@ -194,9 +197,8 @@
form.signin { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 1rem; }
form.signin input[type=text] { padding: 0.55rem 0.8rem; min-width: 16rem; }
button, .btn {
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 0.95rem; font-weight: 600; background: var(--accent);
- color: var(--bg); border: 0; border-radius: 0.5rem;
+ color: var(--bg); border: 0; border-radius: 0.4rem;
padding: 0.6rem 1.2rem; cursor: pointer;
text-decoration: none; display: inline-block;
}
@@ -204,12 +206,38 @@
background: none; color: var(--accent); font-weight: 400; padding: 0;
border-radius: 0; text-decoration: underline; font-size: 0.9rem; cursor: pointer;
}
+ button:not(.linkish):hover, .btn:hover { background: var(--accent-strong); }
+ button.linkish.danger { color: var(--bad); }
footer {
- max-width: 56rem; margin: 0 auto; padding: 1.25rem 0 2rem;
+ max-width: 64rem; margin: 0 auto; padding: 1.25rem 0 2rem;
border-top: 1px solid var(--rule);
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 0.85rem; color: var(--muted);
}
+ /* Page anatomy: every page opens with a mono eyebrow naming the context,
+ an h1, and one human sentence saying what the page is for. */
+ .pagehead { margin: 0 0 1.75rem; }
+ .eyebrow {
+ font-family: var(--mono); font-size: 0.75rem; letter-spacing: 0.08em;
+ text-transform: uppercase; color: var(--muted); margin: 0 0 0.3rem;
+ }
+ .pagehead h1 { margin: 0 0 0.35rem; }
+ .pagehead .lede { margin: 0; font-size: 1.05rem; }
+ /* App tab bar (Heroku-style), and the account nav's active marker.
+ aria-current carries the state; the accent underline draws it. */
+ .tabs {
+ display: flex; flex-wrap: wrap; gap: 1.25rem; margin: 0 0 2rem;
+ border-bottom: 1px solid var(--rule);
+ font-family: var(--mono); font-size: 0.85rem;
+ }
+ .tabs a {
+ color: var(--muted); text-decoration: none;
+ padding: 0 0 0.55rem; border-bottom: 2px solid transparent; margin-bottom: -1px;
+ }
+ .tabs a:hover { color: var(--ink); }
+ .tabs a[aria-current] { color: var(--ink); border-bottom-color: var(--accent); }
+ header nav a[aria-current] { color: var(--ink); font-weight: 600; text-decoration: none; }
+ header nav a.addr { color: var(--muted); text-decoration: none; }
+ header nav a.addr:hover { text-decoration: underline; }
</style>
</head>
<body>
@@ -238,8 +266,12 @@
<div class="brand"><a href="/">CARLOS console</a></div>
{{end}}
{{if .Address}}<nav>
- {{if .Account}}<a href="{{.Nav}}/">Apps</a>{{end}}
- <span class="addr">{{.Address}}</span>
+ {{if .Account}}
+ <a href="{{.Nav}}/"{{if eq .ActiveNav "apps"}} aria-current="page"{{end}}>Apps</a>
+ <a href="{{.Nav}}/team"{{if eq .ActiveNav "team"}} aria-current="page"{{end}}>Team</a>
+ <a href="{{.Nav}}/settings"{{if eq .ActiveNav "settings"}} aria-current="page"{{end}}>Settings</a>
+ {{end}}
+ <a class="addr" href="/settings" title="Your settings">{{.Address}}</a>
<form method="post" action="/auth/signout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="linkish" type="submit">Sign out</button>
diff --git a/internal/console/templates/cli_tokens.html b/internal/console/templates/settings.html
similarity index 77%
rename from internal/console/templates/cli_tokens.html
rename to internal/console/templates/settings.html
index ef81bca12..c5f0f038b 100644
--- a/internal/console/templates/cli_tokens.html
+++ b/internal/console/templates/settings.html
@@ -1,8 +1,13 @@
-{{define "cli_tokens"}}{{template "top" .}}
-<h1>Command-line access</h1>
+{{define "settings"}}{{template "top" .}}
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
-{{if .FlashErr}}<p class="error">{{.FlashErr}}</p>{{end}}
-<p class="lede">Every terminal that can act as {{.Address}}.</p>
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">{{.Address}}</p>
+ <h1>Your settings</h1>
+ <p class="lede">Yours across every account — not any one account's.</p>
+</div>
+<h2>Command-line access</h2>
+<p class="small">Every terminal that can act as {{.Address}}.</p>
{{if .Tokens}}
<div class="tablewrap">
<table>
@@ -18,7 +23,7 @@
<form method="post" action="/auth/cli/tokens/revoke">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
- <button class="linkish" type="submit">Revoke</button>
+ <button class="linkish danger" type="submit">Revoke</button>
</form>
</td>
</tr>
diff --git a/internal/console/templates/team.html b/internal/console/templates/team.html
new file mode 100644
index 000000000..c835842a6
--- /dev/null
+++ b/internal/console/templates/team.html
@@ -0,0 +1,64 @@
+{{define "team"}}{{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>Team</h1>
+ <p class="lede">The people who can see and ship this account's apps.</p>
+</div>
+<div class="tablewrap">
+<table>
+ <tr><th>Address</th><th>Role</th>{{if .IsOwner}}<th></th>{{end}}</tr>
+ {{range .Members}}<tr>
+ <td>{{.Name}}</td>
+ <td>{{.Role}}</td>
+ {{if $.IsOwner}}<td>
+ {{if ne .Name $.Address}}
+ <form method="post" action="{{$.Base}}/members/transfer">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="address" value="{{.Name}}">
+ <button class="linkish" type="submit">Hand over ownership</button>
+ </form>
+ <form method="post" action="{{$.Base}}/members/remove">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="address" value="{{.Name}}">
+ <button class="linkish danger" type="submit">Remove</button>
+ </form>
+ {{end}}
+ </td>{{end}}
+ </tr>{{end}}
+</table>
+</div>
+{{if .IsOwner}}
+<p class="small">Removing a member, handing over ownership and deleting the account each ask
+ you to confirm with your passkey — a signed-in browser on its own isn't enough.</p>
+<h2>Invite a teammate</h2>
+<form class="signin" method="post" action="{{.Base}}/invite">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="address" placeholder="them@keymail.dev">
+ <button type="submit">Send invitation</button>
+</form>
+<p class="small">The invitation arrives in their Keymail Apps inbox — the platform never emails anyone. It works once and expires in 7 days.</p>
+{{if .Invites}}
+<div class="tablewrap">
+<table>
+ <tr><th>Invited</th><th>By</th><th>Expires</th><th></th></tr>
+ {{range .Invites}}
+ <tr>
+ <td>{{.Address}}</td>
+ <td>{{.InvitedBy}}</td>
+ <td>{{fmtTime .ExpiresAt}}</td>
+ <td>
+ <form method="post" action="{{$.Base}}/invite/revoke">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="token" value="{{.Token}}">
+ <button class="linkish danger" type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{end}}
+{{end}}
+{{template "bottom" .}}{{end}}