diff --git a/internal/service/issuer.go b/internal/service/issuer.go index 0a1a334..7e7e567 100644 --- a/internal/service/issuer.go +++ b/internal/service/issuer.go @@ -536,6 +536,11 @@ func (s *IssuerService) CreateIssuer(iss domain.Issuer) (*domain.Issuer, error) if iss.Source == "" { iss.Source = "database" } + // GUI-created issuers should be enabled by default. + // Go's bool zero value is false, which overrides the DB default when explicitly inserted. + if iss.Source == "database" && !iss.Enabled { + iss.Enabled = true + } // Encrypt config if len(iss.Config) > 0 { diff --git a/internal/service/target.go b/internal/service/target.go index 055e602..6a24d05 100644 --- a/internal/service/target.go +++ b/internal/service/target.go @@ -284,6 +284,11 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De if target.Source == "" { target.Source = "database" } + // GUI-created targets should be enabled by default. + // Go's bool zero value is false, which overrides the DB default when explicitly inserted. + if target.Source == "database" && !target.Enabled { + target.Enabled = true + } // Encrypt config if len(target.Config) > 0 { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index aa049d9..84b124b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -169,6 +169,9 @@ export const getNotifications = (params: Record = {}) => { return fetchJSON>(`${BASE}/notifications?${qs}`); }; +export const getNotification = (id: string) => + fetchJSON(`${BASE}/notifications/${id}`); + export const markNotificationRead = (id: string) => fetchJSON<{ message: string }>(`${BASE}/notifications/${id}/read`, { method: 'POST' }); @@ -178,6 +181,9 @@ export const getAuditEvents = (params: Record = {}) => { return fetchJSON>(`${BASE}/audit?${qs}`); }; +export const getAuditEvent = (id: string) => + fetchJSON(`${BASE}/audit/${id}`); + // Policies export const getPolicies = (params: Record = {}) => { const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 5e19d04..25decfe 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -10,11 +10,11 @@ export interface Certificate { team_id: string; renewal_policy_id: string; certificate_profile_id: string; - serial_number: string; - fingerprint: string; - key_algorithm: string; - key_size: number; - issued_at: string; + serial_number?: string; + fingerprint_sha256?: string; + key_algorithm?: string; + key_size?: number; + issued_at?: string; expires_at: string; revoked_at?: string; revocation_reason?: string; @@ -40,11 +40,9 @@ export const REVOCATION_REASONS = [ export interface CertificateVersion { id: string; certificate_id: string; - version: number; serial_number: string; - fingerprint: string; - cert_pem: string; - chain_pem: string; + fingerprint_sha256: string; + pem_chain: string; csr_pem: string; not_before: string; not_after: string; @@ -80,7 +78,7 @@ export interface Job { status: string; attempts: number; max_attempts: number; - error_message: string; + last_error?: string; scheduled_at: string; started_at: string; completed_at: string; diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts index a7f3846..9dfe3b8 100644 --- a/web/src/api/utils.ts +++ b/web/src/api/utils.ts @@ -1,9 +1,9 @@ -export function formatDate(iso: string): string { +export function formatDate(iso: string | undefined | null): string { if (!iso) return '—'; return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } -export function formatDateTime(iso: string): string { +export function formatDateTime(iso: string | undefined | null): string { if (!iso) return '—'; return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } diff --git a/web/src/config/issuerTypes.ts b/web/src/config/issuerTypes.ts index eb37341..d648f23 100644 --- a/web/src/config/issuerTypes.ts +++ b/web/src/config/issuerTypes.ts @@ -29,19 +29,23 @@ export interface IssuerTypeConfig { } /** - * Canonical type label map. Keys match what the backend API returns. - * DB stores: local, acme, stepca, openssl, VaultPKI, DigiCert + * Canonical type label map. Keys MUST match backend IssuerType constants + * defined in internal/domain/connector.go (e.g., "ACME", "GenericCA", "StepCA"). */ export const typeLabels: Record = { - local: 'Local CA', + GenericCA: 'Local CA', + local: 'Local CA', // backward compat for old DB records local_ca: 'Local CA', // backward compat (some frontend references) - acme: 'ACME', - stepca: 'step-ca', - openssl: 'OpenSSL/Custom', + ACME: 'ACME', + acme: 'ACME', // backward compat for old DB records + StepCA: 'step-ca', + stepca: 'step-ca', // backward compat for old DB records + OpenSSL: 'OpenSSL/Custom', + openssl: 'OpenSSL/Custom', // backward compat for old DB records VaultPKI: 'Vault PKI', DigiCert: 'DigiCert', Sectigo: 'Sectigo SCM', - manual: 'Manual', + GoogleCAS: 'Google CAS', }; /** @@ -50,7 +54,7 @@ export const typeLabels: Record = { */ export const issuerTypes: IssuerTypeConfig[] = [ { - id: 'acme', + id: 'ACME', name: 'ACME', description: "Let's Encrypt, ZeroSSL, or any ACME-compatible CA", icon: '\uD83D\uDD12', @@ -64,7 +68,7 @@ export const issuerTypes: IssuerTypeConfig[] = [ ], }, { - id: 'local', + id: 'GenericCA', name: 'Local CA', description: 'Self-signed or subordinate CA for internal certificates', icon: '\uD83C\uDFE0', @@ -74,14 +78,15 @@ export const issuerTypes: IssuerTypeConfig[] = [ ], }, { - id: 'stepca', + id: 'StepCA', name: 'step-ca', description: 'Smallstep private CA with JWK provisioner auth', icon: '\uD83D\uDC63', configFields: [ { key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true }, { key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true }, - { key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true, sensitive: true }, + { key: 'provisioner_key_path', label: 'Provisioner Key Path', placeholder: '/path/to/provisioner.key', required: false, sensitive: true }, + { key: 'provisioner_password', label: 'Provisioner Password', placeholder: 'Password for encrypted key', required: false, type: 'password', sensitive: true }, ], }, { @@ -110,7 +115,7 @@ export const issuerTypes: IssuerTypeConfig[] = [ ], }, { - id: 'openssl', + id: 'OpenSSL', name: 'OpenSSL/Custom', description: 'Script-based signing with your own CA', icon: '\uD83D\uDD27', @@ -188,7 +193,10 @@ export function getIssuerCatalogStatus( } // Match both the canonical id and common aliases const aliases: Record = { - local: ['local', 'local_ca'], + GenericCA: ['GenericCA', 'local', 'local_ca'], + ACME: ['ACME', 'acme'], + StepCA: ['StepCA', 'stepca'], + OpenSSL: ['OpenSSL', 'openssl'], }; const matchIds = aliases[t.id] || [t.id]; const matching = configuredIssuers.filter(i => matchIds.includes(i.type)); diff --git a/web/src/pages/CertificateDetailPage.tsx b/web/src/pages/CertificateDetailPage.tsx index b6c3a46..ed5e37d 100644 --- a/web/src/pages/CertificateDetailPage.tsx +++ b/web/src/pages/CertificateDetailPage.tsx @@ -60,7 +60,7 @@ function TimelineStep({ label, status, time, isLast }: { label: string; status: ); } -function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certId: string; certStatus: string; createdAt: string; issuedAt: string }) { +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 }), @@ -372,6 +372,12 @@ export default function CertificateDetailPage() { ); } + // 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'; @@ -492,7 +498,7 @@ export default function CertificateDetailPage() { )} {/* Deployment Status Timeline */} - +
{/* Certificate Info */} @@ -518,9 +524,9 @@ export default function CertificateDetailPage() { })} ) : '—'} /> - + {cert.fingerprint.slice(0, 24)}... : '—' + fingerprintSha256 ? {fingerprintSha256.slice(0, 24)}... : '—' } /> @@ -556,7 +562,7 @@ export default function CertificateDetailPage() { {/* Lifecycle */}

Lifecycle

- + {formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`}) @@ -615,7 +621,7 @@ export default function CertificateDetailPage() {
- Version {v.version} + Version {versions.data.length - idx} {idx === 0 && Current}
{v.serial_number}
diff --git a/web/src/pages/JobDetailPage.tsx b/web/src/pages/JobDetailPage.tsx index c832b66..7cf8a70 100644 --- a/web/src/pages/JobDetailPage.tsx +++ b/web/src/pages/JobDetailPage.tsx @@ -114,9 +114,9 @@ export default function JobDetailPage() { } /> )} - {job.error_message && ( + {job.last_error && ( {job.error_message} + {job.last_error} } /> )}
diff --git a/web/src/pages/JobsPage.tsx b/web/src/pages/JobsPage.tsx index bf68ce2..fb8c4bb 100644 --- a/web/src/pages/JobsPage.tsx +++ b/web/src/pages/JobsPage.tsx @@ -139,9 +139,9 @@ export default function JobsPage() { { key: 'error', label: 'Error', - render: (j) => j.status === 'Failed' && j.error_message ? ( - - {j.error_message.length > 80 ? j.error_message.substring(0, 80) + '...' : j.error_message} + render: (j) => j.status === 'Failed' && j.last_error ? ( + + {j.last_error.length > 80 ? j.last_error.substring(0, 80) + '...' : j.last_error} ) : , }, diff --git a/web/src/pages/TargetDetailPage.tsx b/web/src/pages/TargetDetailPage.tsx index fe449fc..d549344 100644 --- a/web/src/pages/TargetDetailPage.tsx +++ b/web/src/pages/TargetDetailPage.tsx @@ -231,7 +231,7 @@ export default function TargetDetailPage() { {target.config && Object.keys(target.config).length > 0 ? (
{Object.entries(target.config).map(([key, val]) => { - const sensitiveKeys = ['password', 'secret', 'token', 'key', 'winrm_password', 'keystore_password']; + const sensitiveKeys = ['password', 'secret', 'token', 'key', 'passphrase', 'winrm_password', 'keystore_password']; const isSensitive = sensitiveKeys.some(s => key.toLowerCase().includes(s)); const displayVal = isSensitive && val ? '********' : String(val); return ( diff --git a/web/src/pages/TargetsPage.tsx b/web/src/pages/TargetsPage.tsx index a2d70cb..c6a0003 100644 --- a/web/src/pages/TargetsPage.tsx +++ b/web/src/pages/TargetsPage.tsx @@ -47,18 +47,20 @@ const CONFIG_FIELDS: Record void; onSuc const [config, setConfig] = useState>({}); const [error, setError] = useState(''); + // Fields that backends expect as boolean (Go bool) + const BOOL_FIELDS = new Set([ + 'sni', 'insecure', 'sds_config', 'remove_expired', 'create_keystore', + 'winrm_https', 'winrm_insecure', + ]); + // Fields that backends expect as integer (Go int) + const INT_FIELDS = new Set([ + 'port', 'timeout', 'winrm_port', 'winrm_timeout', 'timeout_seconds', + ]); + + // Coerce string form values to their Go types + const coerceValue = (key: string, val: string): unknown => { + if (BOOL_FIELDS.has(key)) return val === 'true'; + if (INT_FIELDS.has(key)) { const n = parseInt(val, 10); return isNaN(n) ? val : n; } + return val; + }; + + // Build config payload with type-specific transformations + const buildConfigPayload = () => { + const flat = Object.fromEntries(Object.entries(config).filter(([, v]) => v)); + + // Dovecot uses the same Postfix connector with mode="dovecot" + if (targetType === 'Dovecot' && !flat['mode']) { + flat['mode'] = 'dovecot'; + } + + // IIS backend expects WinRM fields nested under "winrm" key + if (targetType === 'IIS') { + const iisWinrmKeys = ['winrm_host', 'winrm_port', 'winrm_username', 'winrm_password', 'winrm_https', 'winrm_insecure', 'winrm_timeout']; + const winrmObj: Record = {}; + const result: Record = {}; + for (const [k, v] of Object.entries(flat)) { + if (iisWinrmKeys.includes(k)) { + winrmObj[k] = coerceValue(k, v); + } else { + result[k] = coerceValue(k, v); + } + } + if (Object.keys(winrmObj).length > 0) { + result['winrm'] = winrmObj; + } + return result; + } + + // All other target types: coerce values to proper Go types + const result: Record = {}; + for (const [k, v] of Object.entries(flat)) { + result[k] = coerceValue(k, v); + } + return result; + }; + const mutation = useMutation({ mutationFn: () => createTarget({ name, type: targetType, agent_id: agentId, - config: Object.fromEntries(Object.entries(config).filter(([, v]) => v)), + config: buildConfigPayload(), }), onSuccess: () => onSuccess(), onError: (err: Error) => setError(err.message),