@startsimpli/integrations 0.1.0 → 0.1.4
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 +453 -0
- package/src/components/CredentialSchemaForm.tsx +196 -0
- package/src/components/index.ts +20 -0
- package/src/components/pages/ConnectionsPage.tsx +610 -0
- package/src/components/pages/types.ts +197 -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.4",
|
|
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
|
+
}
|