import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { useListParams } from '../hooks/useListParams'; import { useNavigate } from 'react-router-dom'; import { getCertificates, createCertificate, revokeCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates, bulkRenewCertificates, bulkReassignCertificates } from '../api/client'; import { useAuth } from '../components/AuthProvider'; import { REVOCATION_REASONS } from '../api/types'; import PageHeader from '../components/PageHeader'; import DataTable from '../components/DataTable'; import type { Column } from '../components/DataTable'; import StatusBadge from '../components/StatusBadge'; import ErrorState from '../components/ErrorState'; import { formatDate, daysUntil, expiryColor } from '../api/utils'; import type { Certificate } from '../api/types'; function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { const [form, setForm] = useState({ name: '', id: '', common_name: '', sans: '', environment: 'production', issuer_id: '', certificate_profile_id: '', owner_id: '', team_id: '', renewal_policy_id: '', tags: '', }); const [error, setError] = useState(''); const { data: profilesResp } = useQuery({ queryKey: ['profiles'], queryFn: () => getProfiles(), }); const { data: issuersResp } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers(), }); // C-001: owner_id, team_id, and renewal_policy_id are required by the // server (handler in internal/api/handler/certificates.go) and by OpenAPI. // Load the catalog so the user selects valid FKs instead of typing free-text // IDs that would 400 at the server. const { data: ownersResp } = useQuery({ queryKey: ['owners', 'form'], queryFn: () => getOwners({ per_page: '500' }), }); const { data: teamsResp } = useQuery({ queryKey: ['teams', 'form'], queryFn: () => getTeams({ per_page: '500' }), }); // G-1: swap from getPolicies (compliance rules, pol-*) to getRenewalPolicies // (lifecycle policies, rp-*). managed_certificates.renewal_policy_id FK // points at renewal_policies(id), so the dropdown must pull from that table // — the previous getPolicies call populated the dropdown with pol-* IDs that // would 400/23503 at the server. See also OnboardingWizard.tsx:603 and // CertificateDetailPage.tsx:169 for the sibling fixes. const { data: policiesResp } = useQuery({ queryKey: ['renewal-policies', 'form'], queryFn: () => getRenewalPolicies(1, 500), }); const profiles = profilesResp?.data || []; const issuers = issuersResp?.data || []; const owners = ownersResp?.data || []; const teams = teamsResp?.data || []; const policies = policiesResp?.data || []; const selectedProfile = profiles.find(p => p.id === form.certificate_profile_id); const ttlLabel = selectedProfile ? selectedProfile.max_ttl_seconds < 3600 ? `${Math.round(selectedProfile.max_ttl_seconds / 60)}m` : selectedProfile.max_ttl_seconds < 86400 ? `${Math.round(selectedProfile.max_ttl_seconds / 3600)}h` : `${Math.round(selectedProfile.max_ttl_seconds / 86400)}d` : null; const mutation = useTrackedMutation({ mutationFn: () => { const payload: Record = { ...form }; // Convert comma-separated SANs to array if (form.sans.trim()) { payload.sans = form.sans.split(',').map(s => s.trim()).filter(Boolean); } else { delete payload.sans; } // Convert comma-separated key=value tags to object if (form.tags.trim()) { const tags: Record = {}; form.tags.split(',').forEach(pair => { const [k, ...v] = pair.split('='); if (k?.trim()) tags[k.trim()] = v.join('=').trim(); }); payload.tags = tags; } else { delete payload.tags; } return createCertificate(payload); }, invalidates: [['certificates']], onSuccess: () => onSuccess(), onError: (err: Error) => setError(err.message), }); const inputClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"; const selectClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink"; return (
e.stopPropagation()}>

New Certificate

{error &&
{error}
}
setForm(f => ({ ...f, name: e.target.value }))} className={inputClass} placeholder="API Production Cert" />
setForm(f => ({ ...f, id: e.target.value }))} className={inputClass} placeholder="mc-api-prod (auto-generated if empty)" />
setForm(f => ({ ...f, common_name: e.target.value }))} className={inputClass} placeholder="api.example.com" />
setForm(f => ({ ...f, sans: e.target.value }))} className={inputClass} placeholder="api.example.com, api-v2.example.com" />
setForm(f => ({ ...f, tags: e.target.value }))} className={inputClass} placeholder="env=prod, team=platform, app=api" />

Comma-separated key=value pairs

); } function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose: () => void; onSuccess: () => void }) { const [reason, setReason] = useState('unspecified'); const [error, setError] = useState(''); const [running, setRunning] = useState(false); const [result, setResult] = useState<{ total_matched: number; total_revoked: number; total_skipped: number; total_failed: number; errors?: { certificate_id: string; error: string }[] } | null>(null); const handleRevoke = async () => { setRunning(true); setError(''); try { const res = await bulkRevokeCertificates({ reason, certificate_ids: ids }); setResult(res); if (res.total_failed === 0) { onSuccess(); } } catch (err) { setError(err instanceof Error ? err.message : 'Bulk revocation failed'); } finally { setRunning(false); } }; return (
e.stopPropagation()}>

Bulk Revoke

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

{error &&
{error}
} {result && (
Matched:{result.total_matched} Revoked:{result.total_revoked} Skipped:{result.total_skipped} Failed:{result.total_failed}
{result.errors && result.errors.length > 0 && (
{result.errors.map((e, i) =>
{e.certificate_id}: {e.error}
)}
)}
)}
{!result && ( )}
); } 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(), }); // L-2 closure (cat-l-8a1fb258a38a): pre-L-2 this looped // `await updateCertificate(id, { owner_id })` over the selection // (N HTTP round-trips). Post-L-2 it's a single POST to // /api/v1/certificates/bulk-reassign. The CI guardrail in // .github/workflows/ci.yml (`Forbidden client-side bulk-action loop // regression guard (L-1)`) catches reintroduction of the loop shape. const handleReassign = async () => { if (!ownerId) return; setRunning(true); setError(''); setProgress(0); try { const result = await bulkReassignCertificates({ certificate_ids: ids, owner_id: ownerId, }); setProgress(result.total_reassigned); if (result.total_failed > 0) { const first = result.errors?.[0]; setError( `${result.total_failed} of ${result.total_matched} failed${ first ? `: ${first.certificate_id} — ${first.error}` : '' }` ); } else { onSuccess(); } } catch (err) { setError(`Bulk reassignment failed: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setRunning(false); } }; 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(); // M-003: bulk revocation is admin-only. The backend rejects non-admin callers // with 403, but we also hide the button in the GUI to avoid a misleading // affordance. Authoritative gate remains server-side. const { admin } = useAuth(); // M-029 Pass 2 (Audit M-010): filter / sort / pagination state migrated // from 9 local useState hooks to useListParams — URL-resident state is // deep-linkable, browser-back-correct, and the hook auto-resets page // to 1 on filter / sort / pageSize change (preserving the F-1 contract // that previously had to be hand-rolled at every onChange site). // // F-1 closure (cat-e-610251c8f72d) preserved: the 8 operator-facing // filters (status / environment / issuer_id / owner_id / profile_id / // team_id / expires_before / sort) all flow through filters[] with // their existing keys. Default page size stays at 50 to match the // pre-migration F-1 baseline (the hook's global default is 25, but // the page-level default takes precedence). const { params: listParams, setPage, setPageSize, setFilter } = useListParams({ pageSize: 50 }); const statusFilter = listParams.filters.status ?? ''; const envFilter = listParams.filters.environment ?? ''; const issuerFilter = listParams.filters.issuer_id ?? ''; const ownerFilter = listParams.filters.owner_id ?? ''; const profileFilter = listParams.filters.profile_id ?? ''; const teamFilter = listParams.filters.team_id ?? ''; const expiresBefore = listParams.filters.expires_before ?? ''; const sortBy = listParams.filters.sort ?? ''; const page = listParams.page; const perPage = listParams.pageSize; 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 { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) }); const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) }); const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) }); // F-1 closure: hydrate the team filter dropdown. const { data: teamsFilterData } = useQuery({ queryKey: ['teams-filter'], queryFn: () => getTeams({ per_page: '100' }) }); const params: Record = {}; if (statusFilter) params.status = statusFilter; if (envFilter) params.environment = envFilter; if (issuerFilter) params.issuer_id = issuerFilter; if (ownerFilter) params.owner_id = ownerFilter; if (profileFilter) params.profile_id = profileFilter; if (teamFilter) params.team_id = teamFilter; if (expiresBefore) params.expires_before = expiresBefore; if (sortBy) params.sort = sortBy; // Pagination (F-1) — re-fetch on page / per_page change. params.page = String(page); params.per_page = String(perPage); const { data, isLoading, error, refetch } = useQuery({ queryKey: ['certificates', params], queryFn: () => getCertificates(params), refetchInterval: 30000, }); // L-1 closure (cat-l-fa0c1ac07ab5): pre-L-1 this looped // `await triggerRenewal(ids[i])` over the selection (N HTTP round- // trips × ~50–200ms each = 5–20s wedge for 100 selected certs). // Post-L-1 it's a single POST to /api/v1/certificates/bulk-renew; // the server resolves the criteria, applies status filters // (RenewalInProgress/Revoked/Archived/Expired all silent-skip), and // enqueues N renewal jobs server-side, returning a per-cert // {certificate_id, job_id} envelope. CI guardrail at // .github/workflows/ci.yml catches loop-shape regression. const handleBulkRenewal = async () => { const ids = Array.from(selectedIds); setBulkRenewProgress({ done: 0, total: ids.length, running: true }); try { const result = await bulkRenewCertificates({ certificate_ids: ids }); setBulkRenewProgress({ done: result.total_enqueued, total: result.total_matched, running: false, }); } catch { // surface as a "0 of N" terminal state — no retries. setBulkRenewProgress({ done: 0, total: ids.length, running: false }); } queryClient.invalidateQueries({ queryKey: ['certificates'] }); setSelectedIds(new Set()); setTimeout(() => setBulkRenewProgress(null), 5000); }; const columns: Column[] = [ { key: 'name', label: 'Certificate', render: (c) => (
{c.common_name}
{c.id}
), }, { key: 'status', label: 'Status', render: (c) => }, { key: 'expires', label: 'Expires', render: (c) => { const days = daysUntil(c.expires_at); return (
{formatDate(c.expires_at)}
{days <= 0 ? 'Expired' : `${days} days`}
); }, }, { key: 'last_renewal', label: 'Last Renewal', render: (c) => {c.last_renewal_at ? formatDate(c.last_renewal_at) : '—'} }, { key: 'last_deploy', label: 'Last Deploy', render: (c) => {c.last_deployment_at ? formatDate(c.last_deployment_at) : '—'} }, { key: 'issuer', label: 'Issuer', render: (c) => {c.issuer_id} }, { key: 'owner', label: 'Owner', render: (c) => {c.owner_id} }, ]; const selectedArray = Array.from(selectedIds); const hasSelection = selectedArray.length > 0; return ( <> setShowCreate(true)} className="btn btn-primary text-xs"> + New Certificate } /> {/* Bulk Action Bar */} {hasSelection && (
{selectedArray.length} selected
{admin && ( )}
)} {/* Bulk Renewal Success */} {bulkRenewProgress && !bulkRenewProgress.running && (
Triggered renewal for {bulkRenewProgress.done} certificate{bulkRenewProgress.done > 1 ? 's' : ''}.
)}
{/* F-1 closure (cat-e-610251c8f72d): team / expires_before / sort */} setFilter('expires_before', e.target.value)} title="Expires before (drives the 'expiring in N days' workflow)" className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" />
{error ? ( refetch()} /> ) : ( navigate(`/certificates/${c.id}`)} emptyMessage="No certificates found" selectable selectedKeys={selectedIds} onSelectionChange={setSelectedIds} pagination={{ page, perPage, total: data?.total ?? 0, onPageChange: setPage, // useListParams.setPageSize auto-drops the page param from // the URL (page resets to 1 implicitly), preserving the // F-1 contract without a manual setPage(1) call. onPerPageChange: setPageSize, }} /> )}
{showCreate && ( setShowCreate(false)} onSuccess={() => { setShowCreate(false); queryClient.invalidateQueries({ queryKey: ['certificates'] }); }} /> )} {showBulkRevoke && ( setShowBulkRevoke(false)} onSuccess={() => { setShowBulkRevoke(false); setSelectedIds(new Set()); queryClient.invalidateQueries({ queryKey: ['certificates'] }); }} /> )} {showBulkReassign && ( setShowBulkReassign(false)} onSuccess={() => { setShowBulkReassign(false); setSelectedIds(new Set()); queryClient.invalidateQueries({ queryKey: ['certificates'] }); }} /> )} ); }