import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getTeams, getRenewalPolicies, getProfiles, getIssuers, bulkRevokeCertificates } 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 = useMutation({ 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); }, 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(), }); 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(); // 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(); const [statusFilter, setStatusFilter] = useState(''); const [envFilter, setEnvFilter] = useState(''); const [issuerFilter, setIssuerFilter] = useState(''); const [ownerFilter, setOwnerFilter] = useState(''); const [profileFilter, setProfileFilter] = 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 { 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' }) }); 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; const { data, isLoading, error, refetch } = useQuery({ queryKey: ['certificates', params], queryFn: () => getCertificates(params), 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', 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' : ''}.
)}
{error ? ( refetch()} /> ) : ( navigate(`/certificates/${c.id}`)} emptyMessage="No certificates found" selectable selectedKeys={selectedIds} onSelectionChange={setSelectedIds} /> )}
{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'] }); }} /> )} ); }