feat(frontend): Phase 6 Locale + Date/Time Discipline — close I18N-H1 + I18N-H2 + I18N-H3 + I18N-M2

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 — `<Tooltip content={…}>{singleChild}</Tooltip>`,
    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 <Timestamp>
    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.
This commit is contained in:
shankar0123
2026-05-14 17:10:19 +00:00
parent 508c7530e9
commit 1fcb05181d
16 changed files with 628 additions and 11 deletions
+2 -1
View File
@@ -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() {
</span>
{metrics && (
<span className="text-xs text-ink-faint ml-auto">
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: <Timestamp iso={metrics.uptime.server_started} />
</span>
)}
</div>
+2 -1
View File
@@ -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 && <span className="ml-2 text-amber-700">(you)</span>}
</td>
<td className="px-3 py-2 text-xs text-ink-muted">
{new Date(req.created_at).toLocaleString()}
<Timestamp iso={req.created_at} />
</td>
<td className="px-3 py-2">
{/* Audit 2026-05-11 A-5 — payload preview toggle.
+63
View File
@@ -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() {
</div>
</section>
)}
{/* Phase 6 closure (I18N-H3): operator timestamp-display preference. */}
<TimestampPreferenceCard />
</div>
);
}
// ──────────────────────────────────────────────────────────────────
// Timestamp-display preference (Phase 6 I18N-H3)
// ──────────────────────────────────────────────────────────────────
function TimestampPreferenceCard() {
const [mode, setMode] = useState<TimestampMode>(() => getTimestampPref().mode);
const [customTz, setCustomTz] = useState<string>(() => getTimestampPref().customTz);
function persist(next: { mode: TimestampMode; customTz: string }) {
setMode(next.mode);
setCustomTz(next.customTz);
setTimestampPref(next);
}
return (
<section className="bg-surface border border-surface-border rounded shadow-sm" data-testid="timestamp-pref-card">
<div className="px-4 py-3 border-b border-surface-border">
<div className="text-sm font-semibold">Timestamp display</div>
<div className="text-xs text-ink-muted">
Default UTC matches the server audit log byte-for-byte. Pick Local for browser time;
Custom for a specific IANA timezone (e.g. <code>America/New_York</code>).
</div>
</div>
<div className="px-4 py-3 text-sm space-y-3">
<div className="flex items-center gap-4">
{(['utc', 'local', 'custom'] as const).map((m) => (
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="timestamp-mode"
value={m}
checked={mode === m}
onChange={() => persist({ mode: m, customTz })}
data-testid={`timestamp-mode-${m}`}
/>
<span className="capitalize">{m === 'utc' ? 'UTC' : m}</span>
</label>
))}
</div>
{mode === 'custom' && (
<div>
<label className="block text-xs font-medium text-ink-muted mb-1">IANA timezone</label>
<input
type="text"
value={customTz}
onChange={(e) => 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"
/>
</div>
)}
</div>
</section>
);
}
+3 -2
View File
@@ -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() {
>
<td className="py-3 font-mono text-xs">{row.actor_id}</td>
<td className="py-3 text-xs text-ink-muted">
{new Date(row.last_password_change_at).toLocaleString()}
<Timestamp iso={row.last_password_change_at} />
</td>
<td className="py-3 text-xs">
{row.failure_count > 0 ? (
@@ -244,7 +245,7 @@ export default function BreakglassPage() {
<td className="py-3 text-xs text-ink-muted">
{isLocked ? (
<span className="text-red-700">
{new Date(row.locked_until!).toLocaleString()}
<Timestamp iso={row.locked_until!} />
</span>
) : (
'—'
+2 -1
View File
@@ -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() {
<td className="px-4 py-2 font-mono text-xs">{m.group_name}</td>
<td className="px-4 py-2 font-mono text-xs">{m.role_id}</td>
<td className="px-4 py-2 text-ink-muted">
{m.created_at ? new Date(m.created_at).toLocaleDateString() : '—'}
{formatDate(m.created_at)}
</td>
<td className="px-4 py-2 text-right">
{canEdit && (
+2 -1
View File
@@ -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() {
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.issuer_url}</td>
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.client_id}</td>
<td className="px-4 py-2 text-ink-muted">
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
{formatDate(p.created_at)}
</td>
</tr>
))}
+3 -2
View File
@@ -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() {
</td>
<td className="px-4 py-2 text-ink-muted">{s.ip_address || '—'}</td>
<td className="px-4 py-2 text-ink-muted">
{s.last_seen_at ? new Date(s.last_seen_at).toLocaleString() : '—'}
<Timestamp iso={s.last_seen_at} />
</td>
<td className="px-4 py-2 text-ink-muted">
{s.absolute_expires_at ? new Date(s.absolute_expires_at).toLocaleString() : '—'}
<Timestamp iso={s.absolute_expires_at} />
</td>
<td className="px-4 py-2 text-right">
{showRevoke && (