mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:11:29 +00:00
feat(scep-intune): GUI monitoring tab + admin endpoints
Phase 9 of the SCEP RFC 8894 + Intune master bundle. Lands the operator-
facing Intune Monitoring tab plus the two admin-gated endpoints it reads
from. Per the constitutional 'complete path' rule: counters tick on
every typed dispatcher branch, the GUI poll is live (30s for stats,
60s for the audit log filter), and the SIGHUP-equivalent reload action
is one click + a confirmation modal — no follow-up plumbing required.
Backend (Phase 9.1 + 9.2 + 9.3):
* internal/service/scep.go gains:
- intuneCounterTab — atomic per-status counters keyed by the same
labels intuneFailReason() emits (success / signature_invalid /
expired / not_yet_valid / wrong_audience / replay / rate_limited /
claim_mismatch / compliance_failed / malformed / unknown_version).
Lock-free on the dispatcher hot path; snapshot() returns a
zero-allocation map for the admin endpoint.
- dispatchIntuneChallenge wires intuneCounters.inc(...) on every
typed return path INCLUDING the success leg (credited before
processEnrollment so a downstream issuer-connector failure
doesn't double-count).
- SetPathID + PathID accessors (so admin rows surface the SCEP
profile path ID per row).
- IntuneStatsSnapshot + IntuneTrustAnchorInfo public types, plus
IntuneStats(now) accessor that walks the trust holder pool and
packages a per-profile snapshot. ReloadIntuneTrust() is the
typed wrapper around TrustAnchorHolder.Reload that returns
ErrSCEPProfileIntuneDisabled when called on a profile where
Intune isn't enabled (admin endpoint maps that to HTTP 409).
* internal/api/handler/admin_scep_intune.go:
- AdminSCEPIntuneService narrow interface (Stats + ReloadTrust)
so the handler depends on a small surface; AdminSCEPIntuneServiceImpl
is the production walker over the per-profile SCEPService map.
- AdminSCEPIntuneHandler.Stats handles GET /api/v1/admin/scep/intune/stats
with the M-008 admin gate (non-admin → 403 + service never
invoked); returns {profiles, profile_count, generated_at}.
- AdminSCEPIntuneHandler.ReloadTrust handles POST
/api/v1/admin/scep/intune/reload-trust. Body is {path_id: '<id>'};
empty body targets the legacy /scep root profile. Returns 200 on
success / 404 on unknown PathID / 409 when the profile is Intune-
disabled / 500 on a parse error from intune.LoadTrustAnchor (the
holder retains its previous pool — fail-safe). 400 on malformed
JSON.
- ErrAdminSCEPProfileNotFound typed error so the handler can
distinguish 'wrong profile' from 'broken file'.
* internal/api/router/router.go: HandlerRegistry gains
AdminSCEPIntune; both routes registered as bearer-auth-required
(the admin-gate is at the handler layer per the M-008 pattern).
* cmd/server/main.go: declares scepServices map[string]*service.SCEPService
BEFORE HandlerRegistry construction so the same map can be referenced
from both the admin handler (constructed early) and the SCEP startup
loop (which populates it later by reference). The per-profile loop
now calls scepService.SetPathID(profile.PathID) and stores the service
pointer into the shared map. AdminSCEPIntune handler is constructed
at the same time as AdminCRLCache.
* internal/api/handler/m008_admin_gate_test.go: AdminGatedHandlers
map gains 'admin_scep_intune.go' with a one-line justification —
the regression scanner enforces the per-handler test triplet
(TestAdminSCEPIntune_NonAdmin_Returns403 + _AdminExplicitFalse_Returns403
+ _AdminPermitted_ForwardsActor) plus their POST siblings for
ReloadTrust.
* api/openapi.yaml: documents both endpoints with request body /
response shape / error mapping; openapi-parity-test now matches
the registered routes.
Frontend (Phase 9.4):
* web/src/pages/SCEPAdminPage.tsx — single-page Intune Monitoring
surface:
- Per-profile cards (one card per SCEP profile). Enabled profiles
get the full counter grid + trust-anchor-expiry badge tone
(good ≥30d / warn 7-30d / bad <7d / EXPIRED). Disabled profiles
get an off-state pill with the env-var hint to opt in.
- Counters polled every 30s via TanStack Query against
GET /admin/scep/intune/stats.
- Recent failures table (last 50) populated from the audit log
filtered to action=scep_pkcsreq_intune AND scep_renewalreq_intune;
merged + sorted by timestamp descending. Polled every 60s.
- Reload trust anchor button per profile + confirmation modal that
explains the SIGHUP equivalence and the fail-safe behavior.
onConfirm runs a TanStack mutation, refetches the stats query
on success, surfaces the underlying error (eg 'trust anchor
cert expired') in the modal on failure (modal stays open so
operator can retry).
- Admin gate: when authRequired && !admin the page renders an
'Admin access required' banner and the underlying admin API
requests are never issued (React Query enabled flag gated on
auth.admin) — server-side enforcement is M-008.
* web/src/api/types.ts: IntuneStatsSnapshot + IntuneTrustAnchorInfo +
IntuneStatsResponse + IntuneReloadTrustResponse.
* web/src/api/client.ts: getAdminSCEPIntuneStats +
reloadAdminSCEPIntuneTrust(pathID).
* web/src/main.tsx: new route /scep/intune. The route is unconditional;
the gating is at the page level so deep-links land cleanly.
* web/src/components/Layout.tsx: 'SCEP Intune' nav link between
Observability and Audit Trail with the appropriate sidebar icon.
Tests (Phase 9.5):
* internal/api/handler/admin_scep_intune_test.go (16 tests):
- M-008 admin-gate triplet for both Stats (GET) and ReloadTrust
(POST): NonAdmin / AdminExplicitFalse / AdminPermitted.
- Method-gate tests (Stats rejects POST, ReloadTrust rejects GET).
- Stats propagates service errors as 500.
- ReloadTrust maps ErrAdminSCEPProfileNotFound→404,
ErrSCEPProfileIntuneDisabled→409, generic err→500.
- Empty body targets legacy root PathID.
- Malformed JSON→400.
- AdminSCEPIntuneServiceImpl handles nil map + unknown PathID.
* web/src/pages/SCEPAdminPage.test.tsx (13 tests):
- Admin gate (non-admin sees gated banner + zero admin API calls;
admin sees the page; no-auth dev mode also passes).
- Profile rendering (counters with correct labels, expiry badge
tone for ≥30d / EXPIRED states, off-state pill for disabled
profiles, empty-state banner when no profiles configured).
- Reload modal (opens on click, calls mutation on Confirm,
keeps modal open + shows error on failure, Cancel skips
mutation).
- Error path renders ErrorState with retry.
- Audit log filter merges PKCSReq + RenewalReq events and sorts
descending.
Verification:
* gofmt clean on touched files
* go vet ./... clean
* staticcheck on intune/service/api/cmd-server clean
* go test -short across api+service+intune+cmd-server: all green
* web tsc --noEmit clean
* Vitest: SCEPAdminPage.test.tsx 13/13 + sibling page suites all
pass
* G-3 docs-drift CI guard: Phase 9 adds no new CERTCTL_* env vars
so the guard does not fire
* openapi-parity-test green (both new admin endpoints documented)
* M-008 regression scanner enforces the per-handler test triplet —
pin updated, all triplets present
Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 9
cowork/scep-rfc8894-intune/progress.md
This commit is contained in:
+17
-1
@@ -1,4 +1,4 @@
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse } from './types';
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse } from './types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
@@ -296,6 +296,22 @@ export const fetchCRL = (issuerId: string) => {
|
||||
export const getAdminCRLCache = () =>
|
||||
fetchJSON<CRLCacheResponse>(`${BASE}/admin/crl/cache`);
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.2 admin endpoint mirror.
|
||||
//
|
||||
// Backend handler: internal/api/handler/admin_scep_intune.go.
|
||||
// Both endpoints are M-008 admin-gated; the SCEPAdminPage component
|
||||
// gates the React-Query `enabled` flag on useAuth().admin so non-admin
|
||||
// callers never see the page (the route itself is also conditional on
|
||||
// the admin flag in main.tsx).
|
||||
export const getAdminSCEPIntuneStats = () =>
|
||||
fetchJSON<IntuneStatsResponse>(`${BASE}/admin/scep/intune/stats`);
|
||||
|
||||
export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
|
||||
fetchJSON<IntuneReloadTrustResponse>(`${BASE}/admin/scep/intune/reload-trust`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path_id: pathID }),
|
||||
});
|
||||
|
||||
// Agents
|
||||
export const getAgents = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
|
||||
@@ -626,3 +626,53 @@ export interface CRLCacheResponse {
|
||||
row_count: number;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.2: admin observability
|
||||
// payload mirror for the per-profile Intune dispatcher.
|
||||
//
|
||||
// Backend types live at internal/service/scep.go (IntuneStatsSnapshot +
|
||||
// IntuneTrustAnchorInfo) and the handler glue in
|
||||
// internal/api/handler/admin_scep_intune.go. Both endpoints are admin-
|
||||
// gated (M-008 pin in m008_admin_gate_test.go) — the GUI hides the
|
||||
// SCEP Intune surface entirely (rather than letting it 403 noisily) by
|
||||
// gating the React-Query enabled flag on useAuth().admin at the call site.
|
||||
export interface IntuneTrustAnchorInfo {
|
||||
subject: string;
|
||||
not_before: string;
|
||||
not_after: string;
|
||||
days_to_expiry: number;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
// IntuneStatsSnapshot — one row per configured SCEP profile. Profiles
|
||||
// where Intune is disabled appear with enabled=false; the remaining
|
||||
// fields stay zero/empty so the GUI can render a "Not enabled" pill.
|
||||
export interface IntuneStatsSnapshot {
|
||||
path_id: string;
|
||||
issuer_id: string;
|
||||
enabled: boolean;
|
||||
trust_anchor_path?: string;
|
||||
trust_anchors?: IntuneTrustAnchorInfo[];
|
||||
audience?: string;
|
||||
challenge_validity_ns?: number;
|
||||
rate_limit_disabled: boolean;
|
||||
replay_cache_size: number;
|
||||
// Counter labels match intuneFailReason() in the backend dispatcher:
|
||||
// success / signature_invalid / expired / not_yet_valid / wrong_audience /
|
||||
// replay / unknown_version / malformed / rate_limited / claim_mismatch /
|
||||
// compliance_failed.
|
||||
counters: Record<string, number>;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface IntuneStatsResponse {
|
||||
profiles: IntuneStatsSnapshot[];
|
||||
profile_count: number;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface IntuneReloadTrustResponse {
|
||||
reloaded: boolean;
|
||||
path_id: string;
|
||||
reloaded_at: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const nav = [
|
||||
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
||||
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||
{ to: '/scep/intune', label: 'SCEP Intune', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
|
||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
];
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import ObservabilityPage from './pages/ObservabilityPage';
|
||||
import JobDetailPage from './pages/JobDetailPage';
|
||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||
import TargetDetailPage from './pages/TargetDetailPage';
|
||||
import SCEPAdminPage from './pages/SCEPAdminPage';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -79,6 +80,12 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="health-monitor" element={<HealthMonitorPage />} />
|
||||
<Route path="digest" element={<DigestPage />} />
|
||||
<Route path="observability" element={<ObservabilityPage />} />
|
||||
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile
|
||||
Intune Monitoring tab. Route is unconditional; the page
|
||||
itself renders an "Admin access required" banner for
|
||||
non-admin callers and skips the underlying API calls so
|
||||
the server never sees a 403-prone request. */}
|
||||
<Route path="scep/intune" element={<SCEPAdminPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.5: Vitest coverage for the
|
||||
// SCEPAdminPage component. Pins:
|
||||
// 1. Admin gate — non-admin callers see the gated banner and the page
|
||||
// MUST NOT issue the underlying admin API requests.
|
||||
// 2. Profile cards render with status + counters + trust-anchor expiry
|
||||
// badge tone (good / warn / bad / EXPIRED).
|
||||
// 3. Disabled profiles render the off-state pill instead of the counter
|
||||
// grid.
|
||||
// 4. Reload button opens the confirmation modal; Confirm calls the
|
||||
// mutation and refetches stats; Cancel closes without calling.
|
||||
// 5. Error path surfaces ErrorState with retry.
|
||||
// 6. Audit log filter merges PKCSReq + RenewalReq events and sorts by
|
||||
// timestamp descending.
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
getAdminSCEPIntuneStats: vi.fn(),
|
||||
reloadAdminSCEPIntuneTrust: vi.fn(),
|
||||
getAuditEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/AuthProvider', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
import SCEPAdminPage from './SCEPAdminPage';
|
||||
import * as client from '../api/client';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function setAuth(opts: { authRequired: boolean; admin: boolean }) {
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
loading: false,
|
||||
authRequired: opts.authRequired,
|
||||
authenticated: true,
|
||||
authType: 'apikey',
|
||||
user: 'tester',
|
||||
admin: opts.admin,
|
||||
login: async () => {},
|
||||
logout: () => {},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
const baseEnabledProfile = {
|
||||
path_id: 'corp',
|
||||
issuer_id: 'iss-corp',
|
||||
enabled: true,
|
||||
trust_anchor_path: '/etc/certctl/intune-corp.pem',
|
||||
trust_anchors: [
|
||||
{
|
||||
subject: 'intune-connector-installation-corp',
|
||||
not_before: '2026-01-01T00:00:00Z',
|
||||
not_after: '2027-01-01T00:00:00Z',
|
||||
days_to_expiry: 250,
|
||||
expired: false,
|
||||
},
|
||||
],
|
||||
audience: 'https://certctl.example.com/scep/corp',
|
||||
challenge_validity_ns: 3_600_000_000_000,
|
||||
rate_limit_disabled: false,
|
||||
replay_cache_size: 12,
|
||||
counters: {
|
||||
success: 42,
|
||||
signature_invalid: 1,
|
||||
expired: 0,
|
||||
not_yet_valid: 0,
|
||||
wrong_audience: 0,
|
||||
replay: 2,
|
||||
rate_limited: 0,
|
||||
claim_mismatch: 3,
|
||||
compliance_failed: 0,
|
||||
malformed: 0,
|
||||
unknown_version: 0,
|
||||
},
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
};
|
||||
|
||||
const disabledProfile = {
|
||||
path_id: 'iot',
|
||||
issuer_id: 'iss-iot',
|
||||
enabled: false,
|
||||
rate_limit_disabled: false,
|
||||
replay_cache_size: 0,
|
||||
counters: {},
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
setAuth({ authRequired: true, admin: true });
|
||||
vi.mocked(client.getAuditEvents).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe('SCEPAdminPage — admin gate', () => {
|
||||
it('renders an Admin access required banner for non-admin callers and skips the admin API', async () => {
|
||||
setAuth({ authRequired: true, admin: false });
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 2, name: /SCEP Intune Monitoring/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(client.getAdminSCEPIntuneStats).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Admin access required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets admin callers through and fetches stats', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
expect(await screen.findByTestId('profile-card-corp')).toBeInTheDocument();
|
||||
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the page accessible when authRequired=false (no-auth dev mode)', async () => {
|
||||
setAuth({ authRequired: false, admin: false });
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [],
|
||||
profile_count: 0,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(client.getAdminSCEPIntuneStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCEPAdminPage — profile rendering', () => {
|
||||
it('renders enabled profile counters with the expected labels and tone', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('counter-corp-success')).toHaveTextContent('42');
|
||||
});
|
||||
expect(screen.getByTestId('counter-corp-replay')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('counter-corp-claim_mismatch')).toHaveTextContent('3');
|
||||
// Expiry badge is "good" tone for >= 30 days remaining.
|
||||
const badge = screen.getByTestId('expiry-badge-corp');
|
||||
expect(badge).toHaveTextContent('250d');
|
||||
});
|
||||
|
||||
it('renders an expiry badge with EXPIRED text and bad tone when an anchor is past NotAfter', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
...baseEnabledProfile,
|
||||
trust_anchors: [
|
||||
{ subject: 'expired-conn', not_before: '2024-01-01T00:00:00Z', not_after: '2025-01-01T00:00:00Z', days_to_expiry: 0, expired: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expiry-badge-corp')).toHaveTextContent(/EXPIRED/);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the off-state pill for disabled profiles instead of the counter grid', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [disabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('profile-card-iot')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Intune disabled/)).toBeInTheDocument();
|
||||
// Counter grid should NOT render for disabled profiles.
|
||||
expect(screen.queryByTestId('counter-iot-success')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders an empty-state banner when no profiles are configured', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [],
|
||||
profile_count: 0,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No SCEP profiles are configured/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCEPAdminPage — reload-trust modal', () => {
|
||||
it('opens the confirmation modal when the Reload trust button is clicked', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('reload-button-corp'));
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reload Intune trust anchor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls reloadAdminSCEPIntuneTrust on Confirm and closes the modal on success', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
vi.mocked(client.reloadAdminSCEPIntuneTrust).mockResolvedValue({
|
||||
reloaded: true,
|
||||
path_id: 'corp',
|
||||
reloaded_at: '2026-04-29T15:01:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('reload-button-corp'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i }));
|
||||
await waitFor(() => {
|
||||
expect(client.reloadAdminSCEPIntuneTrust).toHaveBeenCalledWith('corp');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the modal open and shows the error message when reload fails', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
vi.mocked(client.reloadAdminSCEPIntuneTrust).mockRejectedValue(new Error('trust anchor cert expired'));
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('reload-button-corp'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Reload trust anchor/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/trust anchor cert expired/)).toBeInTheDocument();
|
||||
});
|
||||
// Modal stays open so the operator can read the error and retry.
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Cancel closes the modal without calling the reload mutation', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('reload-button-corp')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('reload-button-corp'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Cancel/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
expect(client.reloadAdminSCEPIntuneTrust).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCEPAdminPage — error + audit-log surface', () => {
|
||||
it('surfaces ErrorState when the stats query fails', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockRejectedValue(new Error('boom'));
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load data/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('merges PKCSReq + RenewalReq audit events and sorts by timestamp descending', async () => {
|
||||
vi.mocked(client.getAdminSCEPIntuneStats).mockResolvedValue({
|
||||
profiles: [baseEnabledProfile],
|
||||
profile_count: 1,
|
||||
generated_at: '2026-04-29T15:00:00Z',
|
||||
} as never);
|
||||
vi.mocked(client.getAuditEvents).mockImplementation((params: Record<string, string> = {}) => {
|
||||
if (params.action === 'scep_pkcsreq_intune') {
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{ id: 'ae-pkcs-1', action: 'scep_pkcsreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'cert-1', details: {}, timestamp: '2026-04-29T14:00:00Z' },
|
||||
],
|
||||
total: 1, page: 1, per_page: 200,
|
||||
} as never);
|
||||
}
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{ id: 'ae-renew-1', action: 'scep_renewalreq_intune', actor: 'scep-client', actor_type: 'system', resource_type: 'certificate', resource_id: 'cert-2', details: {}, timestamp: '2026-04-29T14:30:00Z' },
|
||||
],
|
||||
total: 1, page: 1, per_page: 200,
|
||||
} as never);
|
||||
});
|
||||
|
||||
renderWithQuery(<SCEPAdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('recent-failures-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const rows = screen.getByTestId('recent-failures-table').querySelectorAll('tbody tr');
|
||||
expect(rows.length).toBe(2);
|
||||
// Sorted descending by timestamp — renewal (14:30) comes before pkcs (14:00).
|
||||
expect(rows[0].textContent).toContain('scep_renewalreq_intune');
|
||||
expect(rows[1].textContent).toContain('scep_pkcsreq_intune');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getAdminSCEPIntuneStats, reloadAdminSCEPIntuneTrust, getAuditEvents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { IntuneStatsSnapshot, IntuneTrustAnchorInfo, AuditEvent } from '../api/types';
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.4: per-profile Intune
|
||||
// Monitoring tab.
|
||||
//
|
||||
// Surfaces:
|
||||
// - Status banner per profile (trust anchor expiry countdown, rotates
|
||||
// when < 30 days; the soonest-to-expire anchor wins).
|
||||
// - Live counters table per profile (success / signature_invalid /
|
||||
// claim_mismatch / expired / wrong_audience / replay / rate_limited /
|
||||
// malformed / compliance_failed / not_yet_valid / unknown_version).
|
||||
// Polled every 30s via TanStack Query.
|
||||
// - Recent failures table (last 50) populated from the audit log
|
||||
// filtered to action=scep_pkcsreq_intune (and the renewal sibling).
|
||||
// - Trust anchor reload button (per-profile) with confirmation modal;
|
||||
// calls POST /api/v1/admin/scep/intune/reload-trust under the hood
|
||||
// (the SIGHUP-equivalent path).
|
||||
//
|
||||
// Admin-gated: the page itself 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.
|
||||
|
||||
const COUNTER_LABEL_ORDER = [
|
||||
'success',
|
||||
'signature_invalid',
|
||||
'expired',
|
||||
'not_yet_valid',
|
||||
'wrong_audience',
|
||||
'replay',
|
||||
'rate_limited',
|
||||
'claim_mismatch',
|
||||
'compliance_failed',
|
||||
'malformed',
|
||||
'unknown_version',
|
||||
] as const;
|
||||
|
||||
const COUNTER_PRESENTATION: Record<string, { label: string; tone: 'good' | 'warn' | 'bad' }> = {
|
||||
success: { label: 'Success', tone: 'good' },
|
||||
signature_invalid: { label: 'Signature invalid', tone: 'bad' },
|
||||
expired: { label: 'Expired', tone: 'warn' },
|
||||
not_yet_valid: { label: 'Not yet valid', tone: 'warn' },
|
||||
wrong_audience: { label: 'Wrong audience', tone: 'bad' },
|
||||
replay: { label: 'Replay', tone: 'bad' },
|
||||
rate_limited: { label: 'Rate-limited', tone: 'warn' },
|
||||
claim_mismatch: { label: 'Claim mismatch', tone: 'bad' },
|
||||
compliance_failed: { label: 'Compliance failed', tone: 'warn' },
|
||||
malformed: { label: 'Malformed', tone: 'bad' },
|
||||
unknown_version: { label: 'Unknown version', tone: 'warn' },
|
||||
};
|
||||
|
||||
const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = {
|
||||
good: 'text-emerald-600',
|
||||
warn: 'text-amber-600',
|
||||
bad: 'text-red-600',
|
||||
};
|
||||
|
||||
// soonestExpiryDays returns the smallest days_to_expiry across the
|
||||
// profile's 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?: IntuneTrustAnchorInfo[]): number | null {
|
||||
if (!anchors || anchors.length === 0) return null;
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
for (const a of anchors) {
|
||||
if (a.expired) return -1; // any expired wins
|
||||
if (a.days_to_expiry < min) min = a.days_to_expiry;
|
||||
}
|
||||
return min === Number.POSITIVE_INFINITY ? null : min;
|
||||
}
|
||||
|
||||
function expiryBadge(days: number | null): { text: string; tone: 'good' | 'warn' | 'bad' } {
|
||||
if (days === null) return { text: 'No trust anchors', tone: 'warn' };
|
||||
if (days < 0) return { text: 'EXPIRED', tone: 'bad' };
|
||||
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' };
|
||||
}
|
||||
|
||||
interface ConfirmReloadModalProps {
|
||||
profile: IntuneStatsSnapshot;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
pending: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
|
||||
const pathLabel = profile.path_id || '(legacy /scep root)';
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby="reload-trust-title"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
>
|
||||
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
|
||||
<h3 id="reload-trust-title" className="text-base font-semibold text-ink mb-2">
|
||||
Reload Intune trust anchor
|
||||
</h3>
|
||||
<p className="text-sm text-ink-muted mb-4">
|
||||
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
||||
swaps the trust pool for SCEP profile <strong>{pathLabel}</strong>. Equivalent to sending
|
||||
<code className="text-xs"> SIGHUP </code> 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.
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={pending}
|
||||
className="px-3 py-1.5 text-sm rounded border border-surface-border bg-surface hover:bg-surface-alt"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={pending}
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Reloading…' : 'Reload trust anchor'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: IntuneStatsSnapshot;
|
||||
onRequestReload: (profile: IntuneStatsSnapshot) => void;
|
||||
}
|
||||
|
||||
function ProfileCard({ profile, onRequestReload }: ProfileCardProps) {
|
||||
const pathLabel = profile.path_id || '(legacy /scep root)';
|
||||
if (!profile.enabled) {
|
||||
return (
|
||||
<section className="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}>
|
||||
<header className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
|
||||
<p className="text-xs text-ink-muted">Issuer: {profile.issuer_id}</p>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-alt text-ink-muted">
|
||||
Intune disabled
|
||||
</span>
|
||||
</header>
|
||||
<p className="text-sm text-ink-muted">
|
||||
This profile honors only the static challenge password. To enable Intune dispatch, set
|
||||
<code className="mx-1">CERTCTL_SCEP_PROFILE_{(profile.path_id || 'DEFAULT').toUpperCase()}_INTUNE_ENABLED=true</code>
|
||||
plus the matching trust-anchor path env var, then restart the server.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const days = soonestExpiryDays(profile.trust_anchors);
|
||||
const badge = expiryBadge(days);
|
||||
|
||||
return (
|
||||
<section className="bg-surface border border-surface-border rounded-lg p-5 mb-4" data-testid={`profile-card-${profile.path_id}`}>
|
||||
<header className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
|
||||
<p className="text-xs text-ink-muted">
|
||||
Issuer: {profile.issuer_id}
|
||||
{profile.audience && <> · Audience: <code>{profile.audience}</code></>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
badge.tone === 'good'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: badge.tone === 'warn'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
data-testid={`expiry-badge-${profile.path_id}`}
|
||||
>
|
||||
Trust anchor: {badge.text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestReload(profile)}
|
||||
className="text-xs px-2 py-1 rounded border border-surface-border bg-surface hover:bg-surface-alt"
|
||||
data-testid={`reload-button-${profile.path_id}`}
|
||||
>
|
||||
Reload trust
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
{COUNTER_LABEL_ORDER.map(label => {
|
||||
const value = profile.counters?.[label] ?? 0;
|
||||
const presentation = COUNTER_PRESENTATION[label];
|
||||
return (
|
||||
<div key={label} className="border border-surface-border rounded p-2">
|
||||
<div className={`text-lg font-semibold ${TONE_CLASS[presentation.tone]}`} data-testid={`counter-${profile.path_id}-${label}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-[11px] text-ink-muted uppercase tracking-wide">{presentation.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-ink-muted">
|
||||
<div>
|
||||
<dt className="font-semibold text-ink">Replay cache size</dt>
|
||||
<dd>{profile.replay_cache_size}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-ink">Per-device rate limit</dt>
|
||||
<dd>{profile.rate_limit_disabled ? 'Disabled' : 'Active'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-ink">Trust anchors</dt>
|
||||
<dd>{profile.trust_anchors?.length ?? 0}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{profile.trust_anchors && profile.trust_anchors.length > 0 && (
|
||||
<details className="mt-3 text-xs text-ink-muted">
|
||||
<summary className="cursor-pointer font-semibold text-ink">Trust anchor details</summary>
|
||||
<table className="mt-2 w-full text-left">
|
||||
<thead>
|
||||
<tr className="text-[11px] text-ink-muted uppercase">
|
||||
<th className="py-1 pr-2">Subject</th>
|
||||
<th className="py-1 pr-2">Not after</th>
|
||||
<th className="py-1">Days to expiry</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{profile.trust_anchors.map(a => (
|
||||
<tr key={`${profile.path_id}-${a.subject}-${a.not_after}`} className="border-t border-surface-border">
|
||||
<td className="py-1 pr-2 font-mono">{a.subject || '(empty CN)'}</td>
|
||||
<td className="py-1 pr-2">{formatDateTime(a.not_after)}</td>
|
||||
<td className={`py-1 ${a.expired ? 'text-red-600 font-semibold' : ''}`}>
|
||||
{a.expired ? 'EXPIRED' : a.days_to_expiry}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentFailuresTable({ events }: { events: AuditEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-ink-muted px-4 py-6">
|
||||
No recent Intune-dispatched enrollment events. Counters stay at zero until the first device hits a SCEP profile with Intune enabled.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<table className="w-full text-sm" data-testid="recent-failures-table">
|
||||
<thead className="text-xs text-ink-muted uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="py-2 pl-4 pr-2 text-left">Timestamp</th>
|
||||
<th className="py-2 pr-2 text-left">Action</th>
|
||||
<th className="py-2 pr-2 text-left">Resource</th>
|
||||
<th className="py-2 pr-4 text-left">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map(e => (
|
||||
<tr key={e.id} className="border-t border-surface-border">
|
||||
<td className="py-2 pl-4 pr-2 font-mono text-xs">{formatDateTime(e.timestamp)}</td>
|
||||
<td className="py-2 pr-2">{e.action}</td>
|
||||
<td className="py-2 pr-2">{e.resource_type} · <code className="text-xs">{e.resource_id}</code></td>
|
||||
<td className="py-2 pr-4 text-xs text-ink-muted">
|
||||
{e.details ? Object.entries(e.details).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`).join(' · ') : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SCEPAdminPage() {
|
||||
const auth = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [reloadTarget, setReloadTarget] = useState<IntuneStatsSnapshot | null>(null);
|
||||
const [reloadError, setReloadError] = useState<string | undefined>(undefined);
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ['admin', 'scep', 'intune', 'stats'],
|
||||
queryFn: getAdminSCEPIntuneStats,
|
||||
enabled: !auth.authRequired || auth.admin, // skip the request entirely when non-admin
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Audit-log filter: every Intune-dispatched enrollment (success + failure)
|
||||
// emits action=scep_pkcsreq_intune (initial) or scep_renewalreq_intune
|
||||
// (renewal). The audit endpoint accepts a single action filter; we fetch
|
||||
// both server-side via two queries and merge client-side rather than
|
||||
// adding a comma-separated filter that would require backend changes.
|
||||
const auditPKCSQuery = useQuery({
|
||||
queryKey: ['audit', { action: 'scep_pkcsreq_intune' }],
|
||||
queryFn: () => getAuditEvents({ action: 'scep_pkcsreq_intune' }),
|
||||
enabled: !auth.authRequired || auth.admin,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const auditRenewalQuery = useQuery({
|
||||
queryKey: ['audit', { action: 'scep_renewalreq_intune' }],
|
||||
queryFn: () => getAuditEvents({ action: 'scep_renewalreq_intune' }),
|
||||
enabled: !auth.authRequired || auth.admin,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const reloadMutation = useMutation({
|
||||
mutationFn: (pathID: string) => reloadAdminSCEPIntuneTrust(pathID),
|
||||
onSuccess: () => {
|
||||
setReloadTarget(null);
|
||||
setReloadError(undefined);
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'scep', 'intune', 'stats'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setReloadError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (auth.authRequired && !auth.admin) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="SCEP Intune Monitoring" subtitle="Admin-only observability surface" />
|
||||
<div className="p-6">
|
||||
<ErrorState
|
||||
error={new Error('Admin access required: this page exposes per-profile trust anchor expiries and an admin-only reload action. Sign in with an admin-tagged API key to view it.')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (statsQuery.isLoading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="SCEP Intune Monitoring" subtitle="Per-profile dispatcher state" />
|
||||
<div className="p-6 text-sm text-ink-muted">Loading per-profile stats…</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (statsQuery.error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="SCEP Intune Monitoring" subtitle="Per-profile dispatcher state" />
|
||||
<div className="p-6">
|
||||
<ErrorState error={statsQuery.error as Error} onRetry={() => statsQuery.refetch()} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const profiles = statsQuery.data?.profiles ?? [];
|
||||
const events: AuditEvent[] = [
|
||||
...(auditPKCSQuery.data?.data ?? []),
|
||||
...(auditRenewalQuery.data?.data ?? []),
|
||||
]
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
.slice(0, 50);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="SCEP Intune Monitoring"
|
||||
subtitle={`${profiles.length} SCEP profile${profiles.length === 1 ? '' : 's'} configured · counters auto-refresh every 30s`}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => statsQuery.refetch()}
|
||||
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
|
||||
data-testid="refresh-stats-button"
|
||||
>
|
||||
Refresh now
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="p-6 overflow-y-auto">
|
||||
{profiles.length === 0 && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4">
|
||||
No SCEP profiles are configured. Set <code>CERTCTL_SCEP_ENABLED=true</code> and either the
|
||||
legacy single-profile env vars or <code>CERTCTL_SCEP_PROFILES=...</code> with the indexed
|
||||
per-profile family to register at least one endpoint.
|
||||
</div>
|
||||
)}
|
||||
{profiles.map(p => (
|
||||
<ProfileCard
|
||||
key={p.path_id || '(root)'}
|
||||
profile={p}
|
||||
onRequestReload={profile => {
|
||||
setReloadError(undefined);
|
||||
setReloadTarget(profile);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<section className="bg-surface border border-surface-border rounded-lg mt-6">
|
||||
<div className="px-4 py-3 border-b border-surface-border">
|
||||
<h3 className="text-sm font-semibold text-ink">
|
||||
Recent Intune-dispatched enrollments (last 50)
|
||||
</h3>
|
||||
<p className="text-xs text-ink-muted">
|
||||
Filtered to <code>action=scep_pkcsreq_intune</code> + <code>action=scep_renewalreq_intune</code>.
|
||||
Refreshes every 60s.
|
||||
</p>
|
||||
</div>
|
||||
{auditPKCSQuery.isLoading || auditRenewalQuery.isLoading ? (
|
||||
<p className="text-sm text-ink-muted px-4 py-6">Loading audit log…</p>
|
||||
) : (
|
||||
<RecentFailuresTable events={events} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{reloadTarget && (
|
||||
<ConfirmReloadModal
|
||||
profile={reloadTarget}
|
||||
onCancel={() => {
|
||||
setReloadTarget(null);
|
||||
setReloadError(undefined);
|
||||
}}
|
||||
onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)}
|
||||
pending={reloadMutation.isPending}
|
||||
errorMessage={reloadError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user