phase: 101_tokens_page_overhaul
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:
+384
-92
@@ -7,9 +7,12 @@
|
||||
* Wires the admin-only token endpoints (phase 79 task 02) into the
|
||||
* view:
|
||||
*
|
||||
* • the create row (label + Generate): a BLANK label sends "token"
|
||||
* (the placeholder documents the fallback; the API's 1–120
|
||||
* validator is satisfied). POST /api/tokens → 201 — the ONE
|
||||
* • the create row (name + Generate): a BLANK / whitespace-only
|
||||
* name is REFUSED client-side (phase 101 D3) — the live region
|
||||
* reads `Give the token a name first.`, the name input re-focuses,
|
||||
* and NO request is sent (the server's 422 on a blank/over-long
|
||||
* label is defense in depth; the old blank-label "token"
|
||||
* fallback is GONE). On a named generate: POST /api/tokens → 201 — the ONE
|
||||
* response that carries the plaintext (owner-locked A4) — and the
|
||||
* plaintext appears EXACTLY ONCE: in the #token-once block's mono
|
||||
* read-only field, with a Copy (the clipboard; a non-secure http
|
||||
@@ -18,31 +21,68 @@
|
||||
* loadTokens() / re-show (and the field is wiped with it) — the
|
||||
* plaintext is NOT stored anywhere client-side (no localStorage,
|
||||
* no data attribute), so a re-render can never re-show it;
|
||||
* • the full-width table (AGENTS.md rule 5): Label (textContent —
|
||||
* admin-derived, still text) | Created (locale date+time, the full
|
||||
* ISO in the title) | Last used (locale or "never") | Status — a
|
||||
* plain em-dash for Active, the rose .stale-pill for Revoked (the
|
||||
* stale-pill visual language; the cell carries its aria-label in
|
||||
* BOTH states — WCAG 2.1 AA) | Actions — Revoke, the inline
|
||||
* TWO-STEP confirm (the history-confirm-* pattern — NO native
|
||||
* confirm dialog: the first click swaps to "Revoke? [Yes] [No]",
|
||||
* focus moves to Yes; Yes POSTs /api/tokens/<id>/revoke and the
|
||||
* row re-renders Revoked + the live region line; No or a failed
|
||||
* request restores the Revoke button — retryable). Revoked rows
|
||||
* carry NO action (nothing left to revoke).
|
||||
* • the SPLIT tables (phase 101 D1 — AGENTS.md rule 5, full width,
|
||||
* no skinny list): the ACTIVE table on top (Label | Created |
|
||||
* Last used | Actions — the two-step inline Regenerate BEFORE
|
||||
* the two-step inline Revoke — phase 101 task 03 / D2) and the
|
||||
* REVOKED table below
|
||||
* it (Label | Created | Last used | Revoked — the revoked_at
|
||||
* date). The Status column is GONE from both: the table's
|
||||
* position IS the status (an all-active table needs no status; a
|
||||
* row in the revoked table is a dead token — no actions, nothing
|
||||
* left to revoke). The revoked SECTION (heading + search + table
|
||||
* wrap) is hidden while no token is revoked (an empty table is
|
||||
* noise); the active table keeps its empty-state row;
|
||||
* • the per-table LIVE label search (phase 101 D4): one type=search
|
||||
* input per table — a case-insensitive SUBSTRING over the
|
||||
* rendered rows' label cells, applied on `input` with NO fetch
|
||||
* (each data row toggles its `hidden`), and RE-APPLIED after
|
||||
* every loadTokens() (the queries live in module state —
|
||||
* activeQuery / revokedQuery — and survive re-renders / re-shows,
|
||||
* the phase-77 contract). Zero visible matches with a non-empty
|
||||
* query → the per-table no-match row (`No tokens match
|
||||
* "<query>".` — textContent, the distinct-from-empty-state
|
||||
* language); an empty query shows every row;
|
||||
* • the one-click REGENERATE (phase 101 task 03, D2 — rotation,
|
||||
* atomic): every ACTIVE row carries a Regenerate button BEFORE
|
||||
* its Revoke (the primary lifecycle action — ONE button starts
|
||||
* it). The control is a structural mirror of the Revoke control:
|
||||
* the first click swaps the button, in place, to the house
|
||||
* two-step confirm (`Regenerate? The current token is revoked.
|
||||
* [Yes] [No]` — the history-confirm-* classes, focus to Yes),
|
||||
* and it owns its OWN .tokens-actions wrapper span, so a confirm
|
||||
* in it never clobbers the Revoke control's. Yes → POST
|
||||
* /api/tokens/<id>/regenerate (JSON, NO body) — the server
|
||||
* rotates in ONE transaction: the old row is stamped revoked (it
|
||||
* lands in the revoked table) and the successor is created under
|
||||
* the SAME label (it lands in the active table); the 201 body
|
||||
* ({ id, label, token, created_at }) is the new token's ONLY
|
||||
* plaintext moment (A4). On 201 the re-entrant loadTokens() runs
|
||||
* FIRST (the relocation), THEN the shown-once block reveals the
|
||||
* NEW plaintext — the SAME #token-once block, the value-only
|
||||
* contract (the plaintext lives in the field's value, never a
|
||||
* data attribute) — and the live region reads `Regenerated
|
||||
* "<label>" — copy the new token now; it won't be shown again.`.
|
||||
* A 404 (the row vanished — revoked AND deleted elsewhere, or a
|
||||
* stale render) removes the row, re-fetches (the reconciliation),
|
||||
* and reuses the revoke control's 404 line `That token was
|
||||
* already revoked.`; a 409 (revoked between render and click)
|
||||
* re-fetches + the same line; any other failure / network error
|
||||
* restores the Regenerate button (retryable) with the neutral
|
||||
* house copy ("is the app reachable?" / "try again.").
|
||||
*
|
||||
* Every cell is built with the DOM APIs (textContent) — this file
|
||||
* never builds HTML (the XSS-safe-by-construction house rule; a full-
|
||||
* file source pin enforces it).
|
||||
*
|
||||
* The whoami gate (phase 19 shared-header module, cached promise):
|
||||
* • anonymous → the #tokens-gate is shown, the create row + table
|
||||
* hide, and NO /api/tokens request is made at all (the router
|
||||
* 403s anonymous — the same request-log contract as the history
|
||||
* view);
|
||||
* • admin → the gate hides, the create row + table reveal, and
|
||||
* `loadTokens()` renders the rows; a 0-row fetch (and a failed
|
||||
* load) reveals the empty-state row.
|
||||
* • anonymous → the #tokens-gate is shown, the create row + search
|
||||
* + tables hide, and NO /api/tokens request is made at all (the
|
||||
* router 403s anonymous — the same request-log contract as the
|
||||
* history view);
|
||||
* • admin → the gate hides, the create row + search + active table
|
||||
* reveal, and `loadTokens()` renders the rows; a 0-active-row
|
||||
* fetch (and a failed load) reveals the active empty-state row.
|
||||
*
|
||||
* Phase 76 (task 06) — shell view module: the top-level boot is
|
||||
* `export async function mount(root)` — root is the view's
|
||||
@@ -68,6 +108,23 @@
|
||||
* branch, after the whoami gate passes: anonymous shows the gate and
|
||||
* never fetches.
|
||||
*
|
||||
* Phase 101 (tasks 02 + 03) — the tokens page overhaul: the single
|
||||
* table SPLIT into active + revoked (D1: the table's position IS the
|
||||
* status, the Status column + the em-dash / .stale-pill are gone),
|
||||
* the per-table live label search (D4: client-side, query state
|
||||
* survives re-renders), the required name (D3: a blank name
|
||||
* generates NOTHING), and the one-click REGENERATE (D2: the rotation
|
||||
* is atomic server-side — the old row revoked, the successor created
|
||||
* under the same label — the 201's re-entrant load runs BEFORE the
|
||||
* once-block reveal; a 404 removes the vanished row + re-fetches, a
|
||||
* 409 re-fetches — both landing the house line "That token was
|
||||
* already revoked."; a retryable failure restores the button).
|
||||
* loadTokens keeps its re-entrant core
|
||||
* (once-block hidden + wiped, data rows dropped, the fetch, the error
|
||||
* lines, the return value) and SPLITS the fetched list by
|
||||
* `tok.revoked` — the server's newest-first order kept per table —
|
||||
* before re-applying BOTH persistent filters.
|
||||
*
|
||||
* The clipboard + inline-fallback helper is tokens.js's OWN ~10-line
|
||||
* copy (the per-page duplication house style — history.js keeps the
|
||||
* share link's, app.js the chat page's; no new shared module).
|
||||
@@ -80,9 +137,23 @@ export async function mount(root) {
|
||||
const tableWrap = root.querySelector("#tokens-table-wrap");
|
||||
const tbody = root.querySelector("#tokens-tbody");
|
||||
const emptyRow = root.querySelector("#tokens-empty-row");
|
||||
// Phase 101 (task 02, D4): the per-table search inputs + no-match
|
||||
// rows (the active no-match row ships in #tokens-tbody, hidden; its
|
||||
// <td> text is JS-filled).
|
||||
const searchActive = root.querySelector("#token-search-active");
|
||||
const noMatchRow = root.querySelector("#tokens-no-match-row");
|
||||
// Phase 101 (task 02, D1): the REVOKED section — the visible
|
||||
// sub-heading, the section's own search input, the table wrap, the
|
||||
// tbody, and its no-match row. The section ships hidden;
|
||||
// setRevokedSectionVisible(n) shows it iff n ≥ 1.
|
||||
const revokedHeading = root.querySelector("#tokens-revoked-heading");
|
||||
const searchRevoked = root.querySelector("#token-search-revoked");
|
||||
const revokedWrap = root.querySelector("#tokens-revoked-wrap");
|
||||
const revokedTbody = root.querySelector("#tokens-revoked-tbody");
|
||||
const revokedNoMatchRow = root.querySelector("#tokens-revoked-no-match-row");
|
||||
const gateEl = root.querySelector("#tokens-gate");
|
||||
const statusEl = root.querySelector("#tokens-status");
|
||||
// The create row (label + Generate) — SHIPS hidden (anonymous-safe;
|
||||
// The create row (name + Generate) — SHIPS hidden (anonymous-safe;
|
||||
// the admin branch reveals it).
|
||||
const createRow = root.querySelector("#token-create");
|
||||
const labelInput = root.querySelector("#token-label");
|
||||
@@ -93,6 +164,13 @@ export async function mount(root) {
|
||||
const onceValue = root.querySelector("#token-once-value");
|
||||
const onceCopy = root.querySelector("#token-once-copy");
|
||||
|
||||
/* Phase 101 (task 02, D4): the per-table search queries — module
|
||||
state that SURVIVES every re-render and re-show: a load never
|
||||
resets them (it re-applies them), and the input listeners are
|
||||
the only writers. */
|
||||
let activeQuery = "";
|
||||
let revokedQuery = "";
|
||||
|
||||
/* Action feedback — the role="status" live region (the "never stale"
|
||||
contract: every action lands a line here, success or failure
|
||||
alike). */
|
||||
@@ -141,12 +219,18 @@ export async function mount(root) {
|
||||
);
|
||||
}
|
||||
|
||||
/* One row. The Status cell carries the cell-level aria-label in BOTH
|
||||
states (Active em-dash / the rose Revoked pill — the stale-pill
|
||||
visual language) so the marker is conveyed without the visual
|
||||
(WCAG 2.1 AA). The Actions cell carries the two-step Revoke for
|
||||
active rows only — revoked rows have nothing left to revoke. */
|
||||
function makeRow(tok) {
|
||||
/* One row for ONE of the two tables (phase 101 D1): "active" —
|
||||
Label | Created | Last used | Actions (the two-step inline
|
||||
Regenerate BEFORE the two-step inline Revoke — phase 101
|
||||
task 03 / D2: the rotation is the primary lifecycle action), or
|
||||
"revoked" — Label | Created | Last used | Revoked (the
|
||||
revoked_at date: locale date+time, full ISO on hover — the
|
||||
house tokens-date-cell language). The row's very presence in the
|
||||
revoked table IS the status (the em-dash / .stale-pill column is
|
||||
gone from both tables); revoked rows carry NO actions. The label
|
||||
cell (.tokens-label-cell) is the search filter's data source —
|
||||
applyFilter reads its textContent. */
|
||||
function makeRow(tok, table) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const labelTd = document.createElement("td");
|
||||
@@ -171,43 +255,93 @@ export async function mount(root) {
|
||||
}
|
||||
tr.appendChild(usedTd);
|
||||
|
||||
const statusTd = document.createElement("td");
|
||||
statusTd.className = "tokens-status-cell";
|
||||
if (tok.revoked) {
|
||||
statusTd.setAttribute(
|
||||
"aria-label",
|
||||
"Revoked — this token is dead and can no longer sign in",
|
||||
);
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "stale-pill";
|
||||
pill.title = "Revoked — the token can no longer sign in";
|
||||
pill.textContent = "Revoked";
|
||||
statusTd.appendChild(pill);
|
||||
} else {
|
||||
statusTd.setAttribute(
|
||||
"aria-label",
|
||||
"Active — this token can still be used to sign in",
|
||||
);
|
||||
statusTd.textContent = "—"; // the em-dash: active rows' marker
|
||||
if (table === "revoked") {
|
||||
const revokedTd = document.createElement("td");
|
||||
revokedTd.className = "tokens-date-cell";
|
||||
if (tok.revoked_at) {
|
||||
revokedTd.title = tok.revoked_at; // full ISO on hover
|
||||
revokedTd.textContent = fmtDate(tok.revoked_at);
|
||||
} else {
|
||||
revokedTd.textContent = "—"; // defensive: the server stamps it
|
||||
}
|
||||
tr.appendChild(revokedTd);
|
||||
return tr;
|
||||
}
|
||||
tr.appendChild(statusTd);
|
||||
|
||||
const actionsTd = document.createElement("td");
|
||||
actionsTd.className = "tokens-actions-cell";
|
||||
if (!tok.revoked) {
|
||||
actionsTd.appendChild(makeRevokeControl(tok, tr));
|
||||
}
|
||||
/* Phase 101 (task 03, D2): Regenerate FIRST (the primary
|
||||
lifecycle action) — each control owns its OWN .tokens-actions
|
||||
wrapper span, so a confirm in one never clobbers the other. */
|
||||
actionsTd.append(
|
||||
makeRegenerateControl(tok, tr),
|
||||
makeRevokeControl(tok, tr),
|
||||
);
|
||||
tr.appendChild(actionsTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* The inline two-step Regenerate (phase 101 task 03, D2 — a
|
||||
structural mirror of makeRevokeControl, the D2 contract): ONE
|
||||
button starts the rotation, and its first click replaces it, in
|
||||
place, by the "Regenerate? The current token is revoked. [Yes]
|
||||
[No]" pair (the history-confirm-* classes — a destructive
|
||||
rotation deserves the same confirm weight as Revoke); focus
|
||||
moves to Yes (keyboard-reachable confirm). Yes →
|
||||
confirmRegenerate (POST /api/tokens/<id>/regenerate — the
|
||||
atomic rotation; on 201 the re-entrant load runs FIRST, then
|
||||
the shown-once block reveals the new plaintext). No or a failed
|
||||
request restores the Regenerate button (retryable). The control
|
||||
owns its OWN .tokens-actions wrapper span: the Actions cell
|
||||
hosts two independent confirm scopes side by side (this one +
|
||||
the Revoke control's) — a swap in one never clobbers the other.
|
||||
The shipped state IS the Regenerate button. */
|
||||
function makeRegenerateControl(tok, row) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "tokens-actions";
|
||||
|
||||
const regenBtn = document.createElement("button");
|
||||
regenBtn.type = "button";
|
||||
regenBtn.className = "token-regenerate";
|
||||
regenBtn.setAttribute("aria-label", `Regenerate token: ${tok.label}`);
|
||||
regenBtn.textContent = "Regenerate";
|
||||
|
||||
function restoreRegenerate() {
|
||||
cell.replaceChildren(regenBtn);
|
||||
regenBtn.focus(); // focus returns to the (restored) control
|
||||
}
|
||||
|
||||
regenBtn.addEventListener("click", () => {
|
||||
const label = document.createElement("span");
|
||||
label.className = "history-confirm-text";
|
||||
label.textContent = "Regenerate? The current token is revoked.";
|
||||
const yes = document.createElement("button");
|
||||
yes.type = "button";
|
||||
yes.className = "history-confirm-yes";
|
||||
yes.textContent = "Yes";
|
||||
const no = document.createElement("button");
|
||||
no.type = "button";
|
||||
no.className = "history-confirm-no";
|
||||
no.textContent = "No";
|
||||
yes.addEventListener("click", () =>
|
||||
confirmRegenerate(tok, row, yes, restoreRegenerate));
|
||||
no.addEventListener("click", restoreRegenerate);
|
||||
cell.replaceChildren(label, yes, no);
|
||||
yes.focus(); // the confirm pair takes over the focus
|
||||
});
|
||||
|
||||
cell.appendChild(regenBtn); // the shipped state IS the Regenerate button
|
||||
return cell;
|
||||
}
|
||||
|
||||
/* The inline two-step Revoke (the history-confirm-* pattern — NO
|
||||
native confirm dialog anywhere in this file). The Revoke button is
|
||||
replaced, in place, by the "Revoke? [Yes] [No]" pair; focus moves
|
||||
to Yes (keyboard-reachable confirm). Yes → POST
|
||||
/api/tokens/<id>/revoke → the row re-renders Revoked (+ the live
|
||||
region line); No or a failed request restores the Revoke button
|
||||
(retryable). */
|
||||
/api/tokens/<id>/revoke → the re-entrant load runs (the row
|
||||
LEAVES the active table and lands in the revoked table below —
|
||||
D1) + the live region line; No or a failed request restores the
|
||||
Revoke button (retryable). */
|
||||
function makeRevokeControl(tok, row) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "tokens-actions";
|
||||
@@ -247,12 +381,14 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
/* The confirmed revoke: POST /api/tokens/<id>/revoke (204 —
|
||||
idempotent server-side) → the row STAYS (it is not removed) and
|
||||
re-renders Revoked, and the live region gets `Revoked "<label>".`
|
||||
A 404 means the row is gone (revoked elsewhere) — re-render it
|
||||
Revoked and say so. Any other failure or a network error keeps the
|
||||
row, restores the Revoke button (retryable), and lands the error
|
||||
line. */
|
||||
idempotent server-side) → the live region gets
|
||||
`Revoked "<label>".` and the re-entrant loadTokens() runs: the
|
||||
row LEAVES the active table and lands in the revoked table (its
|
||||
server-stamped revoked_at renders in the Revoked cell — D1). A
|
||||
404 means the token was revoked elsewhere — the same re-load
|
||||
reconciles the tables and the line says so. Any other failure or
|
||||
a network error keeps the row, restores the Revoke button
|
||||
(retryable), and lands the error line. */
|
||||
async function confirmRevoke(tok, row, yesBtn, restoreRevoke) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
@@ -264,8 +400,8 @@ export async function mount(root) {
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
row.replaceWith(makeRow({ ...tok, revoked: true }));
|
||||
announce("That token was already revoked.");
|
||||
await loadTokens(); // the row relocates to the revoked table
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
@@ -273,26 +409,136 @@ export async function mount(root) {
|
||||
restoreRevoke();
|
||||
return;
|
||||
}
|
||||
row.replaceWith(makeRow({ ...tok, revoked: true }));
|
||||
announce(`Revoked "${tok.label}".`);
|
||||
await loadTokens(); // the row relocates to the revoked table (D1)
|
||||
}
|
||||
|
||||
/* The confirmed regenerate (phase 101 task 03, D2): POST
|
||||
/api/tokens/<id>/regenerate (JSON, NO body) — the server rotates
|
||||
atomically in ONE transaction: the old row is stamped revoked
|
||||
(it lands in the revoked table) and the successor is created
|
||||
under the SAME label (it lands in the active table). The 201
|
||||
body ({ id, label, token, created_at }) is the new token's ONLY
|
||||
plaintext moment (A4 — the old plaintext was already one-shot
|
||||
and is gone). The 201 sequence is pinned: the re-entrant
|
||||
loadTokens() runs FIRST (the relocation), THEN the shown-once
|
||||
block reveals the new plaintext in the field's VALUE only (never
|
||||
a data attribute — the same #token-once block a create uses),
|
||||
THEN the live region reads the D2 line. A 404 means the row
|
||||
VANISHED (revoked AND deleted by another admin, or a stale
|
||||
render) — the row is removed, the re-fetch reconciles both
|
||||
tables, and the line reuses the revoke control's 404 copy (one
|
||||
house message for the one common case). A 409 means the row was
|
||||
revoked between render and click — the re-fetch reconciles +
|
||||
the same line. Any other failure or a network error keeps the
|
||||
row, restores the Regenerate button (retryable), and lands the
|
||||
neutral error line. */
|
||||
async function confirmRegenerate(tok, row, yesBtn, restoreRegenerate) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/tokens/${tok.id}/regenerate`, { method: "POST" });
|
||||
} catch {
|
||||
announce(`Couldn't regenerate "${tok.label}" — is the app reachable?`);
|
||||
restoreRegenerate();
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
row.remove(); // the row vanished — the re-fetch reconciles both tables
|
||||
await loadTokens();
|
||||
announce("That token was already revoked.");
|
||||
return;
|
||||
}
|
||||
if (r.status === 409) {
|
||||
await loadTokens(); // the row was revoked between render and click
|
||||
announce("That token was already revoked.");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't regenerate "${tok.label}" — try again.`);
|
||||
restoreRegenerate();
|
||||
return;
|
||||
}
|
||||
const data = await r.json();
|
||||
/* D2: the re-entrant load runs FIRST — the old row relocates to
|
||||
the revoked table, the new row lands in the active one — then
|
||||
the once-block reveals the new plaintext (value only, A4). */
|
||||
await loadTokens();
|
||||
if (onceValue) onceValue.value = data.token;
|
||||
if (onceBlock) onceBlock.hidden = false;
|
||||
announce(
|
||||
`Regenerated "${tok.label}" — copy the new token now; it won't be shown again.`,
|
||||
);
|
||||
}
|
||||
|
||||
/* The revoked SECTION (heading + search input + table wrap — D1)
|
||||
shows iff at least one token is revoked: an empty table is noise.
|
||||
count is the revoked-row count of the last load (0 hides it). */
|
||||
function setRevokedSectionVisible(count) {
|
||||
const show = count > 0;
|
||||
if (revokedHeading) revokedHeading.hidden = !show;
|
||||
if (searchRevoked) searchRevoked.hidden = !show;
|
||||
if (revokedWrap) revokedWrap.hidden = !show;
|
||||
}
|
||||
|
||||
/* The empty-state row reappears exactly when there is nothing else
|
||||
in the tbody (the empty row itself ships in the tbody, hidden). */
|
||||
in the tbody (the empty row + the no-match row both ship in the
|
||||
tbody, hidden — a failed load / 0-row fetch restores BOTH). */
|
||||
function showEmptyState() {
|
||||
if (!tbody) return;
|
||||
tbody.replaceChildren(emptyRow);
|
||||
tbody.replaceChildren(emptyRow, noMatchRow);
|
||||
if (emptyRow) emptyRow.hidden = false;
|
||||
if (noMatchRow) noMatchRow.hidden = true;
|
||||
}
|
||||
|
||||
/* One table's LIVE label filter (phase 101 D4) — pure DOM, NO
|
||||
fetch. The query is trimmed + lowercased for the MATCH; the
|
||||
no-match copy quotes the ORIGINAL user text. The rendered rows
|
||||
are the source of truth: the filter reads each data row's label
|
||||
cell (.tokens-label-cell) textContent — case-insensitive
|
||||
substring — and toggles row.hidden. The state rows (the
|
||||
no-match row, the empty-state row) are NEVER data rows. The
|
||||
no-match row is visible ⟺ the query is non-empty AND zero data
|
||||
rows are visible — its <td> textContent = `No tokens match
|
||||
"<query>".` (the query inside the quotes is textContent, never
|
||||
HTML). An empty query shows every row and hides the no-match
|
||||
row. */
|
||||
function applyFilter(targetTbody, targetNoMatchRow, rawQuery) {
|
||||
if (!targetTbody) return;
|
||||
const query = (rawQuery ?? "").trim().toLowerCase();
|
||||
let visible = 0;
|
||||
for (const tr of targetTbody.querySelectorAll("tr")) {
|
||||
if (tr === targetNoMatchRow || tr === emptyRow) continue; // state rows
|
||||
const labelCell = tr.querySelector(".tokens-label-cell");
|
||||
const label = (labelCell ? labelCell.textContent : "").toLowerCase();
|
||||
tr.hidden = query !== "" && !label.includes(query);
|
||||
if (!tr.hidden) visible += 1;
|
||||
}
|
||||
if (!targetNoMatchRow) return;
|
||||
const noMatch = query !== "" && visible === 0;
|
||||
targetNoMatchRow.hidden = !noMatch;
|
||||
if (noMatch) {
|
||||
const td = targetNoMatchRow.querySelector("td");
|
||||
if (td) td.textContent = `No tokens match "${rawQuery}".`;
|
||||
}
|
||||
}
|
||||
|
||||
/* GET /api/tokens → render the rows (newest-first — the server's
|
||||
order). A 0-row fetch shows the empty-state row. Re-entrant (the
|
||||
phase-77 re-show contract): a re-show re-run must REPLACE the
|
||||
list, not append a duplicate set — the data rows (every <tr>
|
||||
EXCEPT the hidden #tokens-empty-row, which the load itself
|
||||
re-hides / reveals) are dropped before the fetch — and the
|
||||
once-block HIDDEN + its field wiped (the plaintext is gone: a
|
||||
re-render can never re-show it).
|
||||
order). Re-entrant (the phase-77 re-show contract): a re-show
|
||||
re-run must REPLACE the list, not append a duplicate set — the
|
||||
data rows (every <tr> EXCEPT the hidden state rows, which the
|
||||
load itself re-hides / reveals) are dropped before the fetch —
|
||||
and the once-block HIDDEN + its field wiped (the plaintext is
|
||||
gone: a re-render can never re-show it).
|
||||
Phase 101 (D1): the render step SPLITS the fetched list by
|
||||
`tok.revoked` — the server's newest-first order kept per table:
|
||||
the active rows fill #tokens-tbody, the revoked rows fill
|
||||
#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 active table AND the populated revoked section — both
|
||||
honest); the revoked SECTION shows iff ≥ 1 revoked row. FINALLY
|
||||
both persistent filters re-apply (D4 — a re-render never loses
|
||||
the queries).
|
||||
A FAILED load announces its line in the live region (the house
|
||||
copy: "is the app reachable?" / "try again.") and RETURNS the
|
||||
outcome: true when the fetch settled (a 0-row fetch is a
|
||||
@@ -303,49 +549,74 @@ export async function mount(root) {
|
||||
if (onceValue) onceValue.value = "";
|
||||
if (tbody) {
|
||||
for (const tr of tbody.querySelectorAll("tr")) {
|
||||
if (tr !== emptyRow) tr.remove();
|
||||
if (tr !== emptyRow && tr !== noMatchRow) tr.remove();
|
||||
}
|
||||
}
|
||||
if (revokedTbody) {
|
||||
for (const tr of revokedTbody.querySelectorAll("tr")) {
|
||||
if (tr !== revokedNoMatchRow) tr.remove();
|
||||
}
|
||||
}
|
||||
if (emptyRow) emptyRow.hidden = true;
|
||||
if (noMatchRow) noMatchRow.hidden = true;
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/tokens");
|
||||
} catch {
|
||||
announce("Couldn't load tokens — is the app reachable?");
|
||||
showEmptyState();
|
||||
setRevokedSectionVisible(0);
|
||||
return false;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce("Couldn't load tokens — try again.");
|
||||
showEmptyState();
|
||||
setRevokedSectionVisible(0);
|
||||
return false;
|
||||
}
|
||||
const { tokens } = await r.json();
|
||||
if (!tokens.length) {
|
||||
showEmptyState();
|
||||
return true;
|
||||
/* D1: the split — the newest-first server order kept per table. */
|
||||
const active = tokens.filter((t) => !t.revoked);
|
||||
const revoked = tokens.filter((t) => t.revoked);
|
||||
for (const tok of active) {
|
||||
tbody.appendChild(makeRow(tok, "active"));
|
||||
}
|
||||
for (const tok of tokens) {
|
||||
tbody.appendChild(makeRow(tok));
|
||||
for (const tok of revoked) {
|
||||
if (revokedTbody) revokedTbody.appendChild(makeRow(tok, "revoked"));
|
||||
}
|
||||
if (emptyRow) emptyRow.hidden = active.length !== 0;
|
||||
setRevokedSectionVisible(revoked.length);
|
||||
/* D4: the persistent queries survive the re-render — BOTH
|
||||
filters re-apply after the load. */
|
||||
applyFilter(tbody, noMatchRow, activeQuery);
|
||||
applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Generate: the label comes from #token-label — a BLANK label sends
|
||||
"token" (the placeholder documents the fallback; the API's 1–120
|
||||
validator is satisfied). The button runs the §7.4 never-stale
|
||||
lifecycle: "Generating…" while the POST is in flight, re-enabled
|
||||
on success AND failure (the finally — a click can never leave it
|
||||
stuck disabled). On 201 the re-entrant list load runs FIRST (it
|
||||
hides the once-block — the re-render contract) and THEN the
|
||||
once-block reveals with the plaintext (the 201 body's token is
|
||||
the ONE plaintext that exists, A4 — it lives in this closure
|
||||
until the next loadTokens() hides the block again), the live
|
||||
region gets the shown-once line, and the label input clears (a
|
||||
new token is a new hand-out). A failed create keeps the label
|
||||
/* Generate: the name comes from #token-label — a BLANK /
|
||||
whitespace-only name is REFUSED client-side (phase 101 D3): the
|
||||
live region reads `Give the token a name first.`, the name input
|
||||
re-focuses, and NO request is sent (the server's 422 on a
|
||||
blank/over-long label stands unchanged — defense in depth; the
|
||||
old blank-label "token" fallback is DELETED — the name is the
|
||||
hand-out identity, not an optional decoration). The button runs the §7.4
|
||||
never-stale lifecycle: "Generating…" while the POST is in flight,
|
||||
re-enabled on success AND failure (the finally — a click can
|
||||
never leave it stuck disabled). On 201 the re-entrant list load
|
||||
runs FIRST (it hides the once-block — the re-render contract) and
|
||||
THEN the once-block reveals with the plaintext (the 201 body's
|
||||
token is the ONE plaintext that exists, A4 — it lives in this
|
||||
closure until the next loadTokens() hides the block again), the
|
||||
live region gets the shown-once line, and the name input clears
|
||||
(a new token is a new hand-out). A failed create keeps the label
|
||||
(retryable) and lands the error line. */
|
||||
async function generateToken() {
|
||||
const label = (labelInput ? labelInput.value : "").trim() || "token";
|
||||
const label = (labelInput ? labelInput.value : "").trim();
|
||||
if (!label) {
|
||||
announce("Give the token a name first.");
|
||||
if (labelInput) labelInput.focus();
|
||||
return; // refused client-side (D3) — no request is sent
|
||||
}
|
||||
if (generateBtn) generateBtn.disabled = true;
|
||||
if (generateBtn) generateBtn.textContent = "Generating…";
|
||||
let created = null;
|
||||
@@ -381,16 +652,20 @@ export async function mount(root) {
|
||||
* exactly once, via the chat module (app.js) at shell boot. The
|
||||
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
|
||||
* the header uses (zero extra requests). Anonymous: the gate in,
|
||||
* the create row + table out — and NO /api/tokens request at all
|
||||
* (the router 403s anonymous, so the view must never call it). */
|
||||
* the create row + search + tables out — and NO /api/tokens request
|
||||
* at all (the router 403s anonymous, so the view must never call
|
||||
* it). */
|
||||
if (!(await fetchIsAdmin())) {
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
if (createRow) createRow.hidden = true;
|
||||
if (searchActive) searchActive.hidden = true;
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
setRevokedSectionVisible(0);
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
if (createRow) createRow.hidden = false;
|
||||
if (searchActive) searchActive.hidden = false;
|
||||
if (tableWrap) tableWrap.hidden = false;
|
||||
if (onceBlock) onceBlock.hidden = true; // ships hidden; only a 201 reveals it
|
||||
/* Phase 77: a user-initiated re-show of this already-mounted view
|
||||
@@ -411,6 +686,23 @@ export async function mount(root) {
|
||||
if (onceCopy) {
|
||||
onceCopy.addEventListener("click", () => void copyTokenAction());
|
||||
}
|
||||
/* Phase 101 (task 02, D4): the per-table search listeners — armed
|
||||
in the ADMIN branch only (after the whoami gate, like the refresh
|
||||
listener): set the module query + applyFilter, NO fetch. The
|
||||
queries persist across re-renders / re-shows (loadTokens
|
||||
re-applies them). */
|
||||
if (searchActive) {
|
||||
searchActive.addEventListener("input", () => {
|
||||
activeQuery = searchActive.value;
|
||||
applyFilter(tbody, noMatchRow, activeQuery);
|
||||
});
|
||||
}
|
||||
if (searchRevoked) {
|
||||
searchRevoked.addEventListener("input", () => {
|
||||
revokedQuery = searchRevoked.value;
|
||||
applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery);
|
||||
});
|
||||
}
|
||||
started = true;
|
||||
loadTokens();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user