@startsimpli/integrations 0.2.19 → 0.2.21
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/__tests__/translation-corpus.test.ts +428 -0
- package/src/api/connections.ts +129 -0
- package/src/api/index.ts +1 -0
- package/src/components/pages/ConnectionsPage.tsx +214 -64
- package/src/components/pages/types.ts +58 -0
- package/src/corpus/translation.ts +321 -0
|
@@ -44,20 +44,105 @@ 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
|
+
MemberConnectionsApi,
|
|
53
56
|
} from './types';
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
type ClassNames = Partial<Record<
|
|
59
|
+
'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error', string
|
|
60
|
+
>>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* TWO SURFACES, ONE COMPOSER (bd startsim-768w.22.25.6).
|
|
64
|
+
*
|
|
65
|
+
* A discriminated union rather than an optional slug, because the two modes do
|
|
66
|
+
* not merely differ in their arguments — `personal` has no slug ANYWHERE, which
|
|
67
|
+
* is the member surface's whole security property (see `MemberConnectionsApi`).
|
|
68
|
+
* Making `foundrySlug` optional would let a personal mount pass one and read as
|
|
69
|
+
* though it meant something.
|
|
70
|
+
*/
|
|
71
|
+
export type ConnectionsPageProps =
|
|
72
|
+
| {
|
|
73
|
+
mode?: 'foundry';
|
|
74
|
+
api: ConnectionsApi;
|
|
75
|
+
foundrySlug: string;
|
|
76
|
+
classNames?: ClassNames;
|
|
77
|
+
}
|
|
78
|
+
| {
|
|
79
|
+
mode: 'personal';
|
|
80
|
+
api: MemberConnectionsApi;
|
|
81
|
+
/** There is no slug on this surface. The token's audience names the foundry. */
|
|
82
|
+
foundrySlug?: never;
|
|
83
|
+
classNames?: ClassNames;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The api as the components below use it: SLUGLESS, because the slug is a
|
|
88
|
+
* property of the mount and not of any control on the page.
|
|
89
|
+
*
|
|
90
|
+
* The foundry-level verbs are optional, and that is what gates the UI. A control
|
|
91
|
+
* renders when the verb behind it exists, so personal mode cannot render a
|
|
92
|
+
* button whose handler would be `undefined` — the type checker, not a
|
|
93
|
+
* `mode === 'personal'` sprinkled through nine call sites, is what keeps the
|
|
94
|
+
* two surfaces honest.
|
|
95
|
+
*/
|
|
96
|
+
interface BoundApi {
|
|
97
|
+
connectorCatalog(): Promise<{ results: ConnectorCatalogEntry[] }>;
|
|
98
|
+
connections(): Promise<{ results: Connection[]; permissions: ConnectionPermissions }>;
|
|
99
|
+
connect(input: {
|
|
100
|
+
connector_key: string;
|
|
101
|
+
name?: string;
|
|
102
|
+
credentials?: Record<string, unknown>;
|
|
103
|
+
config?: Record<string, unknown>;
|
|
104
|
+
capabilities_enabled?: string[];
|
|
105
|
+
}): Promise<Connection>;
|
|
106
|
+
checkConnection(id: string): Promise<{ ok: boolean; account_label: string; detail: string }>;
|
|
107
|
+
rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
|
|
108
|
+
disconnect(id: string): Promise<void>;
|
|
109
|
+
updateConnection?(id: string, input: {
|
|
110
|
+
name?: string; capabilities_enabled?: string[]; config?: Record<string, unknown>;
|
|
111
|
+
}): Promise<Connection>;
|
|
112
|
+
connectionChoices?(id: string, params?: { kind?: string; cursor?: string }):
|
|
113
|
+
Promise<ConnectionChoicePage>;
|
|
114
|
+
wireTarget?(id: string, input: {
|
|
115
|
+
choice_id: string; name?: string; options?: Record<string, unknown>;
|
|
116
|
+
}): Promise<ConnectionTarget>;
|
|
117
|
+
confirmTarget?(id: string, destinationId: string): Promise<ConnectionTarget>;
|
|
118
|
+
connectionActions?(id: string): Promise<{ results: ConnectorAction[] }>;
|
|
119
|
+
invokeConnectionAction?(id: string, action: string, input: {
|
|
120
|
+
destination_id: string; record_type?: string; records?: unknown[];
|
|
121
|
+
}): Promise<ConnectionInvokeResult>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function bindApi(props: ConnectionsPageProps): BoundApi {
|
|
125
|
+
if (props.mode === 'personal') {
|
|
126
|
+
// Already slugless. Passed through rather than wrapped, so nothing here can
|
|
127
|
+
// grow a slug argument by accident.
|
|
128
|
+
return props.api;
|
|
129
|
+
}
|
|
130
|
+
const { api, foundrySlug: slug } = props;
|
|
131
|
+
return {
|
|
132
|
+
connectorCatalog: () => api.connectorCatalog(slug),
|
|
133
|
+
connections: () => api.connections(slug),
|
|
134
|
+
connect: (input) => api.connect(slug, input),
|
|
135
|
+
checkConnection: (id) => api.checkConnection(slug, id),
|
|
136
|
+
rotateCredential: (id, credentials) => api.rotateCredential(slug, id, credentials),
|
|
137
|
+
disconnect: (id) => api.disconnect(slug, id),
|
|
138
|
+
updateConnection: (id, input) => api.updateConnection(slug, id, input),
|
|
139
|
+
connectionChoices: (id, params) => api.connectionChoices(slug, id, params),
|
|
140
|
+
wireTarget: (id, input) => api.wireTarget(slug, id, input),
|
|
141
|
+
confirmTarget: (id, destinationId) => api.confirmTarget(slug, id, destinationId),
|
|
142
|
+
connectionActions: (id) => api.connectionActions(slug, id),
|
|
143
|
+
invokeConnectionAction: (id, action, input) =>
|
|
144
|
+
api.invokeConnectionAction(slug, id, action, input),
|
|
145
|
+
};
|
|
61
146
|
}
|
|
62
147
|
|
|
63
148
|
const DEFAULTS = {
|
|
@@ -85,19 +170,49 @@ const CAPABILITY_COPY: Record<string, string> = {
|
|
|
85
170
|
writeback_credential: 'Issue a token this vendor uses to call us',
|
|
86
171
|
};
|
|
87
172
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
173
|
+
/**
|
|
174
|
+
* A refusal must read as a refusal, not as an empty page — and it must name the
|
|
175
|
+
* refusal that actually happened. This runs in ONE place: the catch around the
|
|
176
|
+
* two GETs `reload` makes. So trace which 403 can reach here on each surface.
|
|
177
|
+
*
|
|
178
|
+
* Control plane: the caller lacks the connections permission on this foundry.
|
|
179
|
+
*
|
|
180
|
+
* Member surface: `IsSeatedMember` denied — no ACTIVE `CompanyMembership` and
|
|
181
|
+
* not the org's `owner`. That is the ONLY 403 either GET produces;
|
|
182
|
+
* `MyConnectorCatalogView` answers a foundry that has allowed nothing with
|
|
183
|
+
* `{results: []}` and a 200, and `MyConnectionsView.get` runs no per-connector
|
|
184
|
+
* policy at all. The allowlist refusal (`_NOT_ALLOWED`) is raised by the POST,
|
|
185
|
+
* lands in `CatalogCard`'s own catch, and renders the server's wording verbatim.
|
|
186
|
+
* Blaming the allowlist here would send someone whose SEAT is gone to ask an
|
|
187
|
+
* admin to allow a connector — the admin could allow every one of them and this
|
|
188
|
+
* page would still refuse.
|
|
189
|
+
*/
|
|
190
|
+
function readFailureMessage(err: unknown, personal: boolean): string {
|
|
90
191
|
const status = (err as { status?: number })?.status;
|
|
91
192
|
if (status === 403) {
|
|
92
|
-
return
|
|
193
|
+
return personal
|
|
194
|
+
? 'You are not a member of this foundry, or your access to it has ended. Ask an admin to add you.'
|
|
195
|
+
: 'You do not have access to this foundry’s connections. Ask an admin for the connections permission.';
|
|
93
196
|
}
|
|
94
197
|
if (status === 401) return 'Your session has expired. Sign in again.';
|
|
95
198
|
const message = (err as { message?: string })?.message;
|
|
96
199
|
return message || 'Could not load connections.';
|
|
97
200
|
}
|
|
98
201
|
|
|
99
|
-
export function ConnectionsPage(
|
|
202
|
+
export function ConnectionsPage(props: ConnectionsPageProps) {
|
|
203
|
+
const { classNames = {} } = props;
|
|
204
|
+
const personal = props.mode === 'personal';
|
|
100
205
|
const cn = { ...DEFAULTS, ...classNames };
|
|
206
|
+
// Keyed on the PARTS, never on `props` — a props object is a new object every
|
|
207
|
+
// render, so memoising on it would rebuild the binding each time, change
|
|
208
|
+
// `reload`'s identity each time, and turn the load effect into a loop.
|
|
209
|
+
const injected = props.api;
|
|
210
|
+
const slug = props.mode === 'personal' ? undefined : props.foundrySlug;
|
|
211
|
+
const api = React.useMemo(
|
|
212
|
+
() => bindApi(props),
|
|
213
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
214
|
+
[personal, injected, slug],
|
|
215
|
+
);
|
|
101
216
|
|
|
102
217
|
const [catalog, setCatalog] = React.useState<ConnectorCatalogEntry[]>([]);
|
|
103
218
|
const [connections, setConnections] = React.useState<Connection[]>([]);
|
|
@@ -111,19 +226,22 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
111
226
|
setLoading(true);
|
|
112
227
|
try {
|
|
113
228
|
const [cat, conns] = await Promise.all([
|
|
114
|
-
|
|
115
|
-
|
|
229
|
+
// In personal mode this is the ALLOWED catalog, already narrowed by the
|
|
230
|
+
// foundry's policy server-side. Rendered as handed: filtering here would
|
|
231
|
+
// put the withheld connectors in the browser to be filtered out of.
|
|
232
|
+
api.connectorCatalog(),
|
|
233
|
+
api.connections(),
|
|
116
234
|
]);
|
|
117
235
|
setCatalog(cat.results ?? []);
|
|
118
236
|
setConnections(conns.results ?? []);
|
|
119
237
|
setPermissions(conns.permissions ?? { view: true, manage: false, issue_tokens: false });
|
|
120
238
|
setLoadError('');
|
|
121
239
|
} catch (err) {
|
|
122
|
-
setLoadError(readFailureMessage(err));
|
|
240
|
+
setLoadError(readFailureMessage(err, personal));
|
|
123
241
|
} finally {
|
|
124
242
|
setLoading(false);
|
|
125
243
|
}
|
|
126
|
-
}, [api,
|
|
244
|
+
}, [api, personal]);
|
|
127
245
|
|
|
128
246
|
React.useEffect(() => { void reload(); }, [reload]);
|
|
129
247
|
|
|
@@ -134,16 +252,26 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
134
252
|
return (
|
|
135
253
|
<div className={cn.root}>
|
|
136
254
|
<section className={cn.section}>
|
|
137
|
-
|
|
255
|
+
{/* WHOSE ACCOUNTS THESE ARE. On the member surface the list is already
|
|
256
|
+
owner-scoped server-side (`for_owner`), and the heading has to say so
|
|
257
|
+
— a member looking at "Connected accounts" inside their foundry's app
|
|
258
|
+
has no way to tell whether they are seeing the foundry's keys. */}
|
|
259
|
+
<h2 className={cn.heading}>
|
|
260
|
+
{personal ? 'Your connected accounts' : 'Connected accounts'}
|
|
261
|
+
</h2>
|
|
138
262
|
{loading && connections.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
|
|
139
263
|
{!loading && connections.length === 0 ? (
|
|
140
|
-
<p className={cn.muted}>
|
|
264
|
+
<p className={cn.muted}>
|
|
265
|
+
{personal
|
|
266
|
+
? 'You have not connected an account yet.'
|
|
267
|
+
: 'Nothing connected yet.'}
|
|
268
|
+
</p>
|
|
141
269
|
) : null}
|
|
142
270
|
{connections.map((connection) => (
|
|
143
271
|
<ConnectionCard
|
|
144
272
|
key={connection.id}
|
|
145
273
|
api={api}
|
|
146
|
-
|
|
274
|
+
personal={personal}
|
|
147
275
|
connection={connection}
|
|
148
276
|
// The CONNECTOR's own catalog row. It carries the capability list,
|
|
149
277
|
// so the card can offer a capability this connection does not have
|
|
@@ -160,17 +288,26 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
|
|
|
160
288
|
</section>
|
|
161
289
|
|
|
162
290
|
<section className={cn.section}>
|
|
163
|
-
<h2 className={cn.heading}>
|
|
291
|
+
<h2 className={cn.heading}>
|
|
292
|
+
{personal ? 'Connect your own account' : 'Available connectors'}
|
|
293
|
+
</h2>
|
|
294
|
+
{personal && !loading && catalog.length === 0 ? (
|
|
295
|
+
<p className={cn.muted}>
|
|
296
|
+
Your foundry has not opened any connector to members yet. An admin
|
|
297
|
+
decides which ones you may connect your own account on.
|
|
298
|
+
</p>
|
|
299
|
+
) : null}
|
|
164
300
|
{catalog.map((entry) => (
|
|
165
301
|
<CatalogCard
|
|
166
302
|
key={entry.key}
|
|
167
303
|
api={api}
|
|
168
|
-
|
|
304
|
+
personal={personal}
|
|
169
305
|
entry={entry}
|
|
170
|
-
// What
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
306
|
+
// What ALREADY exists on this connector — in personal mode that is
|
|
307
|
+
// the member's own cards and nobody else's, because `connections`
|
|
308
|
+
// came back owner-scoped. A second vendor account is legitimate and
|
|
309
|
+
// stays possible; someone who is really trying to install a rotated
|
|
310
|
+
// key is not, and they are the ones this form used to swallow.
|
|
174
311
|
existing={connections.filter((c) => c.connector_key === entry.key)}
|
|
175
312
|
canManage={permissions.manage}
|
|
176
313
|
classNames={cn}
|
|
@@ -256,12 +393,12 @@ function orderCapabilities(declared: string[], selected: Iterable<string>): stri
|
|
|
256
393
|
// --------------------------------------------------------------------------
|
|
257
394
|
|
|
258
395
|
function CatalogCard({
|
|
259
|
-
api,
|
|
396
|
+
api, personal, entry, existing, canManage, classNames: cn, onConnected,
|
|
260
397
|
}: {
|
|
261
|
-
api:
|
|
262
|
-
|
|
398
|
+
api: BoundApi;
|
|
399
|
+
personal: boolean;
|
|
263
400
|
entry: ConnectorCatalogEntry;
|
|
264
|
-
/** Cards
|
|
401
|
+
/** Cards already held on this connector — the foundry's, or the member's own. */
|
|
265
402
|
existing: Connection[];
|
|
266
403
|
canManage: boolean;
|
|
267
404
|
classNames: typeof DEFAULTS;
|
|
@@ -303,7 +440,7 @@ function CatalogCard({
|
|
|
303
440
|
// recordings are stored.
|
|
304
441
|
if (typeof v === 'boolean' || typeof v === 'number' || v) config[spec.key] = v;
|
|
305
442
|
}
|
|
306
|
-
await api.connect(
|
|
443
|
+
await api.connect({
|
|
307
444
|
connector_key: entry.key, name: entry.name, credentials, config,
|
|
308
445
|
// Always sent. Leaving it off is what wrote the inert cards.
|
|
309
446
|
capabilities_enabled: orderCapabilities(entry.capabilities, capabilities),
|
|
@@ -437,10 +574,9 @@ function CatalogCard({
|
|
|
437
574
|
* rotation quietly a config change.
|
|
438
575
|
*/
|
|
439
576
|
function ReplaceKeyForm({
|
|
440
|
-
api,
|
|
577
|
+
api, connection, entry, classNames: cn, onDone,
|
|
441
578
|
}: {
|
|
442
|
-
api:
|
|
443
|
-
foundrySlug: string;
|
|
579
|
+
api: BoundApi;
|
|
444
580
|
connection: Connection;
|
|
445
581
|
entry?: ConnectorCatalogEntry;
|
|
446
582
|
classNames: typeof DEFAULTS;
|
|
@@ -499,7 +635,7 @@ function ReplaceKeyForm({
|
|
|
499
635
|
onClick={() => {
|
|
500
636
|
setBusy(true);
|
|
501
637
|
setError('');
|
|
502
|
-
api.rotateCredential(
|
|
638
|
+
api.rotateCredential(connection.id, submitted)
|
|
503
639
|
.then(() => { setValues({}); onDone(); })
|
|
504
640
|
// The server's typed answer, verbatim: it already scrubbed itself of
|
|
505
641
|
// the values just submitted, and "the vendor rejected this key" is
|
|
@@ -541,10 +677,9 @@ function ReplaceKeyForm({
|
|
|
541
677
|
* `ingest_transcripts` the off position is the whole privacy posture.
|
|
542
678
|
*/
|
|
543
679
|
function ConnectionSettingsForm({
|
|
544
|
-
api,
|
|
680
|
+
api, connection, fields, classNames: cn, onDone,
|
|
545
681
|
}: {
|
|
546
|
-
api:
|
|
547
|
-
foundrySlug: string;
|
|
682
|
+
api: BoundApi;
|
|
548
683
|
connection: Connection;
|
|
549
684
|
fields: ConnectorFieldSpec[];
|
|
550
685
|
classNames: typeof DEFAULTS;
|
|
@@ -578,7 +713,7 @@ function ConnectionSettingsForm({
|
|
|
578
713
|
? Number(value)
|
|
579
714
|
: value;
|
|
580
715
|
}
|
|
581
|
-
await api.updateConnection(
|
|
716
|
+
await api.updateConnection!(connection.id, { config });
|
|
582
717
|
onDone();
|
|
583
718
|
} catch (err) {
|
|
584
719
|
setError((err as { message?: string })?.message || 'Those settings were not saved.');
|
|
@@ -615,10 +750,10 @@ function ConnectionSettingsForm({
|
|
|
615
750
|
}
|
|
616
751
|
|
|
617
752
|
function ConnectionCard({
|
|
618
|
-
api,
|
|
753
|
+
api, personal, connection, entry, canManage, classNames: cn, onChanged,
|
|
619
754
|
}: {
|
|
620
|
-
api:
|
|
621
|
-
|
|
755
|
+
api: BoundApi;
|
|
756
|
+
personal: boolean;
|
|
622
757
|
connection: Connection;
|
|
623
758
|
/** This connector's catalog row: its capabilities AND its field schema. */
|
|
624
759
|
entry?: ConnectorCatalogEntry;
|
|
@@ -692,7 +827,7 @@ function ConnectionCard({
|
|
|
692
827
|
className={cn.button}
|
|
693
828
|
disabled={Boolean(busy)}
|
|
694
829
|
onClick={() => void guard('check', async () => {
|
|
695
|
-
const res = await api.checkConnection(
|
|
830
|
+
const res = await api.checkConnection(connection.id);
|
|
696
831
|
setNotice(res.detail || (res.ok ? 'Connection is healthy.' : ''));
|
|
697
832
|
onChanged();
|
|
698
833
|
})}
|
|
@@ -714,19 +849,26 @@ function ConnectionCard({
|
|
|
714
849
|
>
|
|
715
850
|
{replacing ? 'Cancel' : 'Replace key'}
|
|
716
851
|
</button>
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
852
|
+
{/* DISCOVER -> WIRE -> FIRE is foundry-level and stays there. The
|
|
853
|
+
member router serves no choices, targets or actions at all, so
|
|
854
|
+
the control is gated on the verb EXISTING rather than on a mode
|
|
855
|
+
flag — there is no way to render a button here whose handler is
|
|
856
|
+
undefined. */}
|
|
857
|
+
{api.connectionChoices && api.connectionActions ? (
|
|
858
|
+
<button
|
|
859
|
+
type="button"
|
|
860
|
+
className={cn.button}
|
|
861
|
+
disabled={Boolean(busy)}
|
|
862
|
+
onClick={() => void guard('discover', async () => {
|
|
863
|
+
const page = await api.connectionChoices!(connection.id);
|
|
864
|
+
setChoices(page.results ?? []);
|
|
865
|
+
const available = await api.connectionActions!(connection.id);
|
|
866
|
+
setActions(available.results ?? []);
|
|
867
|
+
})}
|
|
868
|
+
>
|
|
869
|
+
{busy === 'discover' ? 'Loading…' : 'Workflows'}
|
|
870
|
+
</button>
|
|
871
|
+
) : null}
|
|
730
872
|
{/* SETTINGS, which this card has never had. A connector's
|
|
731
873
|
`config_schema` ships on every catalog row and was rendered
|
|
732
874
|
exactly once — on the connect form, before the account existed.
|
|
@@ -735,7 +877,7 @@ function ConnectionCard({
|
|
|
735
877
|
recordings of people's conversations are stored was reachable
|
|
736
878
|
only by editing the database. `ReplaceKeyForm` already pointed at
|
|
737
879
|
"the connection's own settings" as if this existed. */}
|
|
738
|
-
{configFields.length ? (
|
|
880
|
+
{configFields.length && api.updateConnection ? (
|
|
739
881
|
<button
|
|
740
882
|
type="button"
|
|
741
883
|
className={cn.button}
|
|
@@ -769,7 +911,6 @@ function ConnectionCard({
|
|
|
769
911
|
{settingsOpen ? (
|
|
770
912
|
<ConnectionSettingsForm
|
|
771
913
|
api={api}
|
|
772
|
-
foundrySlug={foundrySlug}
|
|
773
914
|
connection={connection}
|
|
774
915
|
fields={configFields}
|
|
775
916
|
classNames={cn}
|
|
@@ -780,7 +921,6 @@ function ConnectionCard({
|
|
|
780
921
|
{replacing ? (
|
|
781
922
|
<ReplaceKeyForm
|
|
782
923
|
api={api}
|
|
783
|
-
foundrySlug={foundrySlug}
|
|
784
924
|
connection={connection}
|
|
785
925
|
entry={entry}
|
|
786
926
|
classNames={cn}
|
|
@@ -809,7 +949,7 @@ function ConnectionCard({
|
|
|
809
949
|
className={cn.button}
|
|
810
950
|
disabled={Boolean(busy)}
|
|
811
951
|
onClick={() => void guard('disconnect', async () => {
|
|
812
|
-
await api.disconnect(
|
|
952
|
+
await api.disconnect(connection.id);
|
|
813
953
|
setConfirmingDisconnect(false);
|
|
814
954
|
setNotice('Disconnected.');
|
|
815
955
|
onChanged();
|
|
@@ -837,21 +977,26 @@ function ConnectionCard({
|
|
|
837
977
|
scope="connection"
|
|
838
978
|
declared={offered}
|
|
839
979
|
selected={selected}
|
|
840
|
-
|
|
980
|
+
// On the member surface there is no PATCH, so the picker is
|
|
981
|
+
// INFORMATIONAL rather than absent. A member chose these at connect time
|
|
982
|
+
// and `POST` accepted them; hiding the list afterwards would make that
|
|
983
|
+
// choice look like it never happened, and leave them unable to see what
|
|
984
|
+
// their own account is doing.
|
|
985
|
+
disabled={!canManage || !api.updateConnection || Boolean(busy)}
|
|
841
986
|
idPrefix={`connection-${connection.id}`}
|
|
842
987
|
onToggle={(capability, on) => setSelected((prev) => (
|
|
843
988
|
on ? [...prev, capability] : prev.filter((c) => c !== capability)
|
|
844
989
|
))}
|
|
845
990
|
classNames={cn}
|
|
846
991
|
>
|
|
847
|
-
{canManage ? (
|
|
992
|
+
{canManage && api.updateConnection ? (
|
|
848
993
|
<div className="mt-2 flex items-center gap-3">
|
|
849
994
|
<button
|
|
850
995
|
type="button"
|
|
851
996
|
className={cn.button}
|
|
852
997
|
disabled={!dirty || pending.length === 0 || Boolean(busy)}
|
|
853
998
|
onClick={() => void guard('capabilities', async () => {
|
|
854
|
-
await api.updateConnection(
|
|
999
|
+
await api.updateConnection!(connection.id, {
|
|
855
1000
|
capabilities_enabled: pending,
|
|
856
1001
|
});
|
|
857
1002
|
setNotice('Saved.');
|
|
@@ -866,6 +1011,11 @@ function ConnectionCard({
|
|
|
866
1011
|
</span>
|
|
867
1012
|
) : null}
|
|
868
1013
|
</div>
|
|
1014
|
+
) : personal ? (
|
|
1015
|
+
<p className={cn.muted}>
|
|
1016
|
+
Chosen when you connected. Ask an admin to change what this account
|
|
1017
|
+
may do, or disconnect it and connect again.
|
|
1018
|
+
</p>
|
|
869
1019
|
) : null}
|
|
870
1020
|
</CapabilityPicker>
|
|
871
1021
|
|
|
@@ -897,7 +1047,7 @@ function ConnectionCard({
|
|
|
897
1047
|
className={cn.button}
|
|
898
1048
|
disabled={Boolean(busy)}
|
|
899
1049
|
onClick={() => void guard('wire', async () => {
|
|
900
|
-
setTarget(await api.wireTarget(
|
|
1050
|
+
setTarget(await api.wireTarget!(connection.id, {
|
|
901
1051
|
choice_id: choice.id,
|
|
902
1052
|
}));
|
|
903
1053
|
})}
|
|
@@ -925,7 +1075,7 @@ function ConnectionCard({
|
|
|
925
1075
|
className={cn.button}
|
|
926
1076
|
disabled={Boolean(busy)}
|
|
927
1077
|
onClick={() => void guard('confirm', async () => {
|
|
928
|
-
setTarget(await api.confirmTarget(
|
|
1078
|
+
setTarget(await api.confirmTarget!(connection.id, target.id));
|
|
929
1079
|
})}
|
|
930
1080
|
>
|
|
931
1081
|
Confirm this target
|
|
@@ -940,8 +1090,8 @@ function ConnectionCard({
|
|
|
940
1090
|
className={cn.button}
|
|
941
1091
|
disabled={Boolean(busy)}
|
|
942
1092
|
onClick={() => void guard('invoke', async () => {
|
|
943
|
-
const result = await api.invokeConnectionAction(
|
|
944
|
-
|
|
1093
|
+
const result = await api.invokeConnectionAction!(
|
|
1094
|
+
connection.id, available.name,
|
|
945
1095
|
{ destination_id: target.id, records: [] },
|
|
946
1096
|
);
|
|
947
1097
|
// `accepted` is not `completed`. Saying "done" here would
|
|
@@ -220,3 +220,61 @@ export interface ConnectionsApi {
|
|
|
220
220
|
}): Promise<ConnectionInvokeResult>;
|
|
221
221
|
connectionRuns(slug: string, id: string): Promise<{ results: ConnectionRun[] }>;
|
|
222
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* THE SAME PAGE, FOR A PERSON RATHER THAN A FOUNDRY (bd startsim-768w.22.25.6).
|
|
226
|
+
*
|
|
227
|
+
* A seated member of a foundry connects THEIR OWN account — their Granola key,
|
|
228
|
+
* not the foundry's — from inside their tenant app. The endpoints are
|
|
229
|
+
* `connections/me/...` and the differences from the interface above are not
|
|
230
|
+
* stylistic; each one is a shape the member router deliberately does not serve
|
|
231
|
+
* (`backend/apps/connectors/member_urls.py`, `api/member_views.py`):
|
|
232
|
+
*
|
|
233
|
+
* NO SLUG. Every method here is slugless, and that is the whole security
|
|
234
|
+
* property rather than a convenience. The member surface names the foundry
|
|
235
|
+
* through the TOKEN'S AUDIENCE, so — in the router's own words — "there is no id
|
|
236
|
+
* here to substitute, no slug to swap, and therefore nothing in the URL space
|
|
237
|
+
* that names anybody else." A client whose signatures take a slug hands that
|
|
238
|
+
* affordance back at the type level, and reads to the next person as a client
|
|
239
|
+
* that scopes by slug. It must not exist.
|
|
240
|
+
*
|
|
241
|
+
* NO `updateConnection`. `MyConnectionDetailView` serves GET and DELETE only.
|
|
242
|
+
* Renaming a card or changing its config or enabled capabilities stays on the
|
|
243
|
+
* control-plane router; a member who needs one asks an admin.
|
|
244
|
+
*
|
|
245
|
+
* NO choices, targets, actions or tokens. Discovering what a credential reaches,
|
|
246
|
+
* wiring it to a destination, invoking it and minting `ConnectionToken`s are
|
|
247
|
+
* foundry-level and stay owner- or admin-exclusive. That is a design decision,
|
|
248
|
+
* not a backlog item — a member's key is theirs to install and revoke, not a
|
|
249
|
+
* lever on the foundry's pipelines.
|
|
250
|
+
*
|
|
251
|
+
* `connectorCatalog` here returns the ALLOWED catalog — `member_connectable`
|
|
252
|
+
* for this foundry, filtered server-side, default-deny per connector. The
|
|
253
|
+
* composer renders exactly what it is handed: a client-side filter over the
|
|
254
|
+
* full catalog would ship the withheld vendors in the bundle.
|
|
255
|
+
*/
|
|
256
|
+
export interface MemberConnectionsApi {
|
|
257
|
+
connectorCatalog(): Promise<{ results: ConnectorCatalogEntry[] }>;
|
|
258
|
+
connections(): Promise<{
|
|
259
|
+
results: Connection[];
|
|
260
|
+
permissions: ConnectionPermissions;
|
|
261
|
+
}>;
|
|
262
|
+
connect(input: {
|
|
263
|
+
connector_key: string;
|
|
264
|
+
name?: string;
|
|
265
|
+
credentials?: Record<string, unknown>;
|
|
266
|
+
config?: Record<string, unknown>;
|
|
267
|
+
/**
|
|
268
|
+
* Chosen once, at connect time, and not editable afterwards on this
|
|
269
|
+
* surface — there is no PATCH. The picker on an existing card is therefore
|
|
270
|
+
* informational, and saying so is the honest rendering of both halves.
|
|
271
|
+
*/
|
|
272
|
+
capabilities_enabled?: string[];
|
|
273
|
+
}): Promise<Connection>;
|
|
274
|
+
checkConnection(id: string): Promise<{
|
|
275
|
+
ok: boolean; account_label: string; detail: string;
|
|
276
|
+
}>;
|
|
277
|
+
rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
|
|
278
|
+
disconnect(id: string): Promise<void>;
|
|
279
|
+
connectionRuns(id: string): Promise<{ results: ConnectionRun[] }>;
|
|
280
|
+
}
|