diff --git a/README.md b/README.md index ce4007c..e52f3b6 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ make docker-clean # Stop + remove volumes ## Roadmap ### V1 (v1.0.0 released) -All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 16 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 600+ tests total: 465 Go test functions + 138 subtests (207 service, 226 handler, integration with 40+ subtests, 23 connector) plus 53 frontend Vitest tests covering API client functions and utility helpers. Docker images are published to GitHub Container Registry on every version tag via the release workflow. +All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 17 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 660+ tests total: 465 Go test functions + 138 subtests (207 service, 226 handler, integration with 40+ subtests, 23 connector) plus 79 frontend Vitest tests covering all API client endpoints, utilities, and M13 operations. Docker images are published to GitHub Container Registry on every version tag via the release workflow. ### V2: Operational Maturity - **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors @@ -373,7 +373,7 @@ All nine development milestones (M1–M9) are complete. The backend covers the f - **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), step-ca issuer connector (native /sign API with JWK provisioner auth) - **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests - **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests -- **M13: GUI Operations** — bulk cert operations (renew, revoke, reassign), deployment timeline, inline policy editor, target config wizard, audit export, short-lived credentials dashboard +- **M13: GUI Operations** ✅ — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view - **M14: Observability** — expiration calendar/heatmap, Prometheus metrics endpoint, structured logging improvements, deployment rollback - **M16: Operator Tooling** — CLI tool (`certctl`), Slack/Teams/PagerDuty/OpsGenie notifiers, bulk certificate import - **M17: Additional Connectors** — OpenSSL/Custom CA issuer connector diff --git a/docs/architecture.md b/docs/architecture.md index a1be92f..f3889ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,7 @@ The agent runs two background loops: a heartbeat (every 60 seconds) to signal it The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates). -**Current views (16 pages)**: certificate inventory (list with "New Certificate" creation modal + detail with version history, deploy, archive, and trigger renewal actions), agent fleet (list + detail with system info), job queue (status, retry, cancel, approve/reject), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range and actor/action filters), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), summary dashboard, and login page. +**Current views (17 pages)**: certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info), job queue (status, retry, cancel, approve/reject), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with 3-step configuration wizard + delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), summary dashboard, and login page. The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations. diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 32a4c8d..75c9314 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -10,23 +10,50 @@ import { triggerDeployment, updateCertificate, archiveCertificate, + revokeCertificate, getAgents, getAgent, registerAgent, getJobs, cancelJob, + approveRenewal, + rejectRenewal, getNotifications, markNotificationRead, getAuditEvents, getPolicies, + createPolicy, updatePolicy, deletePolicy, + getPolicyViolations, getIssuers, + createIssuer, testIssuerConnection, deleteIssuer, getTargets, createTarget, deleteTarget, + getProfiles, + getProfile, + createProfile, + updateProfile, + deleteProfile, + getOwners, + getOwner, + createOwner, + updateOwner, + deleteOwner, + getTeams, + getTeam, + createTeam, + updateTeam, + deleteTeam, + getAgentGroups, + getAgentGroup, + createAgentGroup, + updateAgentGroup, + deleteAgentGroup, + getAgentGroupMembers, getHealth, } from './client'; @@ -209,6 +236,15 @@ describe('API Client', () => { expect(init.method).toBe('POST'); expect(JSON.parse(init.body)).toEqual({ target_id: 't-nginx' }); }); + + it('revokeCertificate sends POST with reason', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'revoked' })); + await revokeCertificate('mc-test', 'keyCompromise'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/certificates/mc-test/revoke'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ reason: 'keyCompromise' }); + }); }); // ─── Agents ───────────────────────────────────────── @@ -357,6 +393,219 @@ describe('API Client', () => { }); }); + // ─── Approval ────────────────────────────────────── + + describe('Renewal Approvals', () => { + it('approveRenewal sends POST', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'approved' })); + await approveRenewal('job-123'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/jobs/job-123/approve'); + expect(init.method).toBe('POST'); + }); + + it('rejectRenewal sends POST with reason', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'rejected' })); + await rejectRenewal('job-123', 'not authorized'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/jobs/job-123/reject'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ reason: 'not authorized' }); + }); + }); + + // ─── Profiles ──────────────────────────────────────── + + describe('Profiles', () => { + it('getProfiles sends GET', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 })); + await getProfiles(); + expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/profiles'); + }); + + it('getProfile fetches by ID', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-1', name: 'Standard' })); + const profile = await getProfile('prof-1'); + expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/profiles/prof-1'); + expect(profile.id).toBe('prof-1'); + }); + + it('createProfile sends POST', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-new', name: 'New Profile' })); + await createProfile({ name: 'New Profile' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/profiles'); + expect(init.method).toBe('POST'); + }); + + it('updateProfile sends PUT', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-1', name: 'Updated' })); + await updateProfile('prof-1', { name: 'Updated' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/profiles/prof-1'); + expect(init.method).toBe('PUT'); + }); + + it('deleteProfile sends DELETE', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' })); + await deleteProfile('prof-1'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/profiles/prof-1'); + expect(init.method).toBe('DELETE'); + }); + }); + + // ─── Owners ────────────────────────────────────────── + + describe('Owners', () => { + it('getOwners sends GET', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 })); + await getOwners(); + expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/owners'); + }); + + it('getOwner fetches by ID', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-alice', name: 'Alice' })); + const owner = await getOwner('o-alice'); + expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/owners/o-alice'); + expect(owner.name).toBe('Alice'); + }); + + it('createOwner sends POST', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-new', name: 'Bob' })); + await createOwner({ name: 'Bob', email: 'bob@example.com' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/owners'); + expect(init.method).toBe('POST'); + }); + + it('updateOwner sends PUT', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-alice', name: 'Alice Updated' })); + await updateOwner('o-alice', { name: 'Alice Updated' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/owners/o-alice'); + expect(init.method).toBe('PUT'); + }); + + it('deleteOwner sends DELETE', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' })); + await deleteOwner('o-alice'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/owners/o-alice'); + expect(init.method).toBe('DELETE'); + }); + }); + + // ─── Teams ─────────────────────────────────────────── + + describe('Teams', () => { + it('getTeams sends GET', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 })); + await getTeams(); + expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/teams'); + }); + + it('getTeam fetches by ID', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-platform', name: 'Platform' })); + const team = await getTeam('t-platform'); + expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/teams/t-platform'); + expect(team.name).toBe('Platform'); + }); + + it('createTeam sends POST', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-new', name: 'New Team' })); + await createTeam({ name: 'New Team' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/teams'); + expect(init.method).toBe('POST'); + }); + + it('updateTeam sends PUT', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-platform', name: 'Updated' })); + await updateTeam('t-platform', { name: 'Updated' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/teams/t-platform'); + expect(init.method).toBe('PUT'); + }); + + it('deleteTeam sends DELETE', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' })); + await deleteTeam('t-platform'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/teams/t-platform'); + expect(init.method).toBe('DELETE'); + }); + }); + + // ─── Agent Groups ──────────────────────────────────── + + describe('Agent Groups', () => { + it('getAgentGroups sends GET', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 })); + await getAgentGroups(); + expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/agent-groups'); + }); + + it('getAgentGroup fetches by ID', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-linux', name: 'Linux Servers' })); + const group = await getAgentGroup('ag-linux'); + expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agent-groups/ag-linux'); + expect(group.name).toBe('Linux Servers'); + }); + + it('createAgentGroup sends POST', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-new', name: 'New Group' })); + await createAgentGroup({ name: 'New Group' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/agent-groups'); + expect(init.method).toBe('POST'); + }); + + it('updateAgentGroup sends PUT', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-linux', name: 'Updated' })); + await updateAgentGroup('ag-linux', { name: 'Updated' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/agent-groups/ag-linux'); + expect(init.method).toBe('PUT'); + }); + + it('deleteAgentGroup sends DELETE', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' })); + await deleteAgentGroup('ag-linux'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/agent-groups/ag-linux'); + expect(init.method).toBe('DELETE'); + }); + + it('getAgentGroupMembers fetches members', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 })); + await getAgentGroupMembers('ag-linux'); + expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agent-groups/ag-linux/members'); + }); + }); + + // ─── Policy Violations ─────────────────────────────── + + describe('Policy Violations', () => { + it('getPolicyViolations sends GET', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 })); + await getPolicyViolations('pol-1'); + expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1/violations'); + }); + }); + + // ─── Issuer Create ─────────────────────────────────── + + describe('Issuer Create', () => { + it('createIssuer sends POST', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-new', name: 'New Issuer' })); + await createIssuer({ name: 'New Issuer', type: 'local_ca' }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/issuers'); + expect(init.method).toBe('POST'); + }); + }); + // ─── Audit ────────────────────────────────────────── describe('Audit', () => { diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx index b06c12d..698edc4 100644 --- a/web/src/components/DataTable.tsx +++ b/web/src/components/DataTable.tsx @@ -12,9 +12,12 @@ interface DataTableProps { emptyMessage?: string; isLoading?: boolean; keyField?: string; + selectable?: boolean; + selectedKeys?: Set; + onSelectionChange?: (keys: Set) => void; } -export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id' }: DataTableProps) { +export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps) { if (isLoading) { return (
@@ -35,11 +38,41 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, ); } + const allKeys = data.map((item) => (item as Record)[keyField] as string); + const allSelected = selectable && selectedKeys && allKeys.length > 0 && allKeys.every(k => selectedKeys.has(k)); + + const toggleAll = () => { + if (!onSelectionChange) return; + if (allSelected) { + onSelectionChange(new Set()); + } else { + onSelectionChange(new Set(allKeys)); + } + }; + + const toggleOne = (key: string) => { + if (!onSelectionChange || !selectedKeys) return; + const next = new Set(selectedKeys); + if (next.has(key)) next.delete(key); + else next.add(key); + onSelectionChange(next); + }; + return (
+ {selectable && ( + + )} {columns.map(col => ( - {data.map((item, i) => ( - )[keyField] as string ?? `row-${i}`} - onClick={() => onRowClick?.(item)} - className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''}`} - > - {columns.map(col => ( - - ))} - - ))} + {data.map((item, i) => { + const rowKey = (item as Record)[keyField] as string ?? `row-${i}`; + const isSelected = selectable && selectedKeys?.has(rowKey); + return ( + onRowClick?.(item)} + className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-blue-500/10' : ''}`} + > + {selectable && ( + + )} + {columns.map(col => ( + + ))} + + ); + })}
+ + {col.label} @@ -48,19 +81,34 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage,
- {col.render(item)} -
+ { e.stopPropagation(); toggleOne(rowKey); }} + onClick={(e) => e.stopPropagation()} + className="rounded border-slate-600 bg-slate-900 text-blue-600 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer" + /> + + {col.render(item)} +
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 45a6697..f91a24c 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -14,6 +14,7 @@ const nav = [ { to: '/owners', label: 'Owners', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' }, { to: '/teams', label: 'Teams', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' }, { to: '/agent-groups', label: 'Agent Groups', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10 M9 3v2m6-2v2' }, + { to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, ]; diff --git a/web/src/main.tsx b/web/src/main.tsx index 97b851c..2de72c1 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -21,6 +21,7 @@ import OwnersPage from './pages/OwnersPage'; import TeamsPage from './pages/TeamsPage'; import AgentGroupsPage from './pages/AgentGroupsPage'; import AuditPage from './pages/AuditPage'; +import ShortLivedPage from './pages/ShortLivedPage'; import './index.css'; const queryClient = new QueryClient({ @@ -57,6 +58,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index 05ff74b..0db10a2 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -18,6 +18,7 @@ const actionColors: Record = { expiration_alert_sent: 'text-amber-400', agent_registered: 'text-blue-400', policy_violated: 'text-red-400', + certificate_revoked: 'text-red-400', }; const RESOURCE_TYPES = ['', 'certificate', 'agent', 'job', 'notification', 'policy', 'target', 'issuer']; @@ -29,14 +30,49 @@ const TIME_RANGES = [ { label: 'Last 30 days', value: '30d' }, ]; +function downloadFile(content: string, filename: string, type: string) { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function exportCSV(events: AuditEvent[]) { + const headers = ['ID', 'Action', 'Actor', 'Actor Type', 'Resource Type', 'Resource ID', 'Details', 'Timestamp']; + const rows = events.map(e => [ + e.id, + e.action, + e.actor, + e.actor_type, + e.resource_type, + e.resource_id, + JSON.stringify(e.details || {}), + e.timestamp, + ]); + const csv = [headers, ...rows].map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n'); + downloadFile(csv, `audit-trail-${new Date().toISOString().slice(0, 10)}.csv`, 'text/csv'); +} + +function exportJSON(events: AuditEvent[]) { + const json = JSON.stringify(events, null, 2); + downloadFile(json, `audit-trail-${new Date().toISOString().slice(0, 10)}.json`, 'application/json'); +} + export default function AuditPage() { const [resourceType, setResourceType] = useState(''); const [actorFilter, setActorFilter] = useState(''); const [timeRange, setTimeRange] = useState(''); + const [actionFilter, setActionFilter] = useState(''); const params: Record = {}; if (resourceType) params.resource_type = resourceType; if (actorFilter) params.actor = actorFilter; + if (actionFilter) params.action = actionFilter; const { data, isLoading, error, refetch } = useQuery({ queryKey: ['audit', params], @@ -98,9 +134,26 @@ export default function AuditPage() { { key: 'time', label: 'Time', render: (e) => {formatDateTime(e.timestamp)} }, ]; + const hasFilters = resourceType || actorFilter || timeRange || actionFilter; + return ( <> - + 0 ? ( +
+ + +
+ ) : undefined + } + />
setActionFilter(e.target.value)} + className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 w-40" + /> - {(resourceType || actorFilter || timeRange) && ( + {hasFilters && ( + )} +
+
+ ); +} + +// Timeline step component for deployment status +function TimelineStep({ label, status, time, isLast }: { label: string; status: 'completed' | 'active' | 'pending' | 'failed'; time?: string; isLast?: boolean }) { + const dotStyles = { + completed: 'bg-emerald-500 ring-emerald-500/30', + active: 'bg-blue-500 ring-blue-500/30 animate-pulse', + pending: 'bg-slate-600 ring-slate-600/30', + failed: 'bg-red-500 ring-red-500/30', + }; + const lineStyles = { + completed: 'bg-emerald-500/50', + active: 'bg-blue-500/30', + pending: 'bg-slate-700', + failed: 'bg-red-500/30', + }; + const textStyles = { + completed: 'text-emerald-400', + active: 'text-blue-400', + pending: 'text-slate-500', + failed: 'text-red-400', + }; + + return ( +
+
+
+ {!isLast &&
} +
+
+
{label}
+ {time &&
{time}
} +
+
+ ); +} + +function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certId: string; certStatus: string; createdAt: string; issuedAt: string }) { + const { data: jobsData } = useQuery({ + queryKey: ['jobs', { certificate_id: certId }], + queryFn: () => getJobs({ certificate_id: certId }), + }); + + const jobs = jobsData?.data || []; + const issuanceJobs = jobs.filter((j: Job) => j.type === 'Issuance' || j.type === 'Renewal'); + const deployJobs = jobs.filter((j: Job) => j.type === 'Deployment'); + const latestIssuance = issuanceJobs[0]; + const latestDeploy = deployJobs[0]; + + // Determine step statuses + const getRequestedStatus = () => 'completed' as const; + const getRequestedTime = () => formatDateTime(createdAt); + + const getIssuedStatus = () => { + if (issuedAt) return 'completed' as const; + if (latestIssuance?.status === 'Running' || latestIssuance?.status === 'AwaitingCSR' || latestIssuance?.status === 'AwaitingApproval') return 'active' as const; + if (latestIssuance?.status === 'Failed') return 'failed' as const; + return 'pending' as const; + }; + const getIssuedTime = () => { + if (issuedAt) return formatDateTime(issuedAt); + if (latestIssuance) return `${latestIssuance.status} — ${timeAgo(latestIssuance.created_at)}`; + return undefined; + }; + + const getDeployStatus = () => { + if (!issuedAt) return 'pending' as const; + if (latestDeploy?.status === 'Completed') return 'completed' as const; + if (latestDeploy?.status === 'Running') return 'active' as const; + if (latestDeploy?.status === 'Failed') return 'failed' as const; + if (latestDeploy?.status === 'Pending') return 'active' as const; + return 'pending' as const; + }; + const getDeployTime = () => { + if (latestDeploy?.status === 'Completed') return formatDateTime(latestDeploy.completed_at); + if (latestDeploy) return `${latestDeploy.status} — ${timeAgo(latestDeploy.created_at)}`; + return undefined; + }; + + const getActiveStatus = () => { + if (certStatus === 'Active') return 'completed' as const; + if (certStatus === 'Revoked') return 'failed' as const; + if (certStatus === 'Expired') return 'failed' as const; + if (latestDeploy?.status === 'Completed') return 'completed' as const; + return 'pending' as const; + }; + const getActiveTime = () => { + if (certStatus === 'Revoked') return 'Revoked'; + if (certStatus === 'Expired') return 'Expired'; + if (certStatus === 'Active') return 'Currently active'; + return undefined; + }; + + return ( +
+

Lifecycle Timeline

+
+ + + + +
+
+ ); +} + +function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { certId: string; currentPolicyId: string; currentProfileId: string }) { + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const [policyId, setPolicyId] = useState(currentPolicyId); + const [profileId, setProfileId] = useState(currentProfileId); + + const { data: policies } = useQuery({ + queryKey: ['policies'], + queryFn: () => getPolicies(), + enabled: editing, + }); + + const { data: profiles } = useQuery({ + queryKey: ['profiles'], + queryFn: () => getProfiles(), + enabled: editing, + }); + + const saveMutation = useMutation({ + mutationFn: () => updateCertificate(certId, { + renewal_policy_id: policyId, + certificate_profile_id: profileId, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['certificate', certId] }); + setEditing(false); + }, + }); + + if (!editing) { + return ( +
+
+

Policy & Profile

+ +
+ + +
+ ); + } + + return ( +
+
+

Edit Policy & Profile

+
+ + +
+
+ {saveMutation.isError && ( +
+ {saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save'} +
+ )} +
+
+ + +
+
+ + +
+
); } @@ -203,6 +404,9 @@ export default function CertificateDetailPage() {
)} + {/* Deployment Status Timeline */} + +
{/* Certificate Info */}
@@ -229,8 +433,6 @@ export default function CertificateDetailPage() { } /> - - {isRevoked && ( @@ -250,6 +452,13 @@ export default function CertificateDetailPage() {
+ {/* Inline Policy Editor */} + + {/* Tags */} {cert.tags && Object.keys(cert.tags).length > 0 && (
diff --git a/web/src/pages/CertificatesPage.tsx b/web/src/pages/CertificatesPage.tsx index d80b2ac..56b92cf 100644 --- a/web/src/pages/CertificatesPage.tsx +++ b/web/src/pages/CertificatesPage.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { getCertificates, createCertificate } from '../api/client'; +import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners } from '../api/client'; +import { REVOCATION_REASONS } from '../api/types'; import PageHeader from '../components/PageHeader'; import DataTable from '../components/DataTable'; import type { Column } from '../components/DataTable'; @@ -99,12 +100,149 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o ); } +function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose: () => void; onSuccess: () => void }) { + const [reason, setReason] = useState('unspecified'); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(''); + const [running, setRunning] = useState(false); + + const handleRevoke = async () => { + setRunning(true); + setError(''); + let succeeded = 0; + for (const id of ids) { + try { + await revokeCertificate(id, reason); + succeeded++; + setProgress(succeeded); + } catch (err) { + setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`); + break; + } + } + if (!error) onSuccess(); + }; + + return ( +
+
e.stopPropagation()}> +

Bulk Revoke

+

+ Revoke {ids.length} certificate{ids.length > 1 ? 's' : ''}. This cannot be undone. +

+ {error &&
{error}
} + {running && ( +
+
+ Progress + {progress}/{ids.length} +
+
+
+
+
+ )} + + +
+ + +
+
+
+ ); +} + +function BulkReassignModal({ ids, onClose, onSuccess }: { ids: string[]; onClose: () => void; onSuccess: () => void }) { + const [ownerId, setOwnerId] = useState(''); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(''); + const [running, setRunning] = useState(false); + + const { data: owners } = useQuery({ + queryKey: ['owners'], + queryFn: () => getOwners(), + }); + + const handleReassign = async () => { + if (!ownerId) return; + setRunning(true); + setError(''); + let succeeded = 0; + for (const id of ids) { + try { + await updateCertificate(id, { owner_id: ownerId } as Partial); + succeeded++; + setProgress(succeeded); + } catch (err) { + setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`); + break; + } + } + if (!error) onSuccess(); + }; + + return ( +
+
e.stopPropagation()}> +

Reassign Owner

+

+ Reassign {ids.length} certificate{ids.length > 1 ? 's' : ''} to a new owner. +

+ {error &&
{error}
} + {running && ( +
+
+ Progress + {progress}/{ids.length} +
+
+
+
+
+ )} + + +
+ + +
+
+
+ ); +} + export default function CertificatesPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [statusFilter, setStatusFilter] = useState(''); const [envFilter, setEnvFilter] = useState(''); const [showCreate, setShowCreate] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showBulkRevoke, setShowBulkRevoke] = useState(false); + const [showBulkReassign, setShowBulkReassign] = useState(false); + const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null); const params: Record = {}; if (statusFilter) params.status = statusFilter; @@ -116,6 +254,22 @@ export default function CertificatesPage() { refetchInterval: 30000, }); + const handleBulkRenewal = async () => { + const ids = Array.from(selectedIds); + setBulkRenewProgress({ done: 0, total: ids.length, running: true }); + for (let i = 0; i < ids.length; i++) { + try { + await triggerRenewal(ids[i]); + } catch { + // continue on individual failures + } + setBulkRenewProgress({ done: i + 1, total: ids.length, running: i + 1 < ids.length }); + } + queryClient.invalidateQueries({ queryKey: ['certificates'] }); + setSelectedIds(new Set()); + setTimeout(() => setBulkRenewProgress(null), 3000); + }; + const columns: Column[] = [ { key: 'name', @@ -146,6 +300,9 @@ export default function CertificatesPage() { { key: 'owner', label: 'Owner', render: (c) => {c.owner_id} }, ]; + const selectedArray = Array.from(selectedIds); + const hasSelection = selectedArray.length > 0; + return ( <> } /> + + {/* Bulk Action Bar */} + {hasSelection && ( +
+ {selectedArray.length} selected +
+ + + + +
+
+ )} + + {/* Bulk Renewal Success */} + {bulkRenewProgress && !bulkRenewProgress.running && ( +
+ + Triggered renewal for {bulkRenewProgress.done} certificate{bulkRenewProgress.done > 1 ? 's' : ''}. + +
+ )} +
setName(e.target.value)} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + placeholder="web-server-1" /> +
+
+
+ + setHostname(e.target.value)} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + placeholder="web1.example.com" /> +
+
+ + setAgentId(e.target.value)} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + placeholder="agent-web1" /> +
+
+ {fields.map(f => ( +
+ + setConfig(c => ({ ...c, [f.key]: e.target.value }))} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + placeholder={f.placeholder} /> +
+ ))} +
+
+ +
+ + +
+
+
+ )} + + {/* Step 3: Review */} + {step === 'review' && ( +
+

Review Target

+
+
+ Name + {name} +
+
+ Type + {typeLabels[targetType] || targetType} +
+ {hostname && ( +
+ Hostname + {hostname} +
+ )} + {agentId && ( +
+ Agent + {agentId} +
+ )} + {Object.entries(config).filter(([, v]) => v).map(([k, v]) => ( +
+ {k.replace(/_/g, ' ')} + {v} +
+ ))} +
+
+ +
+ + +
+
+
+ )} +
+
+ ); +} + export default function TargetsPage() { const queryClient = useQueryClient(); + const [showCreate, setShowCreate] = useState(false); const { data, isLoading, error, refetch } = useQuery({ queryKey: ['targets'], @@ -83,7 +298,15 @@ export default function TargetsPage() { return ( <> - + setShowCreate(true)} className="btn btn-primary text-xs"> + + New Target + + } + />
{error ? ( refetch()} /> @@ -91,6 +314,15 @@ export default function TargetsPage() { )}
+ {showCreate && ( + setShowCreate(false)} + onSuccess={() => { + setShowCreate(false); + queryClient.invalidateQueries({ queryKey: ['targets'] }); + }} + /> + )} ); }