mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 16:38:55 +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:
Executable
+47
@@ -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 / <Timestamp> 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 " • <Timestamp iso={...} /> — 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."
|
||||||
@@ -1 +1 @@
|
|||||||
132
|
134
|
||||||
|
|||||||
@@ -16,6 +16,15 @@
|
|||||||
# corresponding line; when the allowlist is empty, this guard becomes
|
# corresponding line; when the allowlist is empty, this guard becomes
|
||||||
# strictly enforcing and the allowlist file should be removed.
|
# 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:
|
# Algorithm:
|
||||||
# 1. Count current unbound labels (labels NOT preceded by htmlFor= on
|
# 1. Count current unbound labels (labels NOT preceded by htmlFor= on
|
||||||
# the same line OR within the wrapping JSX block).
|
# the same line OR within the wrapping JSX block).
|
||||||
|
|||||||
@@ -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('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<TimestampPref>;
|
||||||
|
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 <Timestamp> 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 */ }
|
||||||
|
}
|
||||||
+86
-2
@@ -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.
|
||||||
|
// • <Timestamp iso={...} /> (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 {
|
export function formatDate(iso: string | undefined | null): string {
|
||||||
if (!iso) return '—';
|
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 {
|
export function formatDateTime(iso: string | undefined | null): string {
|
||||||
if (!iso) return '—';
|
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 <Timestamp /> 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
|
// D-2 (master): widened to accept undefined/null since several Go-side
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
|
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import Timestamp from '../components/Timestamp';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
|
||||||
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||||
@@ -67,7 +68,7 @@ export default function ObservabilityPage() {
|
|||||||
</span>
|
</span>
|
||||||
{metrics && (
|
{metrics && (
|
||||||
<span className="text-xs text-ink-faint ml-auto">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../api/client';
|
} from '../../api/client';
|
||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
import Timestamp from '../../components/Timestamp';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
import { STALE_TIME } from '../../api/queryConstants';
|
import { STALE_TIME } from '../../api/queryConstants';
|
||||||
|
|
||||||
@@ -375,7 +376,7 @@ export default function ApprovalsPage() {
|
|||||||
{isMine && <span className="ml-2 text-amber-700">(you)</span>}
|
{isMine && <span className="ml-2 text-amber-700">(you)</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs text-ink-muted">
|
<td className="px-3 py-2 text-xs text-ink-muted">
|
||||||
{new Date(req.created_at).toLocaleString()}
|
<Timestamp iso={req.created_at} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{/* Audit 2026-05-11 A-5 — payload preview toggle.
|
{/* Audit 2026-05-11 A-5 — payload preview toggle.
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client';
|
import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client';
|
||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import { STALE_TIME } from '../../api/queryConstants';
|
import { STALE_TIME } from '../../api/queryConstants';
|
||||||
|
import { getTimestampPref, setTimestampPref, type TimestampMode } from '../../api/timestampPref';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 1 Phase 10 — AuthSettingsPage (stub).
|
// Bundle 1 Phase 10 — AuthSettingsPage (stub).
|
||||||
@@ -164,6 +166,67 @@ export default function AuthSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Phase 6 closure (I18N-H3): operator timestamp-display preference. */}
|
||||||
|
<TimestampPreferenceCard />
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../api/client';
|
} from '../../api/client';
|
||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
import Timestamp from '../../components/Timestamp';
|
||||||
import ErrorState from '../../components/ErrorState';
|
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 font-mono text-xs">{row.actor_id}</td>
|
||||||
<td className="py-3 text-xs text-ink-muted">
|
<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>
|
||||||
<td className="py-3 text-xs">
|
<td className="py-3 text-xs">
|
||||||
{row.failure_count > 0 ? (
|
{row.failure_count > 0 ? (
|
||||||
@@ -244,7 +245,7 @@ export default function BreakglassPage() {
|
|||||||
<td className="py-3 text-xs text-ink-muted">
|
<td className="py-3 text-xs text-ink-muted">
|
||||||
{isLocked ? (
|
{isLocked ? (
|
||||||
<span className="text-red-700">
|
<span className="text-red-700">
|
||||||
{new Date(row.locked_until!).toLocaleString()}
|
<Timestamp iso={row.locked_until!} />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'—'
|
'—'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
import { formatDate } from '../../api/utils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 2 Phase 8 — GroupMappingsPage.
|
// 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.group_name}</td>
|
||||||
<td className="px-4 py-2 font-mono text-xs">{m.role_id}</td>
|
<td className="px-4 py-2 font-mono text-xs">{m.role_id}</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">
|
<td className="px-4 py-2 text-ink-muted">
|
||||||
{m.created_at ? new Date(m.created_at).toLocaleDateString() : '—'}
|
{formatDate(m.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right">
|
<td className="px-4 py-2 text-right">
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuthMe } from '../../hooks/useAuthMe';
|
|||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
|
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
|
||||||
|
import { formatDate } from '../../api/utils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 2 Phase 8 — OIDCProvidersPage.
|
// 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.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 font-mono text-xs">{p.client_id}</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">
|
<td className="px-4 py-2 text-ink-muted">
|
||||||
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
|
{formatDate(p.created_at)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { listSessions, revokeSession, type SessionInfo } from '../../api/client';
|
import { listSessions, revokeSession, type SessionInfo } from '../../api/client';
|
||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
import Timestamp from '../../components/Timestamp';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -175,10 +176,10 @@ export default function SessionsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">{s.ip_address || '—'}</td>
|
<td className="px-4 py-2 text-ink-muted">{s.ip_address || '—'}</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">
|
<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>
|
||||||
<td className="px-4 py-2 text-ink-muted">
|
<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>
|
||||||
<td className="px-4 py-2 text-right">
|
<td className="px-4 py-2 text-right">
|
||||||
{showRevoke && (
|
{showRevoke && (
|
||||||
|
|||||||
Reference in New Issue
Block a user