# 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 "