@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.
@@ -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
- export interface ConnectionsPageProps {
56
- api: ConnectionsApi;
57
- foundrySlug: string;
58
- classNames?: Partial<Record<
59
- 'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error', string
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
- /** A refusal must read as a refusal, not as an empty page. */
89
- function readFailureMessage(err: unknown): string {
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 'You do not have access to this foundry’s connections. Ask an admin for the connections permission.';
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({ api, foundrySlug, classNames = {} }: ConnectionsPageProps) {
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
- api.connectorCatalog(foundrySlug),
115
- api.connections(foundrySlug),
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, foundrySlug]);
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
- <h2 className={cn.heading}>Connected accounts</h2>
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}>Nothing connected yet.</p>
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
- foundrySlug={foundrySlug}
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}>Available connectors</h2>
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
- foundrySlug={foundrySlug}
330
+ personal={personal}
169
331
  entry={entry}
170
- // What this foundry ALREADY has on this connector. A second vendor
171
- // account is legitimate and stays possible; an admin who is really
172
- // trying to install a new key is not, and they are the ones this
173
- // form used to swallow.
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, foundrySlug, entry, existing, canManage, classNames: cn, onConnected,
444
+ api, personal, entry, existing, canManage, classNames: cn, onConnected,
445
+ onPolicyChanged,
260
446
  }: {
261
- api: ConnectionsApi;
262
- foundrySlug: string;
447
+ api: BoundApi;
448
+ personal: boolean;
263
449
  entry: ConnectorCatalogEntry;
264
- /** Cards this foundry already holds on this connector. */
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(foundrySlug, {
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, foundrySlug, connection, entry, classNames: cn, onDone,
741
+ api, connection, entry, classNames: cn, onDone,
441
742
  }: {
442
- api: ConnectionsApi;
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(foundrySlug, connection.id, submitted)
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, foundrySlug, connection, fields, classNames: cn, onDone,
844
+ api, connection, fields, classNames: cn, onDone,
545
845
  }: {
546
- api: ConnectionsApi;
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(foundrySlug, connection.id, { config });
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, foundrySlug, connection, entry, canManage, classNames: cn, onChanged,
917
+ api, personal, connection, entry, canManage, classNames: cn, onChanged,
619
918
  }: {
620
- api: ConnectionsApi;
621
- foundrySlug: string;
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(foundrySlug, connection.id);
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
- <button
718
- type="button"
719
- className={cn.button}
720
- disabled={Boolean(busy)}
721
- onClick={() => void guard('discover', async () => {
722
- const page = await api.connectionChoices(foundrySlug, connection.id);
723
- setChoices(page.results ?? []);
724
- const available = await api.connectionActions(foundrySlug, connection.id);
725
- setActions(available.results ?? []);
726
- })}
727
- >
728
- {busy === 'discover' ? 'Loading…' : 'Workflows'}
729
- </button>
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(foundrySlug, connection.id);
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
- disabled={!canManage || Boolean(busy)}
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(foundrySlug, connection.id, {
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(foundrySlug, connection.id, {
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(foundrySlug, connection.id, target.id));
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
- foundrySlug, connection.id, available.name,
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