phase: 101_tokens_page_overhaul
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 13s

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`.
This commit is contained in:
2026-09-12 15:16:02 -04:00
parent 58e9d94cff
commit 4dbac1660a
32 changed files with 2321 additions and 150 deletions
@@ -0,0 +1,78 @@
# 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:
```bash
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`
@@ -0,0 +1,35 @@
# Task 01 — `POST /api/tokens/{id}/regenerate`: the atomic rotation + `TokenListItem.revoked_at`
**Phase:** `101_tokens_page_overhaul` · **Story:** n/a (owner request)
## Objective
The admin can rotate an active token server-side: one atomic call revokes the old row and creates its successor (same label, new plaintext returned exactly once) — plus the list API exposes `revoked_at` so the revoked table can show the revocation date.
## Work
1. `app/core/tokens.py`:
- `class TokenAlreadyRevoked(Exception)` — module-level, one-sentence docstring (a dead token cannot be rotated — the 409's reason).
- `regenerate_token(db, token_id: uuid.UUID) -> tuple[ApiToken, str] | None`:
- `row = db.get(ApiToken, token_id)` — `None` → return `None` (the endpoint maps it to the 404).
- `row.revoked_at is not None` → `raise TokenAlreadyRevoked`.
- `revoke(db, row.id)` (stamps the original revocation time — the existing primitive; it returns True here by construction) + `new_row, plaintext = create_token(db, row.label)` (the SAME label — D2).
- `db.flush()` and return `(new_row, plaintext)` — the CALLER commits (the `create_token` convention: the service flushes, the endpoint owns the commit). ONE commit covers stamp + create (atomicity: a create failure rolls the revoke back with it — note this in the docstring).
- Module docstring: the rotation's contract (the old plaintext was already one-shot; the new one is too — A4 never weakens).
2. `app/api/tokens.py` — `@router.post("/{token_id}/regenerate", response_model=TokenCreated, status_code=201)`:
- `result = token_service.regenerate_token(db, token_id)`; `None` → 404 `token not found` (the revoke endpoint's exact message); `except TokenAlreadyRevoked` → 409 `token already revoked`; success → `db.commit()` + `db.refresh(new_row)` (the server-default `created_at`) + `TokenCreated(id, label, token, created_at)`.
- `list_tokens` — pass `revoked_at=row.revoked_at` into each `TokenListItem` (D5).
- The new route's docstring: rotate-once semantics + the A4 note (this 201 is the new token's ONLY plaintext moment).
3. `app/schemas.py` — `TokenListItem` gains `revoked_at: datetime | None = None` (the docstring: null while active; the ISO-8601 timestamp the revoked table renders — the D5 note).
4. Unit (`tests/unit/test_tokens.py` extensions) — the `regenerate_token` matrix (the existing file's DB-session pattern): active row → a NEW row (different id) with the SAME label + a well-formed new plaintext (`bor_` + 32 hex) whose sha256 is the stored hash; the OLD row stamped with a `revoked_at`; the new plaintext round-trips through `find_active_by_token`; the old plaintext no longer authenticates (`find_active_by_token(old)` → `None`); missing id → `None`; an already-revoked id → `TokenAlreadyRevoked`; the rotation is atomic (a create failure — e.g. simulate by exhausting… if the existing harness cannot fail `create_token`, pin the flush-only/commit-caller contract by asserting the session is NOT committed by the service: the caller's rollback undoes both writes).
5. Integration (`tests/integration/test_tokens_api.py` extensions): 201 (body carries the new plaintext — `bor_`-prefixed, ≠ the original; the follow-up list shows the old row `revoked: true` + non-null `revoked_at` and the new row active, same label, newer `created_at`); 404 unknown id (one message); 409 an already-revoked id (revoked via the endpoint first); 403 anonymous + 403 a token-user session (the router-wide gate — the existing 403 pattern in the file); the list response shape now includes `revoked_at` (null for active, timestamp for revoked — the phase-79 list pins updated in place).
## Testing & Quality
- Unit: the matrix above (`tests/unit/test_tokens.py`).
- Integration: the endpoint matrix + the list shape (`tests/integration/test_tokens_api.py`).
- Coverage: **>90%** on this task's new/modified code (full gate: `app/`).
## Completion Criteria
- [ ] `POST /api/tokens/{id}/regenerate` → 201 with the new token's plaintext (same label); the old row is revoked in the SAME transaction; 404 / 409 pinned; the admin gate pins hold (403 anonymous + token-user)
- [ ] `GET /api/tokens` items carry `revoked_at` (null active / timestamp revoked); the existing phase-79 list assertions (no plaintext, no hashes, newest-first) still pass
- [ ] the A4 contract: the list never gains a credential field (unit + integration pinned)
- [ ] full test suite green, coverage >90%
- [ ] no behavior change in completed work (the phase-79 auth-enforcement + token-gate suites green)
@@ -0,0 +1,58 @@
# Task 02 — The view: active/revoked tables, per-table search, the required name
**Phase:** `101_tokens_page_overhaul` · **Story:** n/a (owner request)
## Objective
The Tokens view splits its list into an active table and a revoked table below it (each with its own live label search), and a blank name can no longer generate a token — the `token` fallback is gone.
## Work
1. `frontend/index.html` `#view-tokens` (the static skeleton — the D1/D4 shapes from `00_phase.md`; keep every existing id that survives):
- The create row: the input's `aria-label` → `Token name`, the `placeholder` → `e.g. alice — required`; the row's comment updated (a blank name is REFUSED client-side — D3 — the server's 422 is defense in depth).
- A search row between the create row/once-block area and the active table's wrap: `<input type="search" id="token-search-active" aria-label="Search active tokens" placeholder="Search active tokens…" class="token-search">` (ships hidden — revealed in the admin branch with the create row).
- The active table (`#tokens-table`): thead loses the Status `<th>` — four columns now: Label | Created | Last used | `<th scope="col"><span class="visually-hidden">Actions</span></th>` (unchanged); `#tokens-empty-row` → `colspan="4"` (copy unchanged); a NEW `<tr class="tokens-empty-row" id="tokens-no-match-row" hidden><td colspan="4"></td></tr>` (its text JS-filled — the no-match copy, D4).
- The NEW revoked section AFTER the active table's wrap (ships `hidden` — tokens.js shows it when ≥ 1 revoked token):
```html
<h2 id="tokens-revoked-heading" class="tokens-revoked-heading">Revoked tokens</h2>
<input type="search" id="token-search-revoked" aria-label="Search revoked tokens" placeholder="Search revoked tokens…" class="token-search">
<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">
<caption class="visually-hidden">Revoked tokens — newest first</caption>
<thead><tr>
<th scope="col">Label</th><th scope="col">Created</th>
<th scope="col">Last used</th><th scope="col">Revoked</th>
</tr></thead>
<tbody id="tokens-revoked-tbody">
<tr class="tokens-empty-row" id="tokens-revoked-no-match-row" hidden><td colspan="4"></td></tr>
</tbody>
</table>
</div>
```
(the view's HTML comments carry the D1/D2/D4 provenance — the house comment style; the section's ids are all new, nothing collides).
2. `frontend/assets/tokens.js` (the split + search + required name):
- Module lookups: the revoked section's heading / search input / wrap / tbody / no-match row + the active no-match row (scoped to `root`, the phase-76 contract).
- Module state: `activeQuery = ""` / `revokedQuery = ""` (the D4 persistent queries — initialized once, NEVER reset by a load).
- `makeRow(tok, table)` — `table` is `"active"` | `"revoked"`: shared Label/Created/Last-used cells (the `title` hovers carry over); the active variant's Actions cell (Regenerate — task 03 — + the existing two-step Revoke, UNCHANGED in this task); the revoked variant's last cell = the `revoked_at` date (locale date+time via the existing `fmtDate`, full ISO in `title`, class `tokens-date-cell`) and NO actions.
- `loadTokens()` — the re-entrant core is preserved (once-block hidden + field wiped, data rows dropped, the fetch, the error lines, the return value — all UNCHANGED); the render step now SPLITS the fetched list by `tok.revoked` (server order kept per table: active rows → `#tokens-tbody`, revoked rows → `#tokens-revoked-tbody`); the active empty-state row shows iff there are zero ACTIVE rows (a 0-active fetch with revoked rows shows the empty-state row AND the populated revoked section — both honest); the revoked SECTION (heading + search input + wrap — a small `setRevokedSectionVisible(n)` helper) shows iff n ≥ 1; FINALLY both filters re-apply (`applyFilter` below — D4: a re-render never loses the queries).
- `applyFilter(tbody, noMatchRow, query, )` — pure DOM: trim + lowercase the query; over the tbody's data rows (everything EXCEPT the no-match row), `row.hidden = query && !label.toLowerCase().includes(query.toLowerCase())` (read the label from the row's label cell textContent — the rows are the data source of truth after a render); `noMatchRow` hidden unless `query && zeroDataRowsVisible` — its `<td>` textContent = `No tokens match "${query}".` (the ORIGINAL, untrimmed user query inside the quotes — textContent only).
- The `input` listeners (armed in the admin branch, after the whoami gate — the anonymous branch arms nothing): set the module query + `applyFilter` (NO fetch — D4).
- `generateToken()` (D3): `const label = (labelInput ? labelInput.value : "").trim();` — `if (!label) { announce("Give the token a name first."); if (labelInput) labelInput.focus(); return; }` — the `|| "token"` DELETED; the placeholder/comment (step 1) and the function's comment updated; the rest of the flow (the disabled/"Generating…" lifecycle, the 201 reveal, the list re-load FIRST, the label clear, the error lines) byte-identical in behavior.
- Module docstring: the phase-101 section (the split, the search contract, the required name, the revoked column).
- The `bor:view-refresh` listener is UNCHANGED (it calls `loadTokens()`, which now splits + re-applies the filters — the queries survive a re-show for free).
3. `frontend/assets/styles.css` (the token region):
- `.token-search { width: 100%; min-height: 44px; padding: 0.55rem 0.8rem; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface); color: var(--ink); font: inherit; font-size: 0.9rem; }` + `:focus-visible` via the global 3px outline rule (no custom focus rule — the house pattern) + a comment (the search inputs' surface — the `#tune-note`/archive-upload input family, AA pairs, no new hue).
- `.tokens-revoked-heading { margin: 1.5rem 0 0.5rem; font-family: var(--mono); font-size: 1rem; color: var(--brand-ink); }` (the sub-heading — the phase-97 `.kb-level h2` voice, AA on `--bg`).
- The no-match rows reuse the existing `.tokens-empty-row` styling (the class is shared — verify it covers both tables; if the empty-row rule is `#tokens-empty-row`-specific, generalize it to `.tokens-empty-row` — the styling is already class-based, so likely a no-op).
- The `≤640px` block: verify the existing `.token-create` / `.tokens-table-wrap` squeeze rules cover the new inputs + the second table (class-based — expected no change; add a search-input width rule only if the mobile layout needs it).
4. Source pins: locate the phase-79 tokens.js full-file source-pin unit file under `tests/unit/` (grep `tokens.js` in `tests/unit/` — if a dedicated file exists, EXTEND it; if the pins live inside `test_api_tokens_model.py` or similar, extend there; if no dedicated pin file exists, CREATE `tests/unit/test_tokens_ui.py`): the two-tbody split in `loadTokens`, the section show/hide helper, `applyFilter` (case-insensitivity, the re-apply-after-load call, the no-match text with the original query, the data-row exclusion), the required-name block (the exact announce copy `Give the token a name first.`, the early return BEFORE any fetch, and a negative pin: the string `"token"` fallback pattern `|| "token"` is GONE from the file), the search inputs' armed-in-admin-branch wiring + the styles.css class pins.
## Testing & Quality
- Unit: the source pins above are this task's test layer for the JS (`app/` untouched this task — task 01's API pins already cover the server side).
- Coverage: **>90%** on this task's new/modified code (frontend pins cover the JS; the browser proof is task 04's E2E).
- Note: do NOT add an E2E file here — the split/search/name proofs land in task 04.
## Completion Criteria
- [ ] the view skeleton carries the four-column active table, the revoked section (heading + search + the four-column table) shipping hidden, and both search inputs (ids per the D1/D4 contract)
- [ ] `loadTokens` splits by `revoked`, hides the section when empty, keeps the once-block / error / return-value contracts byte-identical, and re-applies BOTH persistent queries after every render (source-pinned)
- [ ] a blank/whitespace name announces `Give the token a name first.`, re-focuses, and sends NO request (source-pinned; the `|| "token"` pattern is gone from the file)
- [ ] full test suite green, coverage >90%
- [ ] no behavior change in completed work (the phase-79 gate / whoami / once-block pins green; the Regenerate button is NOT built yet — task 03 adds it)
@@ -0,0 +1,32 @@
# Task 03 — The Regenerate control: one button, the house confirm, the once-block reveal
**Phase:** `101_tokens_page_overhaul` · **Story:** n/a (owner request)
## Objective
Every active token row carries a **Regenerate** button (before its Revoke): one click opens the house two-step confirm, Yes rotates the token server-side, and the new plaintext appears in the shown-once block — the old token relocates to the revoked table.
## Work
1. `frontend/assets/tokens.js`:
- `makeRegenerateControl(tok, row)` — a structural mirror of `makeRevokeControl` (the D2 contract from `00_phase.md`): a `.token-regenerate` button labeled `Regenerate` (`aria-label: "Regenerate token: <label>"`); first click swaps the cell's contents to the confirm pair — `span.history-confirm-text` `Regenerate? The current token is revoked.` + Yes (`.history-confirm-yes`) + No (`.history-confirm-no`), focus to Yes; No / failure → `restoreRegenerate()` (the button back, focus restored — the revoke control's restore pattern, copied).
- `confirmRegenerate(tok, row, yesBtn, restoreRegenerate)`: `yesBtn.disabled = true`; `POST /api/tokens/<id>/regenerate` (JSON — no body):
- **201** → parse `{ id, label, token, created_at }` → `await loadTokens()` FIRST (the re-entrant load: the old row moves to the revoked table, the new row lands in the active one — the D2 relocation) → the once-block reveal (`if (onceValue) onceValue.value = data.token;` + `if (onceBlock) onceBlock.hidden = false;` — the SAME once-block, the A4 value-only contract: the plaintext lives in the field's value, never a data attribute) → `announce(`Regenerated "${tok.label}" — copy the new token now; it won't be shown again.`)`.
- **404** → the row is gone (revoked elsewhere / regenerated elsewhere): `row.replaceWith(makeRow({ ...tok, revoked: true }, "revoked"))` — wait: the row is an ACTIVE-table row; a 404 means the id no longer exists at all. Mirror the revoke control's 404 handling: re-render the row in the REVOKED table's state is wrong if the row is gone — instead `row.remove()` + `announce("That token no longer exists.")`… **Decision (pinned here):** a 404 on regenerate means the row vanished (revoked AND deleted by another admin, or a stale render) — `row.remove()` + `await loadTokens()` (the re-fetch reconciles both tables) + `announce("That token was already revoked.")` (the revoke control's existing 404 line — one house message for the one common case).
- **409** → the row was revoked between render and click: `await loadTokens()` (reconciles) + `announce("That token was already revoked.")`.
- **other non-2xx / network** → `announce("Couldn't regenerate \"<label>\" — try again.")` (network: the `is the app reachable?` variant — the house two-line convention) + `restoreRegenerate()` (retryable, the button back).
- The active `makeRow`'s Actions cell: `actionsTd.append(makeRegenerateControl(tok, tr), makeRevokeControl(tok, tr))` — Regenerate FIRST (the primary lifecycle action, D2); the two controls each own their own `<span class="tokens-actions">` cell-content swap (verify the two swap-scopes don't interfere — the revoke control swaps `cell`'s children; give each control its OWN wrapper span (the regenerate control gets its own, the revoke control keeps its existing one) so a confirm in one never clobbers the other).
- The `#tokens-status` live-region contract is unchanged (every outcome lands a line — D2's copy is pinned).
- Module docstring: the regenerate section (the rotation semantics + the 404/409 copy).
2. `frontend/assets/styles.css` (the token region): `.token-regenerate` — the `.token-revoke` button's visual language (same size/border/radius/focus; color `var(--ink-soft)` on transparent, hover `var(--brand-soft)` / `var(--brand-ink)` — the neutral action's hover, NOT the revoke's error hover; a `.token-regenerate:hover:disabled` state consistent with the revoke button's disabled rule) + the comment (the rotation button — no new hue, phase-92 invariant; the confirm pair reuses the `history-confirm-*` classes unchanged).
3. Source pins (the file established in task 02): `makeRegenerateControl` (the button label + aria-label, the confirm text EXACTLY `Regenerate? The current token is revoked.`, the focus-to-Yes, the restore path), `confirmRegenerate` (the POST path `/api/tokens/` + `regenerate`, the 201 sequence — load FIRST then once-block reveal then announce (the D2 line, exact), the 404/409 copy, the neutral retry copy, the disabled-while-in-flight), the Actions cell order (Regenerate before Revoke), the per-control wrapper spans, + the `.token-regenerate` CSS pin.
## Testing & Quality
- Unit: the source pins above are this task's test layer (`app/` untouched — task 01's API pins cover the endpoint).
- Coverage: **>90%** on this task's new/modified code (frontend pins cover the JS; the browser proof is task 04's E2E).
- Note: do NOT add an E2E file here — the rotation proof lands in task 04.
## Completion Criteria
- [ ] an active row shows Regenerate (before Revoke); the confirm copy/focus/restore are pinned; Yes → 201 → the old row is in the revoked table, the new row active (same label), the once-block shows the new plaintext, the D2 line is announced (source-pinned; task 04 proves it in a browser)
- [ ] the 404 / 409 / failure branches are pinned (the house copy, the retryable restore, the load-based reconciliation)
- [ ] the revoke control's behavior is byte-identical (its pins green — the shared cell hosts two independent confirm scopes)
- [ ] full test suite green, coverage >90%
- [ ] no behavior change in completed work (the phase-79 once-block / copy / gate pins green)
@@ -0,0 +1,34 @@
# Task 04 — The dedicated E2E: the split tables, the search, the required name, the rotation + regressions + commit
**Phase:** `101_tokens_page_overhaul` · **Story:** n/a (owner request)
## Objective
Pin the owner-visible contract in a browser: revoked tokens live in their own table below the active ones, both tables search, a nameless token cannot be created, and Regenerate rotates the credential end to end (the new token signs in, the old one is refused) — then the phase-79 suite moves its revoked-row pins to the new table.
## Work
1. `tests/e2e/test_tokens_page.py` (new — the phase's dedicated A16 suite, run in isolation). Fixtures: `app_server` + `mock_llm` + `db_ready` (the `conftest` pattern); admin via `tests/e2e/auth_helpers.login(page, app_url, next="/tokens.html")`; a token-user context via a FRESH browser context + `auth_helpers.login_with_token(page, app_url, token)` (the phase-79 helper — drives the real gate). An autouse fixture deletes `e2e-`-labeled tokens (the `test_api_tokens.py` cleanup pattern — `DELETE FROM api_tokens WHERE label LIKE 'e2e-%'`).
- **`test_revoked_tokens_move_to_their_own_table`** — create two tokens through the UI create row (`e2e-act`, `e2e-rev` — label → Generate → the once-block appears each time); Revoke `e2e-rev` (the existing two-step: click Revoke → click Yes): `#tokens-tbody` shows ONLY `e2e-act`; the active table's `<thead>` has exactly four `<th>` (NO Status column); `#tokens-revoked-heading` reads `Revoked tokens` and is visible; `#tokens-revoked-tbody` shows the `e2e-rev` row with a non-empty Revoked date cell (locale format, ≠ the created date) and NO buttons in its row; `#tokens-status` reads `Revoked "e2e-rev".`.
- **`test_both_tables_are_searchable`** — with `e2e-a1` + `e2e-a2` active and one revoked token present: type `e2e-a1` in `#token-search-active` → only the `e2e-a1` row visible (`e2e-a2` hidden); type `zzz` → the no-match row visible reading `No tokens match "zzz".`; clear the input → both rows back, no-match hidden. The same three-beat on `#token-search-revoked` (filter the revoked row in/out). The queries SURVIVE a re-show: type a query, leave via the Tuning nav link, come back (the phase-77 refresh re-renders) → the filter is still applied (the visible set unchanged).
- **`test_a_token_cannot_be_generated_without_a_name`** — with the label empty, click Generate: `#token-once` stays hidden, `#tokens-status` reads `Give the token a name first.`, the active table's data-row count is unchanged, and `GET /api/tokens` (admin cookie via `page.request.get`) shows no new row (the request never happened). Whitespace-only label → the same refusal.
- **`test_regenerate_rotates_the_token`** — create `e2e-rot` (capture its plaintext from `#token-once-value`); on its row click Regenerate → the confirm pair appears (`Regenerate? The current token is revoked.`, focus on Yes) → click Yes: `#token-once` re-appears with a NEW plaintext (≠ the original, matches `bor_[0-9a-f]{32}`); `#tokens-status` reads `Regenerated "e2e-rot" — copy the new token now; it won't be shown again.`; the active table has exactly ONE `e2e-rot` row (the new one — its created cell is newer than the old row's); the revoked table now holds an `e2e-rot` row (the original — its Revoked date is set); a fresh token-user context signs in with the NEW token (the gate closes, the chat view usable — `login_with_token` + an assertion on the signed-in state) and the ORIGINAL token is refused in another fresh context (the `#auth-gate-error` role=alert visible after submitting it — the rotation killed it, the phase-79 revocation semantics).
2. `tests/e2e/test_api_tokens.py` — UPDATE IN PLACE (the phase-97 task-07/08 precedent — the layout changed, the auth contracts did not):
- The revoked-row assertions relocate to `#tokens-revoked-tbody`: `test_admin_generates_token_in_ui` (its post-revoke expectations — the row's NEW home; the `.stale-pill` assertion drops with the Status column — assert the revoked row + its Revoked date cell instead) and `test_revocation_closes_the_door` (the two-step revoke now lands the row in the revoked table — update the locator + the pill assertion the same way; the `Revoked "e2e-revoke".` live-region line is UNCHANGED).
- Any Status-column / 5-`<th>` / `colspan="5"` references update to the four-column contract; `#tokens-empty-row`'s colspan is 4.
- EVERYTHING else — the anonymous lockout, the shared-chats-stay-open, the token-user app usage, the cached-token reload, the 403 walls, the sign-out clears, the wrong-token generic error — stays byte-identical (this suite is the phase-79 auth contract; only its layout locators move).
3. Regression sweep (each in isolation, `--no-cov`, DB up): the updated `test_api_tokens.py` + `test_admin_auth.py` (the gate contract — expected untouched) + `test_shared_header.py` (the whoami/header plumbing — expected untouched).
4. 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:
```bash
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"
```
## Testing & Quality
- E2E (mandatory, A16): `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` green in isolation.
- Coverage: **>90%** on `app/` (the full-suite gate).
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] the four E2E tests pass in isolation (the split + the column contract, both searches incl. the re-show persistence, the nameless refusal, the full rotation with the new-token-signs-in / old-token-refused proof)
- [ ] the updated `test_api_tokens.py` passes in isolation (the phase-79 auth contract intact, the layout pins relocated)
- [ ] the regression suites pass in isolation
- [ ] full suite green, coverage >90%, ruff + pyright clean
- [ ] phase dir moved to `complete/`, one atomic `--no-gpg-sign` Conventional Commit