From d59597b732cd9dd9d72a05c5d13eb7ef29196e40 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 5 May 2026 21:23:49 +0000 Subject: [PATCH] feat: add Issue Certificate button and dialog to HostDetailPage --- frontend/src/pages/HostDetailPage.tsx | 204 +++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index cab0e04..211e879 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -43,10 +43,12 @@ import { Remove as RemoveIcon, Schedule as ScheduleIcon, VpnKey as VpnKeyIcon, + ContentCopy as CopyIcon, } from '@mui/icons-material' import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client' import type { CreateHostRequest, + IssuedCert, MaintenanceWindow, WindowRecurrence, HealthCheckType, @@ -306,6 +308,110 @@ function HealthCheckFormDialog({ open, title, initial, onClose, onSubmit }: Heal ) } +// ── Create Host Form ────────────────────────────────────────────────────────── +// ── One-Time Key Display Dialog ─────────────────────────────────────────────── + +interface KeyDisplayDialogProps { + open: boolean + cert: IssuedCert | null + onClose: () => void +} + +function KeyDisplayDialog({ open, cert, onClose }: KeyDisplayDialogProps) { + const [copiedField, setCopiedField] = useState<'cert' | 'key' | null>(null) + + const handleCopy = async (text: string, field: 'cert' | 'key') => { + await navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + } + + return ( + + Certificate Issued — Save Your Private Key + + + This private key will NOT be shown again. Copy and store it securely + before closing this dialog. + + {cert && ( + <> + + Serial: {cert.serial_number}  |  Expires: {new Date(cert.expires_at).toLocaleDateString()} + + + + Certificate (cert.pem) + + + + + + {cert.cert_pem} + + + + + Private Key (key.pem) + + + + + + {cert.key_pem} + + + + )} + + + + + + ) +} + // ── Create Host Form ────────────────────────────────────────────────────────── function CreateHostForm() { @@ -451,6 +557,15 @@ export default function HostDetailPage() { // Delete health check dialog const [hcDeleteOpen, setHcDeleteOpen] = useState(false) const [hcDeleteTarget, setHcDeleteTarget] = useState(null) + + // Certificate state + const [certExists, setCertExists] = useState(false) + const [issueCertOpen, setIssueCertOpen] = useState(false) + const [issuedCert, setIssuedCert] = useState(null) + const [issueCertLoading, setIssueCertLoading] = useState(false) + const [keyDialogOpen, setKeyDialogOpen] = useState(false) + const [issueCertHostname, setIssueCertHostname] = useState('') + const [issueCertError, setIssueCertError] = useState(null) // ── Fetch host ──────────────────────────────────────────────────────────── useEffect(() => { @@ -460,6 +575,18 @@ export default function HostDetailPage() { .catch(() => setError('Host not found or access denied.')) .finally(() => setLoading(false)) }, [id]) + + // ── Check cert existence ─────────────────────────────────────────────────── + useEffect(() => { + if (!id || id === 'new') return + certsApi.list({ host_id: id }) + .then(res => { + const certs = res.data + const hasActive = Array.isArray(certs) && certs.some((c: { status: string }) => c.status === 'active') + setCertExists(hasActive) + }) + .catch(() => setCertExists(false)) + }, [id]) // ── Fetch windows ───────────────────────────────────────────────────────── const fetchWindows = useCallback(async () => { @@ -563,6 +690,32 @@ export default function HostDetailPage() { showSnack('No client certificate found for this host', 'error') } } + + // ── Issue client certificate ────────────────────────────────────────────── + const handleOpenIssueCert = () => { + setIssueCertHostname(String(host?.fqdn ?? '')) + setIssueCertError(null) + setIssueCertOpen(true) + } + + const handleIssueCertSubmit = async () => { + if (!id || !issueCertHostname.trim()) { setIssueCertError('Hostname is required'); return } + setIssueCertLoading(true) + setIssueCertError(null) + try { + const res = await certsApi.issue(id, issueCertHostname.trim()) + setIssuedCert(res.data) + setIssueCertOpen(false) + setKeyDialogOpen(true) + setCertExists(true) + } catch (e: unknown) { + const msg = (e as { response?: { data?: { error?: { message?: string } } } }) + ?.response?.data?.error?.message ?? 'Failed to issue certificate' + setIssueCertError(msg) + } finally { + setIssueCertLoading(false) + } + } // ── Create health check ────────────────────────────────────────────────── const handleHcCreateSubmit = async (values: HealthCheckFormValues) => { @@ -684,11 +837,23 @@ export default function HostDetailPage() { {String(host?.fqdn ?? '')} - - - - - + + {!certExists && ( + + )} + + + + + + @@ -959,6 +1124,35 @@ export default function HostDetailPage() { + + {/* Issue Certificate Dialog */} + setIssueCertOpen(false)} maxWidth="sm" fullWidth> + Issue Client Certificate + + {issueCertError && {issueCertError}} + setIssueCertHostname(e.target.value)} + required + fullWidth + helperText="Common name for the certificate (usually the host FQDN)" + /> + + + + + + + + {/* One-time key display dialog */} + setKeyDialogOpen(false)} + /> {/* Snackbar */}