diff --git a/ui/src/pages/Admin/OutboundConnectionEditor.tsx b/ui/src/pages/Admin/OutboundConnectionEditor.tsx new file mode 100644 index 00000000..92ebd3f2 --- /dev/null +++ b/ui/src/pages/Admin/OutboundConnectionEditor.tsx @@ -0,0 +1,470 @@ +import { useEffect, useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Button, FormField, Input, Select, SectionHeader, Toggle, useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { + useOutboundConnection, + useCreateOutboundConnection, + useUpdateOutboundConnection, + useTestOutboundConnection, + type OutboundConnectionDto, + type OutboundConnectionRequest, + type OutboundConnectionTestResult, + type OutboundMethod, + type OutboundAuthKind, + type TrustMode, +} from '../../api/queries/admin/outboundConnections'; +import { useEnvironments } from '../../api/queries/admin/environments'; +import sectionStyles from '../../styles/section-card.module.css'; + +// ── Form state ────────────────────────────────────────────────────────── + +interface FormState { + name: string; + description: string; + url: string; + method: OutboundMethod; + headers: Array<{ key: string; value: string }>; + defaultBodyTmpl: string; + tlsTrustMode: TrustMode; + tlsCaPemPaths: string[]; + hmacSecret: string; // empty = keep existing (edit) / no secret (create) + authKind: OutboundAuthKind; + bearerToken: string; + basicUsername: string; + basicPassword: string; + allowAllEnvs: boolean; + allowedEnvIds: string[]; +} + +function initialForm(existing?: OutboundConnectionDto): FormState { + return { + name: existing?.name ?? '', + description: existing?.description ?? '', + url: existing?.url ?? 'https://', + method: existing?.method ?? 'POST', + headers: existing + ? Object.entries(existing.defaultHeaders).map(([key, value]) => ({ key, value })) + : [], + defaultBodyTmpl: existing?.defaultBodyTmpl ?? '', + tlsTrustMode: existing?.tlsTrustMode ?? 'SYSTEM_DEFAULT', + tlsCaPemPaths: existing?.tlsCaPemPaths ?? [], + hmacSecret: '', + authKind: existing?.authKind ?? 'NONE', + bearerToken: '', + basicUsername: '', + basicPassword: '', + allowAllEnvs: !existing || existing.allowedEnvironmentIds.length === 0, + allowedEnvIds: existing?.allowedEnvironmentIds ?? [], + }; +} + +function toRequest(f: FormState): OutboundConnectionRequest { + const defaultHeaders = Object.fromEntries( + f.headers.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value]), + ); + const allowedEnvironmentIds = f.allowAllEnvs ? [] : f.allowedEnvIds; + const auth = + f.authKind === 'NONE' + ? {} + : f.authKind === 'BEARER' + ? { tokenCiphertext: f.bearerToken } + : { username: f.basicUsername, passwordCiphertext: f.basicPassword }; + return { + name: f.name, + description: f.description || null, + url: f.url, + method: f.method, + defaultHeaders, + defaultBodyTmpl: f.defaultBodyTmpl || null, + tlsTrustMode: f.tlsTrustMode, + tlsCaPemPaths: f.tlsCaPemPaths, + hmacSecret: f.hmacSecret ? f.hmacSecret : null, + auth, + allowedEnvironmentIds, + }; +} + +// ── Select option arrays ─────────────────────────────────────────────── + +const METHOD_OPTIONS: Array<{ value: OutboundMethod; label: string }> = [ + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + { value: 'PATCH', label: 'PATCH' }, +]; + +const TRUST_OPTIONS: Array<{ value: TrustMode; label: string }> = [ + { value: 'SYSTEM_DEFAULT', label: 'System default (recommended)' }, + { value: 'TRUST_PATHS', label: 'Trust additional CA PEMs' }, + { value: 'TRUST_ALL', label: 'Trust all (INSECURE)' }, +]; + +const AUTH_OPTIONS: Array<{ value: OutboundAuthKind; label: string }> = [ + { value: 'NONE', label: 'None' }, + { value: 'BEARER', label: 'Bearer token' }, + { value: 'BASIC', label: 'Basic' }, +]; + +// ── Component ────────────────────────────────────────────────────────── + +export default function OutboundConnectionEditor() { + const { id } = useParams<{ id: string }>(); + const isNew = !id; + const navigate = useNavigate(); + const { toast } = useToast(); + + const existingQ = useOutboundConnection(isNew ? undefined : id); + const envQ = useEnvironments(); + const createMut = useCreateOutboundConnection(); + // Hooks must be called unconditionally; pass placeholder id when unknown. + const updateMut = useUpdateOutboundConnection(id ?? 'placeholder'); + const testMut = useTestOutboundConnection(); + + const [form, setForm] = useState(() => initialForm()); + const [initialized, setInitialized] = useState(isNew); + const [testResult, setTestResult] = useState(null); + const [showSecret, setShowSecret] = useState(false); + + useEffect(() => { + if (!initialized && existingQ.data) { + setForm(initialForm(existingQ.data)); + setInitialized(true); + } + }, [existingQ.data, initialized]); + + if (!isNew && existingQ.isLoading) return ; + + const isSaving = createMut.isPending || updateMut.isPending; + + const onSave = () => { + const payload = toRequest(form); + if (isNew) { + createMut.mutate(payload, { + onSuccess: () => { + toast({ title: 'Created', description: form.name, variant: 'success' }); + navigate('/admin/outbound-connections'); + }, + onError: (e) => toast({ title: 'Create failed', description: String(e), variant: 'error' }), + }); + } else { + updateMut.mutate(payload, { + onSuccess: () => toast({ title: 'Updated', description: form.name, variant: 'success' }), + onError: (e) => toast({ title: 'Update failed', description: String(e), variant: 'error' }), + }); + } + }; + + const onTest = () => { + if (!id) return; + testMut.mutate(id, { + onSuccess: (r) => setTestResult(r), + onError: (e) => + setTestResult({ + status: 0, + latencyMs: 0, + responseSnippet: null, + tlsProtocol: null, + tlsCipherSuite: null, + peerCertificateSubject: null, + peerCertificateExpiresAtEpochMs: null, + error: String(e), + }), + }); + }; + + const envs = envQ.data ?? []; + + return ( +
+
+ + {isNew ? 'New Outbound Connection' : `Edit: ${existingQ.data?.name ?? ''}`} + + + + +
+ +
+ + {/* Name */} + + setForm({ ...form, name: e.target.value })} + placeholder="slack-ops" + /> + + + {/* Description */} + +