@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.
- package/README.md +178 -0
- package/package.json +18 -9
- package/src/__tests__/connections.test.tsx +338 -0
- package/src/components/CredentialSchemaForm.tsx +196 -0
- package/src/components/index.ts +20 -0
- package/src/components/pages/ConnectionsPage.tsx +426 -0
- package/src/components/pages/types.ts +176 -0
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
+
}
|