feat(ui): outbound connection editor — TLS config, test action, env restriction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
470
ui/src/pages/Admin/OutboundConnectionEditor.tsx
Normal file
470
ui/src/pages/Admin/OutboundConnectionEditor.tsx
Normal file
@@ -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<FormState>(() => initialForm());
|
||||
const [initialized, setInitialized] = useState(isNew);
|
||||
const [testResult, setTestResult] = useState<OutboundConnectionTestResult | null>(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 <PageLoader />;
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<SectionHeader>
|
||||
{isNew ? 'New Outbound Connection' : `Edit: ${existingQ.data?.name ?? ''}`}
|
||||
</SectionHeader>
|
||||
<Link to="/admin/outbound-connections">
|
||||
<Button variant="secondary" size="sm">Back</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className={sectionStyles.section} style={{ display: 'grid', gap: 16 }}>
|
||||
|
||||
{/* Name */}
|
||||
<FormField label="Name" htmlFor="oc-name">
|
||||
<Input
|
||||
id="oc-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="slack-ops"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Description */}
|
||||
<FormField label="Description" htmlFor="oc-description">
|
||||
<textarea
|
||||
id="oc-description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
rows={2}
|
||||
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* URL */}
|
||||
<FormField label="URL" htmlFor="oc-url">
|
||||
<Input
|
||||
id="oc-url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Method */}
|
||||
<FormField label="Method" htmlFor="oc-method">
|
||||
<Select
|
||||
options={METHOD_OPTIONS}
|
||||
value={form.method}
|
||||
onChange={(e) => setForm({ ...form, method: e.target.value as OutboundMethod })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Default headers */}
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>Default headers</div>
|
||||
{form.headers.map((h, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Input
|
||||
value={h.key}
|
||||
onChange={(e) => {
|
||||
const next = [...form.headers];
|
||||
next[i] = { ...next[i], key: e.target.value };
|
||||
setForm({ ...form, headers: next });
|
||||
}}
|
||||
placeholder="Header"
|
||||
/>
|
||||
<Input
|
||||
value={h.value}
|
||||
onChange={(e) => {
|
||||
const next = [...form.headers];
|
||||
next[i] = { ...next[i], value: e.target.value };
|
||||
setForm({ ...form, headers: next });
|
||||
}}
|
||||
placeholder="Value"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setForm({ ...form, headers: form.headers.filter((_, idx) => idx !== i) })}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setForm({ ...form, headers: [...form.headers, { key: '', value: '' }] })}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Default body template */}
|
||||
<FormField label="Default body template" htmlFor="oc-body">
|
||||
<textarea
|
||||
id="oc-body"
|
||||
value={form.defaultBodyTmpl}
|
||||
onChange={(e) => setForm({ ...form, defaultBodyTmpl: e.target.value })}
|
||||
rows={4}
|
||||
style={{ width: '100%', boxSizing: 'border-box', fontFamily: 'monospace', fontSize: '0.8125rem' }}
|
||||
placeholder={'{"text": "{{message}}"}'}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* TLS trust mode */}
|
||||
<FormField label="TLS trust mode" htmlFor="oc-tls">
|
||||
<Select
|
||||
options={TRUST_OPTIONS}
|
||||
value={form.tlsTrustMode}
|
||||
onChange={(e) => setForm({ ...form, tlsTrustMode: e.target.value as TrustMode })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{form.tlsTrustMode === 'TRUST_ALL' && (
|
||||
<div style={{ background: 'var(--amber-bg, #fef3c7)', color: 'var(--amber, #92400e)', padding: '8px 12px', borderRadius: 4, fontSize: '0.875rem' }}>
|
||||
TLS certificate validation is DISABLED. Do not use TRUST_ALL in production.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.tlsTrustMode === 'TRUST_PATHS' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>CA PEM paths</div>
|
||||
{form.tlsCaPemPaths.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Input
|
||||
value={p}
|
||||
onChange={(e) => {
|
||||
const next = [...form.tlsCaPemPaths];
|
||||
next[i] = e.target.value;
|
||||
setForm({ ...form, tlsCaPemPaths: next });
|
||||
}}
|
||||
placeholder="/etc/ssl/certs/my-ca.pem"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setForm({ ...form, tlsCaPemPaths: form.tlsCaPemPaths.filter((_, idx) => idx !== i) })
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setForm({ ...form, tlsCaPemPaths: [...form.tlsCaPemPaths, ''] })}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
Add path
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HMAC secret */}
|
||||
<FormField
|
||||
label="HMAC secret"
|
||||
htmlFor="oc-hmac"
|
||||
hint={existingQ.data?.hmacSecretSet ? 'Leave blank to keep existing secret' : undefined}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Input
|
||||
id="oc-hmac"
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={form.hmacSecret}
|
||||
onChange={(e) => setForm({ ...form, hmacSecret: e.target.value })}
|
||||
placeholder={
|
||||
existingQ.data?.hmacSecretSet
|
||||
? '<secret set — leave blank to keep>'
|
||||
: 'Optional signing secret'
|
||||
}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowSecret((s) => !s)}>
|
||||
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{/* Auth kind */}
|
||||
<FormField label="Auth" htmlFor="oc-auth">
|
||||
<Select
|
||||
options={AUTH_OPTIONS}
|
||||
value={form.authKind}
|
||||
onChange={(e) => setForm({ ...form, authKind: e.target.value as OutboundAuthKind })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{form.authKind === 'BEARER' && (
|
||||
<FormField label="Bearer token" htmlFor="oc-bearer">
|
||||
<Input
|
||||
id="oc-bearer"
|
||||
value={form.bearerToken}
|
||||
onChange={(e) => setForm({ ...form, bearerToken: e.target.value })}
|
||||
placeholder="Token stored encrypted at rest"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{form.authKind === 'BASIC' && (
|
||||
<>
|
||||
<FormField label="Basic username" htmlFor="oc-basic-user">
|
||||
<Input
|
||||
id="oc-basic-user"
|
||||
value={form.basicUsername}
|
||||
onChange={(e) => setForm({ ...form, basicUsername: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Basic password" htmlFor="oc-basic-pass">
|
||||
<Input
|
||||
id="oc-basic-pass"
|
||||
type="password"
|
||||
value={form.basicPassword}
|
||||
onChange={(e) => setForm({ ...form, basicPassword: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allowed environments */}
|
||||
<div>
|
||||
<Toggle
|
||||
checked={form.allowAllEnvs}
|
||||
onChange={(e) => setForm({ ...form, allowAllEnvs: (e.target as HTMLInputElement).checked })}
|
||||
label="Allow in all environments"
|
||||
/>
|
||||
{!form.allowAllEnvs && (
|
||||
<div style={{ display: 'grid', gap: 4, marginTop: 8, marginLeft: 4 }}>
|
||||
{envs.map((env) => (
|
||||
<label key={env.id} style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.allowedEnvIds.includes(env.id)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...form.allowedEnvIds, env.id]
|
||||
: form.allowedEnvIds.filter((x) => x !== env.id);
|
||||
setForm({ ...form, allowedEnvIds: next });
|
||||
}}
|
||||
/>
|
||||
{env.displayName}
|
||||
</label>
|
||||
))}
|
||||
{envs.length === 0 && (
|
||||
<span style={{ color: 'var(--text-muted, #6b7280)', fontSize: '0.875rem' }}>No environments found</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button variant="primary" onClick={onSave} loading={isSaving} disabled={isSaving}>
|
||||
{isNew ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
{!isNew && (
|
||||
<Button variant="secondary" onClick={onTest} loading={testMut.isPending} disabled={testMut.isPending}>
|
||||
{testMut.isPending ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<section className={sectionStyles.section}>
|
||||
<SectionHeader>Test result</SectionHeader>
|
||||
{testResult.error ? (
|
||||
<div style={{ color: 'var(--error, #b91c1c)', fontSize: '0.875rem' }}>
|
||||
Error: {testResult.error}
|
||||
</div>
|
||||
) : (
|
||||
<dl style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '4px 16px', fontSize: '0.875rem', margin: 0 }}>
|
||||
<dt>Status</dt><dd style={{ margin: 0 }}>{testResult.status}</dd>
|
||||
<dt>Latency</dt><dd style={{ margin: 0 }}>{testResult.latencyMs} ms</dd>
|
||||
<dt>TLS protocol</dt><dd style={{ margin: 0 }}>{testResult.tlsProtocol ?? '—'}</dd>
|
||||
{testResult.responseSnippet && (
|
||||
<>
|
||||
<dt>Response</dt>
|
||||
<dd style={{ margin: 0 }}><code>{testResult.responseSnippet}</code></dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
||||
const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnectionsPage'));
|
||||
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
|
||||
const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
|
||||
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||
@@ -86,6 +87,8 @@ export const router = createBrowserRouter([
|
||||
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
||||
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'outbound-connections', element: <SuspenseWrapper><OutboundConnectionsPage /></SuspenseWrapper> },
|
||||
{ path: 'outbound-connections/new', element: <SuspenseWrapper><OutboundConnectionEditor /></SuspenseWrapper> },
|
||||
{ path: 'outbound-connections/:id', element: <SuspenseWrapper><OutboundConnectionEditor /></SuspenseWrapper> },
|
||||
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
|
||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||
|
||||
Reference in New Issue
Block a user