import { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client'; import { REVOCATION_REASONS } from '../api/types'; import PageHeader from '../components/PageHeader'; import StatusBadge from '../components/StatusBadge'; import ErrorState from '../components/ErrorState'; import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils'; import type { Job } from '../api/types'; function InfoRow({ label, value, editable, onEdit }: { label: string; value: React.ReactNode; editable?: boolean; onEdit?: () => void }) { return (
{label}
{value} {editable && onEdit && ( )}
); } // 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-200', active: 'bg-brand-400 ring-brand-200 animate-pulse', pending: 'bg-surface-muted ring-surface-border', failed: 'bg-red-500 ring-red-200', }; const lineStyles = { completed: 'bg-emerald-300', active: 'bg-brand-200', pending: 'bg-surface-border', failed: 'bg-red-300', }; const textStyles = { completed: 'text-emerald-600', active: 'text-brand-400', pending: 'text-ink-faint', failed: 'text-red-600', }; 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; }; // Verification step (M25: post-deployment TLS verification) const getVerifiedStatus = () => { if (!latestDeploy || latestDeploy.status !== 'Completed') return 'pending' as const; if (latestDeploy.verification_status === 'success') return 'completed' as const; if (latestDeploy.verification_status === 'failed') return 'failed' as const; if (latestDeploy.verification_status === 'skipped') return 'completed' as const; if (latestDeploy.verification_status === 'pending') return 'active' as const; return 'pending' as const; }; const getVerifiedTime = () => { if (!latestDeploy || latestDeploy.status !== 'Completed') return undefined; if (latestDeploy.verification_status === 'success' && latestDeploy.verified_at) { return `Verified ${formatDateTime(latestDeploy.verified_at)}`; } if (latestDeploy.verification_status === 'failed') { return latestDeploy.verification_error || 'Verification failed'; } if (latestDeploy.verification_status === 'skipped') return 'Skipped (best-effort)'; if (latestDeploy.verification_status === 'pending') return 'Awaiting verification'; 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; }; // Only show verification step if deployment has completed and verification data exists const showVerificationStep = latestDeploy?.status === 'Completed' && latestDeploy?.verification_status; return (

Lifecycle Timeline

{showVerificationStep && ( )}
); } 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'}
)}
); } export default function CertificateDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeploy, setShowDeploy] = useState(false); const [deployTargetId, setDeployTargetId] = useState(''); const [showRevoke, setShowRevoke] = useState(false); const [revokeReason, setRevokeReason] = useState('unspecified'); const [showExport, setShowExport] = useState(false); const [pkcs12Password, setPkcs12Password] = useState(''); const [exporting, setExporting] = useState(false); const { data: cert, isLoading, error, refetch } = useQuery({ queryKey: ['certificate', id], queryFn: () => getCertificate(id!), enabled: !!id, }); const { data: versions } = useQuery({ queryKey: ['certificate-versions', id], queryFn: () => getCertificateVersions(id!), enabled: !!id, }); const { data: targets } = useQuery({ queryKey: ['targets'], queryFn: () => getTargets(), enabled: showDeploy, }); // Fetch profile for EKU display (S/MIME, code signing badges) const { data: profile } = useQuery({ queryKey: ['profile', cert?.certificate_profile_id], queryFn: () => getProfile(cert!.certificate_profile_id), enabled: !!cert?.certificate_profile_id, }); const renewMutation = useMutation({ mutationFn: () => triggerRenewal(id!), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['certificate', id] }); queryClient.invalidateQueries({ queryKey: ['certificates'] }); }, }); const deployMutation = useMutation({ mutationFn: () => triggerDeployment(id!, deployTargetId), onSuccess: () => { setShowDeploy(false); setDeployTargetId(''); queryClient.invalidateQueries({ queryKey: ['certificate', id] }); }, }); const archiveMutation = useMutation({ mutationFn: () => archiveCertificate(id!), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['certificates'] }); navigate('/certificates'); }, }); const revokeMutation = useMutation({ mutationFn: () => revokeCertificate(id!, revokeReason), onSuccess: () => { setShowRevoke(false); setRevokeReason('unspecified'); queryClient.invalidateQueries({ queryKey: ['certificate', id] }); queryClient.invalidateQueries({ queryKey: ['certificates'] }); }, }); const handleExportPEM = async () => { setExporting(true); try { const blob = await downloadCertificatePEM(id!); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${cert?.common_name || id}.pem`; a.click(); URL.revokeObjectURL(url); } catch (err) { alert(`Export failed: ${err instanceof Error ? err.message : err}`); } finally { setExporting(false); } }; const handleExportPKCS12 = async () => { setExporting(true); try { const blob = await exportCertificatePKCS12(id!, pkcs12Password); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${cert?.common_name || id}.p12`; a.click(); URL.revokeObjectURL(url); setShowExport(false); setPkcs12Password(''); } catch (err) { alert(`Export failed: ${err instanceof Error ? err.message : err}`); } finally { setExporting(false); } }; if (isLoading) { return ( <>
Loading...
); } if (error || !cert) { return ( <> refetch()} /> ); } // Derive certificate metadata from latest version (backend doesn't include these on the cert object) const latestVersion = versions?.data?.[0]; const serialNumber = cert.serial_number || latestVersion?.serial_number; const fingerprintSha256 = cert.fingerprint_sha256 || latestVersion?.fingerprint_sha256; const issuedAt = cert.issued_at || latestVersion?.not_before; const days = daysUntil(cert.expires_at); const isRevoked = cert.status === 'Revoked'; const isArchived = cert.status === 'Archived'; const canRevoke = !isRevoked && !isArchived; return ( <> {canRevoke && ( )} {!isArchived && ( )}
} />
{renewMutation.isSuccess && (
Renewal triggered successfully. A renewal job has been created.
)} {renewMutation.isError && (
Failed to trigger renewal: {renewMutation.error instanceof Error ? renewMutation.error.message : 'Unknown error'}
)} {deployMutation.isSuccess && (
Deployment triggered. A deployment job has been created.
)} {deployMutation.isError && (
Failed to deploy: {deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
)} {archiveMutation.isError && (
Failed to archive: {archiveMutation.error instanceof Error ? archiveMutation.error.message : 'Unknown error'}
)} {revokeMutation.isSuccess && (
Certificate revoked successfully. It has been added to the CRL.
)} {revokeMutation.isError && (
Failed to revoke: {revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
)} {/* Revocation Banner */} {isRevoked && (
Certificate Revoked
Reason: {REVOCATION_REASONS.find(r => r.value === cert.revocation_reason)?.label || cert.revocation_reason || 'Unspecified'} {cert.revoked_at && <> · Revoked {formatDateTime(cert.revoked_at)}}
)} {/* Deployment Status Timeline */}
{/* Certificate Info */}

Certificate Details

} /> {cert.sans.map((san, i) => { const isEmail = san.includes('@'); return ( {i > 0 && ', '} {isEmail ? ( email {san} ) : san} ); })} ) : '—'} /> {fingerprintSha256.slice(0, 24)}... : '—' } /> {profile?.allowed_ekus && profile.allowed_ekus.length > 0 && ( {profile.allowed_ekus.map(eku => { const ekuStyles: Record = { serverAuth: 'bg-blue-50 text-blue-700', clientAuth: 'bg-green-50 text-green-700', emailProtection: 'bg-purple-50 text-purple-700', codeSigning: 'bg-amber-50 text-amber-700', timeStamping: 'bg-teal-50 text-teal-700', }; const ekuLabels: Record = { serverAuth: 'TLS Server', clientAuth: 'TLS Client', emailProtection: 'S/MIME', codeSigning: 'Code Signing', timeStamping: 'Timestamping', }; return ( {ekuLabels[eku] || eku} ); })}
} /> )}
{/* Lifecycle */}

Lifecycle

{formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`}) } /> {isRevoked && ( <> {cert.revoked_at ? formatDateTime(cert.revoked_at) : '—'} } /> {REVOCATION_REASONS.find(r => r.value === cert.revocation_reason)?.label || cert.revocation_reason || '—'} } /> )}
{/* Inline Policy Editor */} {/* Tags */} {cert.tags && Object.keys(cert.tags).length > 0 && (

Tags

{Object.entries(cert.tags).map(([k, v]) => ( {k}: {v} ))}
)} {/* Version History */}

Version History {versions?.data?.length ? `(${versions.data.length})` : ''}

{!versions?.data?.length ? (

No versions yet

) : (
{versions.data.map((v, idx) => (
Version {versions.data.length - idx} {idx === 0 && Current}
{v.serial_number}
{formatDate(v.not_before)} — {formatDate(v.not_after)}
{formatDateTime(v.created_at)}
{idx > 0 && cert?.status !== 'Archived' && cert?.status !== 'Revoked' && ( )}
))}
)}
{/* Deploy Modal */} {showDeploy && (
setShowDeploy(false)}>
e.stopPropagation()}>

Deploy Certificate

{deployMutation.isError && (
{deployMutation.error instanceof Error ? deployMutation.error.message : 'Unknown error'}
)}
)} {/* PKCS#12 Export Modal */} {showExport && (
setShowExport(false)}>
e.stopPropagation()}>

Export PKCS#12

Downloads a .p12 file containing the certificate chain. Private keys are not included (they remain on the agent).

setPkcs12Password(e.target.value)} placeholder="Leave empty for no encryption" className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4 focus:outline-none focus:border-brand-400" />
)} {/* Revoke Modal */} {showRevoke && (
setShowRevoke(false)}>
e.stopPropagation()}>

Revoke Certificate

This action cannot be undone. The certificate will be added to the CRL and marked as revoked.

{revokeMutation.isError && (
{revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
)}
)} ); }