@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
|
@@ -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
|
}
|