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`.
21 KiB
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.containeralready — 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 visibleRevoked tokensheading + its own search input) is Label | Created | Last used | Revoked (therevoked_atdate — locale date+time, full ISO on hover, the housetokens-date-celllanguage). The table IS the status — the em-dash /.stale-pillcolumn 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, 201TokenCreated): in ONE transaction — stamprevoked_aton the old row (therevokeservice primitive) andcreate_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 → 404token not found; the row is ALREADY revoked → 409token 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 toRegenerate? 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-entrantloadTokens()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 readsRegenerated "<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 readsGive 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-activeabove the active table,#token-search-revokedabove the revoked table — inside the revoked section, so it hides with it), placeholderSearch active tokens…/Search revoked tokens…(labeled viaaria-label— the house visually-hidden-label language for single-purpose inputs). Filtering: case-insensitive SUBSTRING over the label, applied on theinputevent to the CURRENTLY rendered rows (no fetch — toggle each row'shidden), and RE-APPLIED after everyloadTokens()(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 rowNo tokens yet — …). Empty query → all rows visible, no-match row hidden. - D5 — the list API gains
revoked_at:TokenListItemaddsrevoked_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 existingrevoked: boolstays (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 → returnNone;revoked_atset → raiseTokenAlreadyRevoked(a new module-level exception, one sentence docstring: a dead token cannot be rotated); elserevoke(db, row.id)(stamp) +create_token(db, row.label)(new row + plaintext) +db.flush()(the caller commits — thecreate_tokenconvention: 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-widerequire_admincovers it):None→ 404token not found(the revoke endpoint's exact message);TokenAlreadyRevoked→ 409token already revoked; success → commit + refresh +TokenCreated(id, label, token, created_at)of the NEW row. The list endpoint passesrevoked_at=row.revoked_atintoTokenListItem(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): theregenerate_tokenmatrix — active row → (new row, new plaintext) with the SAME label, the old row stamped (its originalrevoked_atsemantics: 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 viafind_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 rowrevoked: true+revoked_atnon-null in the next list; the new row active, same label, newercreated_at); 404 unknown id; 409 already-revoked; 403 anonymous + 403 token-user (the router-wide gate — the existing pattern); the list shape now carriesrevoked_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-searchclass 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 h2voice) + 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 captionRevoked tokens — newest first; thead Label | Created | Last used | Revoked;tbody#tokens-revoked-tbody+ the hidden#tokens-revoked-no-match-rowcolspan="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 stateactiveQuery/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 (therevoked_atlocale date+time, full ISO intitle— thetokens-date-cellclass) + NO actions. Thetitlehover attributes carry over (label, created, last-used, revoked).- The search: per-table
applyFilter(tbody, noMatchRow, query, tableLabel)— case-insensitivelabel.toLowerCase().includes(query.toLowerCase())over the data rows (the no-match/empty rows excluded); rows togglehidden;noMatchRowvisible ⟺ query non-empty AND zero data rows visible (its<td>textContent =No tokens match "<query>".— the query inside the quotes is textContent, never HTML). Theinputlisteners 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)mirroringmakeRevokeControl(thehistory-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 vialoadTokens()+ the same line; other failure/network → neutral retry copy + the confirm restores (therestoreRegeneratepattern — 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-notefont/padding/border family,:focus-visiblevia the global rule, ≥ 44px target height),.tokens-revoked-heading(the sub-heading, the AA pair noted above), the no-match rows'<td>(thetokens-empty-rowstyling family —color: var(--ink-soft)), the Regenerate button (the.token-revokefamily's neutral sibling — or a new.token-regenerateclass styled identically to.token-revokeminus 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≤640pxblock: the search inputs + the two tables squeeze as today (verify thetokens-table-wrapmobile 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/, likelytest_tokens_ui.pyor thetest_api_tokens_model.pysibling; if no dedicated file exists, CREATEtests/unit/test_tokens_ui.py): the two tbodys + the section show/hide, the split inloadTokens, 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):
test_revoked_tokens_move_to_their_own_table— create two tokens (the UI create row, labelse2e-act/e2e-rev); Revokee2e-rev(the two-step): the active table shows ONLYe2e-act(the active thead has NO Status column — four<th>); the revoked section is visible BELOW the active table: its headingRevoked tokens, thee2e-revrow with a Revoked date cell (non-empty, ≠ created) and NO action buttons.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: typee2e-a1→ only that row visible; typezzz→ the no-match row readsNo 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).test_a_token_cannot_be_generated_without_a_name— clear the label, click Generate: NO once-block, the live region readsGive the token a name first., the active table's row count is unchanged (andGET /api/tokensvia the admin cookie shows no new row — the request never happened).test_regenerate_rotates_the_token— createe2e-rot; Regenerate → Yes: the once-block shows a NEW plaintext (≠ the original,bor_+ 32 hex); the active table has thee2e-rotrow (the NEW one — its created timestamp is newer); the revoked table now holds the ORIGINALe2e-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-erroralert — it was revoked by the rotation).- Update
tests/e2e/test_api_tokens.pyin 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. - Regression sweep (each in isolation,
--no-cov, DB up): the updatedtest_api_tokens.py+test_admin_auth.py(the gate contract) +test_shared_header.py(the header/whoami plumbing — expected unchanged). - Full gates + commit:
uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missing>90%;uv run ruff check . && uv run pyrightclean; move101_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
01_regenerate_api.md— the serviceregenerate_token+POST /api/tokens/{id}/regenerate(rotation, atomic) +TokenListItem.revoked_at+ unit/integration02_two_tables_search_required_name.md— the view skeleton (two tables + the revoked section + the search inputs) + the client split/filter + the required-name block03_regenerate_ui.md— the Regenerate control (two-step confirm, the once-block reveal, the 404/409 branches) + the CSS + the source pins04_e2e_tokens_page.md—tests/e2e/test_tokens_page.py+ thetest_api_tokens.pyupdate + the regression sweep + the atomic commit
Testing & Quality
- Unit: the
regenerate_tokenmatrix (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-covin isolation; the updatedtest_api_tokens.py+test_admin_auth.py+test_shared_header.pygreen 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
tokenfallback 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 pytestgreen; coverage >90%; ruff + pyright cleanuv run pytest tests/e2e/test_tokens_page.py -v --no-covgreen in isolation (DB up:podman compose up -d db); the regression suites green in isolation- one atomic Conventional Commit,
--no-gpg-sign