@startsimpli/integrations 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * ONE form for every vendor, rendered from the catalog's own field schema
3
+ * (bd startsim-768w.22.3.4).
4
+ *
5
+ * The backend already refuses to hardcode a vendor: the catalog is
6
+ * `DestinationProvider` rows and the field specs come off the connector class,
7
+ * so adding a vendor costs a row and a class. That is worth nothing if the
8
+ * browser answers with `if (connector === 'x')` — the duplication would just
9
+ * have moved to where nobody is looking. So this component knows about FIELDS
10
+ * and knows nothing about vendors, and the suite next door drives two different
11
+ * schemas through it to prove it.
12
+ *
13
+ * FORWARD COMPATIBILITY IS THE POINT. The catalog is data and will outrun the
14
+ * deployed bundle — a connector shipped today can declare a field type this
15
+ * build has never heard of. An unknown type degrades to a text input and every
16
+ * other field still renders, because a blank form is a support ticket and a
17
+ * text input is a usable answer.
18
+ */
19
+ import * as React from 'react';
20
+
21
+ import type { ConnectorFieldSpec, ConnectorSchemaLike } from './pages/types';
22
+
23
+ export interface CredentialSchemaFormProps {
24
+ /** The catalog's own schema, rendered in DECLARED ORDER. */
25
+ fields: ConnectorFieldSpec[];
26
+ /** Current values, keyed by field key. Secrets are never read back from here. */
27
+ values: Record<string, string>;
28
+ onChange: (key: string, value: string) => void;
29
+ /** Per-field messages. A `pattern` miss WARNS; only `required` blocks submit. */
30
+ errors?: Record<string, string>;
31
+ warnings?: Record<string, string>;
32
+ idPrefix?: string;
33
+ classNames?: {
34
+ field?: string;
35
+ label?: string;
36
+ input?: string;
37
+ help?: string;
38
+ error?: string;
39
+ warning?: string;
40
+ };
41
+ }
42
+
43
+ /** Field types this build renders natively. Anything else becomes text. */
44
+ const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'textarea']);
45
+
46
+ /**
47
+ * Is this field a secret? `type: "secret"` is the server's spelling and
48
+ * `write_only` is the flag it enforces alongside it; `secret: true` is accepted
49
+ * for hand-built schemas. Any one of the three masks the field — asking the
50
+ * question in one place is what stops the three from disagreeing.
51
+ */
52
+ export function isSecret(spec: ConnectorFieldSpec): boolean {
53
+ return spec.type === 'secret' || Boolean(spec.write_only) || Boolean(spec.secret);
54
+ }
55
+
56
+ /**
57
+ * The fields of a schema, whatever envelope it arrived in.
58
+ *
59
+ * The catalog ships `{version, fields[]}`; a caller composing a schema by hand
60
+ * passes a bare array. An UNRECOGNISED VERSION still renders its fields rather
61
+ * than blanking the form — the catalog is data and will outrun this bundle, and
62
+ * a blank connect form is a dead end with nothing to act on.
63
+ */
64
+ export function schemaFields(schema: ConnectorSchemaLike): ConnectorFieldSpec[] {
65
+ if (!schema) return [];
66
+ if (Array.isArray(schema)) return schema;
67
+ return Array.isArray(schema.fields) ? schema.fields : [];
68
+ }
69
+
70
+ function inputType(spec: ConnectorFieldSpec): string {
71
+ if (isSecret(spec)) return 'password';
72
+ const declared = String(spec.type || 'text');
73
+ return NATIVE_TYPES.has(declared) ? declared : 'text';
74
+ }
75
+
76
+ export function CredentialSchemaForm({
77
+ fields,
78
+ values,
79
+ onChange,
80
+ errors = {},
81
+ warnings = {},
82
+ idPrefix = 'csf',
83
+ classNames = {},
84
+ }: CredentialSchemaFormProps) {
85
+ return (
86
+ <div className="space-y-4">
87
+ {fields.map((spec) => {
88
+ const id = `${idPrefix}-${spec.key}`;
89
+ const type = inputType(spec);
90
+ // A SECRET IS NEVER RENDERED. Not "not fetched" — not rendered, even if
91
+ // a value for it somehow reached this component. A masked input holding
92
+ // the real key is one devtools panel away from being the leak the whole
93
+ // credential surface is built to prevent.
94
+ const value = isSecret(spec)
95
+ ? (values[`${spec.key}__draft`] ?? '')
96
+ : (values[spec.key] ?? spec.default ?? '');
97
+ const error = errors[spec.key];
98
+ const warning = warnings[spec.key];
99
+
100
+ return (
101
+ <div key={spec.key} data-field={spec.key} className={classNames.field}>
102
+ <label htmlFor={id} className={classNames.label ?? 'block text-sm font-medium text-gray-900'}>
103
+ {spec.label}
104
+ {spec.required ? <span aria-hidden="true"> *</span> : null}
105
+ </label>
106
+
107
+ {type === 'select' ? (
108
+ <select
109
+ id={id}
110
+ value={value}
111
+ required={Boolean(spec.required)}
112
+ onChange={(e) => onChange(spec.key, e.target.value)}
113
+ className={classNames.input ?? 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm'}
114
+ >
115
+ {(spec.options ?? []).map((option) => (
116
+ <option key={option.value} value={option.value}>{option.label}</option>
117
+ ))}
118
+ </select>
119
+ ) : type === 'textarea' ? (
120
+ <textarea
121
+ id={id}
122
+ value={value}
123
+ required={Boolean(spec.required)}
124
+ placeholder={spec.placeholder || undefined}
125
+ onChange={(e) => onChange(spec.key, e.target.value)}
126
+ className={classNames.input ?? 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm'}
127
+ />
128
+ ) : (
129
+ <input
130
+ id={id}
131
+ type={type}
132
+ value={value}
133
+ required={Boolean(spec.required)}
134
+ placeholder={spec.placeholder || undefined}
135
+ autoComplete={isSecret(spec) ? 'new-password' : 'off'}
136
+ onChange={(e) => onChange(spec.key, e.target.value)}
137
+ className={classNames.input ?? 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm'}
138
+ />
139
+ )}
140
+
141
+ {spec.help ? (
142
+ <p className={classNames.help ?? 'mt-1 text-xs text-gray-500'}>{spec.help}</p>
143
+ ) : null}
144
+ {warning ? (
145
+ <p className={classNames.warning ?? 'mt-1 text-xs text-amber-600'}>{warning}</p>
146
+ ) : null}
147
+ {error ? (
148
+ <p className={classNames.error ?? 'mt-1 text-xs text-red-600'} role="alert">{error}</p>
149
+ ) : null}
150
+ </div>
151
+ );
152
+ })}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Which required fields are still empty. The ONLY client-side gate.
159
+ *
160
+ * A `pattern` miss deliberately does not block: the client never decides a
161
+ * credential is valid — `check()` does, against the vendor. A regex that
162
+ * refuses a legitimate key the vendor would have accepted is a support ticket
163
+ * nobody can work around.
164
+ */
165
+ export function missingRequired(
166
+ fields: ConnectorFieldSpec[],
167
+ values: Record<string, string>,
168
+ ): string[] {
169
+ return fields
170
+ .filter((spec) => spec.required)
171
+ .filter((spec) => !String(values[isSecret(spec) ? `${spec.key}__draft` : spec.key] ?? '').trim())
172
+ .map((spec) => spec.key);
173
+ }
174
+
175
+ /** Fields whose value does not match a declared `pattern`. WARNS, never blocks. */
176
+ export function patternWarnings(
177
+ fields: ConnectorFieldSpec[],
178
+ values: Record<string, string>,
179
+ ): Record<string, string> {
180
+ const out: Record<string, string> = {};
181
+ for (const spec of fields) {
182
+ if (!spec.pattern) continue;
183
+ const value = String(values[isSecret(spec) ? `${spec.key}__draft` : spec.key] ?? '');
184
+ if (!value) continue;
185
+ let ok = true;
186
+ try {
187
+ ok = new RegExp(spec.pattern).test(value);
188
+ } catch {
189
+ // A pattern this build cannot compile is the catalog outrunning the
190
+ // bundle again. Silence beats a false warning.
191
+ continue;
192
+ }
193
+ if (!ok) out[spec.key] = `This does not look like the expected format for ${spec.label}.`;
194
+ }
195
+ return out;
196
+ }
@@ -4,3 +4,23 @@ export * from './EmailList';
4
4
  export * from './EventList';
5
5
  export * from './EventListItem';
6
6
  export * from './ReconsentPromptCard';
7
+
8
+ // The connections layer (bd startsim-768w.22.3.4): ONE schema-driven form and
9
+ // ONE page composer for every connector in the DB-driven catalog. Ship the
10
+ // composer, not only the primitives — otherwise the duplication just moves up
11
+ // one level into each app.
12
+ export * from './CredentialSchemaForm';
13
+ export * from './pages/ConnectionsPage';
14
+ export type {
15
+ ConnectionsApi,
16
+ ConnectorCatalogEntry,
17
+ ConnectorFieldSpec,
18
+ Connection as FoundryConnection,
19
+ ConnectionPermissions,
20
+ ConnectionChoice,
21
+ ConnectionChoicePage,
22
+ ConnectionTarget,
23
+ ConnectorAction,
24
+ ConnectionInvokeResult,
25
+ ConnectionRun,
26
+ } from './pages/types';
@@ -0,0 +1,426 @@
1
+ /**
2
+ * The connections surface, as one composer (bd startsim-768w.22.3.4).
3
+ *
4
+ * `<ConnectionsPage api={apiClient.foundry} foundrySlug={slug} />` is the whole
5
+ * integration. An app owns theme classNames and a ten-line wrapper and nothing
6
+ * else — no context, no provider, no per-app copy of this logic. Same shape as
7
+ * `TeamSettingsPage`, and for the same reason: shipping primitives without the
8
+ * page-level composer just moves the duplication up one level.
9
+ *
10
+ * WHAT IT RENDERS, in the order the job actually happens:
11
+ *
12
+ * 1. connected accounts, each saying what it is and whether it is healthy
13
+ * 2. the catalog — everything connectable, including rows that cannot load
14
+ * 3. per connection: DISCOVER what the credential reaches -> WIRE a choice ->
15
+ * CONFIRM the derived config -> FIRE it, and the run history of both legs
16
+ *
17
+ * Step 3 is the product. A pasted webhook URL is the thing discovery replaces,
18
+ * and the CONFIRM step exists because a derived URL is a guess about the
19
+ * vendor's own settings until a human agrees with it.
20
+ *
21
+ * NOTHING HERE NAMES A VENDOR. Every difference between two vendors arrives as
22
+ * data — the field schema, the capability list, the choice rows and their
23
+ * reasons. A `if (connector === ...)` in this file would undo the DB-driven
24
+ * catalog the backend was rebuilt around.
25
+ */
26
+ import * as React from 'react';
27
+
28
+ import {
29
+ CredentialSchemaForm,
30
+ isSecret,
31
+ missingRequired,
32
+ patternWarnings,
33
+ schemaFields,
34
+ } from '../CredentialSchemaForm';
35
+ import type {
36
+ Connection,
37
+ ConnectionChoice,
38
+ ConnectionPermissions,
39
+ ConnectionTarget,
40
+ ConnectorAction,
41
+ ConnectorCatalogEntry,
42
+ ConnectionsApi,
43
+ } from './types';
44
+
45
+ export interface ConnectionsPageProps {
46
+ api: ConnectionsApi;
47
+ foundrySlug: string;
48
+ classNames?: Partial<Record<
49
+ 'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error', string
50
+ >>;
51
+ }
52
+
53
+ const DEFAULTS = {
54
+ root: 'space-y-8',
55
+ section: 'space-y-3',
56
+ heading: 'text-lg font-semibold text-gray-900',
57
+ card: 'rounded-lg border border-gray-200 p-4',
58
+ button: 'rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50',
59
+ muted: 'text-sm text-gray-500',
60
+ error: 'text-sm text-red-600',
61
+ };
62
+
63
+ /** A refusal must read as a refusal, not as an empty page. */
64
+ function readFailureMessage(err: unknown): string {
65
+ const status = (err as { status?: number })?.status;
66
+ if (status === 403) {
67
+ return 'You do not have access to this foundry’s connections. Ask an admin for the connections permission.';
68
+ }
69
+ if (status === 401) return 'Your session has expired. Sign in again.';
70
+ const message = (err as { message?: string })?.message;
71
+ return message || 'Could not load connections.';
72
+ }
73
+
74
+ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: ConnectionsPageProps) {
75
+ const cn = { ...DEFAULTS, ...classNames };
76
+
77
+ const [catalog, setCatalog] = React.useState<ConnectorCatalogEntry[]>([]);
78
+ const [connections, setConnections] = React.useState<Connection[]>([]);
79
+ const [permissions, setPermissions] = React.useState<ConnectionPermissions>({
80
+ view: true, manage: false, issue_tokens: false,
81
+ });
82
+ const [loadError, setLoadError] = React.useState<string>('');
83
+ const [loading, setLoading] = React.useState(true);
84
+
85
+ const reload = React.useCallback(async () => {
86
+ setLoading(true);
87
+ try {
88
+ const [cat, conns] = await Promise.all([
89
+ api.connectorCatalog(foundrySlug),
90
+ api.connections(foundrySlug),
91
+ ]);
92
+ setCatalog(cat.results ?? []);
93
+ setConnections(conns.results ?? []);
94
+ setPermissions(conns.permissions ?? { view: true, manage: false, issue_tokens: false });
95
+ setLoadError('');
96
+ } catch (err) {
97
+ setLoadError(readFailureMessage(err));
98
+ } finally {
99
+ setLoading(false);
100
+ }
101
+ }, [api, foundrySlug]);
102
+
103
+ React.useEffect(() => { void reload(); }, [reload]);
104
+
105
+ if (loadError) {
106
+ return <p className={cn.error} role="alert">{loadError}</p>;
107
+ }
108
+
109
+ return (
110
+ <div className={cn.root}>
111
+ <section className={cn.section}>
112
+ <h2 className={cn.heading}>Connected accounts</h2>
113
+ {loading && connections.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
114
+ {!loading && connections.length === 0 ? (
115
+ <p className={cn.muted}>Nothing connected yet.</p>
116
+ ) : null}
117
+ {connections.map((connection) => (
118
+ <ConnectionCard
119
+ key={connection.id}
120
+ api={api}
121
+ foundrySlug={foundrySlug}
122
+ connection={connection}
123
+ canManage={permissions.manage}
124
+ classNames={cn}
125
+ onChanged={reload}
126
+ />
127
+ ))}
128
+ </section>
129
+
130
+ <section className={cn.section}>
131
+ <h2 className={cn.heading}>Available connectors</h2>
132
+ {catalog.map((entry) => (
133
+ <CatalogCard
134
+ key={entry.key}
135
+ api={api}
136
+ foundrySlug={foundrySlug}
137
+ entry={entry}
138
+ canManage={permissions.manage}
139
+ classNames={cn}
140
+ onConnected={reload}
141
+ />
142
+ ))}
143
+ </section>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ // --------------------------------------------------------------------------
149
+ // catalog
150
+ // --------------------------------------------------------------------------
151
+
152
+ function CatalogCard({
153
+ api, foundrySlug, entry, canManage, classNames: cn, onConnected,
154
+ }: {
155
+ api: ConnectionsApi;
156
+ foundrySlug: string;
157
+ entry: ConnectorCatalogEntry;
158
+ canManage: boolean;
159
+ classNames: typeof DEFAULTS;
160
+ onConnected: () => void;
161
+ }) {
162
+ const [open, setOpen] = React.useState(false);
163
+ const [values, setValues] = React.useState<Record<string, string>>({});
164
+ const [busy, setBusy] = React.useState(false);
165
+ const [error, setError] = React.useState('');
166
+
167
+ const credentialFields = schemaFields(entry.credential_schema);
168
+ const configFields = schemaFields(entry.config_schema);
169
+ const fields = [...credentialFields, ...configFields];
170
+ const warnings = patternWarnings(fields, values);
171
+ const missing = missingRequired(fields, values);
172
+
173
+ async function submit() {
174
+ setBusy(true);
175
+ setError('');
176
+ try {
177
+ const credentials: Record<string, unknown> = {};
178
+ const config: Record<string, unknown> = {};
179
+ for (const spec of credentialFields) {
180
+ const v = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
181
+ if (v) credentials[spec.key] = v;
182
+ }
183
+ for (const spec of configFields) {
184
+ const v = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
185
+ if (v) config[spec.key] = v;
186
+ }
187
+ await api.connect(foundrySlug, {
188
+ connector_key: entry.key, name: entry.name, credentials, config,
189
+ });
190
+ setOpen(false);
191
+ setValues({});
192
+ onConnected();
193
+ } catch (err) {
194
+ // The server's typed answer, verbatim. It already scrubbed itself of the
195
+ // values just submitted, and it knows things the client cannot guess —
196
+ // "the vendor rejected this key" is not "the network failed".
197
+ setError((err as { message?: string })?.message || 'Could not connect.');
198
+ } finally {
199
+ setBusy(false);
200
+ }
201
+ }
202
+
203
+ return (
204
+ <div className={cn.card}>
205
+ <div className="flex items-start justify-between gap-4">
206
+ <div>
207
+ <p className="font-medium text-gray-900">{entry.name}</p>
208
+ <p className={cn.muted}>{entry.description}</p>
209
+ {entry.capabilities.length ? (
210
+ <p className="mt-1 text-xs text-gray-400">{entry.capabilities.join(' · ')}</p>
211
+ ) : null}
212
+ {/* A row whose class will not load says WHY and stays visible. The
213
+ catalog is data and outruns deployed bundles; blanking the menu
214
+ because one vendor's module moved is the failure this prevents. */}
215
+ {!entry.availability.available ? (
216
+ <p className={cn.error}>Unavailable: {entry.availability.reason}</p>
217
+ ) : null}
218
+ </div>
219
+ {canManage && entry.availability.available ? (
220
+ <button type="button" className={cn.button} onClick={() => setOpen((v) => !v)}>
221
+ {open ? 'Cancel' : 'Connect'}
222
+ </button>
223
+ ) : null}
224
+ </div>
225
+
226
+ {open ? (
227
+ <div className="mt-4 space-y-3">
228
+ <CredentialSchemaForm
229
+ fields={fields}
230
+ values={values}
231
+ warnings={warnings}
232
+ idPrefix={`connect-${entry.key}`}
233
+ onChange={(key, value) => {
234
+ const spec = fields.find((f) => f.key === key);
235
+ setValues((prev) => ({
236
+ ...prev, [spec && isSecret(spec) ? `${key}__draft` : key]: value,
237
+ }));
238
+ }}
239
+ />
240
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
241
+ <button
242
+ type="button"
243
+ className={cn.button}
244
+ disabled={busy || missing.length > 0}
245
+ onClick={() => void submit()}
246
+ >
247
+ {busy ? 'Testing…' : 'Test and save'}
248
+ </button>
249
+ </div>
250
+ ) : null}
251
+ </div>
252
+ );
253
+ }
254
+
255
+ // --------------------------------------------------------------------------
256
+ // a connected account: discover -> wire -> confirm -> fire
257
+ // --------------------------------------------------------------------------
258
+
259
+ function ConnectionCard({
260
+ api, foundrySlug, connection, canManage, classNames: cn, onChanged,
261
+ }: {
262
+ api: ConnectionsApi;
263
+ foundrySlug: string;
264
+ connection: Connection;
265
+ canManage: boolean;
266
+ classNames: typeof DEFAULTS;
267
+ onChanged: () => void;
268
+ }) {
269
+ const [choices, setChoices] = React.useState<ConnectionChoice[] | null>(null);
270
+ const [target, setTarget] = React.useState<ConnectionTarget | null>(null);
271
+ const [actions, setActions] = React.useState<ConnectorAction[]>([]);
272
+ const [busy, setBusy] = React.useState('');
273
+ const [error, setError] = React.useState('');
274
+ const [notice, setNotice] = React.useState('');
275
+
276
+ async function guard(label: string, run: () => Promise<void>) {
277
+ setBusy(label);
278
+ setError('');
279
+ try {
280
+ await run();
281
+ } catch (err) {
282
+ setError((err as { message?: string })?.message || 'That did not work.');
283
+ } finally {
284
+ setBusy('');
285
+ }
286
+ }
287
+
288
+ return (
289
+ <div className={cn.card}>
290
+ <div className="flex items-start justify-between gap-4">
291
+ <div>
292
+ <p className="font-medium text-gray-900">{connection.name}</p>
293
+ <p className={cn.muted}>
294
+ {connection.connector_name}
295
+ {connection.account_label ? ` · ${connection.account_label}` : ''}
296
+ </p>
297
+ <p className="mt-1 text-xs text-gray-400">{connection.status}</p>
298
+ {connection.check_error ? (
299
+ <p className={cn.error}>{connection.check_error}</p>
300
+ ) : null}
301
+ </div>
302
+ {canManage ? (
303
+ <div className="flex gap-2">
304
+ <button
305
+ type="button"
306
+ className={cn.button}
307
+ disabled={Boolean(busy)}
308
+ onClick={() => void guard('check', async () => {
309
+ const res = await api.checkConnection(foundrySlug, connection.id);
310
+ setNotice(res.detail || (res.ok ? 'Connection is healthy.' : ''));
311
+ onChanged();
312
+ })}
313
+ >
314
+ Test
315
+ </button>
316
+ <button
317
+ type="button"
318
+ className={cn.button}
319
+ disabled={Boolean(busy)}
320
+ onClick={() => void guard('discover', async () => {
321
+ const page = await api.connectionChoices(foundrySlug, connection.id);
322
+ setChoices(page.results ?? []);
323
+ const available = await api.connectionActions(foundrySlug, connection.id);
324
+ setActions(available.results ?? []);
325
+ })}
326
+ >
327
+ {busy === 'discover' ? 'Loading…' : 'Workflows'}
328
+ </button>
329
+ </div>
330
+ ) : null}
331
+ </div>
332
+
333
+ {notice ? <p className={cn.muted}>{notice}</p> : null}
334
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
335
+
336
+ {choices ? (
337
+ <div className="mt-4 space-y-2">
338
+ <p className="text-sm font-medium text-gray-900">What this account can reach</p>
339
+ {choices.length === 0 ? <p className={cn.muted}>Nothing to wire yet.</p> : null}
340
+ {choices.map((choice) => (
341
+ <div key={choice.id} className="flex items-start justify-between gap-3 border-t pt-2">
342
+ <div>
343
+ <p className={choice.selectable ? 'text-sm text-gray-900' : 'text-sm text-gray-400'}>
344
+ {choice.label}
345
+ </p>
346
+ {/* An unpickable row is GREYED and says why — never dropped. A
347
+ picker that hides them looks broken with no way to tell. */}
348
+ {!choice.selectable ? (
349
+ <p className="text-xs text-gray-500">
350
+ {choice.reason}
351
+ {choice.remediation ? ` — ${choice.remediation}` : ''}
352
+ </p>
353
+ ) : null}
354
+ </div>
355
+ {choice.selectable ? (
356
+ <button
357
+ type="button"
358
+ className={cn.button}
359
+ disabled={Boolean(busy)}
360
+ onClick={() => void guard('wire', async () => {
361
+ setTarget(await api.wireTarget(foundrySlug, connection.id, {
362
+ choice_id: choice.id,
363
+ }));
364
+ })}
365
+ >
366
+ Wire
367
+ </button>
368
+ ) : null}
369
+ </div>
370
+ ))}
371
+ </div>
372
+ ) : null}
373
+
374
+ {target ? (
375
+ <div className="mt-4 space-y-2 border-t pt-3">
376
+ <p className="text-sm font-medium text-gray-900">{target.name}</p>
377
+ {/* The admin CONFIRMS what discovery proposed. Showing the derived
378
+ config is not a detail — nobody can confirm a URL they were never
379
+ shown, and a confidently wrong one only fails at 3am. */}
380
+ <pre className="overflow-x-auto rounded bg-gray-50 p-2 text-xs text-gray-700">
381
+ {JSON.stringify(target.destination_config, null, 2)}
382
+ </pre>
383
+ {!target.verified ? (
384
+ <button
385
+ type="button"
386
+ className={cn.button}
387
+ disabled={Boolean(busy)}
388
+ onClick={() => void guard('confirm', async () => {
389
+ setTarget(await api.confirmTarget(foundrySlug, connection.id, target.id));
390
+ })}
391
+ >
392
+ Confirm this target
393
+ </button>
394
+ ) : (
395
+ <div className="flex flex-wrap gap-2">
396
+ <p className={cn.muted}>Confirmed.</p>
397
+ {actions.map((available) => (
398
+ <button
399
+ key={available.name}
400
+ type="button"
401
+ className={cn.button}
402
+ disabled={Boolean(busy)}
403
+ onClick={() => void guard('invoke', async () => {
404
+ const result = await api.invokeConnectionAction(
405
+ foundrySlug, connection.id, available.name,
406
+ { destination_id: target.id, records: [] },
407
+ );
408
+ // `accepted` is not `completed`. Saying "done" here would
409
+ // render a success the vendor never reported.
410
+ setNotice(
411
+ result.run_status === 'completed'
412
+ ? 'Finished.'
413
+ : `Accepted — run is ${result.run_status}.`,
414
+ );
415
+ })}
416
+ >
417
+ {available.label}
418
+ </button>
419
+ ))}
420
+ </div>
421
+ )}
422
+ </div>
423
+ ) : null}
424
+ </div>
425
+ );
426
+ }