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
24 files changed,
+2582
−329
docs/superpowers/plans/2026-08-06-console-redesign.md+1342 −0docs/superpowers/specs/2026-08-06-console-redesign-design.md+184 −0internal/console/apicli.go+6 −0internal/console/apicli_test.go+7 −0internal/console/authcli.go+8 −5internal/console/authcli_test.go+33 −4internal/console/ownership.go+8 −7internal/console/server.go+210 −58internal/console/server_account_test.go+46 −5internal/console/server_test.go+393 −54internal/console/templates/account_settings.html+32 −0internal/console/templates/app_activity.html+18 −0internal/console/templates/app_instances.html+59 −0internal/console/templates/app_new.html+17 −0internal/console/templates/app_overview.html+25 −0internal/console/templates/{app.html => app_releases.html}+9 −88internal/console/templates/app_settings.html+32 −0internal/console/templates/appnav.html+13 −0internal/console/templates/apps.html+1 −74internal/console/templates/cli_approve.html+2 −2internal/console/templates/home.html+6 −2internal/console/templates/layout.html+57 −25internal/console/templates/{cli_tokens.html => settings.html}+10 −5internal/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 & 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 & 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 & 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's instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance'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's instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance'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 <site-dir></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 & 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}} |