@startsimpli/integrations 0.2.20 → 0.2.22
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/package.json +1 -1
- package/src/__tests__/connections-me-client.test.ts +122 -0
- package/src/__tests__/connections.test.tsx +206 -1
- package/src/api/connections.ts +129 -0
- package/src/api/index.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/components/pages/ConnectionsPage.tsx +378 -64
- package/src/components/pages/MemberConnectionRoster.tsx +182 -0
- package/src/components/pages/types.ts +138 -0
|
@@ -44,20 +44,131 @@ import {
|
|
|
44
44
|
import type {
|
|
45
45
|
Connection,
|
|
46
46
|
ConnectionChoice,
|
|
47
|
+
ConnectionChoicePage,
|
|
48
|
+
ConnectionInvokeResult,
|
|
47
49
|
ConnectionPermissions,
|
|
48
50
|
ConnectionTarget,
|
|
49
51
|
ConnectorAction,
|
|
50
52
|
ConnectorCatalogEntry,
|
|
51
53
|
ConnectorFieldSpec,
|
|
52
54
|
ConnectionsApi,
|
|
55
|
+
MemberConnectionRow,
|
|
56
|
+
MemberConnectionsApi,
|
|
53
57
|
} from './types';
|
|
58
|
+
import { MemberConnectionRoster } from './MemberConnectionRoster';
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
type ClassNames = Partial<Record<
|
|
61
|
+
'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error', string
|
|
62
|
+
>>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* TWO SURFACES, ONE COMPOSER (bd startsim-768w.22.25.6).
|
|
66
|
+
*
|
|
67
|
+
* A discriminated union rather than an optional slug, because the two modes do
|
|
68
|
+
* not merely differ in their arguments — `personal` has no slug ANYWHERE, which
|
|
69
|
+
* is the member surface's whole security property (see `MemberConnectionsApi`).
|
|
70
|
+
* Making `foundrySlug` optional would let a personal mount pass one and read as
|
|
71
|
+
* though it meant something.
|
|
72
|
+
*/
|
|
73
|
+
export type ConnectionsPageProps =
|
|
74
|
+
| {
|
|
75
|
+
mode?: 'foundry';
|
|
76
|
+
api: ConnectionsApi;
|
|
77
|
+
foundrySlug: string;
|
|
78
|
+
classNames?: ClassNames;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
mode: 'personal';
|
|
82
|
+
api: MemberConnectionsApi;
|
|
83
|
+
/** There is no slug on this surface. The token's audience names the foundry. */
|
|
84
|
+
foundrySlug?: never;
|
|
85
|
+
classNames?: ClassNames;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The api as the components below use it: SLUGLESS, because the slug is a
|
|
90
|
+
* property of the mount and not of any control on the page.
|
|
91
|
+
*
|
|
92
|
+
* The foundry-level verbs are optional, and that is what gates the UI. A control
|
|
93
|
+
* renders when the verb behind it exists, so personal mode cannot render a
|
|
94
|
+
* button whose handler would be `undefined` — the type checker, not a
|
|
95
|
+
* `mode === 'personal'` sprinkled through nine call sites, is what keeps the
|
|
96
|
+
* two surfaces honest.
|
|
97
|
+
*/
|
|
98
|
+
interface BoundApi {
|
|
99
|
+
connectorCatalog(): Promise<{ results: ConnectorCatalogEntry[] }>;
|
|
100
|
+
connections(): Promise<{ results: Connection[]; permissions: ConnectionPermissions }>;
|
|
101
|
+
connect(input: {
|
|
102
|
+
connector_key: string;
|
|
103
|
+
name?: string;
|
|
104
|
+
credentials?: Record<string, unknown>;
|
|
105
|
+
config?: Record<string, unknown>;
|
|
106
|
+
capabilities_enabled?: string[];
|
|
107
|
+
}): Promise<Connection>;
|
|
108
|
+
checkConnection(id: string): Promise<{ ok: boolean; account_label: string; detail: string }>;
|
|
109
|
+
rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
|
|
110
|
+
disconnect(id: string): Promise<void>;
|
|
111
|
+
updateConnection?(id: string, input: {
|
|
112
|
+
name?: string; capabilities_enabled?: string[]; config?: Record<string, unknown>;
|
|
113
|
+
}): Promise<Connection>;
|
|
114
|
+
connectionChoices?(id: string, params?: { kind?: string; cursor?: string }):
|
|
115
|
+
Promise<ConnectionChoicePage>;
|
|
116
|
+
wireTarget?(id: string, input: {
|
|
117
|
+
choice_id: string; name?: string; options?: Record<string, unknown>;
|
|
118
|
+
}): Promise<ConnectionTarget>;
|
|
119
|
+
confirmTarget?(id: string, destinationId: string): Promise<ConnectionTarget>;
|
|
120
|
+
connectionActions?(id: string): Promise<{ results: ConnectorAction[] }>;
|
|
121
|
+
invokeConnectionAction?(id: string, action: string, input: {
|
|
122
|
+
destination_id: string; record_type?: string; records?: unknown[];
|
|
123
|
+
}): Promise<ConnectionInvokeResult>;
|
|
124
|
+
/** Per-member governance (bd startsim-768w.22.25.8). Foundry mode only, and
|
|
125
|
+
absent on an api that predates the endpoints — see `ConnectionsApi`. */
|
|
126
|
+
setMemberConnectorPolicy?(connectorKey: string, allowed: boolean): Promise<unknown>;
|
|
127
|
+
memberConnections?(): Promise<{
|
|
128
|
+
results: MemberConnectionRow[]; permissions: ConnectionPermissions;
|
|
129
|
+
}>;
|
|
130
|
+
disconnectMemberConnection?(id: string): Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bindApi(props: ConnectionsPageProps): BoundApi {
|
|
134
|
+
if (props.mode === 'personal') {
|
|
135
|
+
// Already slugless. Passed through rather than wrapped, so nothing here can
|
|
136
|
+
// grow a slug argument by accident.
|
|
137
|
+
return props.api;
|
|
138
|
+
}
|
|
139
|
+
const { api, foundrySlug: slug } = props;
|
|
140
|
+
// Bound only when the injected client HAS them, so `verb in api` stays the
|
|
141
|
+
// test the components run — a wrapper that always exists and throws inside
|
|
142
|
+
// would render the control and fail on click.
|
|
143
|
+
const memberPolicy = api.setMemberConnectorPolicy
|
|
144
|
+
? { setMemberConnectorPolicy: (key: string, allowed: boolean) =>
|
|
145
|
+
api.setMemberConnectorPolicy!(slug, key, allowed) }
|
|
146
|
+
: {};
|
|
147
|
+
const roster = api.memberConnections
|
|
148
|
+
? { memberConnections: () => api.memberConnections!(slug) }
|
|
149
|
+
: {};
|
|
150
|
+
const cutMember = api.disconnectMemberConnection
|
|
151
|
+
? { disconnectMemberConnection: (id: string) =>
|
|
152
|
+
api.disconnectMemberConnection!(slug, id) }
|
|
153
|
+
: {};
|
|
154
|
+
return {
|
|
155
|
+
...memberPolicy,
|
|
156
|
+
...roster,
|
|
157
|
+
...cutMember,
|
|
158
|
+
connectorCatalog: () => api.connectorCatalog(slug),
|
|
159
|
+
connections: () => api.connections(slug),
|
|
160
|
+
connect: (input) => api.connect(slug, input),
|
|
161
|
+
checkConnection: (id) => api.checkConnection(slug, id),
|
|
162
|
+
rotateCredential: (id, credentials) => api.rotateCredential(slug, id, credentials),
|
|
163
|
+
disconnect: (id) => api.disconnect(slug, id),
|
|
164
|
+
updateConnection: (id, input) => api.updateConnection(slug, id, input),
|
|
165
|
+
connectionChoices: (id, params) => api.connectionChoices(slug, id, params),
|
|
166
|
+
wireTarget: (id, input) => api.wireTarget(slug, id, input),
|
|
167
|
+
confirmTarget: (id, destinationId) => api.confirmTarget(slug, id, destinationId),
|
|
168
|
+
connectionActions: (id) => api.connectionActions(slug, id),
|
|
169
|
+
invokeConnectionAction: (id, action, input) =>
|
|
170
|
+
api.invokeConnectionAction(slug, id, action, input),
|
|
171
|
+
};
|
|
61
172
|
}
|
|
62
173
|
|
|
63
174
|
const DEFAULTS = {
|
|
@@ -85,19 +196,49 @@ const CAPABILITY_COPY: Record<string, string> = {
|
|
|
85
196
|
writeback_credential: 'Issue a token this vendor uses to call us',
|
|
86
197
|
};
|
|
87
198
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
199
|
+
/**
|
|
200
|
+
* A refusal must read as a refusal, not as an empty page — and it must name the
|
|
201
|
+
* refusal that actually happened. This runs in ONE place: the catch around the
|
|
202
|
+
* two GETs `reload` makes. So trace which 403 can reach here on each surface.
|
|
203
|
+
*
|
|
204
|
+
* Control plane: the caller lacks the connections permission on this foundry.
|
|
205
|
+
*
|
|
206
|
+
* Member surface: `IsSeatedMember` denied — no ACTIVE `CompanyMembership` and
|
|
207
|
+
* not the org's `owner`. That is the ONLY 403 either GET produces;
|
|
208
|
+
* `MyConnectorCatalogView` answers a foundry that has allowed nothing with
|
|
209
|
+
* `{results: []}` and a 200, and `MyConnectionsView.get` runs no per-connector
|
|
210
|
+
* policy at all. The allowlist refusal (`_NOT_ALLOWED`) is raised by the POST,
|
|
211
|
+
* lands in `CatalogCard`'s own catch, and renders the server's wording verbatim.
|
|
212
|
+
* Blaming the allowlist here would send someone whose SEAT is gone to ask an
|
|
213
|
+
* admin to allow a connector — the admin could allow every one of them and this
|
|
214
|
+
* page would still refuse.
|
|
215
|
+
*/
|
|
216
|
+
function readFailureMessage(err: unknown, personal: boolean): string {
|
|
90
217
|
const status = (err as { status?: number })?.status;
|
|
91
218
|
if (status === 403) {
|
|
92
|
-
return
|
|
219
|
+
return personal
|
|
220
|
+
? 'You are not a member of this foundry, or your access to it has ended. Ask an admin to add you.'
|
|
221
|
+
: 'You do not have access to this foundry’s connections. Ask an admin for the connections permission.';
|
|
93
222
|
}
|
|
94
223
|
if (status === 401) return 'Your session has expired. Sign in again.';
|
|
95
224
|
const message = (err as { message?: string })?.message;
|
|
96
225
|
return message || 'Could not load connections.';
|
|
97
226
|
}
|
|
98
227
|
|
|
99
|
-
export function ConnectionsPage(
|
|
228
|
+
export function ConnectionsPage(props: ConnectionsPageProps) {
|
|
229
|
+
const { classNames = {} } = props;
|
|
230
|
+
const personal = props.mode === 'personal';
|
|
100
231
|
const cn = { ...DEFAULTS, ...classNames };
|
|
232
|
+
// Keyed on the PARTS, never on `props` — a props object is a new object every
|
|
233
|
+
// render, so memoising on it would rebuild the binding each time, change
|
|
234
|
+
// `reload`'s identity each time, and turn the load effect into a loop.
|
|
235
|
+
const injected = props.api;
|
|
236
|
+
const slug = props.mode === 'personal' ? undefined : props.foundrySlug;
|
|
237
|
+
const api = React.useMemo(
|
|
238
|
+
() => bindApi(props),
|
|
239
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
+
[personal, injected, slug],
|
|
241
|
+
);
|
|
101
242
|
|
|
102
243
|
const [catalog, setCatalog] = React.useState<ConnectorCatalogEntry[]>([]);
|
|
103
244
|
const [connections, setConnections] = React.useState<Connection[]>([]);
|
|
@@ -111,19 +252,22 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
111
252
|
setLoading(true);
|
|
112
253
|
try {
|
|
113
254
|
const [cat, conns] = await Promise.all([
|
|
114
|
-
|
|
115
|
-
|
|
255
|
+
// In personal mode this is the ALLOWED catalog, already narrowed by the
|
|
256
|
+
// foundry's policy server-side. Rendered as handed: filtering here would
|
|
257
|
+
// put the withheld connectors in the browser to be filtered out of.
|
|
258
|
+
api.connectorCatalog(),
|
|
259
|
+
api.connections(),
|
|
116
260
|
]);
|
|
117
261
|
setCatalog(cat.results ?? []);
|
|
118
262
|
setConnections(conns.results ?? []);
|
|
119
263
|
setPermissions(conns.permissions ?? { view: true, manage: false, issue_tokens: false });
|
|
120
264
|
setLoadError('');
|
|
121
265
|
} catch (err) {
|
|
122
|
-
setLoadError(readFailureMessage(err));
|
|
266
|
+
setLoadError(readFailureMessage(err, personal));
|
|
123
267
|
} finally {
|
|
124
268
|
setLoading(false);
|
|
125
269
|
}
|
|
126
|
-
}, [api,
|
|
270
|
+
}, [api, personal]);
|
|
127
271
|
|
|
128
272
|
React.useEffect(() => { void reload(); }, [reload]);
|
|
129
273
|
|
|
@@ -134,16 +278,26 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
134
278
|
return (
|
|
135
279
|
<div className={cn.root}>
|
|
136
280
|
<section className={cn.section}>
|
|
137
|
-
|
|
281
|
+
{/* WHOSE ACCOUNTS THESE ARE. On the member surface the list is already
|
|
282
|
+
owner-scoped server-side (`for_owner`), and the heading has to say so
|
|
283
|
+
— a member looking at "Connected accounts" inside their foundry's app
|
|
284
|
+
has no way to tell whether they are seeing the foundry's keys. */}
|
|
285
|
+
<h2 className={cn.heading}>
|
|
286
|
+
{personal ? 'Your connected accounts' : 'Connected accounts'}
|
|
287
|
+
</h2>
|
|
138
288
|
{loading && connections.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
|
|
139
289
|
{!loading && connections.length === 0 ? (
|
|
140
|
-
<p className={cn.muted}>
|
|
290
|
+
<p className={cn.muted}>
|
|
291
|
+
{personal
|
|
292
|
+
? 'You have not connected an account yet.'
|
|
293
|
+
: 'Nothing connected yet.'}
|
|
294
|
+
</p>
|
|
141
295
|
) : null}
|
|
142
296
|
{connections.map((connection) => (
|
|
143
297
|
<ConnectionCard
|
|
144
298
|
key={connection.id}
|
|
145
299
|
api={api}
|
|
146
|
-
|
|
300
|
+
personal={personal}
|
|
147
301
|
connection={connection}
|
|
148
302
|
// The CONNECTOR's own catalog row. It carries the capability list,
|
|
149
303
|
// so the card can offer a capability this connection does not have
|
|
@@ -160,17 +314,30 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
160
314
|
</section>
|
|
161
315
|
|
|
162
316
|
<section className={cn.section}>
|
|
163
|
-
<h2 className={cn.heading}>
|
|
317
|
+
<h2 className={cn.heading}>
|
|
318
|
+
{personal ? 'Connect your own account' : 'Available connectors'}
|
|
319
|
+
</h2>
|
|
320
|
+
{personal && !loading && catalog.length === 0 ? (
|
|
321
|
+
<p className={cn.muted}>
|
|
322
|
+
Your foundry has not opened any connector to members yet. An admin
|
|
323
|
+
decides which ones you may connect your own account on.
|
|
324
|
+
</p>
|
|
325
|
+
) : null}
|
|
164
326
|
{catalog.map((entry) => (
|
|
165
327
|
<CatalogCard
|
|
166
328
|
key={entry.key}
|
|
167
329
|
api={api}
|
|
168
|
-
|
|
330
|
+
personal={personal}
|
|
169
331
|
entry={entry}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
332
|
+
/* Refreshing the CATALOG is what makes a flipped toggle stick — the
|
|
333
|
+
allowed flag rides on the row, so re-reading connections alone
|
|
334
|
+
would leave the switch showing the value it was rendered with. */
|
|
335
|
+
onPolicyChanged={reload}
|
|
336
|
+
// What ALREADY exists on this connector — in personal mode that is
|
|
337
|
+
// the member's own cards and nobody else's, because `connections`
|
|
338
|
+
// came back owner-scoped. A second vendor account is legitimate and
|
|
339
|
+
// stays possible; someone who is really trying to install a rotated
|
|
340
|
+
// key is not, and they are the ones this form used to swallow.
|
|
174
341
|
existing={connections.filter((c) => c.connector_key === entry.key)}
|
|
175
342
|
canManage={permissions.manage}
|
|
176
343
|
classNames={cn}
|
|
@@ -178,6 +345,24 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
178
345
|
/>
|
|
179
346
|
))}
|
|
180
347
|
</section>
|
|
348
|
+
|
|
349
|
+
{/* THE OTHER HALF OF THE ALLOWLIST (bd startsim-768w.22.25.8): who has
|
|
350
|
+
actually connected. Its own component, and deliberately not folded into
|
|
351
|
+
the two sections above — those render the viewer's own rows and this
|
|
352
|
+
one enumerates OTHER PEOPLE'S accounts, which is a different entity, a
|
|
353
|
+
different serializer and a different disclosure. Keeping "list every
|
|
354
|
+
member's connections" outside the component whose security property is
|
|
355
|
+
that personal and foundry rows never mix means one forgotten `personal`
|
|
356
|
+
guard cannot leak it. It renders in foundry mode only, and only when
|
|
357
|
+
the api has the verb. */}
|
|
358
|
+
{!personal && api.memberConnections ? (
|
|
359
|
+
<MemberConnectionRoster
|
|
360
|
+
list={api.memberConnections}
|
|
361
|
+
disconnect={api.disconnectMemberConnection}
|
|
362
|
+
canManage={permissions.manage}
|
|
363
|
+
classNames={cn}
|
|
364
|
+
/>
|
|
365
|
+
) : null}
|
|
181
366
|
</div>
|
|
182
367
|
);
|
|
183
368
|
}
|
|
@@ -256,16 +441,19 @@ function orderCapabilities(declared: string[], selected: Iterable<string>): stri
|
|
|
256
441
|
// --------------------------------------------------------------------------
|
|
257
442
|
|
|
258
443
|
function CatalogCard({
|
|
259
|
-
api,
|
|
444
|
+
api, personal, entry, existing, canManage, classNames: cn, onConnected,
|
|
445
|
+
onPolicyChanged,
|
|
260
446
|
}: {
|
|
261
|
-
api:
|
|
262
|
-
|
|
447
|
+
api: BoundApi;
|
|
448
|
+
personal: boolean;
|
|
263
449
|
entry: ConnectorCatalogEntry;
|
|
264
|
-
/** Cards
|
|
450
|
+
/** Cards already held on this connector — the foundry's, or the member's own. */
|
|
265
451
|
existing: Connection[];
|
|
266
452
|
canManage: boolean;
|
|
267
453
|
classNames: typeof DEFAULTS;
|
|
268
454
|
onConnected: () => void;
|
|
455
|
+
/** Re-read the catalog after the member toggle is written. Foundry mode only. */
|
|
456
|
+
onPolicyChanged?: () => void;
|
|
269
457
|
}) {
|
|
270
458
|
const [open, setOpen] = React.useState(false);
|
|
271
459
|
const [values, setValues] = React.useState<Record<string, unknown>>({});
|
|
@@ -303,7 +491,7 @@ function CatalogCard({
|
|
|
303
491
|
// recordings are stored.
|
|
304
492
|
if (typeof v === 'boolean' || typeof v === 'number' || v) config[spec.key] = v;
|
|
305
493
|
}
|
|
306
|
-
await api.connect(
|
|
494
|
+
await api.connect({
|
|
307
495
|
connector_key: entry.key, name: entry.name, credentials, config,
|
|
308
496
|
// Always sent. Leaving it off is what wrote the inert cards.
|
|
309
497
|
capabilities_enabled: orderCapabilities(entry.capabilities, capabilities),
|
|
@@ -345,6 +533,20 @@ function CatalogCard({
|
|
|
345
533
|
) : null}
|
|
346
534
|
</div>
|
|
347
535
|
|
|
536
|
+
{/* On the card it governs, not on a settings list of its own — an admin
|
|
537
|
+
should not have to hold two lists of connectors in their head to know
|
|
538
|
+
which ones members can use. */}
|
|
539
|
+
{!personal && api.setMemberConnectorPolicy && entry.member_policy ? (
|
|
540
|
+
<MemberPolicyToggle
|
|
541
|
+
connectorKey={entry.key}
|
|
542
|
+
policy={entry.member_policy}
|
|
543
|
+
canManage={canManage}
|
|
544
|
+
write={api.setMemberConnectorPolicy}
|
|
545
|
+
classNames={cn}
|
|
546
|
+
onChanged={onPolicyChanged}
|
|
547
|
+
/>
|
|
548
|
+
) : null}
|
|
549
|
+
|
|
348
550
|
{open ? (
|
|
349
551
|
<div className="mt-4 space-y-3">
|
|
350
552
|
{/* SAID BEFORE THE KEY IS PASTED, not after the server refuses.
|
|
@@ -414,6 +616,105 @@ function CatalogCard({
|
|
|
414
616
|
);
|
|
415
617
|
}
|
|
416
618
|
|
|
619
|
+
// --------------------------------------------------------------------------
|
|
620
|
+
// who may connect their OWN account (bd startsim-768w.22.25.8)
|
|
621
|
+
// --------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* ONE CONNECTOR, ONE DECISION: may this foundry's members connect their own
|
|
625
|
+
* account on it?
|
|
626
|
+
*
|
|
627
|
+
* DEFAULT-DENY, and the copy has to carry that, because the switch is not a
|
|
628
|
+
* display preference. Turning it on means members send THEIR vendor data into
|
|
629
|
+
* this foundry, under their own credentials, for this foundry to store and
|
|
630
|
+
* process — an admin is agreeing to receive that, and is entitled to read it in
|
|
631
|
+
* those words before flipping anything.
|
|
632
|
+
*
|
|
633
|
+
* TURNING IT OFF IS NOT A KILL SWITCH, and saying so in the confirmation rather
|
|
634
|
+
* than in a docs page is the point. Off stops NEW personal connections
|
|
635
|
+
* immediately; every connection that already exists keeps running, with its
|
|
636
|
+
* credential and its scheduled fetches. Ending those is one explicit disconnect
|
|
637
|
+
* per person from the roster below — destructive, irreversible, and never a side
|
|
638
|
+
* effect of tightening a default. The wording here is the server's own, lifted
|
|
639
|
+
* from `apps/connectors/services/policy.py` so the two cannot drift apart.
|
|
640
|
+
*
|
|
641
|
+
* A connector that CANNOT be opened yet renders disabled WITH the server's
|
|
642
|
+
* reason instead of vanishing: an admin who cannot find Dropbox in this list
|
|
643
|
+
* would reasonably conclude the catalog is broken, when the real answer is that
|
|
644
|
+
* the member surface has no OAuth path yet.
|
|
645
|
+
*
|
|
646
|
+
* THE THIRD STATE IS THE ONE THAT MATTERS: already allowed, and no longer
|
|
647
|
+
* openable — a connector opened while it was an API key that has since moved to
|
|
648
|
+
* OAuth, or whose class stopped importing. The server answers `settable: true`
|
|
649
|
+
* with a `reason`, because the only move an ON switch offers is OFF and the
|
|
650
|
+
* write always accepts that. Disabling it on `reason` alone would draw a switch
|
|
651
|
+
* an admin cannot turn off, which is the exact opposite of what a governance
|
|
652
|
+
* control is for. It renders live, with the reason as a one-way warning.
|
|
653
|
+
*/
|
|
654
|
+
function MemberPolicyToggle({
|
|
655
|
+
connectorKey, policy, canManage, write, classNames: cn, onChanged,
|
|
656
|
+
}: {
|
|
657
|
+
connectorKey: string;
|
|
658
|
+
policy: NonNullable<ConnectorCatalogEntry['member_policy']>;
|
|
659
|
+
canManage: boolean;
|
|
660
|
+
write: (connectorKey: string, allowed: boolean) => Promise<unknown>;
|
|
661
|
+
classNames: typeof DEFAULTS;
|
|
662
|
+
onChanged?: () => void;
|
|
663
|
+
}) {
|
|
664
|
+
const [busy, setBusy] = React.useState(false);
|
|
665
|
+
const [error, setError] = React.useState('');
|
|
666
|
+
const id = `member-policy-${connectorKey}`;
|
|
667
|
+
|
|
668
|
+
async function set(allowed: boolean) {
|
|
669
|
+
setBusy(true);
|
|
670
|
+
setError('');
|
|
671
|
+
try {
|
|
672
|
+
await write(connectorKey, allowed);
|
|
673
|
+
onChanged?.();
|
|
674
|
+
} catch (err) {
|
|
675
|
+
setError((err as { message?: string })?.message || 'Could not save that.');
|
|
676
|
+
} finally {
|
|
677
|
+
setBusy(false);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return (
|
|
682
|
+
<div className="mt-3 border-t border-gray-100 pt-3" data-member-policy={connectorKey}>
|
|
683
|
+
<label htmlFor={id} className="flex items-start gap-2 text-sm">
|
|
684
|
+
<input
|
|
685
|
+
id={id}
|
|
686
|
+
type="checkbox"
|
|
687
|
+
className="mt-0.5"
|
|
688
|
+
checked={policy.allowed}
|
|
689
|
+
disabled={busy || !canManage || !policy.settable}
|
|
690
|
+
onChange={(event) => void set(event.target.checked)}
|
|
691
|
+
/>
|
|
692
|
+
<span>
|
|
693
|
+
<span className="text-gray-900">Members may connect their own account</span>
|
|
694
|
+
<span className={`block ${cn.muted}`}>
|
|
695
|
+
{policy.settable
|
|
696
|
+
? 'Each member signs in with their own key, and sees only their own'
|
|
697
|
+
+ ' data. Allowing this means their vendor data flows into this'
|
|
698
|
+
+ ' foundry. Turning it off later stops new connections — the ones'
|
|
699
|
+
+ ' already connected keep running until you disconnect them below.'
|
|
700
|
+
: policy.reason}
|
|
701
|
+
</span>
|
|
702
|
+
{/* Settable AND blocked: open today, unopenable tomorrow. Said next to
|
|
703
|
+
the switch rather than after the click, because the click is the
|
|
704
|
+
irreversible half. */}
|
|
705
|
+
{policy.settable && policy.reason ? (
|
|
706
|
+
<span className={`block ${cn.error}`} data-member-policy-oneway={connectorKey}>
|
|
707
|
+
This is on, but can no longer be turned back on once off:{' '}
|
|
708
|
+
{policy.reason}
|
|
709
|
+
</span>
|
|
710
|
+
) : null}
|
|
711
|
+
</span>
|
|
712
|
+
</label>
|
|
713
|
+
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
714
|
+
</div>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
417
718
|
// --------------------------------------------------------------------------
|
|
418
719
|
// replacing the key on a connection that already exists
|
|
419
720
|
// --------------------------------------------------------------------------
|
|
@@ -437,10 +738,9 @@ function CatalogCard({
|
|
|
437
738
|
* rotation quietly a config change.
|
|
438
739
|
*/
|
|
439
740
|
function ReplaceKeyForm({
|
|
440
|
-
api,
|
|
741
|
+
api, connection, entry, classNames: cn, onDone,
|
|
441
742
|
}: {
|
|
442
|
-
api:
|
|
443
|
-
foundrySlug: string;
|
|
743
|
+
api: BoundApi;
|
|
444
744
|
connection: Connection;
|
|
445
745
|
entry?: ConnectorCatalogEntry;
|
|
446
746
|
classNames: typeof DEFAULTS;
|
|
@@ -499,7 +799,7 @@ function ReplaceKeyForm({
|
|
|
499
799
|
onClick={() => {
|
|
500
800
|
setBusy(true);
|
|
501
801
|
setError('');
|
|
502
|
-
api.rotateCredential(
|
|
802
|
+
api.rotateCredential(connection.id, submitted)
|
|
503
803
|
.then(() => { setValues({}); onDone(); })
|
|
504
804
|
// The server's typed answer, verbatim: it already scrubbed itself of
|
|
505
805
|
// the values just submitted, and "the vendor rejected this key" is
|
|
@@ -541,10 +841,9 @@ function ReplaceKeyForm({
|
|
|
541
841
|
* `ingest_transcripts` the off position is the whole privacy posture.
|
|
542
842
|
*/
|
|
543
843
|
function ConnectionSettingsForm({
|
|
544
|
-
api,
|
|
844
|
+
api, connection, fields, classNames: cn, onDone,
|
|
545
845
|
}: {
|
|
546
|
-
api:
|
|
547
|
-
foundrySlug: string;
|
|
846
|
+
api: BoundApi;
|
|
548
847
|
connection: Connection;
|
|
549
848
|
fields: ConnectorFieldSpec[];
|
|
550
849
|
classNames: typeof DEFAULTS;
|
|
@@ -578,7 +877,7 @@ function ConnectionSettingsForm({
|
|
|
578
877
|
? Number(value)
|
|
579
878
|
: value;
|
|
580
879
|
}
|
|
581
|
-
await api.updateConnection(
|
|
880
|
+
await api.updateConnection!(connection.id, { config });
|
|
582
881
|
onDone();
|
|
583
882
|
} catch (err) {
|
|
584
883
|
setError((err as { message?: string })?.message || 'Those settings were not saved.');
|
|
@@ -615,10 +914,10 @@ function ConnectionSettingsForm({
|
|
|
615
914
|
}
|
|
616
915
|
|
|
617
916
|
function ConnectionCard({
|
|
618
|
-
api,
|
|
917
|
+
api, personal, connection, entry, canManage, classNames: cn, onChanged,
|
|
619
918
|
}: {
|
|
620
|
-
api:
|
|
621
|
-
|
|
919
|
+
api: BoundApi;
|
|
920
|
+
personal: boolean;
|
|
622
921
|
connection: Connection;
|
|
623
922
|
/** This connector's catalog row: its capabilities AND its field schema. */
|
|
624
923
|
entry?: ConnectorCatalogEntry;
|
|
@@ -692,7 +991,7 @@ function ConnectionCard({
|
|
|
692
991
|
className={cn.button}
|
|
693
992
|
disabled={Boolean(busy)}
|
|
694
993
|
onClick={() => void guard('check', async () => {
|
|
695
|
-
const res = await api.checkConnection(
|
|
994
|
+
const res = await api.checkConnection(connection.id);
|
|
696
995
|
setNotice(res.detail || (res.ok ? 'Connection is healthy.' : ''));
|
|
697
996
|
onChanged();
|
|
698
997
|
})}
|
|
@@ -714,19 +1013,26 @@ function ConnectionCard({
|
|
|
714
1013
|
>
|
|
715
1014
|
{replacing ? 'Cancel' : 'Replace key'}
|
|
716
1015
|
</button>
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1016
|
+
{/* DISCOVER -> WIRE -> FIRE is foundry-level and stays there. The
|
|
1017
|
+
member router serves no choices, targets or actions at all, so
|
|
1018
|
+
the control is gated on the verb EXISTING rather than on a mode
|
|
1019
|
+
flag — there is no way to render a button here whose handler is
|
|
1020
|
+
undefined. */}
|
|
1021
|
+
{api.connectionChoices && api.connectionActions ? (
|
|
1022
|
+
<button
|
|
1023
|
+
type="button"
|
|
1024
|
+
className={cn.button}
|
|
1025
|
+
disabled={Boolean(busy)}
|
|
1026
|
+
onClick={() => void guard('discover', async () => {
|
|
1027
|
+
const page = await api.connectionChoices!(connection.id);
|
|
1028
|
+
setChoices(page.results ?? []);
|
|
1029
|
+
const available = await api.connectionActions!(connection.id);
|
|
1030
|
+
setActions(available.results ?? []);
|
|
1031
|
+
})}
|
|
1032
|
+
>
|
|
1033
|
+
{busy === 'discover' ? 'Loading…' : 'Workflows'}
|
|
1034
|
+
</button>
|
|
1035
|
+
) : null}
|
|
730
1036
|
{/* SETTINGS, which this card has never had. A connector's
|
|
731
1037
|
`config_schema` ships on every catalog row and was rendered
|
|
732
1038
|
exactly once — on the connect form, before the account existed.
|
|
@@ -735,7 +1041,7 @@ function ConnectionCard({
|
|
|
735
1041
|
recordings of people's conversations are stored was reachable
|
|
736
1042
|
only by editing the database. `ReplaceKeyForm` already pointed at
|
|
737
1043
|
"the connection's own settings" as if this existed. */}
|
|
738
|
-
{configFields.length ? (
|
|
1044
|
+
{configFields.length && api.updateConnection ? (
|
|
739
1045
|
<button
|
|
740
1046
|
type="button"
|
|
741
1047
|
className={cn.button}
|
|
@@ -769,7 +1075,6 @@ function ConnectionCard({
|
|
|
769
1075
|
{settingsOpen ? (
|
|
770
1076
|
<ConnectionSettingsForm
|
|
771
1077
|
api={api}
|
|
772
|
-
foundrySlug={foundrySlug}
|
|
773
1078
|
connection={connection}
|
|
774
1079
|
fields={configFields}
|
|
775
1080
|
classNames={cn}
|
|
@@ -780,7 +1085,6 @@ function ConnectionCard({
|
|
|
780
1085
|
{replacing ? (
|
|
781
1086
|
<ReplaceKeyForm
|
|
782
1087
|
api={api}
|
|
783
|
-
foundrySlug={foundrySlug}
|
|
784
1088
|
connection={connection}
|
|
785
1089
|
entry={entry}
|
|
786
1090
|
classNames={cn}
|
|
@@ -809,7 +1113,7 @@ function ConnectionCard({
|
|
|
809
1113
|
className={cn.button}
|
|
810
1114
|
disabled={Boolean(busy)}
|
|
811
1115
|
onClick={() => void guard('disconnect', async () => {
|
|
812
|
-
await api.disconnect(
|
|
1116
|
+
await api.disconnect(connection.id);
|
|
813
1117
|
setConfirmingDisconnect(false);
|
|
814
1118
|
setNotice('Disconnected.');
|
|
815
1119
|
onChanged();
|
|
@@ -837,21 +1141,26 @@ function ConnectionCard({
|
|
|
837
1141
|
scope="connection"
|
|
838
1142
|
declared={offered}
|
|
839
1143
|
selected={selected}
|
|
840
|
-
|
|
1144
|
+
// On the member surface there is no PATCH, so the picker is
|
|
1145
|
+
// INFORMATIONAL rather than absent. A member chose these at connect time
|
|
1146
|
+
// and `POST` accepted them; hiding the list afterwards would make that
|
|
1147
|
+
// choice look like it never happened, and leave them unable to see what
|
|
1148
|
+
// their own account is doing.
|
|
1149
|
+
disabled={!canManage || !api.updateConnection || Boolean(busy)}
|
|
841
1150
|
idPrefix={`connection-${connection.id}`}
|
|
842
1151
|
onToggle={(capability, on) => setSelected((prev) => (
|
|
843
1152
|
on ? [...prev, capability] : prev.filter((c) => c !== capability)
|
|
844
1153
|
))}
|
|
845
1154
|
classNames={cn}
|
|
846
1155
|
>
|
|
847
|
-
{canManage ? (
|
|
1156
|
+
{canManage && api.updateConnection ? (
|
|
848
1157
|
<div className="mt-2 flex items-center gap-3">
|
|
849
1158
|
<button
|
|
850
1159
|
type="button"
|
|
851
1160
|
className={cn.button}
|
|
852
1161
|
disabled={!dirty || pending.length === 0 || Boolean(busy)}
|
|
853
1162
|
onClick={() => void guard('capabilities', async () => {
|
|
854
|
-
await api.updateConnection(
|
|
1163
|
+
await api.updateConnection!(connection.id, {
|
|
855
1164
|
capabilities_enabled: pending,
|
|
856
1165
|
});
|
|
857
1166
|
setNotice('Saved.');
|
|
@@ -866,6 +1175,11 @@ function ConnectionCard({
|
|
|
866
1175
|
</span>
|
|
867
1176
|
) : null}
|
|
868
1177
|
</div>
|
|
1178
|
+
) : personal ? (
|
|
1179
|
+
<p className={cn.muted}>
|
|
1180
|
+
Chosen when you connected. Ask an admin to change what this account
|
|
1181
|
+
may do, or disconnect it and connect again.
|
|
1182
|
+
</p>
|
|
869
1183
|
) : null}
|
|
870
1184
|
</CapabilityPicker>
|
|
871
1185
|
|
|
@@ -897,7 +1211,7 @@ function ConnectionCard({
|
|
|
897
1211
|
className={cn.button}
|
|
898
1212
|
disabled={Boolean(busy)}
|
|
899
1213
|
onClick={() => void guard('wire', async () => {
|
|
900
|
-
setTarget(await api.wireTarget(
|
|
1214
|
+
setTarget(await api.wireTarget!(connection.id, {
|
|
901
1215
|
choice_id: choice.id,
|
|
902
1216
|
}));
|
|
903
1217
|
})}
|
|
@@ -925,7 +1239,7 @@ function ConnectionCard({
|
|
|
925
1239
|
className={cn.button}
|
|
926
1240
|
disabled={Boolean(busy)}
|
|
927
1241
|
onClick={() => void guard('confirm', async () => {
|
|
928
|
-
setTarget(await api.confirmTarget(
|
|
1242
|
+
setTarget(await api.confirmTarget!(connection.id, target.id));
|
|
929
1243
|
})}
|
|
930
1244
|
>
|
|
931
1245
|
Confirm this target
|
|
@@ -940,8 +1254,8 @@ function ConnectionCard({
|
|
|
940
1254
|
className={cn.button}
|
|
941
1255
|
disabled={Boolean(busy)}
|
|
942
1256
|
onClick={() => void guard('invoke', async () => {
|
|
943
|
-
const result = await api.invokeConnectionAction(
|
|
944
|
-
|
|
1257
|
+
const result = await api.invokeConnectionAction!(
|
|
1258
|
+
connection.id, available.name,
|
|
945
1259
|
{ destination_id: target.id, records: [] },
|
|
946
1260
|
);
|
|
947
1261
|
// `accepted` is not `completed`. Saying "done" here would
|