@startsimpli/integrations 0.1.3 → 0.1.5

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.
@@ -60,6 +60,21 @@ const DEFAULTS = {
60
60
  error: 'text-sm text-red-600',
61
61
  };
62
62
 
63
+ /**
64
+ * WHAT AN ADMIN IS AGREEING TO, in their words rather than the wire's.
65
+ *
66
+ * Data, not a branch: an unknown key falls through to the key itself, because
67
+ * the capability vocabulary lives in the connector package and will outrun this
68
+ * bundle exactly the way the credential schema does. A capability this build has
69
+ * never heard of must render as a choice, not vanish from the list.
70
+ */
71
+ const CAPABILITY_COPY: Record<string, string> = {
72
+ poll: 'Fetch new records on a schedule',
73
+ webhook_in: 'Receive events pushed to us',
74
+ action: 'Let us run actions in this account',
75
+ writeback_credential: 'Issue a token this vendor uses to call us',
76
+ };
77
+
63
78
  /** A refusal must read as a refusal, not as an empty page. */
64
79
  function readFailureMessage(err: unknown): string {
65
80
  const status = (err as { status?: number })?.status;
@@ -120,6 +135,12 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
120
135
  api={api}
121
136
  foundrySlug={foundrySlug}
122
137
  connection={connection}
138
+ // What the CONNECTOR declares, so the card can offer a capability
139
+ // this connection does not have yet — which is the only way an
140
+ // account connected before the picker existed ever gets one.
141
+ declared={
142
+ catalog.find((entry) => entry.key === connection.connector_key)?.capabilities
143
+ }
123
144
  canManage={permissions.manage}
124
145
  classNames={cn}
125
146
  onChanged={reload}
@@ -145,6 +166,75 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
145
166
  );
146
167
  }
147
168
 
169
+ // --------------------------------------------------------------------------
170
+ // the capability choice (bd startsim-768w.22.22)
171
+ // --------------------------------------------------------------------------
172
+
173
+ /**
174
+ * WHICH VERBS THIS ACCOUNT IS ALLOWED — the control this page did not have.
175
+ *
176
+ * `POST /connections/` and `PATCH /connections/<id>/` have both accepted
177
+ * `capabilities_enabled[]` since the endpoints were designed. Nothing here ever
178
+ * sent one, so every connection made through the product was stored with an
179
+ * EMPTY list, and an empty list enables nothing: the card goes green on a real
180
+ * `check()` and can then do none of the things it advertises. That is how a
181
+ * connected Granola account on a live foundry ingested nothing forever.
182
+ *
183
+ * Rendered from `entry.capabilities` — the connector's own declaration, off the
184
+ * catalog row — so a new vendor's verbs appear here with no code change, and a
185
+ * verb this build has never heard of still renders.
186
+ */
187
+ function CapabilityPicker({
188
+ scope, declared, selected, disabled, idPrefix, onToggle, classNames: cn, children,
189
+ }: {
190
+ scope: 'catalog' | 'connection';
191
+ declared: string[];
192
+ selected: string[];
193
+ disabled: boolean;
194
+ idPrefix: string;
195
+ onToggle: (capability: string, on: boolean) => void;
196
+ classNames: typeof DEFAULTS;
197
+ /** Whatever COMMITS the choice — inline, so the control and the thing that
198
+ saves it are one region for a screen reader and for a test. */
199
+ children?: React.ReactNode;
200
+ }) {
201
+ if (declared.length === 0) return null;
202
+ return (
203
+ <fieldset data-capabilities={scope} className="mt-3 space-y-1 border-0 p-0">
204
+ <legend className="text-sm font-medium text-gray-900">What this account may do</legend>
205
+ {declared.map((capability) => {
206
+ const id = `${idPrefix}-cap-${capability}`;
207
+ return (
208
+ <label key={capability} htmlFor={id} className="flex items-start gap-2 text-sm">
209
+ <input
210
+ id={id}
211
+ type="checkbox"
212
+ value={capability}
213
+ className="mt-0.5"
214
+ checked={selected.includes(capability)}
215
+ disabled={disabled}
216
+ onChange={(event) => onToggle(capability, event.target.checked)}
217
+ />
218
+ <span>
219
+ <span className="text-gray-900">{capability}</span>
220
+ <span className={`ml-2 ${cn.muted}`}>
221
+ {CAPABILITY_COPY[capability] ?? ''}
222
+ </span>
223
+ </span>
224
+ </label>
225
+ );
226
+ })}
227
+ {children}
228
+ </fieldset>
229
+ );
230
+ }
231
+
232
+ /** Declaration order is the render order, and the wire order. */
233
+ function orderCapabilities(declared: string[], selected: Iterable<string>): string[] {
234
+ const set = new Set(selected);
235
+ return declared.filter((capability) => set.has(capability));
236
+ }
237
+
148
238
  // --------------------------------------------------------------------------
149
239
  // catalog
150
240
  // --------------------------------------------------------------------------
@@ -163,6 +253,13 @@ function CatalogCard({
163
253
  const [values, setValues] = React.useState<Record<string, string>>({});
164
254
  const [busy, setBusy] = React.useState(false);
165
255
  const [error, setError] = React.useState('');
256
+ // Every declared capability starts ON. Not because "on" is a safe default in
257
+ // general, but because the alternative here is a connection that can do
258
+ // nothing — and the list is right there, unchecked with one click, before
259
+ // anything is saved. The privacy control that is genuinely opt-in lives one
260
+ // level down (a connector's own config, e.g. whether a poll fetches meeting
261
+ // transcript bytes at all) and is unaffected by this box.
262
+ const [capabilities, setCapabilities] = React.useState<string[]>(entry.capabilities);
166
263
 
167
264
  const credentialFields = schemaFields(entry.credential_schema);
168
265
  const configFields = schemaFields(entry.config_schema);
@@ -186,9 +283,12 @@ function CatalogCard({
186
283
  }
187
284
  await api.connect(foundrySlug, {
188
285
  connector_key: entry.key, name: entry.name, credentials, config,
286
+ // Always sent. Leaving it off is what wrote the inert cards.
287
+ capabilities_enabled: orderCapabilities(entry.capabilities, capabilities),
189
288
  });
190
289
  setOpen(false);
191
290
  setValues({});
291
+ setCapabilities(entry.capabilities);
192
292
  onConnected();
193
293
  } catch (err) {
194
294
  // The server's typed answer, verbatim. It already scrubbed itself of the
@@ -237,11 +337,34 @@ function CatalogCard({
237
337
  }));
238
338
  }}
239
339
  />
340
+ <CapabilityPicker
341
+ scope="catalog"
342
+ declared={entry.capabilities}
343
+ selected={capabilities}
344
+ disabled={busy}
345
+ idPrefix={`connect-${entry.key}`}
346
+ onToggle={(capability, on) => setCapabilities((prev) => (
347
+ on ? [...prev, capability] : prev.filter((c) => c !== capability)
348
+ ))}
349
+ classNames={cn}
350
+ />
351
+ {/* The server refuses an empty list with a 400 rather than storing a
352
+ card that can do nothing. Saying so here costs the admin a round
353
+ trip instead of a confusing error. */}
354
+ {entry.capabilities.length > 0 && capabilities.length === 0 ? (
355
+ <p className={cn.error}>
356
+ Enable at least one — a connection with nothing enabled can do nothing.
357
+ </p>
358
+ ) : null}
240
359
  {error ? <p className={cn.error} role="alert">{error}</p> : null}
241
360
  <button
242
361
  type="button"
243
362
  className={cn.button}
244
- disabled={busy || missing.length > 0}
363
+ disabled={
364
+ busy
365
+ || missing.length > 0
366
+ || (entry.capabilities.length > 0 && capabilities.length === 0)
367
+ }
245
368
  onClick={() => void submit()}
246
369
  >
247
370
  {busy ? 'Testing…' : 'Test and save'}
@@ -257,11 +380,12 @@ function CatalogCard({
257
380
  // --------------------------------------------------------------------------
258
381
 
259
382
  function ConnectionCard({
260
- api, foundrySlug, connection, canManage, classNames: cn, onChanged,
383
+ api, foundrySlug, connection, declared, canManage, classNames: cn, onChanged,
261
384
  }: {
262
385
  api: ConnectionsApi;
263
386
  foundrySlug: string;
264
387
  connection: Connection;
388
+ declared?: string[];
265
389
  canManage: boolean;
266
390
  classNames: typeof DEFAULTS;
267
391
  onChanged: () => void;
@@ -273,6 +397,26 @@ function ConnectionCard({
273
397
  const [error, setError] = React.useState('');
274
398
  const [notice, setNotice] = React.useState('');
275
399
 
400
+ const enabled = React.useMemo(
401
+ () => connection.capabilities_enabled ?? [], [connection.capabilities_enabled]);
402
+ // The catalog's declaration first, then anything this connection holds that
403
+ // the catalog no longer offers. A capability an admin cannot SEE is one they
404
+ // cannot turn off, and a catalog row that will not load must not blank the
405
+ // card's own state.
406
+ const offered = React.useMemo(() => {
407
+ const seen = new Set<string>();
408
+ return [...(declared ?? []), ...enabled].filter((capability) => {
409
+ if (seen.has(capability)) return false;
410
+ seen.add(capability);
411
+ return true;
412
+ });
413
+ }, [declared, enabled]);
414
+ const [selected, setSelected] = React.useState<string[]>(enabled);
415
+ React.useEffect(() => { setSelected(enabled); }, [enabled]);
416
+
417
+ const pending = orderCapabilities(offered, selected);
418
+ const dirty = pending.join(',') !== orderCapabilities(offered, enabled).join(',');
419
+
276
420
  async function guard(label: string, run: () => Promise<void>) {
277
421
  setBusy(label);
278
422
  setError('');
@@ -330,6 +474,46 @@ function ConnectionCard({
330
474
  ) : null}
331
475
  </div>
332
476
 
477
+ {/* THE FIX FOR THE CARDS THAT ALREADY EXIST. A connection written before
478
+ the connect form asked holds an empty list and can do nothing; the
479
+ connect form can never reach it. Turning `poll` on here is what makes
480
+ the server create the scheduled ingest leg. */}
481
+ <CapabilityPicker
482
+ scope="connection"
483
+ declared={offered}
484
+ selected={selected}
485
+ disabled={!canManage || Boolean(busy)}
486
+ idPrefix={`connection-${connection.id}`}
487
+ onToggle={(capability, on) => setSelected((prev) => (
488
+ on ? [...prev, capability] : prev.filter((c) => c !== capability)
489
+ ))}
490
+ classNames={cn}
491
+ >
492
+ {canManage ? (
493
+ <div className="mt-2 flex items-center gap-3">
494
+ <button
495
+ type="button"
496
+ className={cn.button}
497
+ disabled={!dirty || pending.length === 0 || Boolean(busy)}
498
+ onClick={() => void guard('capabilities', async () => {
499
+ await api.updateConnection(foundrySlug, connection.id, {
500
+ capabilities_enabled: pending,
501
+ });
502
+ setNotice('Saved.');
503
+ onChanged();
504
+ })}
505
+ >
506
+ {busy === 'capabilities' ? 'Saving…' : 'Save'}
507
+ </button>
508
+ {pending.length === 0 ? (
509
+ <span className={cn.error}>
510
+ Enable at least one, or disconnect the account.
511
+ </span>
512
+ ) : null}
513
+ </div>
514
+ ) : null}
515
+ </CapabilityPicker>
516
+
333
517
  {notice ? <p className={cn.muted}>{notice}</p> : null}
334
518
  {error ? <p className={cn.error} role="alert">{error}</p> : null}
335
519