@startsimpli/integrations 0.1.0 → 0.1.3
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/README.md +178 -0
- package/package.json +18 -9
- package/src/__tests__/connections.test.tsx +338 -0
- package/src/components/CredentialSchemaForm.tsx +196 -0
- package/src/components/index.ts +20 -0
- package/src/components/pages/ConnectionsPage.tsx +426 -0
- package/src/components/pages/types.ts +176 -0
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# @startsimpli/integrations
|
|
2
|
+
|
|
3
|
+
Provider-agnostic TypeScript client for the OAuth-backed **email** and **calendar** integrations that StartSimpli apps consume from the Django API. Ships:
|
|
4
|
+
|
|
5
|
+
- **Typed API clients** wrapping `start-simpli-api`'s `/auth/oauth/*`, `/auth/oauth-accounts/`, `/calendar/events/`, and `/email/messages/` endpoints
|
|
6
|
+
- **React Query hooks** for connected accounts, calendar events, and email messages — with built-in re-consent flow handling
|
|
7
|
+
- **Headless React UI primitives** for connect cards, account status, event/email lists, and re-consent prompts
|
|
8
|
+
- **Normalized error types** (`UnauthenticatedError`, `ReconsentRequiredError`, `ApiError`) so the UI can branch on auth failure modes without parsing payloads
|
|
9
|
+
|
|
10
|
+
## The connections layer (foundries)
|
|
11
|
+
|
|
12
|
+
Alongside the email/calendar clients above, this package ships the UI for the
|
|
13
|
+
**connector framework** — the surface a foundry admin uses to connect a vendor
|
|
14
|
+
account and wire it up:
|
|
15
|
+
|
|
16
|
+
- **`CredentialSchemaForm`** — ONE renderer for every vendor, driven by the
|
|
17
|
+
catalog's own field schema (`{version, fields[]}`). It knows about FIELDS and
|
|
18
|
+
nothing about vendors: an unknown field type degrades to a text input, an
|
|
19
|
+
unrecognised schema version still renders its fields, and a field the server
|
|
20
|
+
marks `type: "secret"` / `write_only` is masked and never populated from a
|
|
21
|
+
read response.
|
|
22
|
+
- **`ConnectionsPage`** — the page composer: connected accounts, the catalog,
|
|
23
|
+
and the wiring flow (**discover → wire → confirm → fire**). Injected api, no
|
|
24
|
+
React context and no app-local provider, so an app mounts it in ten lines:
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
<ConnectionsPage api={apiClient.foundry} foundrySlug={slug} />
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**There are no per-vendor components, and a test enforces it** — it greps the
|
|
31
|
+
component tree for vendor keys. The backend refuses to hardcode a vendor (the
|
|
32
|
+
catalog is `DestinationProvider` rows and the field schemas come off the
|
|
33
|
+
connector class); that is worth nothing if the browser answers with
|
|
34
|
+
`if (key === 'n8n')`.
|
|
35
|
+
|
|
36
|
+
**OAuth providers shipped in-box:** `google` and `microsoft`. The Django backend brokers the OAuth handshake — this package never touches client secrets or refresh tokens directly. Reference lifecycle: `email-cal-srcs-dests`.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
Workspace-only:
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@startsimpli/integrations": "workspace:*"
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Peer deps (both optional, only needed for the hooks/components subpaths):
|
|
49
|
+
|
|
50
|
+
- `react ^18 || ^19`
|
|
51
|
+
- `@tanstack/react-query ^5`
|
|
52
|
+
|
|
53
|
+
You can consume `@startsimpli/integrations/types` and `@startsimpli/integrations/api` from a non-React context (e.g. a Node server, an MSW handler) without pulling in React.
|
|
54
|
+
|
|
55
|
+
## Public surface
|
|
56
|
+
|
|
57
|
+
| Export | Type | Description |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `OAuthProvider` | `'google' \| 'microsoft'` | The two providers backed by Django |
|
|
60
|
+
| `AccountStatus` | `'connected' \| 'disconnected' \| 'needs_reauth'` | Status union for `ConnectAccountCard` |
|
|
61
|
+
| `ConnectedAccount` / `CalendarEvent` / `CalendarEventAttendee` / `CalendarEventStatus` / `EmailMessage` / `PaginatedResponse<T>` / `CalendarWindow` / `ApiErrorPayload` | types | DRF response shapes |
|
|
62
|
+
| `ApiError` / `UnauthenticatedError` / `ReconsentRequiredError` | classes | Thrown by every API client; the UI uses `instanceof` to branch |
|
|
63
|
+
| `connectProvider(provider, redirectUri?)` | `POST /auth/oauth/{provider}/initiate/` | Returns the upstream `authorization_url` to redirect to |
|
|
64
|
+
| `revokeAccount(provider, oauthAccountId)` | `POST /auth/oauth/{provider}/revoke/` | Disconnects a connected account |
|
|
65
|
+
| `listConnectedAccounts()` | `GET /auth/oauth-accounts/` | All accounts for the current user |
|
|
66
|
+
| `listCalendarEvents({ from, to, provider?, calendar_id?, page?, page_size? })` | `GET /calendar/events/` | Paginated events in a window |
|
|
67
|
+
| `listEmailMessages({ provider?, account_id?, since?, is_outbound?, page?, page_size? })` | `GET /email/messages/` | Paginated messages |
|
|
68
|
+
| `setApiBase(url)` / `getApiBase()` | functions | Override the default `/api/v1` base URL |
|
|
69
|
+
| `setAuthTokenProvider(fn)` | function | Wire up a token source — injected as `Authorization: Bearer <token>` |
|
|
70
|
+
| `useConnectedAccounts(options?)` | hook | React Query wrapper around `listConnectedAccounts` (30 s `staleTime`) |
|
|
71
|
+
| `useDisconnectAccount()` | hook | Returns a mutator that revokes + invalidates the accounts query |
|
|
72
|
+
| `useCalendarEvents(window, options?)` | hook | Wraps `listCalendarEvents`; surfaces `events`, `reconsentUrl` |
|
|
73
|
+
| `useEmailMessages(filter?, options?)` | hook | Wraps `listEmailMessages`; surfaces `messages`, `reconsentUrl` |
|
|
74
|
+
| `useReconsent(error)` | hook | Extracts a `reconsent_url` from a thrown error + returns `openReconsent()` |
|
|
75
|
+
| `CONNECTED_ACCOUNTS_KEY` | const tuple | Stable React Query key for invalidation |
|
|
76
|
+
| `AccountStatusBadge` | component | "Connected / Needs re-auth / Disconnected" pill |
|
|
77
|
+
| `ConnectAccountCard` | component | Per-provider connect/disconnect card |
|
|
78
|
+
| `EmailList` / `EventList` / `EventListItem` | components | Headless list renderers |
|
|
79
|
+
| `ReconsentPromptCard` | component | Re-consent CTA card driven by `useReconsent` |
|
|
80
|
+
|
|
81
|
+
Subpath exports: `.`, `./types` (zero React), `./api` (zero React).
|
|
82
|
+
|
|
83
|
+
### Adapters that ship today
|
|
84
|
+
|
|
85
|
+
| Provider | OAuth | Mail | Calendar |
|
|
86
|
+
| --- | :---: | :---: | :---: |
|
|
87
|
+
| **Google** | yes (`gmail.readonly`, `calendar.readonly`) | yes | yes |
|
|
88
|
+
| **Microsoft** | yes (`Mail.Read`, `Calendars.Read`) | yes | yes |
|
|
89
|
+
|
|
90
|
+
No other adapters (Apollo, Hunter, Stripe, …) live in this package — those concerns belong in their own packages or directly in the Django backend. The single source of truth for available providers is `OAuthProvider` in `src/types/index.ts`.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
This is a thin client over Django — all provider credentials (Google client ID/secret, Microsoft app registration, scopes, refresh-token vault) live in `start-simpli-api`. From the browser side, you only need to wire up:
|
|
95
|
+
|
|
96
|
+
| Setup call | Why |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `setApiBase('https://api.example.com/api/v1')` | Point at your Django host. Defaults to `/api/v1` (relative). |
|
|
99
|
+
| `setAuthTokenProvider(() => getAccessToken())` | Provide a fresh JWT/session token for every request. Returning `null` falls back to cookie auth (the fetch wrapper uses `credentials: 'include'`). |
|
|
100
|
+
|
|
101
|
+
Errors surfaced from the Django API are normalized:
|
|
102
|
+
|
|
103
|
+
- **401** → `UnauthenticatedError`
|
|
104
|
+
- **403** with `reconsent_url` in the payload → `ReconsentRequiredError` (drives the re-consent prompt)
|
|
105
|
+
- All other non-2xx → `ApiError` with `status` + `payload`
|
|
106
|
+
|
|
107
|
+
## Usage
|
|
108
|
+
|
|
109
|
+
Real example from `raise-simpli/web-app/src/app/(dashboard)/settings/integrations/page.tsx`:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
'use client'
|
|
113
|
+
import {
|
|
114
|
+
ConnectAccountCard,
|
|
115
|
+
useConnectedAccounts,
|
|
116
|
+
useDisconnectAccount,
|
|
117
|
+
type ConnectedAccount,
|
|
118
|
+
type OAuthProvider,
|
|
119
|
+
} from '@startsimpli/integrations'
|
|
120
|
+
|
|
121
|
+
const PROVIDERS: OAuthProvider[] = ['microsoft', 'google']
|
|
122
|
+
|
|
123
|
+
export default function IntegrationsPage() {
|
|
124
|
+
const { data: accounts = [], isLoading, error } = useConnectedAccounts()
|
|
125
|
+
const disconnect = useDisconnectAccount()
|
|
126
|
+
|
|
127
|
+
const accountByProvider = (provider: OAuthProvider): ConnectedAccount | undefined =>
|
|
128
|
+
accounts.find((a) => a.provider === provider)
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
132
|
+
{PROVIDERS.map((provider) => (
|
|
133
|
+
<ConnectAccountCard
|
|
134
|
+
key={provider}
|
|
135
|
+
provider={provider}
|
|
136
|
+
account={accountByProvider(provider)}
|
|
137
|
+
onDisconnect={(id) => disconnect(provider, id)}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Calendar window with re-consent handling:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
const { events, reconsentUrl, isLoading } = useCalendarEvents({
|
|
149
|
+
from: startOfWeek,
|
|
150
|
+
to: endOfWeek,
|
|
151
|
+
}, { provider: 'google', pageSize: 200 })
|
|
152
|
+
|
|
153
|
+
if (reconsentUrl) return <ReconsentPromptCard href={reconsentUrl} />
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
App startup wiring (called once):
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { setApiBase, setAuthTokenProvider } from '@startsimpli/integrations'
|
|
160
|
+
import { getAccessToken } from '@startsimpli/auth/client'
|
|
161
|
+
|
|
162
|
+
setApiBase(process.env.NEXT_PUBLIC_API_BASE_URL!)
|
|
163
|
+
setAuthTokenProvider(() => getAccessToken())
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Verification
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
cd packages/integrations
|
|
170
|
+
pnpm vitest run
|
|
171
|
+
pnpm tsc --noEmit
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The test suite (`src/__tests__/api.test.ts`) ships **10 tests** covering the OAuth/calendar/email clients and the 401 / 403-with-reconsent / 5xx error normalisation paths against an MSW-mocked Django backend.
|
|
175
|
+
|
|
176
|
+
## Shared-first
|
|
177
|
+
|
|
178
|
+
Per CLAUDE.md **rule 9**, do **not** copy fetch helpers, types, or React Query hooks for calendar/email into an app's `src/`. The package guards this — `raise-simpli/web-app/src/__tests__/no_app_local_calendar_code.test.ts` exists to fail CI if app-local duplicates reappear. Extend `@startsimpli/integrations` instead.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -16,27 +16,36 @@
|
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"type-check": "tsc --noEmit",
|
|
21
|
-
"test": "vitest run",
|
|
22
|
-
"test:watch": "vitest"
|
|
23
|
-
},
|
|
24
19
|
"dependencies": {},
|
|
25
20
|
"peerDependencies": {
|
|
26
21
|
"react": ">=18.0.0",
|
|
27
22
|
"@tanstack/react-query": ">=5.0.0"
|
|
28
23
|
},
|
|
29
24
|
"peerDependenciesMeta": {
|
|
30
|
-
"react": {
|
|
31
|
-
|
|
25
|
+
"react": {
|
|
26
|
+
"optional": true
|
|
27
|
+
},
|
|
28
|
+
"@tanstack/react-query": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
32
31
|
},
|
|
33
32
|
"devDependencies": {
|
|
34
33
|
"@tanstack/react-query": "^5.0.0",
|
|
34
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
35
|
+
"@testing-library/react": "^16.1.0",
|
|
36
|
+
"@testing-library/user-event": "^14.5.2",
|
|
35
37
|
"@types/node": "^22.0.0",
|
|
36
38
|
"@types/react": "^19.0.0",
|
|
39
|
+
"jsdom": "^29.0.0",
|
|
37
40
|
"msw": "^2.0.0",
|
|
38
41
|
"react": "^19.0.0",
|
|
42
|
+
"react-dom": "^19.0.0",
|
|
39
43
|
"typescript": "^6.0.0",
|
|
40
44
|
"vitest": "^3.0.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"type-check": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest"
|
|
41
50
|
}
|
|
42
|
-
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connections layer: a generic renderer and a page composer
|
|
3
|
+
* (bd startsim-768w.22.3.2 RED, startsim-768w.22.3.4 GREEN).
|
|
4
|
+
*
|
|
5
|
+
* THE ONE RULE THIS SUITE EXISTS TO ENFORCE: no per-vendor components, ever.
|
|
6
|
+
* The backend already refuses to hardcode a vendor — the catalog is
|
|
7
|
+
* `DestinationProvider` rows and the field schemas come off the connector class
|
|
8
|
+
* — and that is worth nothing if the browser answers with `if (key === 'n8n')`.
|
|
9
|
+
* So every assertion below drives TWO different schemas through ONE component
|
|
10
|
+
* and checks the output differs only in the way the DATA differs.
|
|
11
|
+
*
|
|
12
|
+
* The composer is injected, not contextual: `<ConnectionsPage api={...}
|
|
13
|
+
* foundrySlug="..." />`, the same shape as `TeamSettingsPage`. No React context,
|
|
14
|
+
* no app-local provider — an app mounts it in ten lines and owns nothing but
|
|
15
|
+
* theme classNames.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
18
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
19
|
+
import userEvent from '@testing-library/user-event';
|
|
20
|
+
|
|
21
|
+
import { CredentialSchemaForm, schemaFields } from '../components/CredentialSchemaForm';
|
|
22
|
+
import { ConnectionsPage } from '../components/pages/ConnectionsPage';
|
|
23
|
+
import type { ConnectionsApi } from '../components/pages/types';
|
|
24
|
+
|
|
25
|
+
// --------------------------------------------------------------------------
|
|
26
|
+
// two schemas, one component
|
|
27
|
+
// --------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Vendor A: a single secret. The Granola shape. */
|
|
30
|
+
const ONE_SECRET = [
|
|
31
|
+
{ key: 'api_key', label: 'API key', type: 'secret', write_only: true, required: true,
|
|
32
|
+
help: 'Starts with grn_' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Vendor B: url + secret + select. The self-hosted n8n shape. */
|
|
36
|
+
const URL_SECRET_SELECT = [
|
|
37
|
+
{ key: 'base_url', label: 'Instance URL', type: 'url', required: true,
|
|
38
|
+
placeholder: 'https://n8n.example.com' },
|
|
39
|
+
{ key: 'api_key', label: 'Public API key', type: 'secret', write_only: true, required: false },
|
|
40
|
+
{
|
|
41
|
+
key: 'environment',
|
|
42
|
+
label: 'Environment',
|
|
43
|
+
type: 'select',
|
|
44
|
+
required: false,
|
|
45
|
+
default: 'production',
|
|
46
|
+
options: [
|
|
47
|
+
{ value: 'production', label: 'Production' },
|
|
48
|
+
{ value: 'staging', label: 'Staging' },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function renderForm(fields: unknown[], props: Record<string, unknown> = {}) {
|
|
54
|
+
const onChange = vi.fn();
|
|
55
|
+
const utils = render(
|
|
56
|
+
<CredentialSchemaForm
|
|
57
|
+
fields={fields as never}
|
|
58
|
+
values={{}}
|
|
59
|
+
onChange={onChange}
|
|
60
|
+
{...props}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
return { ...utils, onChange };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('CredentialSchemaForm — one renderer, every vendor', () => {
|
|
67
|
+
it('renders a one-secret schema from data alone', () => {
|
|
68
|
+
renderForm(ONE_SECRET);
|
|
69
|
+
expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText(/Starts with grn_/i)).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders a url+secret+select schema with the SAME component', () => {
|
|
74
|
+
renderForm(URL_SECRET_SELECT);
|
|
75
|
+
expect(screen.getByLabelText(/Instance URL/i)).toBeInTheDocument();
|
|
76
|
+
expect(screen.getByLabelText(/Public API key/i)).toBeInTheDocument();
|
|
77
|
+
expect(screen.getByLabelText(/Environment/i)).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByRole('option', { name: 'Staging' })).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps the declared array order', () => {
|
|
82
|
+
const { container } = renderForm(URL_SECRET_SELECT);
|
|
83
|
+
const names = Array.from(container.querySelectorAll('[data-field]')).map(
|
|
84
|
+
(el) => el.getAttribute('data-field'),
|
|
85
|
+
);
|
|
86
|
+
expect(names).toEqual(['base_url', 'api_key', 'environment']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('honours placeholder and default', () => {
|
|
90
|
+
renderForm(URL_SECRET_SELECT);
|
|
91
|
+
expect(screen.getByPlaceholderText('https://n8n.example.com')).toBeInTheDocument();
|
|
92
|
+
expect((screen.getByLabelText(/Environment/i) as HTMLSelectElement).value)
|
|
93
|
+
.toBe('production');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('masks a secret field', () => {
|
|
97
|
+
renderForm(ONE_SECRET);
|
|
98
|
+
expect(screen.getByLabelText(/API key/i)).toHaveAttribute('type', 'password');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('NEVER populates a secret from a read response', () => {
|
|
102
|
+
// The read surface returns config, never credential values. If a `values`
|
|
103
|
+
// map ever carries one, the form still must not put it in the DOM — a
|
|
104
|
+
// masked input whose value is the real key is a copy-paste away from a leak.
|
|
105
|
+
renderForm(ONE_SECRET, { values: { api_key: 'grn_live_SHOULD_NOT_RENDER' } });
|
|
106
|
+
expect((screen.getByLabelText(/API key/i) as HTMLInputElement).value).toBe('');
|
|
107
|
+
expect(document.body.innerHTML).not.toContain('grn_live_SHOULD_NOT_RENDER');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('degrades an unknown type to text and still renders its neighbours', () => {
|
|
111
|
+
renderForm([
|
|
112
|
+
{ key: 'first', label: 'First' },
|
|
113
|
+
{ key: 'exotic', label: 'Exotic', type: 'colour-wheel-from-the-future' },
|
|
114
|
+
{ key: 'last', label: 'Last' },
|
|
115
|
+
]);
|
|
116
|
+
expect(screen.getByLabelText(/Exotic/i)).toHaveAttribute('type', 'text');
|
|
117
|
+
expect(screen.getByLabelText(/First/i)).toBeInTheDocument();
|
|
118
|
+
expect(screen.getByLabelText(/Last/i)).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('reports every edit through onChange', async () => {
|
|
122
|
+
const { onChange } = renderForm(ONE_SECRET);
|
|
123
|
+
await userEvent.type(screen.getByLabelText(/API key/i), 'grn_x');
|
|
124
|
+
expect(onChange).toHaveBeenCalled();
|
|
125
|
+
expect(onChange.mock.calls[0][0]).toBe('api_key');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// --------------------------------------------------------------------------
|
|
130
|
+
// the composer
|
|
131
|
+
// --------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
const CATALOG = {
|
|
134
|
+
results: [
|
|
135
|
+
{
|
|
136
|
+
key: 'n8n', name: 'n8n', description: 'Call a workflow', logo_url: '', docs_url: '',
|
|
137
|
+
capabilities: ['action', 'poll'], auth_mode: 'api_key',
|
|
138
|
+
credential_schema: { version: 1, fields: URL_SECRET_SELECT },
|
|
139
|
+
config_schema: { version: 1, fields: [] },
|
|
140
|
+
record_types: [], supports_webhooks: true,
|
|
141
|
+
availability: { available: true, reason: '' },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: 'granola', name: 'Granola', description: 'Meeting notes', logo_url: '', docs_url: '',
|
|
145
|
+
capabilities: ['poll'], auth_mode: 'api_key',
|
|
146
|
+
credential_schema: { version: 1, fields: ONE_SECRET },
|
|
147
|
+
config_schema: { version: 1, fields: [] },
|
|
148
|
+
record_types: [], supports_webhooks: false,
|
|
149
|
+
availability: { available: false, reason: 'ImportError: no granola sdk' },
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const CONNECTED = {
|
|
155
|
+
results: [
|
|
156
|
+
{
|
|
157
|
+
id: 'c1', public_id: 'pub1', name: 'Ops n8n', connector_key: 'n8n', connector_name: 'n8n',
|
|
158
|
+
external_account_id: '', account_label: 'https://n8n.example.com',
|
|
159
|
+
capabilities_enabled: ['action'], config: {}, status: 'connected',
|
|
160
|
+
health: 'ok', auth_mode: 'api_key',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
permissions: { view: true, manage: true, issue_tokens: true },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
|
|
167
|
+
return {
|
|
168
|
+
connectorCatalog: vi.fn().mockResolvedValue(CATALOG),
|
|
169
|
+
connections: vi.fn().mockResolvedValue(CONNECTED),
|
|
170
|
+
connect: vi.fn().mockResolvedValue(CONNECTED.results[0]),
|
|
171
|
+
checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
|
|
172
|
+
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
173
|
+
connectionChoices: vi.fn().mockResolvedValue({
|
|
174
|
+
results: [
|
|
175
|
+
{
|
|
176
|
+
id: 'wf-1:node-a', label: 'Nightly digest — Webhook', kind: 'workflow',
|
|
177
|
+
selectable: true, reason: 'wirable', remediation: '', hidden_by_default: false,
|
|
178
|
+
detail: {},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'wf-2', label: 'Schedule only', kind: 'workflow', selectable: false,
|
|
182
|
+
reason: 'not_wirable:schedule_only',
|
|
183
|
+
remediation: 'Add a Webhook trigger node.', hidden_by_default: false, detail: {},
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
kind: 'workflow', cursor: null, has_more: false,
|
|
187
|
+
}),
|
|
188
|
+
wireTarget: vi.fn().mockResolvedValue({
|
|
189
|
+
id: 'd1', name: 'Nightly digest', provider_key: 'n8n', entity_type: 'contact',
|
|
190
|
+
is_active: true, verified: false,
|
|
191
|
+
destination_config: { webhook_url: 'https://n8n.example.com/webhook/nightly' },
|
|
192
|
+
}),
|
|
193
|
+
confirmTarget: vi.fn().mockResolvedValue({
|
|
194
|
+
id: 'd1', name: 'Nightly digest', provider_key: 'n8n', entity_type: 'contact',
|
|
195
|
+
is_active: true, verified: true,
|
|
196
|
+
destination_config: { webhook_url: 'https://n8n.example.com/webhook/nightly' },
|
|
197
|
+
}),
|
|
198
|
+
connectionActions: vi.fn().mockResolvedValue({
|
|
199
|
+
results: [{ name: 'trigger_workflow', label: 'Trigger a workflow', input_schema: {} }],
|
|
200
|
+
}),
|
|
201
|
+
invokeConnectionAction: vi.fn().mockResolvedValue({
|
|
202
|
+
ok: true, action: 'trigger_workflow', destination_id: 'd1', run_id: 'r1',
|
|
203
|
+
run_status: 'running', external_ref: null, output: {}, error: '',
|
|
204
|
+
}),
|
|
205
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
|
|
206
|
+
...overrides,
|
|
207
|
+
} as ConnectionsApi;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
describe('ConnectionsPage — injected, not contextual', () => {
|
|
211
|
+
it('renders the catalog and the connected list from the injected api', async () => {
|
|
212
|
+
const api = stubApi();
|
|
213
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
214
|
+
expect(await screen.findByText('Ops n8n')).toBeInTheDocument();
|
|
215
|
+
expect(await screen.findByText('Granola')).toBeInTheDocument();
|
|
216
|
+
expect(api.connectorCatalog).toHaveBeenCalledWith('acme');
|
|
217
|
+
expect(api.connections).toHaveBeenCalledWith('acme');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('shows an unavailable catalog row WITH its reason instead of hiding it', async () => {
|
|
221
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
|
|
222
|
+
expect(await screen.findByText(/no granola sdk/i)).toBeInTheDocument();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('renders read-only when the permissions envelope says manage is false', async () => {
|
|
226
|
+
const api = stubApi({
|
|
227
|
+
connections: vi.fn().mockResolvedValue({
|
|
228
|
+
...CONNECTED,
|
|
229
|
+
permissions: { view: true, manage: false, issue_tokens: false },
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
233
|
+
await screen.findByText('Ops n8n');
|
|
234
|
+
expect(screen.queryByRole('button', { name: /^connect$/i })).not.toBeInTheDocument();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('surfaces a DENIED read as a reason, not an empty page', async () => {
|
|
238
|
+
const denied = Object.assign(new Error('forbidden'), { status: 403 });
|
|
239
|
+
const api = stubApi({ connections: vi.fn().mockRejectedValue(denied) });
|
|
240
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
241
|
+
expect(await screen.findByText(/do not have access|not have permission/i)).toBeInTheDocument();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('drives discovery -> wire -> confirm without anything hand-typed', async () => {
|
|
245
|
+
const api = stubApi();
|
|
246
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
247
|
+
await userEvent.click(await screen.findByRole('button', { name: /workflows|discover/i }));
|
|
248
|
+
|
|
249
|
+
// Nothing is dropped: the unpickable row is present and says why.
|
|
250
|
+
expect(await screen.findByText(/Nightly digest/)).toBeInTheDocument();
|
|
251
|
+
expect(screen.getByText(/Add a Webhook trigger node\./)).toBeInTheDocument();
|
|
252
|
+
|
|
253
|
+
await userEvent.click(screen.getByRole('button', { name: /^wire$/i }));
|
|
254
|
+
await waitFor(() => expect(api.wireTarget).toHaveBeenCalledWith('acme', 'c1', {
|
|
255
|
+
choice_id: 'wf-1:node-a',
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
await userEvent.click(await screen.findByRole('button', { name: /confirm/i }));
|
|
259
|
+
await waitFor(() => expect(api.confirmTarget).toHaveBeenCalledWith('acme', 'c1', 'd1'));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('will not offer to wire an unpickable choice', async () => {
|
|
263
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
|
|
264
|
+
await userEvent.click(await screen.findByRole('button', { name: /workflows|discover/i }));
|
|
265
|
+
await screen.findByText(/Schedule only/);
|
|
266
|
+
// One selectable row -> exactly one wire button.
|
|
267
|
+
expect(screen.getAllByRole('button', { name: /^wire$/i })).toHaveLength(1);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('the catalog envelope, as the server actually ships it', () => {
|
|
272
|
+
// REGRESSION. The first browser drive of this page died on
|
|
273
|
+
// `(entry.credential_schema ?? []) is not iterable`, because these fixtures
|
|
274
|
+
// were invented as bare arrays while `schema_to_wire` ships
|
|
275
|
+
// `{version, fields[]}`. Unit tests cannot catch a fixture that lies about
|
|
276
|
+
// the wire — only driving the real API can — so the shape is pinned here now.
|
|
277
|
+
it('reads fields out of the {version, fields} envelope', () => {
|
|
278
|
+
renderForm(ONE_SECRET);
|
|
279
|
+
expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('renders a catalog row whose schema arrives in the envelope', async () => {
|
|
283
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
|
|
284
|
+
expect(await screen.findByText('n8n')).toBeInTheDocument();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('does NOT blank the form on a schema version this build has never seen', () => {
|
|
288
|
+
const { container } = render(
|
|
289
|
+
<CredentialSchemaForm
|
|
290
|
+
fields={schemaFields({ version: 7, fields: ONE_SECRET } as never)}
|
|
291
|
+
values={{}}
|
|
292
|
+
onChange={() => {}}
|
|
293
|
+
/>,
|
|
294
|
+
);
|
|
295
|
+
expect(container.querySelectorAll('[data-field]')).toHaveLength(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('treats a missing or malformed schema as no fields, not a crash', () => {
|
|
299
|
+
expect(schemaFields(null)).toEqual([]);
|
|
300
|
+
expect(schemaFields(undefined)).toEqual([]);
|
|
301
|
+
expect(schemaFields({} as never)).toEqual([]);
|
|
302
|
+
expect(schemaFields({ version: 1 } as never)).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('masks on type:"secret" — the server spelling, not a boolean', () => {
|
|
306
|
+
renderForm([{ key: 'k', label: 'Key', type: 'secret' }]);
|
|
307
|
+
expect(screen.getByLabelText(/Key/i)).toHaveAttribute('type', 'password');
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('no per-vendor components', () => {
|
|
312
|
+
it('the connections layer names no vendor', async () => {
|
|
313
|
+
// The guard, as an assertion rather than a code-review habit. A component
|
|
314
|
+
// file whose text contains a vendor key is the thing the DB-driven catalog
|
|
315
|
+
// exists to prevent; the fixtures above prove two vendors already render
|
|
316
|
+
// through one path, and this stops the third from arriving as a branch.
|
|
317
|
+
const fs = await import('node:fs/promises');
|
|
318
|
+
const path = await import('node:path');
|
|
319
|
+
const dir = path.resolve(__dirname, '../components');
|
|
320
|
+
const offenders: string[] = [];
|
|
321
|
+
const walk = async (d: string): Promise<void> => {
|
|
322
|
+
for (const entry of await fs.readdir(d, { withFileTypes: true })) {
|
|
323
|
+
const full = path.join(d, entry.name);
|
|
324
|
+
if (entry.isDirectory()) { await walk(full); continue; }
|
|
325
|
+
if (!/\.tsx?$/.test(entry.name)) continue;
|
|
326
|
+
const text = await fs.readFile(full, 'utf8');
|
|
327
|
+
for (const vendor of ['n8n', 'granola', 'stripe', 'soundcharts', 'companies_house']) {
|
|
328
|
+
// A vendor named in a COMMENT is fine — it is an example. A vendor in
|
|
329
|
+
// code is a branch.
|
|
330
|
+
const code = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '');
|
|
331
|
+
if (code.toLowerCase().includes(vendor)) offenders.push(`${entry.name}: ${vendor}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
await walk(dir);
|
|
336
|
+
expect(offenders).toEqual([]);
|
|
337
|
+
});
|
|
338
|
+
});
|