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
+37 -1
View File
@@ -15,7 +15,10 @@ Routes (all under ``/api`` via the ``main`` registration):
that carries the plaintext ``token``), ``GET /api/tokens``
(``TokenList`` — newest first, secret-free), ``POST
/api/tokens/{token_id}/revoke`` (204, idempotent; unknown id → 404
``token not found``).
``token not found``), and ``POST /api/tokens/{token_id}/regenerate``
(201 ``TokenCreated`` — the atomic rotation: the old row is revoked
and its successor, same label, is created in ONE transaction; already
revoked → 409, unknown id → 404).
"""
from __future__ import annotations
@@ -87,6 +90,7 @@ def list_tokens(
created_at=row.created_at,
last_used_at=row.last_used_at,
revoked=row.revoked_at is not None,
revoked_at=row.revoked_at, # phase 101 D5: null while active
)
for row in rows
]
@@ -110,3 +114,35 @@ def revoke_token(
raise HTTPException(status_code=404, detail="token not found")
db.commit()
return Response(status_code=204)
@router.post("/{token_id}/regenerate", response_model=TokenCreated, status_code=201)
def regenerate_token(
token_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> TokenCreated:
"""Rotate one active token (201) — the rotation is ONE transaction.
The old row is stamped ``revoked_at`` and the successor (SAME label,
fresh hash) is created in the same commit, so the holder's old token
is dead the instant the new one is live. This 201 is the NEW token's
ONE plaintext moment (A4 — the same contract as the create endpoint;
the old plaintext was already one-shot and is never re-shown).
A dead token cannot be rotated: an already-revoked id is a 409
``token already revoked``, and an unknown id a 404 ``token not
found`` (the revoke endpoint's exact message).
"""
try:
result = token_service.regenerate_token(db, token_id)
except token_service.TokenAlreadyRevoked:
raise HTTPException(
status_code=409, detail="token already revoked"
) from None
if result is None:
raise HTTPException(status_code=404, detail="token not found")
new_row, plaintext = result
db.commit() # ONE commit: the stamp + the create are atomic
db.refresh(new_row) # pulls the server-default created_at
return TokenCreated(
id=new_row.id, label=new_row.label, token=plaintext, created_at=new_row.created_at
)