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:
hsiegeln
2026-04-19 16:59:19 +02:00
parent e7fbf5a7b2
commit 0c5f1b5740
2 changed files with 473 additions and 0 deletions

View 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>
);
}

View File

@@ -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> },