Files
brain-of-reese/.agents/phases/complete/101_tokens_page_overhaul/00_phase.md
T
ducoterra 4dbac1660a
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 101_tokens_page_overhaul
All gates verified green — no defects found, nothing to fix. Final report:

## Phase 101 — final verification pass (all tasks already complete; verified, not re-implemented)

**Verified against the working tree** (task files 01–04 present in `complete/`):
- Active/revoked table split (Status column gone, section hidden while 0 revoked, per-table search with query state surviving re-renders) in `frontend/index.html` + `tokens.js`
- `generateToken()` refuses blank/whitespace names (`Give the token a name first.`, no request); `|| "token"` fallback deleted
- Atomic rotation: `regenerate_token` service + `POST /api/tokens/{id}/regenerate` (201 new-plaintext-once / 404 / 409 / router-wide 403) + `TokenListItem.revoked_at` (D5)
- Regenerate two-step confirm UI + CSS (`.token-regenerate`, neutral hover, no new hue); A4 pins intact

**Test / lint / coverage results:**
- `uv run pytest` → **2065 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed** (isolation, DB up)
- Regression, each in isolation: `test_api_tokens.py` **9 passed**, `test_admin_auth.py` **6 passed**, `test_shared_header.py` **6 passed**, `test_theme_semantic_completion.py` **8 passed** (its revoked-pill pin was correctly re-scoped to the revoked table in this phase)

**Completion criteria:** 1 ✓ split+search (E2E 1–2) · 2 ✓ required name (E2E 3 + source pin) · 3 ✓ rotation end-to-end, old token refused at gate (E2E 4 + API 404/409 pinned) · 4 ✓ A4 holds (list carries no plaintext/hashes) · 5 ✓ suite/coverage/lint green · 6 ✓ E2E + regressions green in isolation · 7 commit left to the harness per executor rules (all changes uncommitted in the working tree)

**Deviations:** none. Next pending phase: `98_sync_summary_visibility`.
2026-09-12 15:16:02 -04:00

21 KiB
Raw Blame History

Phase 101 — Tokens page: active/revoked table split, per-table search, required name, one-click regenerate

Source: Owner request (chat, 2026-09-12) — "The tokens page should move revoked tokens to a separate table below the active ones. Generating a token should not be possible without giving it a name. Both active and revoked token tables should be searchable. I should be able to regenerate active tokens with the click of a button." Story: n/a (owner request — extends 79_api_tokens: the model/service/API on phase 79 tasks 01–03, the single-table admin view on task 06, the once-block + two-step revoke UI, the E2E test_api_tokens.py). Context: api_tokens (migration 0012): id UUID, label (1–120, NOT unique), token_hash (sha256 of the full bor_… string, unique), created_at, last_used_at, revoked_at (NULL = active). app/core/tokens.py service: generate_token, hash_token, create_token(db, label) -> (row, plaintext), find_active_by_token, mark_used, revoke(db, token_id) -> bool (stamps revoked_at only when unset; False when the row is missing). Admin API (app/api/tokens.py, router-wide require_admin): POST /api/tokens (201 TokenCreated{id, label, token, created_at} — the ONLY response carrying the plaintext, owner-locked A4), GET /api/tokens (200 TokenList{tokens: [TokenListItem{id, label, created_at, last_used_at, revoked: bool}]} newest-first — revoked_at is NOT returned today), POST /api/tokens/{id}/revoke (204 idempotent, 404 unknown). The view (frontend/index.html #view-tokens + frontend/assets/tokens.js, phase-76 fold): the create row (#token-label + #token-generate — a BLANK label currently sends the fallback "token"), the shown-once block (#token-once — plaintext in the read-only field's value ONLY, hidden + wiped on the next loadTokens() / re-show), ONE full-width table (#tokens-table / #tokens-tbody, columns Label | Created | Last used | Status (active em-dash vs the rose .stale-pill Revoked) | Actions (the two-step inline Revoke — the history-confirm-* pattern; revoked rows carry no action)), #tokens-empty-row, the #tokens-status live region, the bor:view-refresh re-show contract (re-entrant loadTokens()), the whoami gate (anonymous → the gate, NO fetch). The phase-79 E2E tests/e2e/test_api_tokens.py pins the single-table layout — including REVOKED rows living in #tokens-tbody (e.g. test_revocation_closes_the_door asserts the .stale-pill there) — and auth_helpers.login_with_token(page, app_url, token) drives the real gate (the regenerate proof reuses it).

Objective

The Tokens view splits its list into an ACTIVE table and a REVOKED table below it (each independently searchable), a token can no longer be generated without a name, and every active token carries a one-click Regenerate (a house two-step confirm, then the new plaintext shown once) that rotates the credential — the old token is revoked and lands in the revoked table, the new one (same label) takes its place in the active table.

Dependencies

  • 79_api_tokens (complete) — the model, the service, the admin API, the A4 plaintext-once contract, the view module, the E2E helper (login_with_token).
  • Queue order only: 98_sync_summary_visibility, 99_kb_tree_table_and_back_nav, 100_page_width_consistency (todo) — no code dependency (different views; the tokens table sits in the full-width .container already — phase 100 changes nothing here).

Decisions recorded here (owner review — PLAN.md is being redone by the owner)

  • D1 — the split + the column contracts: the active table keeps the phase-79 shape MINUS the Status column (a table that is all-active needs no status) — Label | Created | Last used | Actions (Actions visually-hidden header, as today). The revoked table (below, its own .table-wrap + a visible Revoked tokens heading + its own search input) is Label | Created | Last used | Revoked (the revoked_at date — locale date+time, full ISO on hover, the house tokens-date-cell language). The table IS the status — the em-dash / .stale-pill column disappears from BOTH tables (the pill's meaning is now the table's position). The revoked section (heading + search + table) is HIDDEN when there are no revoked tokens (an empty table is noise); the active table keeps its empty-state row. Newest-first in both (the server order).
  • D2 — regenerate = rotation, atomic: new endpoint POST /api/tokens/{token_id}/regenerate (admin, 201 TokenCreated): in ONE transaction — stamp revoked_at on the old row (the revoke service primitive) and create_token(db, old.label) (same label — the hand-out name persists) — returning the NEW row + the new plaintext exactly once (A4 preserved: the plaintext is per-token, shown once, never re-shown; the old plaintext was already gone). Unknown id → 404 token not found; the row is ALREADY revoked → 409 token already revoked (a dead token cannot be rotated — the button never offers it, the API stays honest). UI: the active row's Actions cell gains Regenerate (before Revoke) — the house two-step inline confirm (first click swaps to Regenerate? The current token is revoked. [Yes] [No], focus to Yes — a destructive rotation deserves the same confirm weight as Revoke; ONE button starts it, per the owner's "click of a button"). On 201: the re-entrant loadTokens() runs (the old row moves to the revoked table, the new row lands in the active one), the once-block reveals with the new plaintext + Copy, the live region reads Regenerated "<label>" — copy the new token now; it won't be shown again. Failure (409 → the row re-renders in the revoked table + a line; other → neutral retry copy, the button restores — the phase-55 convention).
  • D3 — a name is required: generateToken() trims the label and, when empty, does NOT send — the live region reads Give the token a name first., the label input re-focuses, the Generate button's §7.4 lifecycle is untouched (it was never disabled — the request simply doesn't happen). The || "token" fallback is DELETED (the create row's comment + placeholder update: the name is the hand-out identity, not an optional decoration). The server's 422 on blank/over-long labels stands unchanged (defense in depth — the UI just stops volunteering the fallback).
  • D4 — search = client-side, per table, label-scoped: one type="search" input per table (#token-search-active above the active table, #token-search-revoked above the revoked table — inside the revoked section, so it hides with it), placeholder Search active tokens… / Search revoked tokens… (labeled via aria-label — the house visually-hidden-label language for single-purpose inputs). Filtering: case-insensitive SUBSTRING over the label, applied on the input event to the CURRENTLY rendered rows (no fetch — toggle each row's hidden), and RE-APPLIED after every loadTokens() (the query state survives re-renders / re-shows — the phase-77 contract). Zero visible matches with a non-empty query → a per-table no-match row (No tokens match "<query>". — the query text is textContent-filled; distinct from the active table's true empty-state row No tokens yet — …). Empty query → all rows visible, no-match row hidden.
  • D5 — the list API gains revoked_at: TokenListItem adds revoked_at: datetime | None = None (wire-additive — null for active tokens; the revoked table's Revoked column needs the actual timestamp, and nothing else changes on the wire). The existing revoked: bool stays (the client's table split key).

Design (shared by all tasks — the executor reads this, not the chat)

The API (task 01)

  • app/core/tokens.py — regenerate_token(db, token_id) -> tuple[ApiToken, str] | None (the service surface, unit-testable without FastAPI): fetch the row (db.get) — missing → return None; revoked_at set → raise TokenAlreadyRevoked (a new module-level exception, one sentence docstring: a dead token cannot be rotated); else revoke(db, row.id) (stamp) + create_token(db, row.label) (new row + plaintext) + db.flush() (the caller commits — the create_token convention: the service flushes, the endpoint owns the commit). ONE commit covers both writes (the rotation is atomic — a create failure rolls the revoke back with it).
  • app/api/tokens.py — POST /{token_id}/regenerate (201, response_model=TokenCreated, the router-wide require_admin covers it): None → 404 token not found (the revoke endpoint's exact message); TokenAlreadyRevoked → 409 token already revoked; success → commit + refresh + TokenCreated(id, label, token, created_at) of the NEW row. The list endpoint passes revoked_at=row.revoked_at into TokenListItem (D5). Docstrings: the rotate-once semantics (the docstring-carries-the-contract rule).
  • app/schemas.py — TokenListItem.revoked_at: datetime | None = None (the docstring notes D5).
  • Unit (tests/unit/test_tokens.py): the regenerate_token matrix — active row → (new row, new plaintext) with the SAME label, the old row stamped (its original revoked_at semantics: first stamp), the new plaintext well-formed (bor_ + 32 hex, ≠ the old hash); missing id → None; revoked id → raises; the new row's hash is the sha256 of the new plaintext (the lookup round-trips via find_active_by_token); the old token no longer authenticates (find_active_by_token(old_plaintext) → None).
  • Integration (tests/integration/test_tokens_api.py): the endpoint matrix — 201 (the 201 body carries the NEW plaintext exactly once; the old row revoked: true + revoked_at non-null in the next list; the new row active, same label, newer created_at); 404 unknown id; 409 already-revoked; 403 anonymous + 403 token-user (the router-wide gate — the existing pattern); the list shape now carries revoked_at (null active / timestamp revoked, ISO-8601).

The view (tasks 02 + 03)

frontend/index.html #view-tokens (the static skeleton — tokens.js fills the tbodys):

  • The create row: the input's aria-label → Token name, the placeholder → e.g. alice — required (D3); the comment updated (no fallback — the name is required).
  • A search row above the active table: <input type="search" id="token-search-active" aria-label="Search active tokens" placeholder="Search active tokens…"> (the house input language — a new .token-search class in the CSS, full width, the #tune-note/archive-upload input styling family).
  • The active table: thead drops the Status <th> (four columns now: Label | Created | Last used | the visually-hidden Actions); #tokens-empty-row → colspan="4", copy unchanged; a NEW hidden #tokens-no-match-row (colspan="4", its text JS-filled — ships empty).
  • A NEW revoked section BELOW the active table's wrap (ships hidden): <h2 id="tokens-revoked-heading" class="tokens-revoked-heading">Revoked tokens</h2> (a visible sub-heading — house typography: font-family: var(--mono); font-size: 1rem; color: var(--brand-ink); on the surface-less page background, AA pair, matching the phase-97 .kb-level h2 voice) + the search input #token-search-revoked + <div class="table-wrap tokens-table-wrap" id="tokens-revoked-wrap" role="region" aria-label="Revoked tokens" tabindex="0"> + <table class="tokens-table" id="tokens-revoked-table"> (visually-hidden caption Revoked tokens — newest first; thead Label | Created | Last used | Revoked; tbody#tokens-revoked-tbody + the hidden #tokens-revoked-no-match-row colspan="4").
  • frontend/assets/tokens.js (task 02 — the split + search + required name):
    • loadTokens() — the fetched list splits: revoked === false → #tokens-tbody, true → #tokens-revoked-tbody (the server's newest-first order kept per table); the revoked SECTION (#tokens-revoked-heading + search input + wrap) is shown iff the revoked table has ≥ 1 row; the active empty-state row logic stands (0 active rows → the empty row — a 0-row fetch with revoked tokens shows the empty active table + the populated revoked section: honest states); AFTER rendering, both search filters re-apply (D4 — the queries persist in module state activeQuery / revokedQuery, initialized "", never reset by a load).
    • makeRow(tok, { table }) — the active variant: Label | Created | Last used | Actions (Regenerate — task 03 — + Revoke, the existing two-step, unchanged); the revoked variant: Label | Created | Last used | Revoked (the revoked_at locale date+time, full ISO in title — the tokens-date-cell class) + NO actions. The title hover attributes carry over (label, created, last-used, revoked).
    • The search: per-table applyFilter(tbody, noMatchRow, query, tableLabel) — case-insensitive label.toLowerCase().includes(query.toLowerCase()) over the data rows (the no-match/empty rows excluded); rows toggle hidden; noMatchRow visible ⟺ query non-empty AND zero data rows visible (its <td> textContent = No tokens match "<query>". — the query inside the quotes is textContent, never HTML). The input listeners set the module query + apply (no fetch). The active search input is revealed with the create row (admin branch); the revoked one with the revoked section.
    • generateToken() (D3): const label = (labelInput ? labelInput.value : "").trim(); — if (!label) { announce("Give the token a name first."); if (labelInput) labelInput.focus(); return; } — the || "token" fallback deleted; the rest of the function (the 201 once-block reveal, the list re-load FIRST, the label clear, the error lines) unchanged.
  • frontend/assets/tokens.js (task 03 — regenerate): makeRegenerateControl(tok, row) mirroring makeRevokeControl (the history-confirm-* swap, focus to Yes) — Yes → POST /api/tokens/<id>/regenerate → on 201: loadTokens() (the re-entrant load — the old row relocates, the new row lands) → the once-block reveal (onceValue.value = created.token, onceBlock.hidden = false — the same once-block, the A4 value-only contract) → announce("Regenerated \"<label>\" — copy the new token now; it won't be shown again."); 404 → the row re-renders revoked-in-place + That token was already revoked. (the revoke control's existing 404 handling, mirrored); 409 → the row relocates via loadTokens() + the same line; other failure/network → neutral retry copy + the confirm restores (the restoreRegenerate pattern — focus returns). The active Actions cell order: Regenerate, then Revoke (the rotation is the primary lifecycle action).
  • frontend/assets/styles.css — .token-search (the input row: width: 100%; + the house input surface — reuse the #tune-note font/padding/border family, :focus-visible via the global rule, ≥ 44px target height), .tokens-revoked-heading (the sub-heading, the AA pair noted above), the no-match rows' <td> (the tokens-empty-row styling family — color: var(--ink-soft)), the Regenerate button (the .token-revoke family's neutral sibling — or a new .token-regenerate class styled identically to .token-revoke minus the hover-error: keep it simple — REUSE .token-revoke's classes for the confirm machinery, one extra class for the label if needed; NO new hue — the phase-92 monochrome invariant). The ≤640px block: the search inputs + the two tables squeeze as today (verify the tokens-table-wrap mobile rules cover the second table — they are class-based, so they do).
  • Source pins: extend the existing tokens.js full-file source-pin unit file (the phase-79 task-06 pin — locate it under tests/unit/, likely test_tokens_ui.py or the test_api_tokens_model.py sibling; if no dedicated file exists, CREATE tests/unit/test_tokens_ui.py): the two tbodys + the section show/hide, the split in loadTokens, the filter logic (the case-insensitivity, the re-apply-after-load, the no-match row text), the required-name block (the exact announce copy + NO || "token" anywhere in the file), the regenerate wiring (the POST path, the once-block reveal, the 404/409 branches, the confirm copy) + the styles.css class pins.

The E2E (task 04)

tests/e2e/test_tokens_page.py (new; app_server + mock_llm + db_ready; admin via auth_helpers.login; a token-user context via a FRESH browser context + auth_helpers.login_with_token — the phase-79 pattern; the e2e--labeled token cleanup autouse fixture from test_api_tokens.py):

  1. test_revoked_tokens_move_to_their_own_table — create two tokens (the UI create row, labels e2e-act / e2e-rev); Revoke e2e-rev (the two-step): the active table shows ONLY e2e-act (the active thead has NO Status column — four <th>); the revoked section is visible BELOW the active table: its heading Revoked tokens, the e2e-rev row with a Revoked date cell (non-empty, ≠ created) and NO action buttons.
  2. test_both_tables_are_searchable — with ≥ 2 active tokens (e2e-a1 / e2e-a2 — labels chosen so one is a substring of the other's neighbor: type e2e-a1 → only that row visible; type zzz → the no-match row reads No tokens match "zzz".; clear → both rows back). Same on the revoked search (with the revoked row from test 1's flow or a fresh revoke).
  3. test_a_token_cannot_be_generated_without_a_name — clear the label, click Generate: NO once-block, the live region reads Give the token a name first., the active table's row count is unchanged (and GET /api/tokens via the admin cookie shows no new row — the request never happened).
  4. test_regenerate_rotates_the_token — create e2e-rot; Regenerate → Yes: the once-block shows a NEW plaintext (≠ the original, bor_ + 32 hex); the active table has the e2e-rot row (the NEW one — its created timestamp is newer); the revoked table now holds the ORIGINAL e2e-rot (same label, Revoked date set); the live region carries the D2 line; a fresh token-user context signs in with the NEW token (the gate closes → the app opens — login_with_token), and the ORIGINAL token is refused at the gate (the #auth-gate-error alert — it was revoked by the rotation).
  5. Update tests/e2e/test_api_tokens.py in place (the phase-97 task-07/08 precedent): the revoked-row assertions relocate to #tokens-revoked-tbody (test_admin_generates_token_in_ui's post-revoke state, test_revocation_closes_the_door's .stale-pill → the revoked table's row + its Revoked date cell); the Status-column assertions (if any) drop; every non-layout assertion (the auth flows, the cached token, the 403 walls, the wrong-token error) stays byte-identical.
  6. Regression sweep (each in isolation, --no-cov, DB up): the updated test_api_tokens.py + test_admin_auth.py (the gate contract) + test_shared_header.py (the header/whoami plumbing — expected unchanged).
  7. Full gates + commit: uv run pytest green; uv run pytest --cov=app --cov-report=term-missing >90%; uv run ruff check . && uv run pyright clean; move 101_tokens_page_overhaul → .agents/phases/complete/; one atomic commit:
    git add -A .agents/ app/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(tokens): split active and revoked tables with search, require a name, and add one-click regenerate"
    

Tasks

  1. 01_regenerate_api.md — the service regenerate_token + POST /api/tokens/{id}/regenerate (rotation, atomic) + TokenListItem.revoked_at + unit/integration
  2. 02_two_tables_search_required_name.md — the view skeleton (two tables + the revoked section + the search inputs) + the client split/filter + the required-name block
  3. 03_regenerate_ui.md — the Regenerate control (two-step confirm, the once-block reveal, the 404/409 branches) + the CSS + the source pins
  4. 04_e2e_tokens_page.md — tests/e2e/test_tokens_page.py + the test_api_tokens.py update + the regression sweep + the atomic commit

Testing & Quality

  • Unit: the regenerate_token matrix (tests/unit/test_tokens.py); the source pins (task 03's file) for the view logic.
  • Integration: the endpoint matrix + the list shape (tests/integration/test_tokens_api.py); the existing phase-79 auth-enforcement pins stay green (the new route rides the router-wide gate).
  • E2E (mandatory, A16): uv run pytest tests/e2e/test_tokens_page.py -v --no-cov in isolation; the updated test_api_tokens.py + test_admin_auth.py + test_shared_header.py green in isolation.
  • Coverage: >90% on app/ (uv run pytest --cov=app --cov-report=term-missing).
  • Lint/types: uv run ruff check . && uv run pyright.

Completion Criteria

  • the Tokens view (admin) shows the active tokens in the top table (Label | Created | Last used | Actions) and the revoked ones in a separate table below (Label | Created | Last used | Revoked) — the section hidden while no token is revoked; both tables filter live by label with a per-table no-match row that survives re-renders
  • a blank/whitespace name generates NOTHING (the live-region line, no request, no row); the token fallback is gone
  • Regenerate (one button + the house confirm) rotates: the old token is revoked (lands in the revoked table, refuses new sign-ins immediately) and the new token (same label) is active with its plaintext shown exactly once; 404/409 are pinned at the API level
  • the A4 contract holds end to end (the list never carries plaintext/hashes; the once-block re-shows nothing — the phase-79 pins green)
  • uv run pytest green; coverage >90%; ruff + pyright clean
  • uv run pytest tests/e2e/test_tokens_page.py -v --no-cov green in isolation (DB up: podman compose up -d db); the regression suites green in isolation
  • one atomic Conventional Commit, --no-gpg-sign