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 (
+
+ )
+}
+
// ── 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 && (
+ }
+ onClick={handleOpenIssueCert}
+ >
+ Issue Certificate
+
+ )}
+
+
+
+
+
+
@@ -959,6 +1124,35 @@ export default function HostDetailPage() {
+
+ {/* Issue Certificate Dialog */}
+
+
+ {/* One-time key display dialog */}
+ setKeyDialogOpen(false)}
+ />
{/* Snackbar */}