Files
shankar0123 1fcb05181d 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.
2026-05-14 17:10:19 +00:00

104 lines
3.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Phase 5 closure (UX-H4 regression gate): fail the build when a new
# <label> element ships in production tsx without htmlFor= or a wrapping
# <FormField> primitive (which auto-emits htmlFor via useId()).
#
# Pre-Phase-5: 139 <label> tags, 6 with htmlFor, 0 inputs with id —
# WCAG 1.3.1 fails on ~99% of form fields. The FormField primitive
# (web/src/components/FormField.tsx) closes new label/input pairs by
# construction; this guard prevents reintroducing unbound labels in
# untouched parts of the codebase.
#
# Grace period: during the Phase 5 migration we expect ~133 existing
# unbound labels to stay in place until each owning page migrates
# through. They live in the allowlist file alongside this script
# (no-unbound-label-exceptions.txt). Each migration deletes the
# 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 —
# `<label><input/>...</label>`. 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).
# 2. Compare against the allowlist's recorded count. If today's count
# is HIGHER than the allowlist baseline, a new unbound label was
# added — fail with the diff.
# 3. If today's count is LOWER, congratulate and remind to update
# the baseline.
#
# Strict mode: pass `--strict` to fail on any unbound label, ignoring
# the allowlist. Use once the allowlist is empty.
set -euo pipefail
# Resolve script dir BEFORE cd so baseline path stays valid.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BASELINE_FILE="$SCRIPT_DIR/no-unbound-label-baseline.txt"
cd "$SCRIPT_DIR/../../web"
STRICT=0
[[ "${1:-}" == "--strict" ]] && STRICT=1
# Count <label tags WITHOUT htmlFor= on the same line in production
# tsx (excludes tests + node_modules + dist).
COUNT_UNBOUND=$(
grep -rohE '<label[^>]*>' src \
--include='*.tsx' \
--exclude='*.test.*' \
--exclude-dir='__tests__' \
--exclude-dir='node_modules' \
--exclude-dir='dist' \
2>/dev/null \
| grep -vcE 'htmlFor='
) || true
BASELINE=0
if [[ -f "$BASELINE_FILE" ]]; then
BASELINE=$(cat "$BASELINE_FILE" | tr -d '[:space:]')
fi
echo "Unbound <label> tags in web/src — current: $COUNT_UNBOUND, baseline: $BASELINE"
if [[ $STRICT -eq 1 ]]; then
if [[ $COUNT_UNBOUND -gt 0 ]]; then
echo "FAIL (--strict): $COUNT_UNBOUND unbound <label> tag(s) remain. Migrate to <FormField> or add htmlFor=."
exit 1
fi
echo "PASS (--strict): zero unbound <label> tags."
exit 0
fi
if [[ $COUNT_UNBOUND -gt $BASELINE ]]; then
echo ""
echo "FAIL: A new unbound <label> tag was added ($COUNT_UNBOUND > baseline $BASELINE)."
echo ""
echo "Wrap the new label in <FormField label='…'>{<input … />}</FormField> — the"
echo "primitive at web/src/components/FormField.tsx auto-pairs label htmlFor with"
echo "the child input's id via React's useId() so WCAG 1.3.1 holds by construction."
echo ""
echo "If a raw <label> is genuinely needed (rare: e.g. wrapping a Headless UI"
echo "Switch where Headless UI handles the binding internally), add htmlFor=…"
echo "explicitly. Then update the baseline:"
echo ""
echo " echo $COUNT_UNBOUND > $BASELINE_FILE"
echo ""
exit 1
fi
if [[ $COUNT_UNBOUND -lt $BASELINE ]]; then
echo ""
echo "PASS — and you're under baseline! Drop the baseline to lock in progress:"
echo " echo $COUNT_UNBOUND > $BASELINE_FILE"
echo ""
fi
exit 0