import { useEffect, useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer, updateIssuer } from '../api/client'; 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 { formatDateTime } from '../api/utils'; import type { Issuer } from '../api/types'; import { issuerTypes, typeLabels, getIssuerCatalogStatus, type IssuerTypeConfig } from '../config/issuerTypes'; import TypeSelector from '../components/issuer/TypeSelector'; import ConfigForm from '../components/issuer/ConfigForm'; import ConfigDetailModal from '../components/issuer/ConfigDetailModal'; // Derive display status from backend `enabled` boolean. // // D-2 (diff-05x06-97fab8783a5c, master): pre-D-2 the fall-through chain // here was `issuer.status || 'Unknown'`, which always rendered 'Unknown' // because the Go-side struct never emitted a `status` field — the TS // interface comment claimed status was "derived from enabled" but no // derivation existed. Post-D-2 the phantom `Issuer.status` is gone and // this function is the canonical derivation site. `enabled` is a // required boolean on Go's Issuer struct so the `!== undefined` guard // is now belt-and-suspenders rather than load-bearing, but kept for // defensive rendering against malformed responses. function issuerStatus(issuer: Issuer): string { return issuer.enabled ? 'Enabled' : 'Disabled'; } export default function IssuersPage() { const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null); const [showCreateModal, setShowCreateModal] = useState(false); const [preselectedType, setPreselectedType] = useState(null); const [typeFilter, setTypeFilter] = useState(''); const [configModal, setConfigModal] = useState<{ title: string; config: Record } | null>(null); // B-1 master closure (cat-b-7a34f893a8f9): rename-only Edit affordance. // Pre-B-1 the only way to rename an issuer was delete-and-recreate, // which destroyed cert provenance and forced a re-encryption cycle // through internal/crypto/encryption.go for every cert under the // issuer. Type and credential blob are intentionally NOT editable here // — changing the underlying CA driver type would require // re-encrypting config under a different schema, and credentials are // stored encrypted at rest (we can't decrypt them client-side to // pre-populate). Operators who need to rotate credentials still // delete + recreate. Documented as a deferred follow-up in the L-1 // CHANGELOG entry. const [editingIssuer, setEditingIssuer] = useState(null); const { data, isLoading, error, refetch } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers(), }); // testIssuerConnection updates last_tested_at and test_status server-side; // invalidating ['issuers'] refetches the list so the timestamp + status // columns reflect the new probe state. The local setTestResult banner // still surfaces the immediate pass/fail to the operator. const testMutation = useTrackedMutation({ mutationFn: testIssuerConnection, invalidates: [['issuers']], onSuccess: (_data, id) => setTestResult({ id, ok: true, msg: 'Connection successful' }), onError: (err: Error, id) => setTestResult({ id, ok: false, msg: err.message }), }); const deleteMutation = useTrackedMutation({ mutationFn: deleteIssuer, invalidates: [['issuers']], }); const createMutation = useTrackedMutation({ mutationFn: (data: { name: string; type: string; config: Record }) => createIssuer(data), invalidates: [['issuers']], onSuccess: () => { setShowCreateModal(false); setPreselectedType(null); }, }); // B-1 master closure: updateIssuer is wired to the rename-only Edit // modal. Type and credential blob are NOT mutated here — see editingIssuer // docblock above. Sends `{ name, type, config }` to satisfy the backend // PUT contract (the handler decodes into a full domain.Issuer struct); // type + config are preserved by reading them from the editing target. const updateMutation = useTrackedMutation({ mutationFn: ({ id, data }: { id: string; data: Partial }) => updateIssuer(id, data), invalidates: [['issuers']], onSuccess: () => { setEditingIssuer(null); }, }); const catalogStatus = useMemo( () => getIssuerCatalogStatus(data?.data || []), [data?.data] ); // Filter issuers by type const filteredIssuers = useMemo(() => { if (!data?.data) return []; if (!typeFilter) return data.data; return data.data.filter(i => i.type === typeFilter); }, [data?.data, typeFilter]); const columns: Column[] = [ { key: 'name', label: 'Issuer', render: (i) => (
e.stopPropagation()}> {i.name}
{i.id}
), }, { key: 'type', label: 'Type', render: (i) => ( {typeLabels[i.type] || i.type} ), }, { key: 'status', label: 'Status', render: (i) => , }, { key: 'config', label: 'Config', render: (i) => { if (!i.config || Object.keys(i.config).length === 0) return ; return ( ); }, }, { key: 'created', label: 'Created', render: (i) => {formatDateTime(i.created_at)}, }, { key: 'actions', label: '', render: (i) => (
), }, ]; return ( <> { setPreselectedType(null); setShowCreateModal(true); }} className="px-4 py-2 bg-brand-600 text-white rounded font-medium hover:bg-brand-700 transition-colors text-sm" > + New Issuer } /> {testResult && (
{testResult.id}: {testResult.msg}
)}
{error ? ( refetch()} /> ) : ( <> {/* Issuer Type Catalog Cards */}

Issuer Types

{catalogStatus.map(({ type, status, count }) => ( { setPreselectedType(type.id); setShowCreateModal(true); }} onFilter={() => { // Match both the canonical id and aliases const filterValue = type.id === 'local' ? 'local' : type.id; setTypeFilter(prev => prev === filterValue ? '' : filterValue); }} /> ))}
{/* Configured Issuers Table */}

Configured Issuers

)}
{/* Config Detail Modal */} {configModal && ( setConfigModal(null)} /> )} {/* Create Issuer Modal */} {showCreateModal && ( { createMutation.mutate({ name, type, config }); }} onCancel={() => { setShowCreateModal(false); setPreselectedType(null); }} isSubmitting={createMutation.isPending} /> )} {/* B-1 closure: EditIssuerModal — rename-only. */} setEditingIssuer(null)} onSave={(name) => { if (!editingIssuer) return; updateMutation.mutate({ id: editingIssuer.id, data: { name, // Preserve type + config + enabled — the rename-only // contract. Credential blob stays encrypted at rest. type: editingIssuer.type, config: editingIssuer.config, enabled: editingIssuer.enabled, }, }); }} isSaving={updateMutation.isPending} error={updateMutation.error ? (updateMutation.error as Error).message : null} /> ); } // ─── EditIssuerModal — rename-only Edit modal (B-1) ───────────── // // Locked: type, config (credentials), enabled. Editable: name only. // The audit's "destructive rename workflow" complaint is specifically // about renames; B-1 closes that hazard. Credential rotation still // requires delete-and-recreate (see CHANGELOG B-1 known follow-ups). interface EditIssuerModalProps { issuer: Issuer | null; onClose: () => void; onSave: (name: string) => void; isSaving: boolean; error: string | null; } function EditIssuerModal({ issuer, onClose, onSave, isSaving, error }: EditIssuerModalProps) { const [name, setName] = useState(''); useEffect(() => { if (issuer) setName(issuer.name); }, [issuer]); if (!issuer) return null; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!name.trim()) return; onSave(name.trim()); }; return (
e.stopPropagation()}>

Edit Issuer

{issuer.id}

{error &&
{error}
}
setName(e.target.value)} required className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />

To change issuer type or rotate credentials, delete and recreate. See CHANGELOG B-1 known follow-ups.

); } // ─── Catalog Card ─────────────────────────────────────────────── interface CatalogCardProps { type: IssuerTypeConfig; status: 'connected' | 'available' | 'coming_soon'; count: number; onConfigure: () => void; onFilter: () => void; } function CatalogCard({ type, status, count, onConfigure, onFilter }: CatalogCardProps) { const statusConfig = { connected: { label: `${count} configured`, cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' }, available: { label: 'Available', cls: 'bg-brand-500/10 text-brand-400 border-brand-500/30' }, coming_soon: { label: 'Coming Soon', cls: 'bg-gray-500/10 text-gray-400 border-gray-500/30' }, }; const { label, cls } = statusConfig[status]; return (
{type.icon} {type.name}
{label}

{type.description}

{status === 'connected' && ( )} {status === 'available' && ( )}
); } // ─── Create Issuer Modal ──────────────────────────────────────── interface CreateIssuerModalProps { preselectedType: string | null; onSubmit: (name: string, type: string, config: Record) => void; onCancel: () => void; isSubmitting: boolean; } function CreateIssuerModal({ preselectedType, onSubmit, onCancel, isSubmitting }: CreateIssuerModalProps) { const [step, setStep] = useState<'type' | 'config'>(preselectedType ? 'config' : 'type'); const [selectedType, setSelectedType] = useState(preselectedType); const [form, setForm] = useState>(() => { if (preselectedType) { const tc = issuerTypes.find(t => t.id === preselectedType); const defaults: Record = {}; tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; }); return defaults; } return {}; }); const selectedTypeConfig = issuerTypes.find(t => t.id === selectedType); function handleTypeSelect(typeId: string) { setSelectedType(typeId); const tc = issuerTypes.find(t => t.id === typeId); const defaults: Record = {}; tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; }); setForm(defaults); setStep('config'); } function handleSubmit() { if (!selectedType || !form.name) return; const config = { ...form }; const name = config.name as string; delete config.name; onSubmit(name, selectedType, config); } return (
{/* Header */}

{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}

{/* Content */}
{step === 'type' ? ( ) : (
{/* Name field */}
setForm({ ...form, name: e.target.value })} placeholder="e.g., Production CA" className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors" />
{/* Type-specific fields via ConfigForm */} {selectedTypeConfig && ( setForm({ ...form, [key]: value })} /> )}
)}
{/* Footer */}
{step === 'config' && ( )} {step === 'config' && ( )}
); }