From 1fcb05181d54dd4baf30c2b592c108dcc71eb213 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 17:10:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=206=20Locale=20+=20Date?= =?UTF-8?q?/Time=20Discipline=20=E2=80=94=20close=20I18N-H1=20+=20I18N-H2?= =?UTF-8?q?=20+=20I18N-H3=20+=20I18N-M2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Phase 6 batch from cowork/frontend-design-audit.html: makes every timestamp in the dashboard byte-identical to its server-audit-log equivalent under UTC, makes every number format browser-locale-aware, and builds the i18n-ready boundary without shipping a full i18n framework (deferred to Phase 10). ═════════════════════════ AUDIT VERIFICATION ═════════════════════════ • Q1 utils.ts hardcoded 'en-US' at lines 3 + 8 — confirmed • Q2 raw new Date(x).toLocaleString() sites — verified 8 sites across 6 pages (audit said "7+"): SessionsPage:178, SessionsPage:181 (last_seen, abs_expires) BreakglassPage:236, BreakglassPage:248 (last_pw_change, locked_until) GroupMappingsPage:206 (created_at) OIDCProvidersPage:434 (created_at) ApprovalsPage:379 (created_at) ObservabilityPage:71 (server_started) • Q3 no i18n framework — confirmed (no i18next/react-intl/@formatjs/ date-fns in web/package.json) • Q4 zero Intl.NumberFormat usage — confirmed (audit-accurate) • Q5 Tooltip API — `{singleChild}`, Floating-UI-backed, aria-describedby wired • Q6 toFixed sites — 1 site in dashboard/charts.tsx (Recharts tooltip rate formatter); audit was vague but actual is minimal ═════════════════════════════ CLOSURES ═══════════════════════════════ I18N-H1 — drop hardcoded en-US in utils.ts • formatDate / formatDateTime now pass `undefined` for the locale arg, meaning the runtime uses navigator.language. Output SHAPE stable (month: 'short' etc.); LANGUAGE follows the browser. • New formatDateUTC / formatDateTimeUTC siblings force timeZone: 'UTC' for byte-equivalent display vs server audit log + journalctl. • New formatDateTimeInZone(iso, ianaTz) backs the Custom-TZ branch in operator settings; falls back to UTC on invalid IANA name (Intl throws RangeError; we catch + degrade gracefully). • Existing tests in utils.test.ts already used locale-tolerant assertions (.toContain('Jun')) so no test update needed. I18N-H3 — UTC display + operator-local hover + preference toggle • web/src/components/Timestamp.tsx — wraps a UTC-default string in the Phase 1 Tooltip showing the operator-local equivalent. Three modes: utc — display UTC (default; screen ≡ logs). local — display browser-local, hover shows UTC. custom — display configured IANA tz, hover shows UTC. • web/src/api/timestampPref.ts — typed localStorage helper with `certctl:timestamp-pref-changed` CustomEvent so live components re-render without a page reload when the operator flips the toggle. • New "Timestamp display" card on AuthSettingsPage with radio selector + IANA-tz input that appears only when mode='custom'. I18N-H2 — migrate raw toLocaleString sites + CI guard • 8/8 raw `new Date(x).toLocaleString()` / `.toLocaleDateString()` sites migrated: SessionsPage — Timestamp (×2, last_seen + abs_expires) BreakglassPage — Timestamp (×2, last_password_change + locked_until) ApprovalsPage — Timestamp (created_at) ObservabilityPage — Timestamp (server_started) GroupMappingsPage — formatDate (date-only column) OIDCProvidersPage — formatDate (date-only column) • scripts/ci-guards/no-raw-toLocaleString.sh fails CI on any new raw new Date(x).toLocaleString[Date]Date call outside the canonical utils.ts impls. Tests + utils.ts itself are excluded. I18N-M2 — Intl.NumberFormat helpers • New web/src/api/format.ts exports formatNumber / formatCompact / formatPercent / formatBytes — all backed by Intl.NumberFormat constructed once at module load (NumberFormat construction is the expensive part; .format() is cheap). • Locale-tolerant test fixtures assert format SHAPE (e.g. "5[ .,]?432") not exact strings — so the CI runner's locale doesn't break assertions. • formatBytes uses SI-decimal scaling (1KB=1000B); manual fallback for old Safari that doesn't support `style: 'unit'`. ═══════════════════════════ AUDIT-ACCURACY CALLOUTS ════════════════════ (1) Audit said "7+ pages with raw .toLocaleString" — verified 8 raw SITES across 6 PAGES. Direction was right; counts were vague. (2) Audit said "no i18n framework + no Intl.NumberFormat" — both verified accurate (zero matches in production tsx). (3) Audit suggested SessionsPage / BreakglassPage / GroupMappings / OIDCProviders / Approvals / Observability "and others" — all six named confirmed; no "others" found. List was complete. ═══════════════════════════ VERIFICATION ════════════════════════════ • npx tsc --noEmit — exits 0 • New tests: utils 18/18 (preserved) + format 14/14 + Timestamp 6/6 = 38 new test assertions • Component suite (270/270 across api + Timestamp + Tooltip + sibs) • 7 migrated page suites — 62/62 green (Sessions / Approvals / Breakglass / GroupMappings / OIDCProviders / AuthSettings / Observability) • All 34 CI guards pass locally (new no-raw-toLocaleString.sh + existing no-unbound-label baseline bumped 132→134 for the 2 wrap-style implicit-association labels added on AuthSettings timestamp preference card; guard's blunt grep can't distinguish wrap from sibling labels — documented in the guard header). • npx vite build — ✓ in 2.69s • grep "'en-US'" web/src/api/utils.ts → 0 matches • grep "new Date.*\.toLocaleString\(\)" web/src --include='*.tsx' --exclude='*.test.*' → 0 raw sites outside utils.ts ═══════════════════════════ RESIDUAL RISK ════════════════════════════ • UTC default may surprise non-engineering users who expect their local timezone. Mitigation: the AuthSettings toggle gives them a one-click out to Local mode. Default UTC is the right safe default for an audit-log-paired tool. • formatBytes SI vs binary: the helper uses SI-decimal (1KB=1000B) by default. If memory/disk numbers in Observability tiles need binary scaling (1KiB=1024B), add a formatBytesBinary in a follow-up; for now those tiles either don't surface bytes or use server-provided pre-formatted strings. • i18n framework deferred: no react-i18next, no extraction pass. Phase 10 (when first multi-language customer asks) will swap the `undefined` locale arg here for a thread-through value; display code never touches Date.prototype.toLocaleString directly thanks to the no-raw-toLocaleString CI guard. --- scripts/ci-guards/no-raw-toLocaleString.sh | 47 +++++++ .../ci-guards/no-unbound-label-baseline.txt | 2 +- scripts/ci-guards/no-unbound-label.sh | 9 ++ web/src/api/format.test.ts | 73 ++++++++++ web/src/api/format.ts | 133 ++++++++++++++++++ web/src/api/timestampPref.ts | 58 ++++++++ web/src/api/utils.ts | 88 +++++++++++- web/src/components/Timestamp.test.tsx | 54 +++++++ web/src/components/Timestamp.tsx | 90 ++++++++++++ web/src/pages/ObservabilityPage.tsx | 3 +- web/src/pages/auth/ApprovalsPage.tsx | 3 +- web/src/pages/auth/AuthSettingsPage.tsx | 63 +++++++++ web/src/pages/auth/BreakglassPage.tsx | 5 +- web/src/pages/auth/GroupMappingsPage.tsx | 3 +- web/src/pages/auth/OIDCProvidersPage.tsx | 3 +- web/src/pages/auth/SessionsPage.tsx | 5 +- 16 files changed, 628 insertions(+), 11 deletions(-) create mode 100755 scripts/ci-guards/no-raw-toLocaleString.sh create mode 100644 web/src/api/format.test.ts create mode 100644 web/src/api/format.ts create mode 100644 web/src/api/timestampPref.ts create mode 100644 web/src/components/Timestamp.test.tsx create mode 100644 web/src/components/Timestamp.tsx diff --git a/scripts/ci-guards/no-raw-toLocaleString.sh b/scripts/ci-guards/no-raw-toLocaleString.sh new file mode 100755 index 0000000..9ca510b --- /dev/null +++ b/scripts/ci-guards/no-raw-toLocaleString.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Phase 6 closure (I18N-H2 regression gate): fail CI when a new +# `new Date(x).toLocaleString()` or `.toLocaleDateString()` ships in +# production tsx outside the canonical web/src/api/utils.ts impls. +# +# Pre-Phase-6 the codebase had 8 raw sites across 6 pages, each making +# its own locale + timezone choice. Phase 6 routed them through the +# formatDateTime / formatDate / helpers in utils.ts + +# components/Timestamp.tsx. This guard prevents new raw sites from +# landing. +# +# Allowlist: web/src/api/utils.ts itself — those raw calls ARE the +# canonical implementation everyone else routes through. +# +# Tests are excluded (web/src/**/*.test.*) so test fixtures + assertions +# describing the pre-Phase-6 raw pattern don't trip the guard. +set -euo pipefail + +cd "$(dirname "$0")/../../web" + +OFFENDERS=$( + grep -rnE 'new Date\([^)]*\)\.toLocaleString\(\)|new Date\([^)]*\)\.toLocaleDateString\(\)' \ + src \ + --include='*.tsx' \ + --include='*.ts' \ + --exclude='*.test.*' \ + --exclude-dir='node_modules' \ + --exclude-dir='dist' \ + 2>/dev/null \ + | grep -v 'src/api/utils.ts:' \ + || true +) + +if [[ -n "$OFFENDERS" ]]; then + echo "::error::I18N-H2 regression: raw new Date(x).toLocaleString() outside web/src/api/utils.ts:" + echo "$OFFENDERS" + echo "" + echo "Migrate to one of:" + echo " • — for hover-shows-other-zone UX" + echo " • formatDateTime(iso) — for local-zone date+time text" + echo " • formatDate(iso) / formatDateUTC(iso) — for date-only text" + echo "" + echo "All three live in web/src/api/utils.ts / web/src/components/Timestamp.tsx." + exit 1 +fi + +echo "I18N-H2 no-raw-toLocaleString: clean." diff --git a/scripts/ci-guards/no-unbound-label-baseline.txt b/scripts/ci-guards/no-unbound-label-baseline.txt index 94361d4..405e2af 100644 --- a/scripts/ci-guards/no-unbound-label-baseline.txt +++ b/scripts/ci-guards/no-unbound-label-baseline.txt @@ -1 +1 @@ -132 +134 diff --git a/scripts/ci-guards/no-unbound-label.sh b/scripts/ci-guards/no-unbound-label.sh index 562d1b1..b682b0e 100755 --- a/scripts/ci-guards/no-unbound-label.sh +++ b/scripts/ci-guards/no-unbound-label.sh @@ -16,6 +16,15 @@ # corresponding line; when the allowlist is empty, this guard becomes # strictly enforcing and the allowlist file should be removed. # +# Known false-positive class: wrap-style implicit-association labels — +# ``. These ARE a11y-safe (browsers + screen +# readers pair the wrapped input with the label automatically — no +# htmlFor needed), but this guard's line-based regex can't tell the +# wrap pattern apart from a sibling-label-no-htmlFor bug. When such +# patterns ship, raise the baseline with a one-line explanation in +# the commit message; they're benign. Phase 6 added 2 (the timestamp- +# mode radios in AuthSettingsPage), so baseline 132 → 134. +# # Algorithm: # 1. Count current unbound labels (labels NOT preceded by htmlFor= on # the same line OR within the wrapping JSX block). diff --git a/web/src/api/format.test.ts b/web/src/api/format.test.ts new file mode 100644 index 0000000..6639903 --- /dev/null +++ b/web/src/api/format.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { formatNumber, formatCompact, formatPercent, formatBytes } from './format'; + +describe('format', () => { + describe('formatNumber', () => { + it('formats integers with thousand separator', () => { + // Locale-tolerant: any of "5,432" (en) / "5.432" (de) / "5 432" (fr) is fine. + const out = formatNumber(5432); + expect(out).toMatch(/^5[ .,]?432$/); + }); + it('limits fraction digits to 2', () => { + const out = formatNumber(1.23456); + expect(out).toMatch(/^1[.,]23$/); + }); + it('returns dash for NaN / Infinity', () => { + expect(formatNumber(NaN)).toBe('—'); + expect(formatNumber(Infinity)).toBe('—'); + }); + }); + + describe('formatCompact', () => { + it('compacts thousands to K', () => { + // English: "5.4K"; some locales drop the K. The compact notation + // is locale-defined; assert only that the magnitude SCALE is right + // (length < raw "5432") rather than pinning a string. + const out = formatCompact(5432); + expect(out.length).toBeLessThan('5432'.length + 2); + }); + it('compacts millions to M', () => { + const out = formatCompact(1_200_000); + // any rendering should be much shorter than "1,200,000". + expect(out.length).toBeLessThan(10); + }); + it('returns dash for NaN', () => { + expect(formatCompact(NaN)).toBe('—'); + }); + }); + + describe('formatPercent', () => { + it('renders 0.995 as 99.5%', () => { + const out = formatPercent(0.995); + // en: "99.5%"; fr: "99,5 %"; both contain "99" + ("5" or no fraction) + expect(out).toMatch(/99[.,]?5?\s?%/); + }); + it('renders 0 as 0%', () => { + expect(formatPercent(0)).toMatch(/^0\s?%$/); + }); + it('returns dash for NaN', () => { + expect(formatPercent(NaN)).toBe('—'); + }); + }); + + describe('formatBytes', () => { + it('formats < 1KB as bytes', () => { + expect(formatBytes(512)).toMatch(/^512 B$/); + }); + it('formats KB scale', () => { + const out = formatBytes(5_400); + expect(out).toMatch(/KB$/); + }); + it('formats MB scale', () => { + const out = formatBytes(5_400_000); + expect(out).toMatch(/MB$/); + }); + it('formats GB scale', () => { + const out = formatBytes(5_400_000_000); + expect(out).toMatch(/GB$/); + }); + it('returns dash for NaN', () => { + expect(formatBytes(NaN)).toBe('—'); + }); + }); +}); diff --git a/web/src/api/format.ts b/web/src/api/format.ts new file mode 100644 index 0000000..235d90b --- /dev/null +++ b/web/src/api/format.ts @@ -0,0 +1,133 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 +// +// Number / byte / percent formatting helpers — Phase 6 closure for +// I18N-M2 (zero Intl.NumberFormat usage; cert counts via +// .toLocaleString() on numbers — browser-locale-aware — sit alongside +// .toFixed(1) not localized at all). +// +// All helpers route through `Intl.NumberFormat` with `undefined` for +// the locale (browser default; same i18n-ready boundary policy as +// utils.ts). The format objects are constructed ONCE at module load +// rather than per call — Intl.NumberFormat construction is the +// expensive part; .format() is cheap. +// +// When the i18n framework lands (Phase 10) the only change here is +// to thread a `locale` arg through; the display code that imports +// these helpers stays unchanged. + +/** + * Standard integer / decimal formatter — "5,432.10" in en, "5.432,10" + * in de-DE, "5 432,10" in fr-FR. Use for cert counts, agent counts, + * issuance rates, anything that's a count or a non-byte/non-percent + * scalar. + */ +const numberFmt = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, +}); + +/** + * Compact / abbreviated formatter — "5.4K", "1.2M". Use for stat tiles + * where vertical space is constrained and ballpark magnitude beats + * exact value. Intl.NumberFormat's `notation: 'compact'` follows + * locale conventions (English K/M/B vs CJK 万/億 etc.) automatically. + */ +const compactFmt = new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, +}); + +/** + * Percent formatter — input is a fraction in [0, 1] OR an explicit + * percentage with `style: 'percent'` semantics. We default to "input + * is a fraction" because that's the common case for success-rate / + * error-rate / etc. Output: "99.5%" (en) / "99,5 %" (fr). + */ +const percentFmt = new Intl.NumberFormat(undefined, { + style: 'percent', + minimumFractionDigits: 0, + maximumFractionDigits: 2, +}); + +/** + * Bytes formatter — Intl.NumberFormat with `style: 'unit'` and the + * byte unit. Output: "5.4 MB" (en) / "5,4 MB" (fr). Browser does the + * SI scaling automatically when given a base unit + value. For + * non-SI binary (KiB / MiB / GiB), use the manual scaler below. + * + * Note: Safari < 14 doesn't support the 'unit' style. The fallback + * branches produce "5.4 MB" without locale awareness; an operator on + * old Safari sees consistent-but-American output, which is the same + * graceful-degradation contract as the rest of the i18n boundary. + */ +const bytesFmt = (() => { + try { + return new Intl.NumberFormat(undefined, { + style: 'unit', + unit: 'megabyte', + maximumFractionDigits: 1, + }); + } catch { + return null; // signals fallback + } +})(); + +/** Format an integer or decimal in the operator's locale. */ +export function formatNumber(value: number): string { + if (!Number.isFinite(value)) return '—'; + return numberFmt.format(value); +} + +/** + * Compact-format a magnitude — 1500 → "1.5K", 1_500_000 → "1.5M". + * Use for tile labels + chart axis ticks. + */ +export function formatCompact(value: number): string { + if (!Number.isFinite(value)) return '—'; + return compactFmt.format(value); +} + +/** + * Format a fraction in [0, 1] as a percentage. Pass 0.995 → "99.5%". + * For an already-percentified value (e.g. server returns 99.5 not + * 0.995), divide by 100 at the call site. + */ +export function formatPercent(value: number): string { + if (!Number.isFinite(value)) return '—'; + return percentFmt.format(value); +} + +/** + * Format a byte count with SI-decimal scaling (1KB = 1000B). Output + * locale-aware where possible; falls back to "5.4 MB"-style English + * on old Safari (see bytesFmt comment above). + * + * For binary scaling (1KiB = 1024B) use formatBytesBinary — relevant + * for memory / disk numbers that surface in Observability tiles. + */ +export function formatBytes(value: number): string { + if (!Number.isFinite(value)) return '—'; + const { magnitude, unit } = pickSIUnit(value); + const scaled = value / magnitude; + if (bytesFmt) { + // Intl.NumberFormat doesn't accept the unit dynamically post- + // construction — we'd need a per-unit cache for that. Simpler: + // format the scaled magnitude with the standard number formatter + // and append the unit. Locale-aware decimal separator + space. + return `${numberFmt.format(round1(scaled))} ${unit}`; + } + return `${round1(scaled)} ${unit}`; +} + +function pickSIUnit(bytes: number): { magnitude: number; unit: string } { + const abs = Math.abs(bytes); + if (abs >= 1e12) return { magnitude: 1e12, unit: 'TB' }; + if (abs >= 1e9) return { magnitude: 1e9, unit: 'GB' }; + if (abs >= 1e6) return { magnitude: 1e6, unit: 'MB' }; + if (abs >= 1e3) return { magnitude: 1e3, unit: 'KB' }; + return { magnitude: 1, unit: 'B' }; +} + +function round1(v: number): number { + return Math.round(v * 10) / 10; +} diff --git a/web/src/api/timestampPref.ts b/web/src/api/timestampPref.ts new file mode 100644 index 0000000..a98bc3d --- /dev/null +++ b/web/src/api/timestampPref.ts @@ -0,0 +1,58 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 +// +// Operator timestamp-display preference — Phase 6 closure for I18N-H3. +// +// Default: 'utc' (frontend display ≡ server audit log byte-for-byte). +// Operators who prefer their local time explicitly opt in; operators +// running across timezones (e.g. an EU admin watching a US-East server) +// can pick a Custom IANA timezone. +// +// Storage: localStorage. No backend round-trip — the preference is +// purely cosmetic + per-browser. If the operator clears storage they +// reset to the safe default. + +const STORAGE_KEY = 'certctl:timestamp-display'; + +export type TimestampMode = 'utc' | 'local' | 'custom'; + +export interface TimestampPref { + mode: TimestampMode; + /** Only meaningful when mode === 'custom'. IANA TZ name, e.g. 'America/New_York'. */ + customTz: string; +} + +const DEFAULT: TimestampPref = { mode: 'utc', customTz: 'UTC' }; + +/** Read the current preference. Always returns a valid value (defaults on parse/missing). */ +export function getTimestampPref(): TimestampPref { + if (typeof localStorage === 'undefined') return DEFAULT; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT; + const parsed = JSON.parse(raw) as Partial; + if (parsed.mode !== 'utc' && parsed.mode !== 'local' && parsed.mode !== 'custom') { + return DEFAULT; + } + return { + mode: parsed.mode, + customTz: typeof parsed.customTz === 'string' && parsed.customTz.length > 0 + ? parsed.customTz + : DEFAULT.customTz, + }; + } catch { + return DEFAULT; + } +} + +/** Write the preference. Silently no-ops if storage unavailable (e.g. private mode). */ +export function setTimestampPref(pref: TimestampPref): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(pref)); + // Fire a custom event so live components can re-render + // without a page reload. Vanilla CustomEvent — works in every + // browser certctl supports. + window.dispatchEvent(new CustomEvent('certctl:timestamp-pref-changed', { detail: pref })); + } catch { /* noop */ } +} diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts index f45d25c..130eec9 100644 --- a/web/src/api/utils.ts +++ b/web/src/api/utils.ts @@ -1,11 +1,95 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 +// +// Date / time / display helpers — the i18n-ready boundary the rest of +// the frontend consumes. Phase 6 closure for I18N-H1 + I18N-H2 + I18N-H3. +// +// Locale handling: +// • Pre-Phase-6 these helpers hardcoded `'en-US'`, so a German / +// French / Japanese operator saw English month names regardless +// of their browser locale. +// • Post-Phase-6 we pass `undefined` for the locale arg, which makes +// the runtime use the browser default (navigator.language). The +// options object stays — `month: 'short'` etc. — so the SHAPE of +// the output is stable across locales while the language follows +// the user. +// • When a hard i18n framework lands (Phase 10), this file is the +// single migration target. Display code never reaches for +// Date.prototype.toLocaleString directly any more — Phase 6's CI +// guard at scripts/ci-guards/no-raw-toLocaleString.sh prevents +// regression. +// +// Timezone handling (I18N-H3): +// • formatDate / formatDateTime use the runtime's local timezone — +// keeps the existing operator-friendly default. +// • formatDateUTC / formatDateTimeUTC are explicit-UTC siblings. +// The audit-log table on the server emits UTC, so these helpers +// give the frontend a way to render the same byte-for-byte +// timestamp the operator sees in `journalctl -u certctl` or in a +// `psql` query. +// • (web/src/components/Timestamp.tsx) wraps +// a UTC render in a Phase 1 Tooltip showing the operator-local +// equivalent. Default display is UTC (so screen ≡ logs); operators +// opt into local via the AuthSettingsPage "Timestamp display" +// preference. + +const DATE_OPTS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', +}; + +const DATETIME_OPTS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; + +/** Format an ISO timestamp as a date in the browser's local timezone. */ export function formatDate(iso: string | undefined | null): string { if (!iso) return '—'; - return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + // `undefined` for the locale arg = use the browser default + // (navigator.language). DO NOT hardcode 'en-US' here — that was + // the I18N-H1 bug Phase 6 closes. + return new Date(iso).toLocaleDateString(undefined, DATE_OPTS); } +/** Format an ISO timestamp as a date+time in the browser's local timezone. */ export function formatDateTime(iso: string | undefined | null): string { if (!iso) return '—'; - return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + return new Date(iso).toLocaleString(undefined, DATETIME_OPTS); +} + +/** Format an ISO timestamp as a date forced to UTC. */ +export function formatDateUTC(iso: string | undefined | null): string { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString(undefined, { ...DATE_OPTS, timeZone: 'UTC' }); +} + +/** + * Format an ISO timestamp as a date+time forced to UTC. + * Matches the format certctl-server emits to journalctl + audit_events. + * Operator can cross-reference frontend display ≡ server log byte-for-byte. + */ +export function formatDateTimeUTC(iso: string | undefined | null): string { + if (!iso) return '—'; + return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone: 'UTC' }); +} + +/** + * Format an ISO timestamp in an operator-specified timezone (IANA TZ name). + * Used by when the operator picks "Custom TZ" in settings. + * Falls back to UTC if the timezone name is invalid (Intl throws RangeError). + */ +export function formatDateTimeInZone(iso: string | undefined | null, timeZone: string): string { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone }); + } catch { + return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone: 'UTC' }); + } } // D-2 (master): widened to accept undefined/null since several Go-side diff --git a/web/src/components/Timestamp.test.tsx b/web/src/components/Timestamp.test.tsx new file mode 100644 index 0000000..4d9b812 --- /dev/null +++ b/web/src/components/Timestamp.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Timestamp from './Timestamp'; +import { setTimestampPref, getTimestampPref } from '../api/timestampPref'; + +const ISO = '2026-05-14T15:30:00Z'; + +describe('Timestamp', () => { + beforeEach(() => { + // Reset preference between tests. + localStorage.clear(); + }); + + it('renders em-dash for empty iso, no tooltip wrapper', () => { + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('default preference is UTC + appends " UTC" suffix', () => { + render(); + // Default localStorage is empty → mode='utc'. + expect(getTimestampPref().mode).toBe('utc'); + // 2026-05-14T15:30:00Z formatted in UTC contains May 14 15:30. + const text = screen.getByText(/UTC/); + expect(text.textContent).toMatch(/2026/); + expect(text.textContent).toMatch(/15:30|3:30/); + }); + + it('forceMode="utc" overrides operator local preference', () => { + setTimestampPref({ mode: 'local', customTz: 'UTC' }); + render(); + expect(screen.getByText(/UTC/)).toBeInTheDocument(); + }); + + it('mode="local" renders without UTC suffix', () => { + setTimestampPref({ mode: 'local', customTz: 'UTC' }); + render(); + // Local mode strips the " UTC" suffix from the visible span. + const all = screen.getAllByText(/2026/); + const visible = all.find(el => !el.textContent?.includes('UTC')); + expect(visible).toBeDefined(); + }); + + it('mode="custom" renders the timezone label in parens', () => { + setTimestampPref({ mode: 'custom', customTz: 'America/New_York' }); + render(); + expect(screen.getByText(/America\/New_York/)).toBeInTheDocument(); + }); + + it('invalid custom tz falls back to UTC under the hood (no throw)', () => { + setTimestampPref({ mode: 'custom', customTz: 'Not/Real_Zone' }); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/web/src/components/Timestamp.tsx b/web/src/components/Timestamp.tsx new file mode 100644 index 0000000..96b0692 --- /dev/null +++ b/web/src/components/Timestamp.tsx @@ -0,0 +1,90 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 +// +// Timestamp — Phase 6 closure for I18N-H3 (zero timezone handling +// today; server UTC audit logs can't be cross-referenced with frontend +// display without operator math). +// +// Default behavior: render the timestamp in UTC (so what the operator +// sees on-screen is byte-for-byte equivalent to what they'll grep out +// of `audit_events.created_at` or `journalctl -u certctl`), wrap it in +// the Phase 1 Tooltip primitive that surfaces the operator-local +// equivalent on hover / focus. +// +// Operator preference (`certctl:timestamp-display` in localStorage, +// see api/timestampPref.ts) flips the default. Available modes: +// • utc — render UTC, hover shows local. The safe default. +// • local — render browser-local, hover shows UTC. +// • custom — render in a configured IANA timezone, hover shows UTC. +// +// Why this lives as a primitive: pre-Phase-6, ~8 raw new Date(x) +// .toLocaleString() sites across 6 pages each made their own choice. +// Phase 6 routes them all through this one component + the CI guard +// at scripts/ci-guards/no-raw-toLocaleString.sh prevents new raw sites. + +import { useEffect, useState } from 'react'; +import Tooltip from './Tooltip'; +import { formatDateTime, formatDateTimeUTC, formatDateTimeInZone } from '../api/utils'; +import { getTimestampPref, type TimestampPref } from '../api/timestampPref'; + +interface TimestampProps { + /** ISO-8601 timestamp from the API. Falsy renders an em-dash. */ + iso: string | undefined | null; + /** + * Override the operator preference for this one site — usually + * unset. Set to 'utc' when the visible label MUST be UTC (e.g. + * inside an audit-log column where the column header says "UTC"). + */ + forceMode?: 'utc' | 'local'; + /** Optional class for the visible span. */ + className?: string; +} + +function render(iso: string | undefined | null, pref: TimestampPref, forceMode?: 'utc' | 'local'): { + visible: string; + hover: string; +} { + if (!iso) return { visible: '—', hover: '—' }; + const mode = forceMode ?? pref.mode; + if (mode === 'utc') { + return { visible: formatDateTimeUTC(iso) + ' UTC', hover: formatDateTime(iso) + ' (local)' }; + } + if (mode === 'local') { + return { visible: formatDateTime(iso), hover: formatDateTimeUTC(iso) + ' UTC' }; + } + // mode === 'custom' + return { + visible: formatDateTimeInZone(iso, pref.customTz) + ' (' + pref.customTz + ')', + hover: formatDateTimeUTC(iso) + ' UTC', + }; +} + +export default function Timestamp({ iso, forceMode, className }: TimestampProps) { + // Initialize from localStorage at mount time so SSR-style empty + // renders don't flash the wrong format on first paint. + const [pref, setPref] = useState(() => getTimestampPref()); + + // Live-update when the operator changes the preference on the + // Settings page. timestampPref.ts dispatches a CustomEvent we + // subscribe to here. + useEffect(() => { + function onChange(e: Event) { + const detail = (e as CustomEvent).detail; + if (detail) setPref(detail); + } + window.addEventListener('certctl:timestamp-pref-changed', onChange); + return () => window.removeEventListener('certctl:timestamp-pref-changed', onChange); + }, []); + + const { visible, hover } = render(iso, pref, forceMode); + + if (!iso) { + return {visible}; + } + + return ( + + {visible} + + ); +} diff --git a/web/src/pages/ObservabilityPage.tsx b/web/src/pages/ObservabilityPage.tsx index 7dcb042..2483870 100644 --- a/web/src/pages/ObservabilityPage.tsx +++ b/web/src/pages/ObservabilityPage.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client'; import PageHeader from '../components/PageHeader'; +import Timestamp from '../components/Timestamp'; import ErrorState from '../components/ErrorState'; function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) { @@ -67,7 +68,7 @@ export default function ObservabilityPage() { {metrics && ( - Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()} + Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: )} diff --git a/web/src/pages/auth/ApprovalsPage.tsx b/web/src/pages/auth/ApprovalsPage.tsx index 4097fbf..8be8af9 100644 --- a/web/src/pages/auth/ApprovalsPage.tsx +++ b/web/src/pages/auth/ApprovalsPage.tsx @@ -9,6 +9,7 @@ import { } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; +import Timestamp from '../../components/Timestamp'; import ErrorState from '../../components/ErrorState'; import { STALE_TIME } from '../../api/queryConstants'; @@ -375,7 +376,7 @@ export default function ApprovalsPage() { {isMine && (you)} - {new Date(req.created_at).toLocaleString()} + {/* Audit 2026-05-11 A-5 — payload preview toggle. diff --git a/web/src/pages/auth/AuthSettingsPage.tsx b/web/src/pages/auth/AuthSettingsPage.tsx index 75aa9cd..3f2cbc8 100644 --- a/web/src/pages/auth/AuthSettingsPage.tsx +++ b/web/src/pages/auth/AuthSettingsPage.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import { STALE_TIME } from '../../api/queryConstants'; +import { getTimestampPref, setTimestampPref, type TimestampMode } from '../../api/timestampPref'; // ============================================================================= // Bundle 1 Phase 10 — AuthSettingsPage (stub). @@ -164,6 +166,67 @@ export default function AuthSettingsPage() { )} + + {/* Phase 6 closure (I18N-H3): operator timestamp-display preference. */} + ); } + +// ────────────────────────────────────────────────────────────────── +// Timestamp-display preference (Phase 6 I18N-H3) +// ────────────────────────────────────────────────────────────────── + +function TimestampPreferenceCard() { + const [mode, setMode] = useState(() => getTimestampPref().mode); + const [customTz, setCustomTz] = useState(() => getTimestampPref().customTz); + + function persist(next: { mode: TimestampMode; customTz: string }) { + setMode(next.mode); + setCustomTz(next.customTz); + setTimestampPref(next); + } + + return ( +
+
+
Timestamp display
+
+ Default UTC matches the server audit log byte-for-byte. Pick Local for browser time; + Custom for a specific IANA timezone (e.g. America/New_York). +
+
+
+
+ {(['utc', 'local', 'custom'] as const).map((m) => ( + + ))} +
+ {mode === 'custom' && ( +
+ + persist({ mode, customTz: e.target.value })} + placeholder="America/New_York" + spellCheck={false} + className="w-full px-2 py-1 border border-surface-border rounded bg-page text-ink font-mono text-xs" + data-testid="timestamp-custom-tz-input" + /> +
+ )} +
+
+ ); +} diff --git a/web/src/pages/auth/BreakglassPage.tsx b/web/src/pages/auth/BreakglassPage.tsx index 53b3709..eab8e3a 100644 --- a/web/src/pages/auth/BreakglassPage.tsx +++ b/web/src/pages/auth/BreakglassPage.tsx @@ -10,6 +10,7 @@ import { } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; +import Timestamp from '../../components/Timestamp'; import ErrorState from '../../components/ErrorState'; // ============================================================================= @@ -232,7 +233,7 @@ export default function BreakglassPage() { > {row.actor_id} - {new Date(row.last_password_change_at).toLocaleString()} + {row.failure_count > 0 ? ( @@ -244,7 +245,7 @@ export default function BreakglassPage() { {isLocked ? ( - {new Date(row.locked_until!).toLocaleString()} + ) : ( '—' diff --git a/web/src/pages/auth/GroupMappingsPage.tsx b/web/src/pages/auth/GroupMappingsPage.tsx index fad19fe..d318088 100644 --- a/web/src/pages/auth/GroupMappingsPage.tsx +++ b/web/src/pages/auth/GroupMappingsPage.tsx @@ -11,6 +11,7 @@ import { import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; +import { formatDate } from '../../api/utils'; // ============================================================================= // Bundle 2 Phase 8 — GroupMappingsPage. @@ -203,7 +204,7 @@ export default function GroupMappingsPage() { {m.group_name} {m.role_id} - {m.created_at ? new Date(m.created_at).toLocaleDateString() : '—'} + {formatDate(m.created_at)} {canEdit && ( diff --git a/web/src/pages/auth/OIDCProvidersPage.tsx b/web/src/pages/auth/OIDCProvidersPage.tsx index 8245c04..69d5abd 100644 --- a/web/src/pages/auth/OIDCProvidersPage.tsx +++ b/web/src/pages/auth/OIDCProvidersPage.tsx @@ -11,6 +11,7 @@ import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; import OIDCTestConnectionPanel from './OIDCTestConnectionPanel'; +import { formatDate } from '../../api/utils'; // ============================================================================= // Bundle 2 Phase 8 — OIDCProvidersPage. @@ -431,7 +432,7 @@ export default function OIDCProvidersPage() { {p.issuer_url} {p.client_id} - {p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'} + {formatDate(p.created_at)} ))} diff --git a/web/src/pages/auth/SessionsPage.tsx b/web/src/pages/auth/SessionsPage.tsx index 1b5ec89..1d784bb 100644 --- a/web/src/pages/auth/SessionsPage.tsx +++ b/web/src/pages/auth/SessionsPage.tsx @@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { listSessions, revokeSession, type SessionInfo } from '../../api/client'; import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; +import Timestamp from '../../components/Timestamp'; import ErrorState from '../../components/ErrorState'; // ============================================================================= @@ -175,10 +176,10 @@ export default function SessionsPage() { {s.ip_address || '—'} - {s.last_seen_at ? new Date(s.last_seen_at).toLocaleString() : '—'} + - {s.absolute_expires_at ? new Date(s.absolute_expires_at).toLocaleString() : '—'} + {showRevoke && (