@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.
@@ -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,4 +260,101 @@ 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>;
302
+ }
303
+
304
+ /**
305
+ * THE SAME PAGE, FOR A PERSON RATHER THAN A FOUNDRY (bd startsim-768w.22.25.6).
306
+ *
307
+ * A seated member of a foundry connects THEIR OWN account — their Granola key,
308
+ * not the foundry's — from inside their tenant app. The endpoints are
309
+ * `connections/me/...` and the differences from the interface above are not
310
+ * stylistic; each one is a shape the member router deliberately does not serve
311
+ * (`backend/apps/connectors/member_urls.py`, `api/member_views.py`):
312
+ *
313
+ * NO SLUG. Every method here is slugless, and that is the whole security
314
+ * property rather than a convenience. The member surface names the foundry
315
+ * through the TOKEN'S AUDIENCE, so — in the router's own words — "there is no id
316
+ * here to substitute, no slug to swap, and therefore nothing in the URL space
317
+ * that names anybody else." A client whose signatures take a slug hands that
318
+ * affordance back at the type level, and reads to the next person as a client
319
+ * that scopes by slug. It must not exist.
320
+ *
321
+ * NO `updateConnection`. `MyConnectionDetailView` serves GET and DELETE only.
322
+ * Renaming a card or changing its config or enabled capabilities stays on the
323
+ * control-plane router; a member who needs one asks an admin.
324
+ *
325
+ * NO choices, targets, actions or tokens. Discovering what a credential reaches,
326
+ * wiring it to a destination, invoking it and minting `ConnectionToken`s are
327
+ * foundry-level and stay owner- or admin-exclusive. That is a design decision,
328
+ * not a backlog item — a member's key is theirs to install and revoke, not a
329
+ * lever on the foundry's pipelines.
330
+ *
331
+ * `connectorCatalog` here returns the ALLOWED catalog — `member_connectable`
332
+ * for this foundry, filtered server-side, default-deny per connector. The
333
+ * composer renders exactly what it is handed: a client-side filter over the
334
+ * full catalog would ship the withheld vendors in the bundle.
335
+ */
336
+ export interface MemberConnectionsApi {
337
+ connectorCatalog(): Promise<{ results: ConnectorCatalogEntry[] }>;
338
+ connections(): Promise<{
339
+ results: Connection[];
340
+ permissions: ConnectionPermissions;
341
+ }>;
342
+ connect(input: {
343
+ connector_key: string;
344
+ name?: string;
345
+ credentials?: Record<string, unknown>;
346
+ config?: Record<string, unknown>;
347
+ /**
348
+ * Chosen once, at connect time, and not editable afterwards on this
349
+ * surface — there is no PATCH. The picker on an existing card is therefore
350
+ * informational, and saying so is the honest rendering of both halves.
351
+ */
352
+ capabilities_enabled?: string[];
353
+ }): Promise<Connection>;
354
+ checkConnection(id: string): Promise<{
355
+ ok: boolean; account_label: string; detail: string;
356
+ }>;
357
+ rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
358
+ disconnect(id: string): Promise<void>;
359
+ connectionRuns(id: string): Promise<{ results: ConnectionRun[] }>;
222
360
  }