import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useLocation, useSearchParams } from 'react-router-dom'; import { getAdminESTProfiles, reloadAdminESTTrust, getAuditEvents, } from '../api/client'; import PageHeader from '../components/PageHeader'; import ErrorState from '../components/ErrorState'; import { useAuth } from '../components/AuthProvider'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { formatDateTime } from '../api/utils'; import type { ESTStatsSnapshot, ESTTrustAnchorInfo, AuditEvent, } from '../api/types'; // EST RFC 7030 hardening master bundle Phase 8 — operator-facing EST // administration page with three tabs. // // Profiles (default) — every configured EST profile, lean card per // profile with always-present fields (auth-mode // badges, mTLS trust-anchor expiry countdown, // counter grid). Per-card "Reload trust" action // (admin-gated; opens ConfirmReloadModal). Polled // every 30s via TanStack Query. // Recent Activity — full EST audit log filter covering the four // action codes the service emits // (est_simple_enroll / est_simple_reenroll / // est_server_keygen / est_auth_failed). Merged + // sorted descending. Filter chips for All / // Enrollment / Re-enrollment / ServerKeygen / // AuthFailure. Polled every 60s. // Trust Bundle — for mTLS profiles: per-profile trust bundle // viewer (cert subjects + expiry). The // upload-new-bundle action is intentionally // omitted at GA — operators rotate the file on // disk + use the Reload action on the Profiles // tab. A future phase ships the upload endpoint. // // Admin-gated: the page renders an "Admin access required" banner for // non-admin callers and never issues the underlying admin requests. // Server-side enforcement is the M-008 admin gate; this is a UX hint. // // The 12 counter labels match service/est_counters.go's estCounter* // constants; new labels added there MUST also be added to // COUNTER_LABEL_ORDER + COUNTER_PRESENTATION below. const COUNTER_LABEL_ORDER = [ 'success_simpleenroll', 'success_simplereenroll', 'success_serverkeygen', 'auth_failed_basic', 'auth_failed_mtls', 'auth_failed_channel_binding', 'csr_invalid', 'csr_policy_violation', 'csr_signature_mismatch', 'rate_limited', 'issuer_error', 'internal_error', ] as const; const COUNTER_PRESENTATION: Record = { success_simpleenroll: { label: 'Enrollments', tone: 'good' }, success_simplereenroll: { label: 'Re-enrollments', tone: 'good' }, success_serverkeygen: { label: 'Server-keygen', tone: 'good' }, auth_failed_basic: { label: 'Auth failed (Basic)', tone: 'warn' }, auth_failed_mtls: { label: 'Auth failed (mTLS)', tone: 'warn' }, auth_failed_channel_binding: { label: 'Channel-binding mismatch', tone: 'bad' }, csr_invalid: { label: 'CSR invalid', tone: 'warn' }, csr_policy_violation: { label: 'CSR policy violation', tone: 'warn' }, csr_signature_mismatch: { label: 'CSR signature mismatch', tone: 'bad' }, rate_limited: { label: 'Rate-limited', tone: 'warn' }, issuer_error: { label: 'Issuer error', tone: 'bad' }, internal_error: { label: 'Internal error', tone: 'bad' }, }; const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = { good: 'text-emerald-600', warn: 'text-amber-600', bad: 'text-red-600', }; type TabId = 'profiles' | 'activity' | 'trust'; type ActivityFilter = 'all' | 'enroll' | 'reenroll' | 'serverkeygen' | 'authfail'; const TAB_LABELS: Record = { profiles: 'Profiles', activity: 'Recent Activity', trust: 'Trust Bundle', }; const EST_AUDIT_ACTIONS = [ 'est_simple_enroll', 'est_simple_reenroll', 'est_server_keygen', 'est_auth_failed', ] as const; // ============================================================================= // Tone + badge helpers (shared across tabs). // ============================================================================= function expiryBadge(days: number | null, expired: boolean): { text: string; tone: 'good' | 'warn' | 'bad' } { if (expired) return { text: 'EXPIRED', tone: 'bad' }; if (days === null) return { text: 'Not loaded', tone: 'warn' }; if (days < 7) return { text: `${days}d remaining`, tone: 'bad' }; if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' }; return { text: `${days}d remaining`, tone: 'good' }; } function badgeClass(tone: 'good' | 'warn' | 'bad'): string { if (tone === 'good') return 'bg-emerald-100 text-emerald-800'; if (tone === 'warn') return 'bg-amber-100 text-amber-800'; return 'bg-red-100 text-red-800'; } function pillClass(active: boolean): string { return active ? 'bg-brand-100 text-brand-800 border-brand-300' : 'bg-surface-alt text-ink-muted border-surface-border'; } // soonestExpiryDays returns the smallest days_to_expiry across the // profile's mTLS trust anchor pool. Returns null when the pool is // empty (the per-profile preflight should have refused this state at // boot, but defensive in case the holder is reloaded mid-flight to an // empty file). function soonestExpiryDays(anchors?: ESTTrustAnchorInfo[]): number | null { if (!anchors || anchors.length === 0) return null; let min = Number.POSITIVE_INFINITY; for (const a of anchors) { if (a.expired) return -1; if (a.days_to_expiry < min) min = a.days_to_expiry; } return min === Number.POSITIVE_INFINITY ? null : min; } // ============================================================================= // Profiles tab. // ============================================================================= interface ProfilesTabProps { profiles: ESTStatsSnapshot[]; isLoading: boolean; onRequestReload: (profile: ESTStatsSnapshot) => void; } function ProfilesTab({ profiles, isLoading, onRequestReload }: ProfilesTabProps) { if (isLoading) { return

Loading profiles…

; } if (profiles.length === 0) { return (
No EST profiles are configured. Set CERTCTL_EST_ENABLED=true and either the legacy single-profile env vars or CERTCTL_EST_PROFILES=... with the indexed per-profile family to register at least one endpoint.
); } return ( <> {profiles.map(p => ( ))} ); } interface ProfileSummaryCardProps { profile: ESTStatsSnapshot; onRequestReload: (profile: ESTStatsSnapshot) => void; } function ProfileSummaryCard({ profile, onRequestReload }: ProfileSummaryCardProps) { const pathLabel = profile.path_id || '(legacy /.well-known/est root)'; const trustDays = soonestExpiryDays(profile.trust_anchors); const trustExpired = (profile.trust_anchors ?? []).some(a => a.expired); const trustBadge = profile.mtls_enabled ? expiryBadge(trustDays, trustExpired) : null; return (

{pathLabel}

Issuer: {profile.issuer_id} {profile.profile_id && ( <> {' '}· Profile: {profile.profile_id} )}

{trustBadge && ( mTLS trust: {trustBadge.text} )}
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'} HTTP Basic {profile.basic_auth_configured ? 'configured' : 'not set'} Server-keygen {profile.server_keygen_enabled ? 'enabled' : 'disabled'}
{COUNTER_LABEL_ORDER.map(label => { const presentation = COUNTER_PRESENTATION[label]; const value = profile.counters?.[label] ?? 0; return (
{presentation.label}
{value}
); })}
{profile.mtls_enabled && profile.trust_anchor_path && (

Trust bundle: {profile.trust_anchor_path}

)} {profile.mtls_enabled && (
)}
); } // ============================================================================= // Confirm-reload modal. // ============================================================================= interface ConfirmReloadModalProps { profile: ESTStatsSnapshot; onCancel: () => void; onConfirm: () => void; pending: boolean; errorMessage?: string; } function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) { const pathLabel = profile.path_id || '(legacy /.well-known/est root)'; return (

Reload EST mTLS trust anchor

This re-reads {profile.trust_anchor_path} from disk and atomically swaps the trust pool for EST profile {pathLabel}. Equivalent to sending SIGHUP to the server. If the new file fails to parse, the previous trust pool stays in place — enrollments keep working off the old trust anchor while you fix the file.

{errorMessage && (
{errorMessage}
)}
); } // ============================================================================= // Recent Activity tab. // ============================================================================= interface ActivityTabProps { events: AuditEvent[]; isLoading: boolean; filter: ActivityFilter; setFilter: (f: ActivityFilter) => void; } function activityMatches(filter: ActivityFilter, e: AuditEvent): boolean { if (filter === 'all') return true; if (filter === 'enroll') return e.action === 'est_simple_enroll'; if (filter === 'reenroll') return e.action === 'est_simple_reenroll'; if (filter === 'serverkeygen') return e.action === 'est_server_keygen'; if (filter === 'authfail') return e.action === 'est_auth_failed'; return false; } const ACTIVITY_FILTERS: { id: ActivityFilter; label: string }[] = [ { id: 'all', label: 'All' }, { id: 'enroll', label: 'Enrollment' }, { id: 'reenroll', label: 'Re-enrollment' }, { id: 'serverkeygen', label: 'Server-keygen' }, { id: 'authfail', label: 'Auth failure' }, ]; function ActivityTab({ events, isLoading, filter, setFilter }: ActivityTabProps) { const filtered = useMemo(() => events.filter(e => activityMatches(filter, e)), [events, filter]); return ( <>
{ACTIVITY_FILTERS.map(f => ( ))}
{isLoading &&

Loading audit events…

} {!isLoading && filtered.length === 0 && (

No events match the selected filter.

)} {!isLoading && filtered.length > 0 && (
{filtered.slice(0, 100).map((e, i) => ( ))}
Timestamp Action Subject Resource
{formatDateTime(e.timestamp)} {e.action} {e.actor || '—'} {e.resource_id || '—'}
)} ); } // ============================================================================= // Trust Bundle tab. // ============================================================================= interface TrustBundleTabProps { profiles: ESTStatsSnapshot[]; } function TrustBundleTab({ profiles }: TrustBundleTabProps) { const mtlsProfiles = profiles.filter(p => p.mtls_enabled && p.trust_anchors && p.trust_anchors.length > 0); if (mtlsProfiles.length === 0) { return (
No EST profiles have mTLS enabled. The Trust Bundle tab is only relevant when at least one profile carries an MTLS_CLIENT_CA_TRUST_BUNDLE_PATH.
); } return ( <> {mtlsProfiles.map(p => (

{p.path_id || '(legacy root)'}

{p.trust_anchor_path}
{(p.trust_anchors ?? []).map(a => ( ))}
Subject Not before Not after Days remaining
{a.subject} {formatDateTime(a.not_before)} {formatDateTime(a.not_after)} {a.expired ? 'EXPIRED' : `${a.days_to_expiry}d`}
))} ); } // ============================================================================= // Top-level page. // ============================================================================= function pickInitialTab(searchParams: URLSearchParams): TabId { const fromQuery = searchParams.get('tab'); if (fromQuery === 'activity' || fromQuery === 'trust') return fromQuery; return 'profiles'; } export default function ESTAdminPage() { const auth = useAuth(); const adminAccess = !auth.authRequired || auth.admin; const [searchParams, setSearchParams] = useSearchParams(); const _location = useLocation(); void _location; // reserved for future deep-link cases (mirrors SCEPAdminPage) const [activeTab, setActiveTab] = useState(() => pickInitialTab(searchParams)); const [reloadTarget, setReloadTarget] = useState(null); const [reloadError, setReloadError] = useState(undefined); const [activityFilter, setActivityFilter] = useState('all'); // Keep URL in sync with tab so deep links survive page reloads. useEffect(() => { const next = new URLSearchParams(searchParams); if (activeTab === 'profiles') { next.delete('tab'); } else { next.set('tab', activeTab); } if (next.toString() !== searchParams.toString()) { setSearchParams(next, { replace: true }); } }, [activeTab, searchParams, setSearchParams]); // Per-profile snapshot. Polled every 30s on the profiles tab. const profilesQuery = useQuery({ queryKey: ['admin', 'est', 'profiles'], queryFn: getAdminESTProfiles, enabled: adminAccess, refetchInterval: 30_000, }); // EST audit-log queries — four parallel queries (one per action) so // the activity tab can present a merged + filterable feed without a // dedicated server endpoint. const auditQueries = EST_AUDIT_ACTIONS.map(action => // eslint-disable-next-line react-hooks/rules-of-hooks useQuery({ queryKey: ['audit', { action }], queryFn: () => getAuditEvents({ action }), enabled: adminAccess && activeTab === 'activity', refetchInterval: 60_000, }), ); const allAuditEvents: AuditEvent[] = useMemo(() => { const merged: AuditEvent[] = []; for (const q of auditQueries) { if (q.data?.data) merged.push(...q.data.data); } return merged.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [auditQueries.map(q => q.dataUpdatedAt).join('|')]); const auditLoading = auditQueries.some(q => q.isLoading); // M-009 useTrackedMutation guard: every mutation in this page MUST // route through useTrackedMutation so the audit / progress hooks fire. const reloadMutation = useTrackedMutation< Awaited>, Error, string >({ mutationFn: (pathID: string) => reloadAdminESTTrust(pathID), invalidates: [['admin', 'est', 'profiles']], onSuccess: () => { setReloadTarget(null); setReloadError(undefined); }, onError: (err: Error) => { setReloadError(err.message); }, }); if (auth.authRequired && !auth.admin) { return ( <>
); } const profiles = profilesQuery.data?.profiles ?? []; return ( <> { void profilesQuery.refetch(); }} className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt" data-testid="est-refresh-stats-button" > Refresh now } />
{profilesQuery.error && activeTab === 'profiles' && ( profilesQuery.refetch()} /> )} {activeTab === 'profiles' && !profilesQuery.error && ( { setReloadError(undefined); setReloadTarget(profile); }} /> )} {activeTab === 'activity' && ( )} {activeTab === 'trust' && }
{reloadTarget && ( { setReloadTarget(null); setReloadError(undefined); }} onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)} pending={reloadMutation.isPending} errorMessage={reloadError} /> )} ); }