@startsimpli/integrations 0.2.21 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -17,6 +17,7 @@ export type {
17
17
  ConnectorCatalogEntry,
18
18
  ConnectorFieldSpec,
19
19
  Connection as FoundryConnection,
20
+ MemberConnectionRow,
20
21
  ConnectionPermissions,
21
22
  ConnectionChoice,
22
23
  ConnectionChoicePage,
@@ -52,8 +52,10 @@ import type {
52
52
  ConnectorCatalogEntry,
53
53
  ConnectorFieldSpec,
54
54
  ConnectionsApi,
55
+ MemberConnectionRow,
55
56
  MemberConnectionsApi,
56
57
  } from './types';
58
+ import { MemberConnectionRoster } from './MemberConnectionRoster';
57
59
 
58
60
  type ClassNames = Partial<Record<
59
61
  'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error', string
@@ -119,6 +121,13 @@ interface BoundApi {
119
121
  invokeConnectionAction?(id: string, action: string, input: {
120
122
  destination_id: string; record_type?: string; records?: unknown[];
121
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>;
122
131
  }
123
132
 
124
133
  function bindApi(props: ConnectionsPageProps): BoundApi {
@@ -128,7 +137,24 @@ function bindApi(props: ConnectionsPageProps): BoundApi {
128
137
  return props.api;
129
138
  }
130
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
+ : {};
131
154
  return {
155
+ ...memberPolicy,
156
+ ...roster,
157
+ ...cutMember,
132
158
  connectorCatalog: () => api.connectorCatalog(slug),
133
159
  connections: () => api.connections(slug),
134
160
  connect: (input) => api.connect(slug, input),
@@ -303,6 +329,10 @@ export function ConnectionsPage(props: ConnectionsPageProps) {
303
329
  api={api}
304
330
  personal={personal}
305
331
  entry={entry}
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}
306
336
  // What ALREADY exists on this connector — in personal mode that is
307
337
  // the member's own cards and nobody else's, because `connections`
308
338
  // came back owner-scoped. A second vendor account is legitimate and
@@ -315,6 +345,24 @@ export function ConnectionsPage(props: ConnectionsPageProps) {
315
345
  />
316
346
  ))}
317
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}
318
366
  </div>
319
367
  );
320
368
  }
@@ -394,6 +442,7 @@ function orderCapabilities(declared: string[], selected: Iterable<string>): stri
394
442
 
395
443
  function CatalogCard({
396
444
  api, personal, entry, existing, canManage, classNames: cn, onConnected,
445
+ onPolicyChanged,
397
446
  }: {
398
447
  api: BoundApi;
399
448
  personal: boolean;
@@ -403,6 +452,8 @@ function CatalogCard({
403
452
  canManage: boolean;
404
453
  classNames: typeof DEFAULTS;
405
454
  onConnected: () => void;
455
+ /** Re-read the catalog after the member toggle is written. Foundry mode only. */
456
+ onPolicyChanged?: () => void;
406
457
  }) {
407
458
  const [open, setOpen] = React.useState(false);
408
459
  const [values, setValues] = React.useState<Record<string, unknown>>({});
@@ -482,6 +533,20 @@ function CatalogCard({
482
533
  ) : null}
483
534
  </div>
484
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
+
485
550
  {open ? (
486
551
  <div className="mt-4 space-y-3">
487
552
  {/* SAID BEFORE THE KEY IS PASTED, not after the server refuses.
@@ -551,6 +616,105 @@ function CatalogCard({
551
616
  );
552
617
  }
553
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
+
554
718
  // --------------------------------------------------------------------------
555
719
  // replacing the key on a connection that already exists
556
720
  // --------------------------------------------------------------------------
@@ -0,0 +1,182 @@
1
+ /**
2
+ * WHO IN THIS FOUNDRY HAS CONNECTED THEIR OWN ACCOUNT (bd startsim-768w.22.25.8).
3
+ *
4
+ * The other half of the allowlist. `MemberPolicyToggle` decides which connectors
5
+ * are open; this says who walked through the door, and gives an admin the one
6
+ * verb offboarding needs — cut a departing member's pipes.
7
+ *
8
+ * ITS OWN FILE, ON PURPOSE. `ConnectionsPage` renders two lists that belong to
9
+ * whoever is looking at it, and its whole security property is that the personal
10
+ * and foundry surfaces never see each other's rows. This list is the deliberate
11
+ * exception: it enumerates OTHER PEOPLE'S accounts, from a different endpoint,
12
+ * through a different serializer, behind a foundry permission. Keeping it out of
13
+ * the composer's own body means one forgotten `personal` guard in there cannot
14
+ * put somebody else's connections on a member's screen.
15
+ *
16
+ * METADATA ONLY, and there is nothing to widen. The server sends who, which
17
+ * vendor, whether it is working and when it was last checked; there is no
18
+ * credential, no masked hint, no config, and no verb here that reads into an
19
+ * account or replaces its key. Ending someone's access is governance and an
20
+ * admin needs it. Taking their account over is impersonation and no foundry
21
+ * permission grants it.
22
+ */
23
+ import * as React from 'react';
24
+
25
+ import type {
26
+ ConnectionPermissions,
27
+ MemberConnectionRow,
28
+ } from './types';
29
+
30
+ type ClassNames = {
31
+ section: string; heading: string; card: string; button: string;
32
+ muted: string; error: string;
33
+ };
34
+
35
+ export function MemberConnectionRoster({
36
+ list, disconnect, canManage, classNames: cn,
37
+ }: {
38
+ list: () => Promise<{ results: MemberConnectionRow[]; permissions: ConnectionPermissions }>;
39
+ disconnect?: (id: string) => Promise<void>;
40
+ canManage: boolean;
41
+ classNames: ClassNames;
42
+ }) {
43
+ const [rows, setRows] = React.useState<MemberConnectionRow[]>([]);
44
+ const [loading, setLoading] = React.useState(true);
45
+ const [error, setError] = React.useState('');
46
+
47
+ const reload = React.useCallback(async () => {
48
+ setLoading(true);
49
+ try {
50
+ const page = await list();
51
+ setRows(page.results ?? []);
52
+ setError('');
53
+ } catch (err) {
54
+ // A control plane that predates these endpoints answers 404. That is not
55
+ // an error an admin can act on, so the section reports nothing and shows
56
+ // nothing rather than putting a red bar under a working page.
57
+ const status = (err as { status?: number })?.status;
58
+ setError(status === 404 ? '' : ((err as { message?: string })?.message
59
+ || 'Could not load members’ connections.'));
60
+ setRows([]);
61
+ } finally {
62
+ setLoading(false);
63
+ }
64
+ }, [list]);
65
+
66
+ React.useEffect(() => { void reload(); }, [reload]);
67
+
68
+ return (
69
+ <section className={cn.section} data-member-roster>
70
+ <h2 className={cn.heading}>Members’ own connections</h2>
71
+ <p className={cn.muted}>
72
+ Accounts individual members connected with their own credentials. You can
73
+ see that they exist and whether they are working; you cannot see or
74
+ replace their keys. Disconnecting is permanent — it stops the account’s
75
+ scheduled fetches, revokes its tokens and deletes the stored key, and the
76
+ member has to reconnect with the vendor to come back.
77
+ </p>
78
+ {loading && rows.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
79
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
80
+ {!loading && !error && rows.length === 0 ? (
81
+ <p className={cn.muted}>No member has connected their own account yet.</p>
82
+ ) : null}
83
+ {rows.map((row) => (
84
+ <MemberConnectionCard
85
+ key={row.id}
86
+ row={row}
87
+ canManage={canManage}
88
+ disconnect={disconnect}
89
+ classNames={cn}
90
+ onChanged={reload}
91
+ />
92
+ ))}
93
+ </section>
94
+ );
95
+ }
96
+
97
+ function MemberConnectionCard({
98
+ row, canManage, disconnect, classNames: cn, onChanged,
99
+ }: {
100
+ row: MemberConnectionRow;
101
+ canManage: boolean;
102
+ disconnect?: (id: string) => Promise<void>;
103
+ classNames: ClassNames;
104
+ onChanged: () => void;
105
+ }) {
106
+ const [confirming, setConfirming] = React.useState(false);
107
+ const [busy, setBusy] = React.useState(false);
108
+ const [error, setError] = React.useState('');
109
+ const who = row.owner?.email || row.owner?.name || 'Unknown member';
110
+
111
+ async function cut() {
112
+ if (!disconnect) return;
113
+ setBusy(true);
114
+ setError('');
115
+ try {
116
+ await disconnect(row.id);
117
+ setConfirming(false);
118
+ onChanged();
119
+ } catch (err) {
120
+ setError((err as { message?: string })?.message || 'Could not disconnect.');
121
+ } finally {
122
+ setBusy(false);
123
+ }
124
+ }
125
+
126
+ return (
127
+ <div className={cn.card} data-member-connection={row.id}>
128
+ <div className="flex items-start justify-between gap-4">
129
+ <div>
130
+ <p className="font-medium text-gray-900">{who}</p>
131
+ <p className={cn.muted}>
132
+ {row.connector_name}
133
+ {row.account_label ? ` · ${row.account_label}` : ''}
134
+ </p>
135
+ <p className="mt-1 text-xs text-gray-400">
136
+ {row.status}
137
+ {row.health ? ` · ${row.health}` : ''}
138
+ {row.last_checked_at
139
+ ? ` · last checked ${new Date(row.last_checked_at).toLocaleString()}`
140
+ : ' · never checked'}
141
+ </p>
142
+ {/* The vendor's own failure text, which the server already scrubbed of
143
+ anything the member submitted. An admin deciding whether to cut a
144
+ pipe is entitled to know it is already broken. */}
145
+ {row.check_error ? <p className={cn.error}>{row.check_error}</p> : null}
146
+ </div>
147
+ {canManage && disconnect ? (
148
+ <button
149
+ type="button"
150
+ className={cn.button}
151
+ disabled={busy}
152
+ onClick={() => setConfirming((v) => !v)}
153
+ >
154
+ {confirming ? 'Cancel' : 'Disconnect'}
155
+ </button>
156
+ ) : null}
157
+ </div>
158
+ {confirming ? (
159
+ <div className="mt-3 space-y-2">
160
+ {/* NAMES THE PERSON, because the cost of this button falls on them and
161
+ not on the admin pressing it. A generic "are you sure?" over a list
162
+ of near-identical rows is how the wrong pipe gets cut. */}
163
+ <p className={cn.muted}>
164
+ Disconnect <strong className="text-gray-900">{who}</strong>’s{' '}
165
+ {row.connector_name} account? Their stored key is deleted and their
166
+ scheduled fetches stop. This cannot be undone — they would have to
167
+ connect again themselves.
168
+ </p>
169
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
170
+ <button
171
+ type="button"
172
+ className={cn.button}
173
+ disabled={busy}
174
+ onClick={() => void cut()}
175
+ >
176
+ {busy ? 'Disconnecting…' : 'Yes, disconnect'}
177
+ </button>
178
+ </div>
179
+ ) : null}
180
+ </div>
181
+ );
182
+ }
@@ -61,6 +61,47 @@ export interface ConnectorCatalogEntry {
61
61
  supports_webhooks: boolean;
62
62
  /** A row whose class will not import says so here rather than vanishing. */
63
63
  availability: { available: boolean; reason: string };
64
+ /**
65
+ * MAY MEMBERS CONNECT THEIR OWN ACCOUNT ON THIS ONE (bd startsim-768w.22.25.8).
66
+ *
67
+ * Present on the FOUNDRY catalog only — the member catalog is already filtered
68
+ * to what is allowed, so the field would be a constant `true` there and a
69
+ * client could mistake it for a control the member has.
70
+ *
71
+ * Optional because an app may be running against a control plane that predates
72
+ * this field; the toggle renders when the catalog says so and the api has the
73
+ * verb, never on an assumption about either.
74
+ *
75
+ * allowed the stored answer — is this connector open to members today
76
+ * settable may it be CHANGED at all. `false` is not "you lack permission";
77
+ * it is "this connector cannot be offered to members yet"
78
+ * reason why not, in an admin's words, when `settable` is false
79
+ */
80
+ member_policy?: { allowed: boolean; settable: boolean; reason: string };
81
+ }
82
+
83
+ /**
84
+ * One member's own connection, as the ADMIN roster sees it (bd
85
+ * startsim-768w.22.25.8).
86
+ *
87
+ * Note what is NOT here and cannot be added by widening a type: no credential,
88
+ * no masked hint, no config, no token. An admin governs which connectors are
89
+ * open and ends access when somebody leaves; neither act needs to see inside
90
+ * another person's account. The server serializes exactly these fields for the
91
+ * same reason.
92
+ */
93
+ export interface MemberConnectionRow {
94
+ id: string;
95
+ owner: { id: string; email: string; name: string };
96
+ connector_key: string;
97
+ connector_name: string;
98
+ name: string;
99
+ account_label: string;
100
+ status: ConnectionStatus;
101
+ health: string;
102
+ last_checked_at?: string | null;
103
+ check_error?: string;
104
+ created_at?: string;
64
105
  }
65
106
 
66
107
  export type ConnectionStatus = 'connected' | 'needs_reauth' | 'error' | 'disabled';
@@ -219,6 +260,45 @@ export interface ConnectionsApi {
219
260
  destination_id: string; record_type?: string; records?: unknown[];
220
261
  }): Promise<ConnectionInvokeResult>;
221
262
  connectionRuns(slug: string, id: string): Promise<{ results: ConnectionRun[] }>;
263
+
264
+ /**
265
+ * GOVERNING PER-MEMBER CONNECTIONS (bd startsim-768w.22.25.8) — three verbs,
266
+ * all OPTIONAL, and the optionality is load-bearing rather than timidity.
267
+ *
268
+ * Apps consume `@startsimpli/*` as PUBLISHED packages, so a client pinned to
269
+ * an older `@startsimpli/api` is a normal state of the world, not a mistake.
270
+ * Required methods here would make that combination a type error in an app
271
+ * that never asked for this feature. Optional, the composer renders each
272
+ * control only when the verb behind it exists — the same rule `BoundApi`
273
+ * already uses to keep the personal surface from rendering foundry-only
274
+ * buttons.
275
+ */
276
+
277
+ /**
278
+ * Open or close ONE connector to this foundry's members.
279
+ *
280
+ * `allowed: true` is an agreement to RECEIVE member-owned vendor data into
281
+ * this foundry, under each member's own credential. `allowed: false` stops NEW
282
+ * personal connections immediately and leaves existing ones running — ending
283
+ * those is `disconnectMemberConnection`, per connection, deliberately.
284
+ */
285
+ setMemberConnectorPolicy?(slug: string, connectorKey: string, allowed: boolean):
286
+ Promise<{ connector_key: string; member_connect_allowed: boolean; changed: boolean }>;
287
+
288
+ /** The roster: every member-owned connection in the foundry. Metadata only. */
289
+ memberConnections?(slug: string): Promise<{
290
+ results: MemberConnectionRow[];
291
+ permissions: ConnectionPermissions;
292
+ }>;
293
+
294
+ /**
295
+ * END one member's connection — offboarding. The same irreversible disconnect
296
+ * an admin has on a shared connection: the poll stops, the push stops, issued
297
+ * tokens are revoked and the stored credential is deleted. There is no
298
+ * replace-key and no credential read beside it, and that is the design: ending
299
+ * access is governance, taking an account over is impersonation.
300
+ */
301
+ disconnectMemberConnection?(slug: string, id: string): Promise<void>;
222
302
  }
223
303
 
224
304
  /**