From fe1aa79fd0bcebc0e71aee095e73ec7b81c925ac Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 04:23:39 +0200 Subject: [PATCH 1/6] feat(slice-4f): wire Admin section pages (Tokens / Org / Audit-admin) to existing endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin: - PageTokens fetches GET /api/tokens (x-aqa-org), replaces the local fixture with the server payload. Existing 3-row mock stays as fallback for mock-data mode. - PageOrg fetches GET /api/orgs and joins the live org list into the page subtitle (falls back to "padosoft" / "No orgs configured" depending on response). - PageAdminAudit shares the same /api/audit wire as PageAudit (slice 4e) with admin-view subtitle copy. Schema normalizer + unmount-safe cancellation guard applied (same lessons as 4e). Scope note: Users / Roles / SSO are intentionally deferred — no server scaffolding exists for them yet (would require new schemas + routes). Slice 4f ships what's wirable against the current route surface. E2E: 4 new tests in admin-section.e2e.ts (tokens live, orgs live, orgs fallback, audit-admin live). Admin e2e total: 132/133 (+4, 1 skipped). Typecheck + lint clean. --- packages/admin/src/app.tsx | 157 +++++++++++++++---- packages/admin/test/e2e/admin-section.e2e.ts | 108 +++++++++++++ 2 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 packages/admin/test/e2e/admin-section.e2e.ts diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 970a1b9..b6e0d7c 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -12236,9 +12236,37 @@ function PageSSO({ onNavigate }) { // ---------------- Org & project ---------------- function PageOrg({ onNavigate }) { + // v1.7 slice 4f — fetch /api/orgs to surface the live org list. + // The page's existing UI is still mostly fixture-driven (project + // list, branding); the live read lets us update the subtitle so + // the page reflects what's actually configured server-side. + const [orgs, setOrgs] = React.useState(null); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/orgs')); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (cancelled) return; + if (Array.isArray(body?.orgs)) setOrgs(body.orgs); + } catch { + /* mock mode */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + const sub = + orgs === null + ? 'padosoft' + : orgs.length === 0 + ? 'No orgs configured · using mock org "padosoft"' + : orgs.map((o) => o.slug ?? o.name ?? '?').join(', '); return (
- +
@@ -12344,32 +12372,59 @@ function PageOrg({ onNavigate }) { function PageTokens({ onNavigate }) { const [showNew, setShowNew] = React.useState(false); const [createdToken, setCreatedToken] = React.useState(null); - const tokens = [ - { - id: 'tok_ci', - name: 'CI · GitHub Actions', - kind: 'service', - last_used: '2026-05-18T13:48:00Z', - scopes: ['runs:write', 'findings:read'], - created_at: '2026-04-12T08:00:00Z', - }, - { - id: 'tok_sara', - name: 'Sara · CLI laptop', - kind: 'user', - last_used: '2026-05-18T11:14:00Z', - scopes: ['*'], - created_at: '2026-04-02T09:00:00Z', - }, - { - id: 'tok_audit', - name: 'Audit · download bot', - kind: 'service', - last_used: '2026-05-15T16:04:00Z', - scopes: ['audit:read'], - created_at: '2026-05-01T00:00:00Z', - }, - ]; + // v1.7 slice 4f — wire to GET /api/tokens. The server returns + // hashed token records (no raw secret); the create flow (POST + // /api/tokens) returns the freshly-issued raw token exactly once. + // Falls back to a small fixture so mock-data mode still renders. + const FALLBACK_TOKENS = React.useMemo( + () => [ + { + id: 'tok_ci', + name: 'CI · GitHub Actions', + kind: 'service', + last_used: '2026-05-18T13:48:00Z', + scopes: ['runs:write', 'findings:read'], + created_at: '2026-04-12T08:00:00Z', + }, + { + id: 'tok_sara', + name: 'Sara · CLI laptop', + kind: 'user', + last_used: '2026-05-18T11:14:00Z', + scopes: ['*'], + created_at: '2026-04-02T09:00:00Z', + }, + { + id: 'tok_audit', + name: 'Audit · download bot', + kind: 'service', + last_used: '2026-05-15T16:04:00Z', + scopes: ['audit:read'], + created_at: '2026-05-01T00:00:00Z', + }, + ], + [], + ); + const [tokens, setTokens] = React.useState(FALLBACK_TOKENS); + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/tokens'), { + headers: { 'x-aqa-org': 'padosoft' }, + }); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (cancelled) return; + if (Array.isArray(body?.tokens)) setTokens(body.tokens); + } catch { + /* mock mode */ + } + })(); + return () => { + cancelled = true; + }; + }, []); return (
{ + let cancelled = false; + (async () => { + try { + const res = await fetch(apiUrl('/api/audit'), { + headers: { 'x-aqa-org': 'padosoft' }, + }); + if (cancelled || !res.ok) return; + const body = await res.json(); + if (cancelled) return; + if (Array.isArray(body?.events)) setLiveEvents(body.events); + } catch { + /* mock mode */ + } + })(); + return () => { + cancelled = true; + }; + }, []); return (
@@ -12543,7 +12625,24 @@ function PageAdminAudit({ onNavigate }) { } /> - + ({ + at: ev.ts ?? ev.at ?? '', + actor: + typeof ev.actor === 'string' + ? ev.actor + : ev.actor?.id || ev.actor?.type || 'system', + kind: ev.kind ?? 'event', + payload: ev.payload ?? {}, + prev_hash: ev.prev_hash ?? '0'.repeat(64), + hash: ev.hash ?? '0'.repeat(64), + })) + : AUDIT_EVENTS_GOOD + } + demoBad={liveEvents !== null ? [] : AUDIT_EVENTS_BAD} + />
); } diff --git a/packages/admin/test/e2e/admin-section.e2e.ts b/packages/admin/test/e2e/admin-section.e2e.ts new file mode 100644 index 0000000..be7a4fa --- /dev/null +++ b/packages/admin/test/e2e/admin-section.e2e.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; + +/** + * v1.7 slice 4f — Admin section pages (API tokens, Org & project, + * Audit-admin) now read from the existing server endpoints with + * graceful fixture fallback. Users/Roles/SSO are intentionally + * deferred — no server scaffolding exists for them yet (would + * require new schemas + routes), so the slice ships what's wirable. + */ + +async function gotoNav(page: import('@playwright/test').Page, label: string): Promise { + await page.goto('/'); + await expect(page.locator('.sidebar')).toBeVisible(); + const safe = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await page + .locator('.nav-item', { hasText: new RegExp(`^${safe}`, 'i') }) + .first() + .click(); +} + +test.describe('Admin-section wire-up', () => { + test('API tokens page fetches /api/tokens with x-aqa-org', async ({ page }) => { + const seen: { org: string | null } = { org: null }; + await page.route('**/api/tokens**', async (route) => { + if (route.request().method() !== 'GET') return route.continue(); + seen.org = route.request().headers()['x-aqa-org'] ?? null; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + tokens: [ + { + id: 'tok-live-1', + name: 'Live token from server', + kind: 'service', + last_used: '2026-05-19T12:00:00Z', + scopes: ['runs:write'], + created_at: '2026-05-01T00:00:00Z', + }, + ], + }), + }); + }); + await gotoNav(page, 'API tokens'); + await expect(page.locator('h1, .page-title').first()).toContainText(/API tokens/i); + // Live token shows up; fixture tokens are replaced. + await expect(page.locator('text=Live token from server')).toBeVisible(); + await expect(page.locator('text=CI · GitHub Actions')).toHaveCount(0); + expect(seen.org).toBe('padosoft'); + }); + + test('Org & project page fetches /api/orgs and reflects the live list in the subtitle', async ({ + page, + }) => { + await page.route('**/api/orgs**', async (route) => { + if (route.request().method() !== 'GET') return route.continue(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + orgs: [ + { slug: 'padosoft', name: 'Padosoft' }, + { slug: 'acme', name: 'Acme Corp' }, + ], + }), + }); + }); + await gotoNav(page, 'Org & project'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Organization/i); + // Subtitle joins the live orgs. + await expect(page.locator('text=padosoft, acme')).toBeVisible(); + }); + + test('Org page falls back to "padosoft" when /api/orgs is unreachable', async ({ page }) => { + await page.route('**/api/orgs**', (route) => route.abort('failed')); + await gotoNav(page, 'Org & project'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Organization/i); + // The fixture-mode subtitle reads "padosoft". + await expect(page.locator('.page-header').first()).toContainText('padosoft'); + }); + + test('Audit (admin) page fetches /api/audit and renders the live count', async ({ page }) => { + await page.route('**/api/audit**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + events: [ + { + schema_version: '1', + seq: 1, + ts: '2026-05-19T10:00:00Z', + run_id: 'run-1', + actor: { type: 'system', id: 'runner' }, + kind: 'run_started', + payload: {}, + prev_hash: null, + hash: 'a'.repeat(64), + }, + ], + }), + }); + }); + await gotoNav(page, 'Audit (admin)'); + await expect(page.locator('h1, .page-title').first()).toContainText(/Audit/i); + await expect(page.locator('text=1 events · live from /api/audit (admin)')).toBeVisible(); + }); +}); From 91b07452b3c0701299295f35f445baa5ae492ad9 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 04:30:51 +0200 Subject: [PATCH 2/6] review(slice-4f iter 1): normalize ApiToken to fixture shape + comment/UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot iter-1 (5): - PageTokens was storing the raw /api/tokens payload (@aqa/schemas ApiToken: display_name/prefix/owner/last_used_at/scopes) directly, but the page renders fixture-style fields (name/kind/last_used). Added an adapter that maps display_name→name, last_used_at→ last_used, and heuristically derives kind from owner (svc_/bot_/ ci_/service_ prefix → service, otherwise user). A future server schema can expose `kind` directly to remove the heuristic. - Comment about POST /api/tokens issuing a raw token "exactly once" was inaccurate (current server schema only defines prefix). Updated the comment. - E2E mock rewritten to a schema-conforming ApiToken payload so the test exercises the adapter and would catch a real contract drift. - Org subtitle copy aligned with the PR description: when the live list is empty, just "No orgs configured" (was "No orgs configured · using mock org \"padosoft\""). Tests: admin-section e2e 4/4 green. --- packages/admin/src/app.tsx | 30 ++++++++++++++++---- packages/admin/test/e2e/admin-section.e2e.ts | 14 ++++++--- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index b6e0d7c..1103efd 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -12262,7 +12262,7 @@ function PageOrg({ onNavigate }) { orgs === null ? 'padosoft' : orgs.length === 0 - ? 'No orgs configured · using mock org "padosoft"' + ? 'No orgs configured' : orgs.map((o) => o.slug ?? o.name ?? '?').join(', '); return (
@@ -12373,9 +12373,12 @@ function PageTokens({ onNavigate }) { const [showNew, setShowNew] = React.useState(false); const [createdToken, setCreatedToken] = React.useState(null); // v1.7 slice 4f — wire to GET /api/tokens. The server returns - // hashed token records (no raw secret); the create flow (POST - // /api/tokens) returns the freshly-issued raw token exactly once. - // Falls back to a small fixture so mock-data mode still renders. + // @aqa/schemas ApiToken records (id/org/prefix/owner/display_name/ + // scopes/created_at/last_used_at/…) — only the prefix is exposed, + // never the raw secret. The page renders with fixture-style fields + // (name/kind/last_used) so we normalize the server payload to the + // UI shape on load. Falls back to a small fixture so mock-data + // mode still renders. PR #40 Copilot iter 1. const FALLBACK_TOKENS = React.useMemo( () => [ { @@ -12416,7 +12419,24 @@ function PageTokens({ onNavigate }) { if (cancelled || !res.ok) return; const body = await res.json(); if (cancelled) return; - if (Array.isArray(body?.tokens)) setTokens(body.tokens); + if (!Array.isArray(body?.tokens)) return; + // PR #40 Copilot iter 1: adapt @aqa/schemas ApiToken to the + // page's fixture shape. `kind` is derived heuristically from + // owner (svc_/bot_ prefix → service, otherwise user); a real + // adapter would inspect a server-side `kind` field once that + // lands on ApiToken. + const adapted = body.tokens.map((t) => ({ + id: t.id, + name: t.display_name ?? t.name ?? t.id, + kind: + typeof t.owner === 'string' && /^(svc|bot|ci|service)[_-]/i.test(t.owner) + ? 'service' + : 'user', + last_used: t.last_used_at ?? t.last_used ?? null, + scopes: Array.isArray(t.scopes) ? t.scopes : [], + created_at: t.created_at ?? new Date().toISOString(), + })); + setTokens(adapted); } catch { /* mock mode */ } diff --git a/packages/admin/test/e2e/admin-section.e2e.ts b/packages/admin/test/e2e/admin-section.e2e.ts index be7a4fa..5c45e6f 100644 --- a/packages/admin/test/e2e/admin-section.e2e.ts +++ b/packages/admin/test/e2e/admin-section.e2e.ts @@ -27,15 +27,21 @@ test.describe('Admin-section wire-up', () => { await route.fulfill({ status: 200, contentType: 'application/json', + // PR #40 Copilot iter 1: schema-conforming @aqa/schemas + // ApiToken (display_name, prefix, owner, last_used_at, + // ApiTokenScope enum values). body: JSON.stringify({ tokens: [ { + schema_version: '1', id: 'tok-live-1', - name: 'Live token from server', - kind: 'service', - last_used: '2026-05-19T12:00:00Z', - scopes: ['runs:write'], + org: 'padosoft', + prefix: 'aqa_pat_abcd1234', + owner: 'svc_ci', + display_name: 'Live token from server', + scopes: ['runs:create', 'findings:read'], created_at: '2026-05-01T00:00:00Z', + last_used_at: '2026-05-19T12:00:00Z', }, ], }), From 41f48f232e73e0b62cb5bc221c859167adea92f3 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 04:36:00 +0200 Subject: [PATCH 3/6] review(slice-4f iter 2): schema-conforming Org mock + GET-only audit route stub Copilot iter-2 (2): - Org mock payload now matches @aqa/schemas Tenancy.Org (schema_version/slug/display_name/created_at) instead of the truncated {slug, name}. Page consumes slug for the subtitle which still works, but the test now exercises the actual contract. - Audit-admin route stub gated on GET so a future admin POST/DELETE on /api/audit isn't silently hijacked. Tests: admin-section e2e 4/4 green. --- packages/admin/test/e2e/admin-section.e2e.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/admin/test/e2e/admin-section.e2e.ts b/packages/admin/test/e2e/admin-section.e2e.ts index 5c45e6f..f8103cc 100644 --- a/packages/admin/test/e2e/admin-section.e2e.ts +++ b/packages/admin/test/e2e/admin-section.e2e.ts @@ -63,10 +63,21 @@ test.describe('Admin-section wire-up', () => { await route.fulfill({ status: 200, contentType: 'application/json', + // PR #40 Copilot iter 2: schema-conforming Tenancy.Org. body: JSON.stringify({ orgs: [ - { slug: 'padosoft', name: 'Padosoft' }, - { slug: 'acme', name: 'Acme Corp' }, + { + schema_version: '1', + slug: 'padosoft', + display_name: 'Padosoft', + created_at: '2026-01-01T00:00:00Z', + }, + { + schema_version: '1', + slug: 'acme', + display_name: 'Acme Corp', + created_at: '2026-02-01T00:00:00Z', + }, ], }), }); @@ -86,7 +97,11 @@ test.describe('Admin-section wire-up', () => { }); test('Audit (admin) page fetches /api/audit and renders the live count', async ({ page }) => { + // PR #40 Copilot iter 2: gate on GET so a future POST/DELETE + // on /api/audit (e.g. an admin clear-events flow) isn't + // silently hijacked by this stub. await page.route('**/api/audit**', async (route) => { + if (route.request().method() !== 'GET') return route.continue(); await route.fulfill({ status: 200, contentType: 'application/json', From 8436f1e7adb3b0c643137b6dca24c5dfc5805895 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 04:41:58 +0200 Subject: [PATCH 4/6] review(slice-4f iter 3): align fallback token scopes to ApiTokenScope enum Copilot iter-3 (1): - Fallback fixture used pre-schema scopes ('runs:write', '*') that don't match ApiTokenScope. Switched to schema-valid values ('runs:create', 'findings:read', 'admin:everything', 'audit:read'). Future token-creation/revocation flows will validate cleanly against the same enum the server uses. Tests: admin-section e2e 4/4 green. --- packages/admin/src/app.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 1103efd..c7f7739 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -12379,6 +12379,10 @@ function PageTokens({ onNavigate }) { // (name/kind/last_used) so we normalize the server payload to the // UI shape on load. Falls back to a small fixture so mock-data // mode still renders. PR #40 Copilot iter 1. + // PR #40 Copilot iter 3: fallback scope values use the actual + // ApiTokenScope enum (runs:create / findings:edit / + // admin:everything / audit:read), not the pre-schema strings + // ("runs:write" / "*") that wouldn't validate against the server. const FALLBACK_TOKENS = React.useMemo( () => [ { @@ -12386,7 +12390,7 @@ function PageTokens({ onNavigate }) { name: 'CI · GitHub Actions', kind: 'service', last_used: '2026-05-18T13:48:00Z', - scopes: ['runs:write', 'findings:read'], + scopes: ['runs:create', 'findings:read'], created_at: '2026-04-12T08:00:00Z', }, { @@ -12394,7 +12398,7 @@ function PageTokens({ onNavigate }) { name: 'Sara · CLI laptop', kind: 'user', last_used: '2026-05-18T11:14:00Z', - scopes: ['*'], + scopes: ['admin:everything'], created_at: '2026-04-02T09:00:00Z', }, { From 4ea217fcaca9853dfbe0b7332a5c9c4ca3026fd9 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 04:50:44 +0200 Subject: [PATCH 5/6] review(slice-4f iter 4): ApiTokenScope chips, null created_at, shared audit normalizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot iter-4 (3): - Create-token modal chips listed pre-schema scopes ('runs:write', 'findings:write', 'packs:install', 'admin'). Now mirrors @aqa/schemas ApiTokenScope (runs:read/create, findings:read/edit, audit:read, admin:everything) so a future POST /api/tokens can pass them straight through. - Adapter defaulted `created_at` to `new Date().toISOString()` when the server omitted it — a misleading "just-created" date for whatever record lost the timestamp in transit. Now left null; downstream formatters can render a placeholder. - Extracted normalizeAuditEventsForViewer to a top-level helper used by both PageAudit and PageAdminAudit. Same normalization (ts → at, actor: {type,id} → string, prev_hash null → zero sha256, …) in one place — no more divergence as the Event schema evolves. Tests: admin-section + operations e2e green. --- packages/admin/src/app.tsx | 69 +++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index c7f7739..23c2173 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -11168,6 +11168,24 @@ function PageReplay({ onNavigate }) { } // ---------------- Audit log ---------------- +// Shared normalizer: maps @aqa/schemas Event records onto the +// AuditChainViewer's demo-chain shape. Used by both PageAudit and +// PageAdminAudit. PR #40 Copilot iter 4 (de-duplication). +function normalizeAuditEventsForViewer(events) { + if (!Array.isArray(events)) return []; + return events.map((ev) => ({ + at: ev.ts ?? ev.at ?? '', + actor: + typeof ev.actor === 'string' + ? ev.actor + : ev.actor?.id || ev.actor?.type || 'system', + kind: ev.kind ?? 'event', + payload: ev.payload ?? {}, + prev_hash: ev.prev_hash ?? '0'.repeat(64), + hash: ev.hash ?? '0'.repeat(64), + })); +} + function PageAudit({ onNavigate }) { // v1.7 slice 4e — wire to /api/audit (audit:read). The server // route returns Event records as-stored — hash verification is the @@ -11227,27 +11245,8 @@ function PageAudit({ onNavigate }) { } /> ({ - at: ev.ts ?? ev.at ?? '', - actor: - typeof ev.actor === 'string' - ? ev.actor - : ev.actor?.id || ev.actor?.type || 'system', - kind: ev.kind ?? 'event', - payload: ev.payload ?? {}, - prev_hash: ev.prev_hash ?? '0'.repeat(64), - hash: ev.hash ?? '0'.repeat(64), - })) - : AUDIT_EVENTS_GOOD + liveEvents !== null ? normalizeAuditEventsForViewer(liveEvents) : AUDIT_EVENTS_GOOD } demoBad={liveEvents !== null ? [] : AUDIT_EVENTS_BAD} /> @@ -12438,7 +12437,11 @@ function PageTokens({ onNavigate }) { : 'user', last_used: t.last_used_at ?? t.last_used ?? null, scopes: Array.isArray(t.scopes) ? t.scopes : [], - created_at: t.created_at ?? new Date().toISOString(), + // PR #40 Copilot iter 4: leave created_at null when the + // server omits it — defaulting to "now" was misleading + // (showed a fake "just created" date for any record where + // the timestamp was lost in transit). + created_at: t.created_at ?? null, })); setTokens(adapted); } catch { @@ -12578,14 +12581,16 @@ function PageTokens({ onNavigate }) {
+ {/* PR #40 Copilot iter 4: chips mirror the @aqa/schemas + ApiTokenScope enum so a future create-token POST + can pass these straight through. */} {[ 'runs:read', - 'runs:write', + 'runs:create', 'findings:read', - 'findings:write', + 'findings:edit', 'audit:read', - 'packs:install', - 'admin', + 'admin:everything', ].map((s) => ( {s} @@ -12651,19 +12656,7 @@ function PageAdminAudit({ onNavigate }) { /> ({ - at: ev.ts ?? ev.at ?? '', - actor: - typeof ev.actor === 'string' - ? ev.actor - : ev.actor?.id || ev.actor?.type || 'system', - kind: ev.kind ?? 'event', - payload: ev.payload ?? {}, - prev_hash: ev.prev_hash ?? '0'.repeat(64), - hash: ev.hash ?? '0'.repeat(64), - })) - : AUDIT_EVENTS_GOOD + liveEvents !== null ? normalizeAuditEventsForViewer(liveEvents) : AUDIT_EVENTS_GOOD } demoBad={liveEvents !== null ? [] : AUDIT_EVENTS_BAD} /> From 27d072d5a049b979591ebcdacd14700328698603 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Wed, 20 May 2026 04:56:49 +0200 Subject: [PATCH 6/6] review(slice-4f iter 5): null-safe fmtDate/fmtDateTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot iter-5 (1): - fmtDate/fmtDateTime didn't guard against null — a token with null created_at (from iter 4) would render as '1970-01-01'. Both formatters now return an em-dash for null/undefined/ invalid dates, matching the existing fmtRelative null-safety pattern. Tests: admin e2e green. --- packages/admin/src/app.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 23c2173..b8ac1f7 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -733,10 +733,19 @@ function fmtTime(ts) { return d.toISOString().slice(11, 19) + 'Z'; } function fmtDate(ts) { - return new Date(ts).toISOString().slice(0, 10); + // PR #40 Copilot iter 5: null/undefined → em dash, matches the + // fmtRelative guard. Previously this rendered "1970-01-01" for + // tokens whose created_at was missing. + if (ts == null) return '—'; + const t = new Date(ts); + if (Number.isNaN(t.getTime())) return '—'; + return t.toISOString().slice(0, 10); } function fmtDateTime(ts) { - return new Date(ts).toISOString().slice(0, 19).replace('T', ' ') + 'Z'; + if (ts == null) return '—'; + const t = new Date(ts); + if (Number.isNaN(t.getTime())) return '—'; + return t.toISOString().slice(0, 19).replace('T', ' ') + 'Z'; } function fmtDateTimeLocal(ts) { const d = new Date(ts);