docs(phases): author todo queue phases 98-102
98 sync summary visibility (status phases + pending markers), 99 catalog one-line clamp + breadcrumb back-nav, 100 72rem page-width consistency, 101 tokens page overhaul, 102 extensionless filename import.
This commit is contained in:
@@ -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`
|
||||
Reference in New Issue
Block a user