mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-29 16:31:26 +02:00
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.
233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
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).
|
|
//
|
|
// Surfaces:
|
|
//
|
|
// - The current actor's identity, roles, effective permissions
|
|
// (from /v1/auth/me — already cached by useAuthMe).
|
|
// - Bootstrap-endpoint availability so a fresh-deploy operator
|
|
// knows whether they can mint the first admin via curl. Shows
|
|
// "available" pre-admin, "closed" after the first admin lands.
|
|
//
|
|
// Bundle 2 will extend this page with OIDC provider config + session
|
|
// management. Bundle 1 ships only the stub so the route exists and
|
|
// the navigation entry is wired.
|
|
// =============================================================================
|
|
|
|
export default function AuthSettingsPage() {
|
|
const me = useAuthMe();
|
|
const bootstrapQuery = useQuery({
|
|
queryKey: ['auth', 'bootstrap', 'available'],
|
|
queryFn: authBootstrapAvailable,
|
|
staleTime: STALE_TIME.REFERENCE, // slow-changing auth-runtime data
|
|
retry: 0,
|
|
});
|
|
// Audit 2026-05-10 MED-12 — Auth runtime config panel. Gated
|
|
// auth.role.assign server-side; query failure (403) is silently
|
|
// swallowed (panel hidden) for non-admin viewers.
|
|
const runtimeQuery = useQuery({
|
|
queryKey: ['auth', 'runtime-config'],
|
|
queryFn: authRuntimeConfig,
|
|
staleTime: STALE_TIME.REFERENCE, // slow-changing auth-runtime data
|
|
retry: 0,
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-4" data-testid="auth-settings-page">
|
|
<PageHeader
|
|
title="Auth settings"
|
|
subtitle="Bundle 1 RBAC — your identity + bootstrap status. Bundle 2 will add OIDC provider config + session management here."
|
|
/>
|
|
|
|
<section className="bg-surface border border-surface-border rounded">
|
|
<header className="px-4 py-3 border-b border-surface-border">
|
|
<div className="text-sm font-semibold">Current identity</div>
|
|
<div className="text-xs text-ink-muted">From /api/v1/auth/me</div>
|
|
</header>
|
|
<div className="px-4 py-3 text-sm space-y-2" data-testid="auth-settings-identity">
|
|
{me.isLoading && <div className="text-ink-muted">Loading…</div>}
|
|
{me.error && <div className="text-red-700">{me.error.message}</div>}
|
|
{me.data && (
|
|
<>
|
|
<div>
|
|
<span className="text-ink-muted">Actor:</span>{' '}
|
|
<span className="font-mono">{me.data.actor_id}</span>{' '}
|
|
<span className="text-xs text-ink-muted">({me.data.actor_type})</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-ink-muted">Tenant:</span>{' '}
|
|
<span className="font-mono">{me.data.tenant_id}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-ink-muted">Admin:</span>{' '}
|
|
<span data-testid="auth-settings-admin">{me.data.admin ? 'yes' : 'no'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-ink-muted">Roles:</span>{' '}
|
|
<span data-testid="auth-settings-roles">{me.data.roles.join(', ') || '(none)'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-ink-muted">Effective permissions:</span>{' '}
|
|
<span data-testid="auth-settings-permcount">{me.data.effective_permissions.length}</span>
|
|
</div>
|
|
{me.data.effective_permissions.length > 0 && (
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer text-ink-muted">Show permission list</summary>
|
|
<ul className="mt-2 ml-4 list-disc">
|
|
{me.data.effective_permissions.map((p, i) => (
|
|
<li key={i} className="font-mono">
|
|
{p.permission} @ {p.scope_type}
|
|
{p.scope_id ? ` (${p.scope_id})` : ''}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="bg-surface border border-surface-border rounded">
|
|
<header className="px-4 py-3 border-b border-surface-border">
|
|
<div className="text-sm font-semibold">Bootstrap endpoint</div>
|
|
<div className="text-xs text-ink-muted">Bundle 1 Phase 6 — mints the first admin API key when no admin exists yet.</div>
|
|
</header>
|
|
<div className="px-4 py-3 text-sm space-y-2" data-testid="auth-settings-bootstrap">
|
|
{bootstrapQuery.isLoading && <div className="text-ink-muted">Probing…</div>}
|
|
{bootstrapQuery.error && (
|
|
<div className="text-red-700 text-xs">Could not reach /v1/auth/bootstrap: {bootstrapQuery.error.message}</div>
|
|
)}
|
|
{bootstrapQuery.data && (
|
|
<>
|
|
<div>
|
|
<span className="text-ink-muted">Status:</span>{' '}
|
|
<span
|
|
className={
|
|
bootstrapQuery.data.available ? 'text-amber-700 font-semibold' : 'text-ink'
|
|
}
|
|
data-testid="auth-settings-bootstrap-status"
|
|
>
|
|
{bootstrapQuery.data.available ? 'OPEN — first-admin path callable' : 'closed'}
|
|
</span>
|
|
</div>
|
|
{bootstrapQuery.data.available && (
|
|
<div className="text-xs text-amber-700">
|
|
Run: <code className="font-mono">curl -X POST $URL/api/v1/auth/bootstrap -d '{'{'}"token":"…","actor_name":"first-admin"{'}'}'</code> to mint the first admin key.
|
|
</div>
|
|
)}
|
|
{!bootstrapQuery.data.available && (
|
|
<div className="text-xs text-ink-muted">
|
|
Either CERTCTL_BOOTSTRAP_TOKEN is unset, an admin already exists, or the strategy was already consumed.
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Audit 2026-05-10 MED-12 — Auth runtime config panel. */}
|
|
{runtimeQuery.data && (
|
|
<section className="bg-surface border border-surface-border rounded" data-testid="auth-settings-runtime-config">
|
|
<header className="px-4 py-3 border-b border-surface-border">
|
|
<div className="text-sm font-semibold">Auth runtime config</div>
|
|
<div className="text-xs text-ink-muted">
|
|
Deployed CERTCTL_* values gated `auth.role.assign`. Sensitive values (tokens,
|
|
secrets, CIDRs) surface as <em>set/unset</em> or counts only — never raw bytes.
|
|
</div>
|
|
</header>
|
|
<div className="px-4 py-3 text-sm">
|
|
<table className="w-full text-xs font-mono">
|
|
<thead>
|
|
<tr className="text-ink-muted text-left">
|
|
<th className="py-1 pr-4">Setting</th>
|
|
<th className="py-1">Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Object.entries(runtimeQuery.data)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([k, v]) => (
|
|
<tr key={k} className="border-t border-surface-border">
|
|
<td className="py-1 pr-4">{k}</td>
|
|
<td className="py-1">{v || <span className="text-ink-muted">(empty)</span>}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
);
|
|
}
|