@startsimpli/integrations 0.1.0 → 0.1.4
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.
- package/README.md +178 -0
- package/package.json +18 -9
- package/src/__tests__/connections.test.tsx +453 -0
- package/src/components/CredentialSchemaForm.tsx +196 -0
- package/src/components/index.ts +20 -0
- package/src/components/pages/ConnectionsPage.tsx +610 -0
- package/src/components/pages/types.ts +197 -0
|
@@ -0,0 +1,610 @@
|
|
|
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
|
+
/**
|
|
64
|
+
* WHAT AN ADMIN IS AGREEING TO, in their words rather than the wire's.
|
|
65
|
+
*
|
|
66
|
+
* Data, not a branch: an unknown key falls through to the key itself, because
|
|
67
|
+
* the capability vocabulary lives in the connector package and will outrun this
|
|
68
|
+
* bundle exactly the way the credential schema does. A capability this build has
|
|
69
|
+
* never heard of must render as a choice, not vanish from the list.
|
|
70
|
+
*/
|
|
71
|
+
const CAPABILITY_COPY: Record<string, string> = {
|
|
72
|
+
poll: 'Fetch new records on a schedule',
|
|
73
|
+
webhook_in: 'Receive events pushed to us',
|
|
74
|
+
action: 'Let us run actions in this account',
|
|
75
|
+
writeback_credential: 'Issue a token this vendor uses to call us',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** A refusal must read as a refusal, not as an empty page. */
|
|
79
|
+
function readFailureMessage(err: unknown): string {
|
|
80
|
+
const status = (err as { status?: number })?.status;
|
|
81
|
+
if (status === 403) {
|
|
82
|
+
return 'You do not have access to this foundry’s connections. Ask an admin for the connections permission.';
|
|
83
|
+
}
|
|
84
|
+
if (status === 401) return 'Your session has expired. Sign in again.';
|
|
85
|
+
const message = (err as { message?: string })?.message;
|
|
86
|
+
return message || 'Could not load connections.';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ConnectionsPage({ api, foundrySlug, classNames = {} }: ConnectionsPageProps) {
|
|
90
|
+
const cn = { ...DEFAULTS, ...classNames };
|
|
91
|
+
|
|
92
|
+
const [catalog, setCatalog] = React.useState<ConnectorCatalogEntry[]>([]);
|
|
93
|
+
const [connections, setConnections] = React.useState<Connection[]>([]);
|
|
94
|
+
const [permissions, setPermissions] = React.useState<ConnectionPermissions>({
|
|
95
|
+
view: true, manage: false, issue_tokens: false,
|
|
96
|
+
});
|
|
97
|
+
const [loadError, setLoadError] = React.useState<string>('');
|
|
98
|
+
const [loading, setLoading] = React.useState(true);
|
|
99
|
+
|
|
100
|
+
const reload = React.useCallback(async () => {
|
|
101
|
+
setLoading(true);
|
|
102
|
+
try {
|
|
103
|
+
const [cat, conns] = await Promise.all([
|
|
104
|
+
api.connectorCatalog(foundrySlug),
|
|
105
|
+
api.connections(foundrySlug),
|
|
106
|
+
]);
|
|
107
|
+
setCatalog(cat.results ?? []);
|
|
108
|
+
setConnections(conns.results ?? []);
|
|
109
|
+
setPermissions(conns.permissions ?? { view: true, manage: false, issue_tokens: false });
|
|
110
|
+
setLoadError('');
|
|
111
|
+
} catch (err) {
|
|
112
|
+
setLoadError(readFailureMessage(err));
|
|
113
|
+
} finally {
|
|
114
|
+
setLoading(false);
|
|
115
|
+
}
|
|
116
|
+
}, [api, foundrySlug]);
|
|
117
|
+
|
|
118
|
+
React.useEffect(() => { void reload(); }, [reload]);
|
|
119
|
+
|
|
120
|
+
if (loadError) {
|
|
121
|
+
return <p className={cn.error} role="alert">{loadError}</p>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={cn.root}>
|
|
126
|
+
<section className={cn.section}>
|
|
127
|
+
<h2 className={cn.heading}>Connected accounts</h2>
|
|
128
|
+
{loading && connections.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
|
|
129
|
+
{!loading && connections.length === 0 ? (
|
|
130
|
+
<p className={cn.muted}>Nothing connected yet.</p>
|
|
131
|
+
) : null}
|
|
132
|
+
{connections.map((connection) => (
|
|
133
|
+
<ConnectionCard
|
|
134
|
+
key={connection.id}
|
|
135
|
+
api={api}
|
|
136
|
+
foundrySlug={foundrySlug}
|
|
137
|
+
connection={connection}
|
|
138
|
+
// What the CONNECTOR declares, so the card can offer a capability
|
|
139
|
+
// this connection does not have yet — which is the only way an
|
|
140
|
+
// account connected before the picker existed ever gets one.
|
|
141
|
+
declared={
|
|
142
|
+
catalog.find((entry) => entry.key === connection.connector_key)?.capabilities
|
|
143
|
+
}
|
|
144
|
+
canManage={permissions.manage}
|
|
145
|
+
classNames={cn}
|
|
146
|
+
onChanged={reload}
|
|
147
|
+
/>
|
|
148
|
+
))}
|
|
149
|
+
</section>
|
|
150
|
+
|
|
151
|
+
<section className={cn.section}>
|
|
152
|
+
<h2 className={cn.heading}>Available connectors</h2>
|
|
153
|
+
{catalog.map((entry) => (
|
|
154
|
+
<CatalogCard
|
|
155
|
+
key={entry.key}
|
|
156
|
+
api={api}
|
|
157
|
+
foundrySlug={foundrySlug}
|
|
158
|
+
entry={entry}
|
|
159
|
+
canManage={permissions.manage}
|
|
160
|
+
classNames={cn}
|
|
161
|
+
onConnected={reload}
|
|
162
|
+
/>
|
|
163
|
+
))}
|
|
164
|
+
</section>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --------------------------------------------------------------------------
|
|
170
|
+
// the capability choice (bd startsim-768w.22.22)
|
|
171
|
+
// --------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* WHICH VERBS THIS ACCOUNT IS ALLOWED — the control this page did not have.
|
|
175
|
+
*
|
|
176
|
+
* `POST /connections/` and `PATCH /connections/<id>/` have both accepted
|
|
177
|
+
* `capabilities_enabled[]` since the endpoints were designed. Nothing here ever
|
|
178
|
+
* sent one, so every connection made through the product was stored with an
|
|
179
|
+
* EMPTY list, and an empty list enables nothing: the card goes green on a real
|
|
180
|
+
* `check()` and can then do none of the things it advertises. That is how a
|
|
181
|
+
* connected Granola account on a live foundry ingested nothing forever.
|
|
182
|
+
*
|
|
183
|
+
* Rendered from `entry.capabilities` — the connector's own declaration, off the
|
|
184
|
+
* catalog row — so a new vendor's verbs appear here with no code change, and a
|
|
185
|
+
* verb this build has never heard of still renders.
|
|
186
|
+
*/
|
|
187
|
+
function CapabilityPicker({
|
|
188
|
+
scope, declared, selected, disabled, idPrefix, onToggle, classNames: cn, children,
|
|
189
|
+
}: {
|
|
190
|
+
scope: 'catalog' | 'connection';
|
|
191
|
+
declared: string[];
|
|
192
|
+
selected: string[];
|
|
193
|
+
disabled: boolean;
|
|
194
|
+
idPrefix: string;
|
|
195
|
+
onToggle: (capability: string, on: boolean) => void;
|
|
196
|
+
classNames: typeof DEFAULTS;
|
|
197
|
+
/** Whatever COMMITS the choice — inline, so the control and the thing that
|
|
198
|
+
saves it are one region for a screen reader and for a test. */
|
|
199
|
+
children?: React.ReactNode;
|
|
200
|
+
}) {
|
|
201
|
+
if (declared.length === 0) return null;
|
|
202
|
+
return (
|
|
203
|
+
<fieldset data-capabilities={scope} className="mt-3 space-y-1 border-0 p-0">
|
|
204
|
+
<legend className="text-sm font-medium text-gray-900">What this account may do</legend>
|
|
205
|
+
{declared.map((capability) => {
|
|
206
|
+
const id = `${idPrefix}-cap-${capability}`;
|
|
207
|
+
return (
|
|
208
|
+
<label key={capability} htmlFor={id} className="flex items-start gap-2 text-sm">
|
|
209
|
+
<input
|
|
210
|
+
id={id}
|
|
211
|
+
type="checkbox"
|
|
212
|
+
value={capability}
|
|
213
|
+
className="mt-0.5"
|
|
214
|
+
checked={selected.includes(capability)}
|
|
215
|
+
disabled={disabled}
|
|
216
|
+
onChange={(event) => onToggle(capability, event.target.checked)}
|
|
217
|
+
/>
|
|
218
|
+
<span>
|
|
219
|
+
<span className="text-gray-900">{capability}</span>
|
|
220
|
+
<span className={`ml-2 ${cn.muted}`}>
|
|
221
|
+
{CAPABILITY_COPY[capability] ?? ''}
|
|
222
|
+
</span>
|
|
223
|
+
</span>
|
|
224
|
+
</label>
|
|
225
|
+
);
|
|
226
|
+
})}
|
|
227
|
+
{children}
|
|
228
|
+
</fieldset>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Declaration order is the render order, and the wire order. */
|
|
233
|
+
function orderCapabilities(declared: string[], selected: Iterable<string>): string[] {
|
|
234
|
+
const set = new Set(selected);
|
|
235
|
+
return declared.filter((capability) => set.has(capability));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --------------------------------------------------------------------------
|
|
239
|
+
// catalog
|
|
240
|
+
// --------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
function CatalogCard({
|
|
243
|
+
api, foundrySlug, entry, canManage, classNames: cn, onConnected,
|
|
244
|
+
}: {
|
|
245
|
+
api: ConnectionsApi;
|
|
246
|
+
foundrySlug: string;
|
|
247
|
+
entry: ConnectorCatalogEntry;
|
|
248
|
+
canManage: boolean;
|
|
249
|
+
classNames: typeof DEFAULTS;
|
|
250
|
+
onConnected: () => void;
|
|
251
|
+
}) {
|
|
252
|
+
const [open, setOpen] = React.useState(false);
|
|
253
|
+
const [values, setValues] = React.useState<Record<string, string>>({});
|
|
254
|
+
const [busy, setBusy] = React.useState(false);
|
|
255
|
+
const [error, setError] = React.useState('');
|
|
256
|
+
// Every declared capability starts ON. Not because "on" is a safe default in
|
|
257
|
+
// general, but because the alternative here is a connection that can do
|
|
258
|
+
// nothing — and the list is right there, unchecked with one click, before
|
|
259
|
+
// anything is saved. The privacy control that is genuinely opt-in lives one
|
|
260
|
+
// level down (a connector's own config, e.g. whether a poll fetches meeting
|
|
261
|
+
// transcript bytes at all) and is unaffected by this box.
|
|
262
|
+
const [capabilities, setCapabilities] = React.useState<string[]>(entry.capabilities);
|
|
263
|
+
|
|
264
|
+
const credentialFields = schemaFields(entry.credential_schema);
|
|
265
|
+
const configFields = schemaFields(entry.config_schema);
|
|
266
|
+
const fields = [...credentialFields, ...configFields];
|
|
267
|
+
const warnings = patternWarnings(fields, values);
|
|
268
|
+
const missing = missingRequired(fields, values);
|
|
269
|
+
|
|
270
|
+
async function submit() {
|
|
271
|
+
setBusy(true);
|
|
272
|
+
setError('');
|
|
273
|
+
try {
|
|
274
|
+
const credentials: Record<string, unknown> = {};
|
|
275
|
+
const config: Record<string, unknown> = {};
|
|
276
|
+
for (const spec of credentialFields) {
|
|
277
|
+
const v = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
|
|
278
|
+
if (v) credentials[spec.key] = v;
|
|
279
|
+
}
|
|
280
|
+
for (const spec of configFields) {
|
|
281
|
+
const v = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
|
|
282
|
+
if (v) config[spec.key] = v;
|
|
283
|
+
}
|
|
284
|
+
await api.connect(foundrySlug, {
|
|
285
|
+
connector_key: entry.key, name: entry.name, credentials, config,
|
|
286
|
+
// Always sent. Leaving it off is what wrote the inert cards.
|
|
287
|
+
capabilities_enabled: orderCapabilities(entry.capabilities, capabilities),
|
|
288
|
+
});
|
|
289
|
+
setOpen(false);
|
|
290
|
+
setValues({});
|
|
291
|
+
setCapabilities(entry.capabilities);
|
|
292
|
+
onConnected();
|
|
293
|
+
} catch (err) {
|
|
294
|
+
// The server's typed answer, verbatim. It already scrubbed itself of the
|
|
295
|
+
// values just submitted, and it knows things the client cannot guess —
|
|
296
|
+
// "the vendor rejected this key" is not "the network failed".
|
|
297
|
+
setError((err as { message?: string })?.message || 'Could not connect.');
|
|
298
|
+
} finally {
|
|
299
|
+
setBusy(false);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div className={cn.card}>
|
|
305
|
+
<div className="flex items-start justify-between gap-4">
|
|
306
|
+
<div>
|
|
307
|
+
<p className="font-medium text-gray-900">{entry.name}</p>
|
|
308
|
+
<p className={cn.muted}>{entry.description}</p>
|
|
309
|
+
{entry.capabilities.length ? (
|
|
310
|
+
<p className="mt-1 text-xs text-gray-400">{entry.capabilities.join(' · ')}</p>
|
|
311
|
+
) : null}
|
|
312
|
+
{/* A row whose class will not load says WHY and stays visible. The
|
|
313
|
+
catalog is data and outruns deployed bundles; blanking the menu
|
|
314
|
+
because one vendor's module moved is the failure this prevents. */}
|
|
315
|
+
{!entry.availability.available ? (
|
|
316
|
+
<p className={cn.error}>Unavailable: {entry.availability.reason}</p>
|
|
317
|
+
) : null}
|
|
318
|
+
</div>
|
|
319
|
+
{canManage && entry.availability.available ? (
|
|
320
|
+
<button type="button" className={cn.button} onClick={() => setOpen((v) => !v)}>
|
|
321
|
+
{open ? 'Cancel' : 'Connect'}
|
|
322
|
+
</button>
|
|
323
|
+
) : null}
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
{open ? (
|
|
327
|
+
<div className="mt-4 space-y-3">
|
|
328
|
+
<CredentialSchemaForm
|
|
329
|
+
fields={fields}
|
|
330
|
+
values={values}
|
|
331
|
+
warnings={warnings}
|
|
332
|
+
idPrefix={`connect-${entry.key}`}
|
|
333
|
+
onChange={(key, value) => {
|
|
334
|
+
const spec = fields.find((f) => f.key === key);
|
|
335
|
+
setValues((prev) => ({
|
|
336
|
+
...prev, [spec && isSecret(spec) ? `${key}__draft` : key]: value,
|
|
337
|
+
}));
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
<CapabilityPicker
|
|
341
|
+
scope="catalog"
|
|
342
|
+
declared={entry.capabilities}
|
|
343
|
+
selected={capabilities}
|
|
344
|
+
disabled={busy}
|
|
345
|
+
idPrefix={`connect-${entry.key}`}
|
|
346
|
+
onToggle={(capability, on) => setCapabilities((prev) => (
|
|
347
|
+
on ? [...prev, capability] : prev.filter((c) => c !== capability)
|
|
348
|
+
))}
|
|
349
|
+
classNames={cn}
|
|
350
|
+
/>
|
|
351
|
+
{/* The server refuses an empty list with a 400 rather than storing a
|
|
352
|
+
card that can do nothing. Saying so here costs the admin a round
|
|
353
|
+
trip instead of a confusing error. */}
|
|
354
|
+
{entry.capabilities.length > 0 && capabilities.length === 0 ? (
|
|
355
|
+
<p className={cn.error}>
|
|
356
|
+
Enable at least one — a connection with nothing enabled can do nothing.
|
|
357
|
+
</p>
|
|
358
|
+
) : null}
|
|
359
|
+
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
className={cn.button}
|
|
363
|
+
disabled={
|
|
364
|
+
busy
|
|
365
|
+
|| missing.length > 0
|
|
366
|
+
|| (entry.capabilities.length > 0 && capabilities.length === 0)
|
|
367
|
+
}
|
|
368
|
+
onClick={() => void submit()}
|
|
369
|
+
>
|
|
370
|
+
{busy ? 'Testing…' : 'Test and save'}
|
|
371
|
+
</button>
|
|
372
|
+
</div>
|
|
373
|
+
) : null}
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --------------------------------------------------------------------------
|
|
379
|
+
// a connected account: discover -> wire -> confirm -> fire
|
|
380
|
+
// --------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
function ConnectionCard({
|
|
383
|
+
api, foundrySlug, connection, declared, canManage, classNames: cn, onChanged,
|
|
384
|
+
}: {
|
|
385
|
+
api: ConnectionsApi;
|
|
386
|
+
foundrySlug: string;
|
|
387
|
+
connection: Connection;
|
|
388
|
+
declared?: string[];
|
|
389
|
+
canManage: boolean;
|
|
390
|
+
classNames: typeof DEFAULTS;
|
|
391
|
+
onChanged: () => void;
|
|
392
|
+
}) {
|
|
393
|
+
const [choices, setChoices] = React.useState<ConnectionChoice[] | null>(null);
|
|
394
|
+
const [target, setTarget] = React.useState<ConnectionTarget | null>(null);
|
|
395
|
+
const [actions, setActions] = React.useState<ConnectorAction[]>([]);
|
|
396
|
+
const [busy, setBusy] = React.useState('');
|
|
397
|
+
const [error, setError] = React.useState('');
|
|
398
|
+
const [notice, setNotice] = React.useState('');
|
|
399
|
+
|
|
400
|
+
const enabled = React.useMemo(
|
|
401
|
+
() => connection.capabilities_enabled ?? [], [connection.capabilities_enabled]);
|
|
402
|
+
// The catalog's declaration first, then anything this connection holds that
|
|
403
|
+
// the catalog no longer offers. A capability an admin cannot SEE is one they
|
|
404
|
+
// cannot turn off, and a catalog row that will not load must not blank the
|
|
405
|
+
// card's own state.
|
|
406
|
+
const offered = React.useMemo(() => {
|
|
407
|
+
const seen = new Set<string>();
|
|
408
|
+
return [...(declared ?? []), ...enabled].filter((capability) => {
|
|
409
|
+
if (seen.has(capability)) return false;
|
|
410
|
+
seen.add(capability);
|
|
411
|
+
return true;
|
|
412
|
+
});
|
|
413
|
+
}, [declared, enabled]);
|
|
414
|
+
const [selected, setSelected] = React.useState<string[]>(enabled);
|
|
415
|
+
React.useEffect(() => { setSelected(enabled); }, [enabled]);
|
|
416
|
+
|
|
417
|
+
const pending = orderCapabilities(offered, selected);
|
|
418
|
+
const dirty = pending.join(',') !== orderCapabilities(offered, enabled).join(',');
|
|
419
|
+
|
|
420
|
+
async function guard(label: string, run: () => Promise<void>) {
|
|
421
|
+
setBusy(label);
|
|
422
|
+
setError('');
|
|
423
|
+
try {
|
|
424
|
+
await run();
|
|
425
|
+
} catch (err) {
|
|
426
|
+
setError((err as { message?: string })?.message || 'That did not work.');
|
|
427
|
+
} finally {
|
|
428
|
+
setBusy('');
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<div className={cn.card}>
|
|
434
|
+
<div className="flex items-start justify-between gap-4">
|
|
435
|
+
<div>
|
|
436
|
+
<p className="font-medium text-gray-900">{connection.name}</p>
|
|
437
|
+
<p className={cn.muted}>
|
|
438
|
+
{connection.connector_name}
|
|
439
|
+
{connection.account_label ? ` · ${connection.account_label}` : ''}
|
|
440
|
+
</p>
|
|
441
|
+
<p className="mt-1 text-xs text-gray-400">{connection.status}</p>
|
|
442
|
+
{connection.check_error ? (
|
|
443
|
+
<p className={cn.error}>{connection.check_error}</p>
|
|
444
|
+
) : null}
|
|
445
|
+
</div>
|
|
446
|
+
{canManage ? (
|
|
447
|
+
<div className="flex gap-2">
|
|
448
|
+
<button
|
|
449
|
+
type="button"
|
|
450
|
+
className={cn.button}
|
|
451
|
+
disabled={Boolean(busy)}
|
|
452
|
+
onClick={() => void guard('check', async () => {
|
|
453
|
+
const res = await api.checkConnection(foundrySlug, connection.id);
|
|
454
|
+
setNotice(res.detail || (res.ok ? 'Connection is healthy.' : ''));
|
|
455
|
+
onChanged();
|
|
456
|
+
})}
|
|
457
|
+
>
|
|
458
|
+
Test
|
|
459
|
+
</button>
|
|
460
|
+
<button
|
|
461
|
+
type="button"
|
|
462
|
+
className={cn.button}
|
|
463
|
+
disabled={Boolean(busy)}
|
|
464
|
+
onClick={() => void guard('discover', async () => {
|
|
465
|
+
const page = await api.connectionChoices(foundrySlug, connection.id);
|
|
466
|
+
setChoices(page.results ?? []);
|
|
467
|
+
const available = await api.connectionActions(foundrySlug, connection.id);
|
|
468
|
+
setActions(available.results ?? []);
|
|
469
|
+
})}
|
|
470
|
+
>
|
|
471
|
+
{busy === 'discover' ? 'Loading…' : 'Workflows'}
|
|
472
|
+
</button>
|
|
473
|
+
</div>
|
|
474
|
+
) : null}
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
{/* THE FIX FOR THE CARDS THAT ALREADY EXIST. A connection written before
|
|
478
|
+
the connect form asked holds an empty list and can do nothing; the
|
|
479
|
+
connect form can never reach it. Turning `poll` on here is what makes
|
|
480
|
+
the server create the scheduled ingest leg. */}
|
|
481
|
+
<CapabilityPicker
|
|
482
|
+
scope="connection"
|
|
483
|
+
declared={offered}
|
|
484
|
+
selected={selected}
|
|
485
|
+
disabled={!canManage || Boolean(busy)}
|
|
486
|
+
idPrefix={`connection-${connection.id}`}
|
|
487
|
+
onToggle={(capability, on) => setSelected((prev) => (
|
|
488
|
+
on ? [...prev, capability] : prev.filter((c) => c !== capability)
|
|
489
|
+
))}
|
|
490
|
+
classNames={cn}
|
|
491
|
+
>
|
|
492
|
+
{canManage ? (
|
|
493
|
+
<div className="mt-2 flex items-center gap-3">
|
|
494
|
+
<button
|
|
495
|
+
type="button"
|
|
496
|
+
className={cn.button}
|
|
497
|
+
disabled={!dirty || pending.length === 0 || Boolean(busy)}
|
|
498
|
+
onClick={() => void guard('capabilities', async () => {
|
|
499
|
+
await api.updateConnection(foundrySlug, connection.id, {
|
|
500
|
+
capabilities_enabled: pending,
|
|
501
|
+
});
|
|
502
|
+
setNotice('Saved.');
|
|
503
|
+
onChanged();
|
|
504
|
+
})}
|
|
505
|
+
>
|
|
506
|
+
{busy === 'capabilities' ? 'Saving…' : 'Save'}
|
|
507
|
+
</button>
|
|
508
|
+
{pending.length === 0 ? (
|
|
509
|
+
<span className={cn.error}>
|
|
510
|
+
Enable at least one, or disconnect the account.
|
|
511
|
+
</span>
|
|
512
|
+
) : null}
|
|
513
|
+
</div>
|
|
514
|
+
) : null}
|
|
515
|
+
</CapabilityPicker>
|
|
516
|
+
|
|
517
|
+
{notice ? <p className={cn.muted}>{notice}</p> : null}
|
|
518
|
+
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
519
|
+
|
|
520
|
+
{choices ? (
|
|
521
|
+
<div className="mt-4 space-y-2">
|
|
522
|
+
<p className="text-sm font-medium text-gray-900">What this account can reach</p>
|
|
523
|
+
{choices.length === 0 ? <p className={cn.muted}>Nothing to wire yet.</p> : null}
|
|
524
|
+
{choices.map((choice) => (
|
|
525
|
+
<div key={choice.id} className="flex items-start justify-between gap-3 border-t pt-2">
|
|
526
|
+
<div>
|
|
527
|
+
<p className={choice.selectable ? 'text-sm text-gray-900' : 'text-sm text-gray-400'}>
|
|
528
|
+
{choice.label}
|
|
529
|
+
</p>
|
|
530
|
+
{/* An unpickable row is GREYED and says why — never dropped. A
|
|
531
|
+
picker that hides them looks broken with no way to tell. */}
|
|
532
|
+
{!choice.selectable ? (
|
|
533
|
+
<p className="text-xs text-gray-500">
|
|
534
|
+
{choice.reason}
|
|
535
|
+
{choice.remediation ? ` — ${choice.remediation}` : ''}
|
|
536
|
+
</p>
|
|
537
|
+
) : null}
|
|
538
|
+
</div>
|
|
539
|
+
{choice.selectable ? (
|
|
540
|
+
<button
|
|
541
|
+
type="button"
|
|
542
|
+
className={cn.button}
|
|
543
|
+
disabled={Boolean(busy)}
|
|
544
|
+
onClick={() => void guard('wire', async () => {
|
|
545
|
+
setTarget(await api.wireTarget(foundrySlug, connection.id, {
|
|
546
|
+
choice_id: choice.id,
|
|
547
|
+
}));
|
|
548
|
+
})}
|
|
549
|
+
>
|
|
550
|
+
Wire
|
|
551
|
+
</button>
|
|
552
|
+
) : null}
|
|
553
|
+
</div>
|
|
554
|
+
))}
|
|
555
|
+
</div>
|
|
556
|
+
) : null}
|
|
557
|
+
|
|
558
|
+
{target ? (
|
|
559
|
+
<div className="mt-4 space-y-2 border-t pt-3">
|
|
560
|
+
<p className="text-sm font-medium text-gray-900">{target.name}</p>
|
|
561
|
+
{/* The admin CONFIRMS what discovery proposed. Showing the derived
|
|
562
|
+
config is not a detail — nobody can confirm a URL they were never
|
|
563
|
+
shown, and a confidently wrong one only fails at 3am. */}
|
|
564
|
+
<pre className="overflow-x-auto rounded bg-gray-50 p-2 text-xs text-gray-700">
|
|
565
|
+
{JSON.stringify(target.destination_config, null, 2)}
|
|
566
|
+
</pre>
|
|
567
|
+
{!target.verified ? (
|
|
568
|
+
<button
|
|
569
|
+
type="button"
|
|
570
|
+
className={cn.button}
|
|
571
|
+
disabled={Boolean(busy)}
|
|
572
|
+
onClick={() => void guard('confirm', async () => {
|
|
573
|
+
setTarget(await api.confirmTarget(foundrySlug, connection.id, target.id));
|
|
574
|
+
})}
|
|
575
|
+
>
|
|
576
|
+
Confirm this target
|
|
577
|
+
</button>
|
|
578
|
+
) : (
|
|
579
|
+
<div className="flex flex-wrap gap-2">
|
|
580
|
+
<p className={cn.muted}>Confirmed.</p>
|
|
581
|
+
{actions.map((available) => (
|
|
582
|
+
<button
|
|
583
|
+
key={available.name}
|
|
584
|
+
type="button"
|
|
585
|
+
className={cn.button}
|
|
586
|
+
disabled={Boolean(busy)}
|
|
587
|
+
onClick={() => void guard('invoke', async () => {
|
|
588
|
+
const result = await api.invokeConnectionAction(
|
|
589
|
+
foundrySlug, connection.id, available.name,
|
|
590
|
+
{ destination_id: target.id, records: [] },
|
|
591
|
+
);
|
|
592
|
+
// `accepted` is not `completed`. Saying "done" here would
|
|
593
|
+
// render a success the vendor never reported.
|
|
594
|
+
setNotice(
|
|
595
|
+
result.run_status === 'completed'
|
|
596
|
+
? 'Finished.'
|
|
597
|
+
: `Accepted — run is ${result.run_status}.`,
|
|
598
|
+
);
|
|
599
|
+
})}
|
|
600
|
+
>
|
|
601
|
+
{available.label}
|
|
602
|
+
</button>
|
|
603
|
+
))}
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
) : null}
|
|
608
|
+
</div>
|
|
609
|
+
);
|
|
610
|
+
}
|