mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 05:18:51 +00:00
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:
@@ -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(<Timestamp iso={null} />);
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default preference is UTC + appends " UTC" suffix', () => {
|
||||
render(<Timestamp iso={ISO} />);
|
||||
// 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(<Timestamp iso={ISO} forceMode="utc" />);
|
||||
expect(screen.getByText(/UTC/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mode="local" renders without UTC suffix', () => {
|
||||
setTimestampPref({ mode: 'local', customTz: 'UTC' });
|
||||
render(<Timestamp iso={ISO} />);
|
||||
// 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(<Timestamp iso={ISO} />);
|
||||
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(<Timestamp iso={ISO} />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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<TimestampPref>(() => 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<TimestampPref>).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 <span className={className}>{visible}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={hover}>
|
||||
<span className={className}>{visible}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user