@startsimpli/integrations 0.2.20 → 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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
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",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The member connector client (bd startsim-768w.22.25.6).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS SUITE IS FOR, and it is one thing: the URLs. Every method on
|
|
5
|
+
* `connectionsMe` must land under `/connections/me/`, and NOTHING it sends may
|
|
6
|
+
* name a foundry, a slug, or a user. The member router's guarantee — "there is
|
|
7
|
+
* no id here to substitute, no slug to swap" — is a property of the URL space,
|
|
8
|
+
* and a client is exactly where that guarantee gets quietly given back.
|
|
9
|
+
*
|
|
10
|
+
* So each assertion below reads the URL that actually reached `fetch`, not the
|
|
11
|
+
* arguments the caller passed.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { connectionsMe } from '../api/connections';
|
|
16
|
+
import { setApiBase, setAuthTokenProvider } from '../api/_fetch';
|
|
17
|
+
|
|
18
|
+
interface Call { url: string; method: string; body: unknown }
|
|
19
|
+
|
|
20
|
+
let calls: Call[] = [];
|
|
21
|
+
|
|
22
|
+
function jsonOnce(payload: unknown, status = 200) {
|
|
23
|
+
return new Response(JSON.stringify(payload), {
|
|
24
|
+
status, headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
calls = [];
|
|
30
|
+
setApiBase('/api/v1');
|
|
31
|
+
setAuthTokenProvider(null);
|
|
32
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit = {}) => {
|
|
33
|
+
calls.push({
|
|
34
|
+
url: String(url),
|
|
35
|
+
method: init.method ?? 'GET',
|
|
36
|
+
body: init.body ? JSON.parse(String(init.body)) : undefined,
|
|
37
|
+
});
|
|
38
|
+
return jsonOnce({ results: [], permissions: {} });
|
|
39
|
+
}));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
43
|
+
|
|
44
|
+
describe('connectionsMe — every route is /connections/me/', () => {
|
|
45
|
+
it('reads the ALLOWED catalog, not the global one', async () => {
|
|
46
|
+
await connectionsMe.connectorCatalog();
|
|
47
|
+
expect(calls[0].url).toBe('/api/v1/connections/me/catalog/');
|
|
48
|
+
expect(calls[0].method).toBe('GET');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('lists my connections with no query string at all', async () => {
|
|
52
|
+
await connectionsMe.connections();
|
|
53
|
+
// No `?owner=`, no `?foundry=`. A parameter that scopes the list is a
|
|
54
|
+
// parameter someone can change.
|
|
55
|
+
expect(calls[0].url).toBe('/api/v1/connections/me/');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('connects by POSTing the input as the body, unwrapped', async () => {
|
|
59
|
+
await connectionsMe.connect({
|
|
60
|
+
connector_key: 'granola', name: 'Mine',
|
|
61
|
+
credentials: { api_key: 'grn_x' }, capabilities_enabled: ['poll'],
|
|
62
|
+
});
|
|
63
|
+
expect(calls[0].url).toBe('/api/v1/connections/me/');
|
|
64
|
+
expect(calls[0].method).toBe('POST');
|
|
65
|
+
expect(calls[0].body).toEqual({
|
|
66
|
+
connector_key: 'granola', name: 'Mine',
|
|
67
|
+
credentials: { api_key: 'grn_x' }, capabilities_enabled: ['poll'],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('tests against the STORED credential, sending no key', async () => {
|
|
72
|
+
await connectionsMe.checkConnection('abc');
|
|
73
|
+
expect(calls[0].url).toBe('/api/v1/connections/me/abc/check/');
|
|
74
|
+
expect(calls[0].method).toBe('POST');
|
|
75
|
+
// Deliberately empty: a check that accepts a pasted key is a second connect
|
|
76
|
+
// path, and the router does not read this body.
|
|
77
|
+
expect(calls[0].body).toEqual({});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rotates through the credential route, wrapping the map the way the API expects', async () => {
|
|
81
|
+
await connectionsMe.rotateCredential('abc', { api_key: 'grn_new' });
|
|
82
|
+
expect(calls[0].url).toBe('/api/v1/connections/me/abc/credential/');
|
|
83
|
+
expect(calls[0].body).toEqual({ credentials: { api_key: 'grn_new' } });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('disconnects with DELETE on the detail route', async () => {
|
|
87
|
+
await connectionsMe.disconnect('abc');
|
|
88
|
+
expect(calls[0].url).toBe('/api/v1/connections/me/abc/');
|
|
89
|
+
expect(calls[0].method).toBe('DELETE');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('reads runs off the connection it belongs to', async () => {
|
|
93
|
+
await connectionsMe.connectionRuns('abc');
|
|
94
|
+
expect(calls[0].url).toBe('/api/v1/connections/me/abc/runs/');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('carries no slug, tenant or user anywhere in any request it makes', async () => {
|
|
98
|
+
await connectionsMe.connectorCatalog();
|
|
99
|
+
await connectionsMe.connections();
|
|
100
|
+
await connectionsMe.connect({ connector_key: 'granola' });
|
|
101
|
+
await connectionsMe.checkConnection('abc');
|
|
102
|
+
await connectionsMe.rotateCredential('abc', { api_key: 'x' });
|
|
103
|
+
await connectionsMe.disconnect('abc');
|
|
104
|
+
await connectionsMe.connectionRuns('abc');
|
|
105
|
+
|
|
106
|
+
for (const call of calls) {
|
|
107
|
+
expect(call.url.startsWith('/api/v1/connections/me/')).toBe(true);
|
|
108
|
+
expect(call.url).not.toMatch(/[?&]/);
|
|
109
|
+
const serialised = JSON.stringify(call.body ?? {});
|
|
110
|
+
expect(serialised).not.toMatch(/slug|foundry|tenant|company|owner|user_id/i);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('follows setApiBase, which is how a tenant app proxies same-origin', async () => {
|
|
115
|
+
// bd startsim-768w.22.25.7: the tenant serves `/central-api` from its own
|
|
116
|
+
// origin. If this client ignored the base, the browser would talk to central
|
|
117
|
+
// cross-origin with a bearer token attached.
|
|
118
|
+
setApiBase('/central-api/v1');
|
|
119
|
+
await connectionsMe.connections();
|
|
120
|
+
expect(calls[0].url).toBe('/central-api/v1/connections/me/');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -20,7 +20,7 @@ import userEvent from '@testing-library/user-event';
|
|
|
20
20
|
|
|
21
21
|
import { CredentialSchemaForm, schemaFields } from '../components/CredentialSchemaForm';
|
|
22
22
|
import { ConnectionsPage } from '../components/pages/ConnectionsPage';
|
|
23
|
-
import type { ConnectionsApi } from '../components/pages/types';
|
|
23
|
+
import type { ConnectionsApi, MemberConnectionsApi } from '../components/pages/types';
|
|
24
24
|
|
|
25
25
|
// --------------------------------------------------------------------------
|
|
26
26
|
// two schemas, one component
|
|
@@ -792,3 +792,208 @@ describe('ConnectionCard — a connected account has settings', () => {
|
|
|
792
792
|
expect(await within(section).findByText(/keeping verbatim meeting speech forever/i)).toBeInTheDocument();
|
|
793
793
|
});
|
|
794
794
|
});
|
|
795
|
+
|
|
796
|
+
// --------------------------------------------------------------------------
|
|
797
|
+
// personal mode (bd startsim-768w.22.25.6)
|
|
798
|
+
// --------------------------------------------------------------------------
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* THE SAME COMPOSER, POINTED AT THE MEMBER SURFACE.
|
|
802
|
+
*
|
|
803
|
+
* A member of a foundry sets THEIR OWN key on a connector, from inside their
|
|
804
|
+
* tenant app, and the page they use is this one — `mode="personal"`. There is no
|
|
805
|
+
* second component, because a second component is a second place for the
|
|
806
|
+
* capability picker, the rotation form and the disconnect confirmation to drift.
|
|
807
|
+
*
|
|
808
|
+
* What differs is not cosmetic, and every difference below is forced by
|
|
809
|
+
* `backend/apps/connectors/member_urls.py`:
|
|
810
|
+
*
|
|
811
|
+
* * NO SLUG anywhere in the member client's signatures. The member endpoints
|
|
812
|
+
* are `connections/me/...` and the foundry is named by the token's audience
|
|
813
|
+
* — "there is no id here to substitute, no slug to swap". A client that
|
|
814
|
+
* takes a slug re-introduces, in the types, the exact affordance that URL
|
|
815
|
+
* space was designed not to have.
|
|
816
|
+
* * NO PATCH. `MyConnectionDetailView` serves GET and DELETE only, so the
|
|
817
|
+
* capability picker is informational and there is no Save beside it, and no
|
|
818
|
+
* settings form.
|
|
819
|
+
* * NO targets, choices, actions or tokens. Those are foundry-level and stay
|
|
820
|
+
* on the control-plane router, so the Workflows control is not rendered.
|
|
821
|
+
* * THE CATALOG IS THE SERVER'S ANSWER. `me/catalog/` returns only what the
|
|
822
|
+
* foundry has allowed members to connect. Filtering client-side would ship
|
|
823
|
+
* the disallowed vendors in the bundle.
|
|
824
|
+
*/
|
|
825
|
+
const MEMBER_CATALOG = {
|
|
826
|
+
// Only Granola. The foundry allowed one connector to its members; n8n is in
|
|
827
|
+
// the foundry-wide catalog and must not appear on this surface at all.
|
|
828
|
+
results: [
|
|
829
|
+
{
|
|
830
|
+
...CATALOG.results[1],
|
|
831
|
+
availability: { available: true, reason: '' },
|
|
832
|
+
},
|
|
833
|
+
],
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const MY_CONNECTIONS = {
|
|
837
|
+
results: [
|
|
838
|
+
{
|
|
839
|
+
id: 'm1', public_id: 'mpub1', name: 'My Granola', connector_key: 'granola',
|
|
840
|
+
connector_name: 'Granola', external_account_id: '', account_label: 'me@example.com',
|
|
841
|
+
capabilities_enabled: ['poll'], config: {}, status: 'connected',
|
|
842
|
+
health: 'ok', auth_mode: 'api_key',
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
permissions: { view: true, manage: true, issue_tokens: false },
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
function stubMemberApi(overrides: Partial<MemberConnectionsApi> = {}): MemberConnectionsApi {
|
|
849
|
+
return {
|
|
850
|
+
connectorCatalog: vi.fn().mockResolvedValue(MEMBER_CATALOG),
|
|
851
|
+
connections: vi.fn().mockResolvedValue(MY_CONNECTIONS),
|
|
852
|
+
connect: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
|
|
853
|
+
checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
|
|
854
|
+
rotateCredential: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
|
|
855
|
+
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
856
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
|
|
857
|
+
...overrides,
|
|
858
|
+
} as MemberConnectionsApi;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function myCard(api: MemberConnectionsApi = stubMemberApi()) {
|
|
862
|
+
render(<ConnectionsPage mode="personal" api={api} />);
|
|
863
|
+
const card = await screen.findByText('My Granola');
|
|
864
|
+
return (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
describe('ConnectionsPage — personal mode speaks to the member surface', () => {
|
|
868
|
+
it('calls the member endpoints with NO slug argument', async () => {
|
|
869
|
+
const api = stubMemberApi();
|
|
870
|
+
render(<ConnectionsPage mode="personal" api={api} />);
|
|
871
|
+
expect(await screen.findByText('My Granola')).toBeInTheDocument();
|
|
872
|
+
// Not `toHaveBeenCalledWith(undefined)` — called with nothing. A slug
|
|
873
|
+
// parameter that happens to be undefined is still a slug parameter.
|
|
874
|
+
expect((api.connectorCatalog as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([]);
|
|
875
|
+
expect((api.connections as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([]);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it('renders EXACTLY the catalog the server returned — no client-side filter', async () => {
|
|
879
|
+
// The foundry-wide catalog holds n8n. The member catalog does not. If this
|
|
880
|
+
// page ever merges the two, or filters a full list in the browser, the
|
|
881
|
+
// vendor the foundry deliberately withheld ships in the bundle anyway.
|
|
882
|
+
render(<ConnectionsPage mode="personal" api={stubMemberApi()} />);
|
|
883
|
+
expect(await screen.findByText('Granola')).toBeInTheDocument();
|
|
884
|
+
expect(screen.queryByText('n8n')).toBeNull();
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it('offers the controls a member actually has: Test, Replace key, Disconnect', async () => {
|
|
888
|
+
const section = await myCard();
|
|
889
|
+
expect(within(section).getByRole('button', { name: /^Test$/i })).toBeInTheDocument();
|
|
890
|
+
expect(within(section).getByRole('button', { name: /Replace key/i })).toBeInTheDocument();
|
|
891
|
+
expect(within(section).getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it('does NOT offer Workflows — choices, targets and actions are foundry-level', async () => {
|
|
895
|
+
const section = await myCard();
|
|
896
|
+
expect(within(section).queryByRole('button', { name: /Workflows/i })).toBeNull();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('shows the capabilities but offers no Save, because there is no PATCH', async () => {
|
|
900
|
+
const section = await myCard();
|
|
901
|
+
// Informational, not hidden: a member chose these at connect time and is
|
|
902
|
+
// entitled to see what their account is doing.
|
|
903
|
+
expect(within(section).getByText(/Fetch new records on a schedule/i)).toBeInTheDocument();
|
|
904
|
+
expect(within(section).queryByRole('button', { name: /^Save$/i })).toBeNull();
|
|
905
|
+
expect(within(section).queryByRole('button', { name: /Settings/i })).toBeNull();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('connects with no slug, sending credentials and capabilities', async () => {
|
|
909
|
+
const connect = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
|
|
910
|
+
const api = stubMemberApi({ connect, connections: vi.fn().mockResolvedValue({
|
|
911
|
+
...MY_CONNECTIONS, results: [],
|
|
912
|
+
}) });
|
|
913
|
+
render(<ConnectionsPage mode="personal" api={api} />);
|
|
914
|
+
await userEvent.click(await screen.findByRole('button', { name: /^Connect$/i }));
|
|
915
|
+
await userEvent.type(screen.getByLabelText(/API key/i), 'grn_mine');
|
|
916
|
+
await userEvent.click(screen.getByRole('button', { name: /Test and save/i }));
|
|
917
|
+
|
|
918
|
+
await waitFor(() => expect(connect).toHaveBeenCalled());
|
|
919
|
+
const [payload, ...rest] = connect.mock.calls[0];
|
|
920
|
+
expect(rest).toEqual([]);
|
|
921
|
+
expect(payload.connector_key).toBe('granola');
|
|
922
|
+
expect(payload.credentials).toEqual({ api_key: 'grn_mine' });
|
|
923
|
+
expect(payload.capabilities_enabled).toEqual(['poll']);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('rotates and disconnects through the slugless member client', async () => {
|
|
927
|
+
const rotateCredential = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
|
|
928
|
+
const disconnect = vi.fn().mockResolvedValue(undefined);
|
|
929
|
+
const section = await myCard(stubMemberApi({ rotateCredential, disconnect }));
|
|
930
|
+
|
|
931
|
+
await userEvent.click(within(section).getByRole('button', { name: /Replace key/i }));
|
|
932
|
+
await userEvent.type(within(section).getByLabelText(/API key/i), 'grn_rotated');
|
|
933
|
+
await userEvent.click(within(section).getByRole('button', { name: /Test and replace/i }));
|
|
934
|
+
await waitFor(() => expect(rotateCredential).toHaveBeenCalled());
|
|
935
|
+
expect(rotateCredential.mock.calls[0]).toEqual(['m1', { api_key: 'grn_rotated' }]);
|
|
936
|
+
|
|
937
|
+
await userEvent.click(within(section).getByRole('button', { name: /^Disconnect$/i }));
|
|
938
|
+
await userEvent.click(within(section).getByRole('button', { name: /Yes, disconnect/i }));
|
|
939
|
+
await waitFor(() => expect(disconnect).toHaveBeenCalled());
|
|
940
|
+
expect(disconnect.mock.calls[0]).toEqual(['m1']);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('says the account is YOURS, never the foundry’s', async () => {
|
|
944
|
+
render(<ConnectionsPage mode="personal" api={stubMemberApi()} />);
|
|
945
|
+
expect(await screen.findByText(/Your connected accounts/i)).toBeInTheDocument();
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('reads a 403 on load as “you have no seat here”, not “ask for permission”', async () => {
|
|
949
|
+
// The ONLY 403 either load GET can produce is `IsSeatedMember` denying — the
|
|
950
|
+
// catalog answers a foundry that has allowed nothing with an empty 200, and
|
|
951
|
+
// the list runs no per-connector policy. So this copy must name the seat.
|
|
952
|
+
// Blaming the allowlist would send someone whose membership has ended to ask
|
|
953
|
+
// an admin to allow a connector, which cannot fix them.
|
|
954
|
+
const api = stubMemberApi({
|
|
955
|
+
connections: vi.fn().mockRejectedValue(Object.assign(new Error('nope'), { status: 403 })),
|
|
956
|
+
});
|
|
957
|
+
render(<ConnectionsPage mode="personal" api={api} />);
|
|
958
|
+
const alert = await screen.findByRole('alert');
|
|
959
|
+
expect(alert.textContent).toMatch(/not a member of this foundry/i);
|
|
960
|
+
expect(alert.textContent).not.toMatch(/connections permission/i);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('renders the SERVER’s allowlist refusal when connecting is what was refused', async () => {
|
|
964
|
+
// `_NOT_ALLOWED` is raised by the POST, not the load, and it lands in the
|
|
965
|
+
// catalog card's own catch. That is the one place the allowlist wording
|
|
966
|
+
// belongs, and it comes from the server verbatim rather than being guessed
|
|
967
|
+
// at here — the server knows which connector was refused.
|
|
968
|
+
const refusal = 'your foundry has not allowed members to connect their own account on this connector.';
|
|
969
|
+
const api = stubMemberApi({
|
|
970
|
+
connect: vi.fn().mockRejectedValue(Object.assign(new Error(refusal), { status: 403 })),
|
|
971
|
+
connections: vi.fn().mockResolvedValue({ ...MY_CONNECTIONS, results: [] }),
|
|
972
|
+
});
|
|
973
|
+
render(<ConnectionsPage mode="personal" api={api} />);
|
|
974
|
+
|
|
975
|
+
await userEvent.click(await screen.findByRole('button', { name: /^Connect$/i }));
|
|
976
|
+
await userEvent.type(screen.getByLabelText(/API key/i), 'grn_live');
|
|
977
|
+
await userEvent.click(screen.getByRole('button', { name: /Test and save/i }));
|
|
978
|
+
|
|
979
|
+
await waitFor(() => expect(screen.getByText(refusal)).toBeInTheDocument());
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
describe('ConnectionsPage — foundry mode is untouched by personal mode', () => {
|
|
984
|
+
it('still defaults to foundry mode and still passes the slug', async () => {
|
|
985
|
+
const api = stubApi();
|
|
986
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
987
|
+
expect(await screen.findByText('Ops n8n')).toBeInTheDocument();
|
|
988
|
+
expect(api.connectorCatalog).toHaveBeenCalledWith('acme');
|
|
989
|
+
expect(screen.getByText('Connected accounts')).toBeInTheDocument();
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it('still offers Workflows and the capability Save in foundry mode', async () => {
|
|
993
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
|
|
994
|
+
const card = await screen.findByText('Ops n8n');
|
|
995
|
+
const section = (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
|
|
996
|
+
expect(within(section).getByRole('button', { name: /Workflows/i })).toBeInTheDocument();
|
|
997
|
+
expect(within(section).getByRole('button', { name: /^Save$/i })).toBeInTheDocument();
|
|
998
|
+
});
|
|
999
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/v1/connections/me/` — a member's OWN vendor accounts
|
|
3
|
+
* (bd startsim-768w.22.25.6).
|
|
4
|
+
*
|
|
5
|
+
* The client half of the member connector surface. An app inside a foundry's
|
|
6
|
+
* tenant mounts `<ConnectionsPage mode="personal" api={connectionsMe} />` and
|
|
7
|
+
* owns nothing else: this object satisfies `MemberConnectionsApi`, so the same
|
|
8
|
+
* composer the command center uses renders a member's own keys.
|
|
9
|
+
*
|
|
10
|
+
* NO SLUG. Not on any method, not as an ignored parameter, not as an optional
|
|
11
|
+
* one — and that is the point rather than a convenience. The member router
|
|
12
|
+
* (`backend/apps/connectors/member_urls.py`) names the foundry through the
|
|
13
|
+
* TOKEN'S AUDIENCE, and says why in its own words: "there is no id here to
|
|
14
|
+
* substitute, no slug to swap, and therefore nothing in the URL space that names
|
|
15
|
+
* anybody else." A client whose signatures take a slug hands that affordance
|
|
16
|
+
* straight back at the type level and reads, to whoever maintains it next, as a
|
|
17
|
+
* client that scopes by slug. A member of two foundries holds two tokens; the
|
|
18
|
+
* token in flight decides which foundry answers, and nothing here can override
|
|
19
|
+
* that.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IS ABSENT IS ALSO THE CONTRACT. There is no `updateConnection` because
|
|
22
|
+
* `MyConnectionDetailView` serves GET and DELETE only; no choices, targets,
|
|
23
|
+
* actions or `ConnectionToken`s because those are foundry-level and stay
|
|
24
|
+
* owner-exclusive on the control-plane router. Adding one here would be a client
|
|
25
|
+
* for a route that does not exist.
|
|
26
|
+
*
|
|
27
|
+
* WHERE IT POINTS. Through `request()`, so `setApiBase` and
|
|
28
|
+
* `setAuthTokenProvider` apply: a tenant app points the base at its own
|
|
29
|
+
* same-origin proxy (bd startsim-768w.22.25.7) and the browser never makes a
|
|
30
|
+
* cross-origin call to central with a bearer token on it.
|
|
31
|
+
*/
|
|
32
|
+
import type {
|
|
33
|
+
Connection,
|
|
34
|
+
ConnectionPermissions,
|
|
35
|
+
ConnectionRun,
|
|
36
|
+
ConnectorCatalogEntry,
|
|
37
|
+
MemberConnectionsApi,
|
|
38
|
+
} from '../components/pages/types';
|
|
39
|
+
import { request } from './_fetch';
|
|
40
|
+
|
|
41
|
+
/** Mounted at `/api/v1/connections/` (`config/urls.py`), so this rides the base. */
|
|
42
|
+
const BASE = '/connections/me';
|
|
43
|
+
|
|
44
|
+
export const connectionsMe: MemberConnectionsApi = {
|
|
45
|
+
/**
|
|
46
|
+
* The connectors THIS foundry has opened to its members — not the global
|
|
47
|
+
* catalog. The narrowing is `policy_service.member_connectable`, server-side,
|
|
48
|
+
* default-deny per connector per foundry. Rendered as handed: filtering in the
|
|
49
|
+
* browser would mean shipping the withheld vendors to be filtered out of.
|
|
50
|
+
*/
|
|
51
|
+
connectorCatalog(): Promise<{ results: ConnectorCatalogEntry[] }> {
|
|
52
|
+
return request<{ results: ConnectorCatalogEntry[] }>(`${BASE}/catalog/`);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* MY cards. Already owner-scoped by `Connection.objects.for_owner(foundry,
|
|
57
|
+
* user)` — there is no query parameter that widens this and no id to swap.
|
|
58
|
+
*
|
|
59
|
+
* `permissions` comes back with `issue_tokens: false` because the router has
|
|
60
|
+
* no such route, not because of anything about the caller.
|
|
61
|
+
*/
|
|
62
|
+
connections(): Promise<{ results: Connection[]; permissions: ConnectionPermissions }> {
|
|
63
|
+
return request<{ results: Connection[]; permissions: ConnectionPermissions }>(`${BASE}/`);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Connect my own account. BLOCKING: the server runs `check()` against the
|
|
68
|
+
* vendor before storing anything, so a key that has not been proven to work is
|
|
69
|
+
* never saved as connected.
|
|
70
|
+
*
|
|
71
|
+
* `capabilities_enabled` is chosen HERE and only here — there is no PATCH on
|
|
72
|
+
* this surface, so this request is the one place a member says what their
|
|
73
|
+
* account may do.
|
|
74
|
+
*
|
|
75
|
+
* A 403 back from this is not "you lack a permission": it is the foundry not
|
|
76
|
+
* having allowed members to connect their own account on this connector. The
|
|
77
|
+
* server re-checks that here rather than trusting that the catalog it rendered
|
|
78
|
+
* earlier is what the browser actually sent.
|
|
79
|
+
*/
|
|
80
|
+
connect(input: {
|
|
81
|
+
connector_key: string;
|
|
82
|
+
name?: string;
|
|
83
|
+
credentials?: Record<string, unknown>;
|
|
84
|
+
config?: Record<string, unknown>;
|
|
85
|
+
capabilities_enabled?: string[];
|
|
86
|
+
}): Promise<Connection> {
|
|
87
|
+
return request<Connection>(`${BASE}/`, { method: 'POST', body: input });
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Test it now, against the STORED credential. The body is deliberately empty:
|
|
92
|
+
* a "test connection" button that accepts a pasted key is a second connect
|
|
93
|
+
* path, and the router does not read this body at all.
|
|
94
|
+
*/
|
|
95
|
+
checkConnection(id: string): Promise<{ ok: boolean; account_label: string; detail: string }> {
|
|
96
|
+
return request<{ ok: boolean; account_label: string; detail: string }>(
|
|
97
|
+
`${BASE}/${id}/check/`, { method: 'POST', body: {} },
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rotate MY key. The old one stays live until the new one passes `check()`,
|
|
103
|
+
* so a bad paste changes nothing. A PARTIAL map is legal and normal: send the
|
|
104
|
+
* one field being replaced and the stored values fill in the rest.
|
|
105
|
+
*/
|
|
106
|
+
rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection> {
|
|
107
|
+
return request<Connection>(`${BASE}/${id}/credential/`, {
|
|
108
|
+
method: 'POST', body: { credentials },
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* DISCONNECT, not delete. One transaction stops the scheduled fetches, revokes
|
|
114
|
+
* every token issued for the account and deletes the stored credential; the
|
|
115
|
+
* card survives as `disabled`, so the record that this vendor was connected is
|
|
116
|
+
* not thrown away.
|
|
117
|
+
*
|
|
118
|
+
* A card that is not mine answers 404 rather than 403 — on this surface
|
|
119
|
+
* existence is itself the payload.
|
|
120
|
+
*/
|
|
121
|
+
disconnect(id: string): Promise<void> {
|
|
122
|
+
return request<void>(`${BASE}/${id}/`, { method: 'DELETE' });
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/** Recent activity on my card: a merge over the run tables it owns. */
|
|
126
|
+
connectionRuns(id: string): Promise<{ results: ConnectionRun[] }> {
|
|
127
|
+
return request<{ results: ConnectionRun[] }>(`${BASE}/${id}/runs/`);
|
|
128
|
+
},
|
|
129
|
+
};
|
package/src/api/index.ts
CHANGED
|
@@ -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
|
+
}
|