@startsimpli/integrations 0.2.21 → 0.2.23
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.
|
|
3
|
+
"version": "0.2.23",
|
|
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",
|
|
@@ -997,3 +997,113 @@ describe('ConnectionsPage — foundry mode is untouched by personal mode', () =>
|
|
|
997
997
|
expect(within(section).getByRole('button', { name: /^Save$/i })).toBeInTheDocument();
|
|
998
998
|
});
|
|
999
999
|
});
|
|
1000
|
+
|
|
1001
|
+
// --------------------------------------------------------------------------
|
|
1002
|
+
// the members' roster: a verb only where it still does something
|
|
1003
|
+
// (bd startsim-768w.22.25.19)
|
|
1004
|
+
// --------------------------------------------------------------------------
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* One row per status, because the whole defect was a button that never read
|
|
1008
|
+
* the status. `disabled` is the row AFTER a disconnect — key deleted, tokens
|
|
1009
|
+
* revoked, fetches stopped. `error` and `needs_reauth` still hold a stored
|
|
1010
|
+
* credential and a live poll leg, and they are the rows an admin offboarding
|
|
1011
|
+
* somebody most needs to cut, so the suppression has to be narrow enough to
|
|
1012
|
+
* leave them alone.
|
|
1013
|
+
*/
|
|
1014
|
+
const MEMBER_ROWS = [
|
|
1015
|
+
{
|
|
1016
|
+
id: 'm-live', owner: { id: 'u1', email: 'ana@acme.test', name: 'Ana' },
|
|
1017
|
+
connector_key: 'n8n', connector_name: 'n8n', name: 'Ana n8n',
|
|
1018
|
+
account_label: 'ana@acme.test', status: 'connected', health: 'ok',
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
id: 'm-broken', owner: { id: 'u2', email: 'bo@acme.test', name: 'Bo' },
|
|
1022
|
+
connector_key: 'granola', connector_name: 'Granola', name: 'Bo Granola',
|
|
1023
|
+
account_label: 'bo@acme.test', status: 'error', health: 'failing',
|
|
1024
|
+
check_error: 'Vendor said 401',
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
id: 'm-stale', owner: { id: 'u3', email: 'cy@acme.test', name: 'Cy' },
|
|
1028
|
+
connector_key: 'granola', connector_name: 'Granola', name: 'Cy Granola',
|
|
1029
|
+
account_label: 'cy@acme.test', status: 'needs_reauth', health: 'expired',
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
id: 'm-gone', owner: { id: 'u4', email: 'di@acme.test', name: 'Di' },
|
|
1033
|
+
connector_key: 'n8n', connector_name: 'n8n', name: 'Di n8n',
|
|
1034
|
+
account_label: 'di@acme.test', status: 'disabled', health: 'disconnected',
|
|
1035
|
+
},
|
|
1036
|
+
];
|
|
1037
|
+
|
|
1038
|
+
function rosterApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
|
|
1039
|
+
return stubApi({
|
|
1040
|
+
memberConnections: vi.fn().mockResolvedValue({
|
|
1041
|
+
results: MEMBER_ROWS,
|
|
1042
|
+
permissions: { view: true, manage: true, issue_tokens: false },
|
|
1043
|
+
}),
|
|
1044
|
+
disconnectMemberConnection: vi.fn().mockResolvedValue(undefined),
|
|
1045
|
+
...overrides,
|
|
1046
|
+
} as Partial<ConnectionsApi>);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** The card element for one member row, by the id the roster stamps on it. */
|
|
1050
|
+
async function memberCard(id: string): Promise<HTMLElement> {
|
|
1051
|
+
return waitFor(() => {
|
|
1052
|
+
const el = document.querySelector<HTMLElement>(`div[data-member-connection="${id}"]`);
|
|
1053
|
+
if (!el) throw new Error(`no member row rendered for ${id}`);
|
|
1054
|
+
return el;
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
describe('MemberConnectionRoster — Disconnect is not offered twice', () => {
|
|
1059
|
+
it('does NOT offer Disconnect on a row that is already disconnected', async () => {
|
|
1060
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1061
|
+
const gone = await memberCard('m-gone');
|
|
1062
|
+
expect(within(gone).queryByRole('button', { name: /Disconnect/i })).toBeNull();
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('says the account is gone and that only the member can bring it back', async () => {
|
|
1066
|
+
// The row STAYS. Who used to hold a pipe into this foundry is the answer an
|
|
1067
|
+
// offboarding review is looking for; hiding it would lose that.
|
|
1068
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1069
|
+
const gone = await memberCard('m-gone');
|
|
1070
|
+
const note = gone.querySelector('[data-member-connection-disconnected]');
|
|
1071
|
+
expect(note).not.toBeNull();
|
|
1072
|
+
expect(note!.textContent).toMatch(/Disconnected\./);
|
|
1073
|
+
expect(note!.textContent).toMatch(/di@acme\.test has to connect with n8n again/);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it('never claims a deleted key "cannot be undone"', async () => {
|
|
1077
|
+
// The exact copy the bead objects to, over a key that is already gone.
|
|
1078
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1079
|
+
const gone = await memberCard('m-gone');
|
|
1080
|
+
expect(within(gone).queryByText(/cannot be undone/i)).toBeNull();
|
|
1081
|
+
expect(within(gone).queryByText(/stored key is deleted and their/i)).toBeNull();
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('KEEPS Disconnect on a failing row, whose key is still stored', async () => {
|
|
1085
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1086
|
+
const broken = await memberCard('m-broken');
|
|
1087
|
+
expect(within(broken).getByRole('button', { name: /^Disconnect$/i }))
|
|
1088
|
+
.toBeInTheDocument();
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('KEEPS Disconnect on a needs_reauth row, whose poll leg is still live', async () => {
|
|
1092
|
+
render(<ConnectionsPage api={rosterApi()} foundrySlug="acme" />);
|
|
1093
|
+
const stale = await memberCard('m-stale');
|
|
1094
|
+
expect(within(stale).getByRole('button', { name: /^Disconnect$/i }))
|
|
1095
|
+
.toBeInTheDocument();
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it('still cuts a connected row, naming the person before it does', async () => {
|
|
1099
|
+
const api = rosterApi();
|
|
1100
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
1101
|
+
const live = await memberCard('m-live');
|
|
1102
|
+
await userEvent.click(within(live).getByRole('button', { name: /^Disconnect$/i }));
|
|
1103
|
+
const prompt = within(live).getByText(/cannot be undone/i);
|
|
1104
|
+
expect(prompt.textContent).toContain('ana@acme.test');
|
|
1105
|
+
await userEvent.click(within(live).getByRole('button', { name: /Yes, disconnect/i }));
|
|
1106
|
+
await waitFor(() =>
|
|
1107
|
+
expect(api.disconnectMemberConnection).toHaveBeenCalledWith('acme', 'm-live'));
|
|
1108
|
+
});
|
|
1109
|
+
});
|
package/src/components/index.ts
CHANGED
|
@@ -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,209 @@
|
|
|
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
|
+
/**
|
|
112
|
+
* ALREADY CUT (bd startsim-768w.22.25.19). `disabled` is the status a row
|
|
113
|
+
* carries AFTER a disconnect: the key is deleted, the tokens are revoked and
|
|
114
|
+
* the scheduled fetches are stopped. There is nothing left for this verb to
|
|
115
|
+
* do, so offering it — and then confirming with "this cannot be undone" over
|
|
116
|
+
* a key that is already gone — describes an action the server would refuse.
|
|
117
|
+
*
|
|
118
|
+
* ONLY `disabled`. `error` and `needs_reauth` still hold a stored credential
|
|
119
|
+
* and a live poll leg; they are exactly the rows an admin offboarding someone
|
|
120
|
+
* most needs to cut, so the test for this asserts they keep the button.
|
|
121
|
+
*/
|
|
122
|
+
const alreadyDisconnected = row.status === 'disabled';
|
|
123
|
+
|
|
124
|
+
async function cut() {
|
|
125
|
+
if (!disconnect) return;
|
|
126
|
+
setBusy(true);
|
|
127
|
+
setError('');
|
|
128
|
+
try {
|
|
129
|
+
await disconnect(row.id);
|
|
130
|
+
setConfirming(false);
|
|
131
|
+
onChanged();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
setError((err as { message?: string })?.message || 'Could not disconnect.');
|
|
134
|
+
} finally {
|
|
135
|
+
setBusy(false);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className={cn.card} data-member-connection={row.id}>
|
|
141
|
+
<div className="flex items-start justify-between gap-4">
|
|
142
|
+
<div>
|
|
143
|
+
<p className="font-medium text-gray-900">{who}</p>
|
|
144
|
+
<p className={cn.muted}>
|
|
145
|
+
{row.connector_name}
|
|
146
|
+
{row.account_label ? ` · ${row.account_label}` : ''}
|
|
147
|
+
</p>
|
|
148
|
+
<p className="mt-1 text-xs text-gray-400">
|
|
149
|
+
{row.status}
|
|
150
|
+
{row.health ? ` · ${row.health}` : ''}
|
|
151
|
+
{row.last_checked_at
|
|
152
|
+
? ` · last checked ${new Date(row.last_checked_at).toLocaleString()}`
|
|
153
|
+
: ' · never checked'}
|
|
154
|
+
</p>
|
|
155
|
+
{/* The vendor's own failure text, which the server already scrubbed of
|
|
156
|
+
anything the member submitted. An admin deciding whether to cut a
|
|
157
|
+
pipe is entitled to know it is already broken. */}
|
|
158
|
+
{row.check_error ? <p className={cn.error}>{row.check_error}</p> : null}
|
|
159
|
+
{/* THE ROW STAYS. Who USED to have a pipe into this foundry is the
|
|
160
|
+
answer an offboarding review is looking for, so a disconnected
|
|
161
|
+
account is not hidden — it is labelled, and its verb is gone. */}
|
|
162
|
+
{alreadyDisconnected ? (
|
|
163
|
+
<p className={cn.muted} data-member-connection-disconnected>
|
|
164
|
+
Disconnected. This account’s key was deleted and its scheduled
|
|
165
|
+
fetches stopped. Nothing here can bring it back —{' '}
|
|
166
|
+
{who} has to connect with {row.connector_name} again themselves.
|
|
167
|
+
</p>
|
|
168
|
+
) : null}
|
|
169
|
+
</div>
|
|
170
|
+
{canManage && disconnect && !alreadyDisconnected ? (
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
className={cn.button}
|
|
174
|
+
disabled={busy}
|
|
175
|
+
onClick={() => setConfirming((v) => !v)}
|
|
176
|
+
>
|
|
177
|
+
{confirming ? 'Cancel' : 'Disconnect'}
|
|
178
|
+
</button>
|
|
179
|
+
) : null}
|
|
180
|
+
</div>
|
|
181
|
+
{/* `alreadyDisconnected` again, not because the button can open this today
|
|
182
|
+
but because a reload can flip the row to `disabled` under an open
|
|
183
|
+
prompt — and the one thing this prompt must never do is warn that an
|
|
184
|
+
action "cannot be undone" about a key that is already gone. */}
|
|
185
|
+
{confirming && !alreadyDisconnected ? (
|
|
186
|
+
<div className="mt-3 space-y-2">
|
|
187
|
+
{/* NAMES THE PERSON, because the cost of this button falls on them and
|
|
188
|
+
not on the admin pressing it. A generic "are you sure?" over a list
|
|
189
|
+
of near-identical rows is how the wrong pipe gets cut. */}
|
|
190
|
+
<p className={cn.muted}>
|
|
191
|
+
Disconnect <strong className="text-gray-900">{who}</strong>’s{' '}
|
|
192
|
+
{row.connector_name} account? Their stored key is deleted and their
|
|
193
|
+
scheduled fetches stop. This cannot be undone — they would have to
|
|
194
|
+
connect again themselves.
|
|
195
|
+
</p>
|
|
196
|
+
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
className={cn.button}
|
|
200
|
+
disabled={busy}
|
|
201
|
+
onClick={() => void cut()}
|
|
202
|
+
>
|
|
203
|
+
{busy ? 'Disconnecting…' : 'Yes, disconnect'}
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
) : null}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -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
|
/**
|