@startsimpli/integrations
v0.2.35
Published
Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.
Readme
@startsimpli/integrations
Provider-agnostic TypeScript client for the OAuth-backed email and calendar integrations that StartSimpli apps consume from the Django API. Ships:
- Typed API clients wrapping
start-simpli-api's/auth/oauth/*,/auth/oauth-accounts/,/calendar/events/, and/email/messages/endpoints - React Query hooks for connected accounts, calendar events, and email messages — with built-in re-consent flow handling
- Headless React UI primitives for connect cards, account status, event/email lists, and re-consent prompts
- Normalized error types (
UnauthenticatedError,ReconsentRequiredError,ApiError) so the UI can branch on auth failure modes without parsing payloads
The connections layer (foundries)
Alongside the email/calendar clients above, this package ships the UI for the connector framework — the surface a foundry admin uses to connect a vendor account and wire it up:
CredentialSchemaForm— ONE renderer for every vendor, driven by the catalog's own field schema ({version, fields[]}). It knows about FIELDS and nothing about vendors: an unknown field type degrades to a text input, an unrecognised schema version still renders its fields, and a field the server markstype: "secret"/write_onlyis masked and never populated from a read response.ConnectionsPage— the page composer: connected accounts, the catalog, and the wiring flow (discover → wire → confirm → fire). Injected api, no React context and no app-local provider, so an app mounts it in ten lines:<ConnectionsPage api={apiClient.foundry} foundrySlug={slug} />
There are no per-vendor components, and a test enforces it — it greps the
component tree for vendor keys. The backend refuses to hardcode a vendor (the
catalog is DestinationProvider rows and the field schemas come off the
connector class); that is worth nothing if the browser answers with
if (key === 'n8n').
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.
Install
Workspace-only:
"dependencies": {
"@startsimpli/integrations": "workspace:*"
}Peer deps (both optional, only needed for the hooks/components subpaths):
react ^18 || ^19@tanstack/react-query ^5
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.
Public surface
| Export | Type | Description |
| --- | --- | --- |
| OAuthProvider | 'google' \| 'microsoft' | The two providers backed by Django |
| AccountStatus | 'connected' \| 'disconnected' \| 'needs_reauth' | Status union for ConnectAccountCard |
| ConnectedAccount / CalendarEvent / CalendarEventAttendee / CalendarEventStatus / EmailMessage / PaginatedResponse<T> / CalendarWindow / ApiErrorPayload | types | DRF response shapes |
| ApiError / UnauthenticatedError / ReconsentRequiredError | classes | Thrown by every API client; the UI uses instanceof to branch |
| connectProvider(provider, redirectUri?) | POST /auth/oauth/{provider}/initiate/ | Returns the upstream authorization_url to redirect to |
| revokeAccount(provider, oauthAccountId) | POST /auth/oauth/{provider}/revoke/ | Disconnects a connected account |
| listConnectedAccounts() | GET /auth/oauth-accounts/ | All accounts for the current user |
| listCalendarEvents({ from, to, provider?, calendar_id?, page?, page_size? }) | GET /calendar/events/ | Paginated events in a window |
| listEmailMessages({ provider?, account_id?, since?, is_outbound?, page?, page_size? }) | GET /email/messages/ | Paginated messages |
| setApiBase(url) / getApiBase() | functions | Override the default /api/v1 base URL |
| setAuthTokenProvider(fn) | function | Wire up a token source — injected as Authorization: Bearer <token> |
| useConnectedAccounts(options?) | hook | React Query wrapper around listConnectedAccounts (30 s staleTime) |
| useDisconnectAccount() | hook | Returns a mutator that revokes + invalidates the accounts query |
| useCalendarEvents(window, options?) | hook | Wraps listCalendarEvents; surfaces events, reconsentUrl |
| useEmailMessages(filter?, options?) | hook | Wraps listEmailMessages; surfaces messages, reconsentUrl |
| useReconsent(error) | hook | Extracts a reconsent_url from a thrown error + returns openReconsent() |
| CONNECTED_ACCOUNTS_KEY | const tuple | Stable React Query key for invalidation |
| AccountStatusBadge | component | "Connected / Needs re-auth / Disconnected" pill |
| ConnectAccountCard | component | Per-provider connect/disconnect card |
| EmailList / EventList / EventListItem | components | Headless list renderers |
| ReconsentPromptCard | component | Re-consent CTA card driven by useReconsent |
Subpath exports: ., ./types (zero React), ./api (zero React).
Adapters that ship today
| Provider | OAuth | Mail | Calendar |
| --- | :---: | :---: | :---: |
| Google | yes (gmail.readonly, calendar.readonly) | yes | yes |
| Microsoft | yes (Mail.Read, Calendars.Read) | yes | yes |
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.
Configuration
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:
| Setup call | Why |
| --- | --- |
| setApiBase('https://api.example.com/api/v1') | Point at your Django host. Defaults to /api/v1 (relative). |
| setAuthTokenProvider(() => getAccessToken()) | Provide a fresh JWT/session token for every request. Returning null falls back to cookie auth (the fetch wrapper uses credentials: 'include'). |
Errors surfaced from the Django API are normalized:
- 401 →
UnauthenticatedError - 403 with
reconsent_urlin the payload →ReconsentRequiredError(drives the re-consent prompt) - All other non-2xx →
ApiErrorwithstatus+payload
Usage
Real example from raise-simpli/web-app/src/app/(dashboard)/settings/integrations/page.tsx:
'use client'
import {
ConnectAccountCard,
useConnectedAccounts,
useDisconnectAccount,
type ConnectedAccount,
type OAuthProvider,
} from '@startsimpli/integrations'
const PROVIDERS: OAuthProvider[] = ['microsoft', 'google']
export default function IntegrationsPage() {
const { data: accounts = [], isLoading, error } = useConnectedAccounts()
const disconnect = useDisconnectAccount()
const accountByProvider = (provider: OAuthProvider): ConnectedAccount | undefined =>
accounts.find((a) => a.provider === provider)
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{PROVIDERS.map((provider) => (
<ConnectAccountCard
key={provider}
provider={provider}
account={accountByProvider(provider)}
onDisconnect={(id) => disconnect(provider, id)}
/>
))}
</div>
)
}Calendar window with re-consent handling:
const { events, reconsentUrl, isLoading } = useCalendarEvents({
from: startOfWeek,
to: endOfWeek,
}, { provider: 'google', pageSize: 200 })
if (reconsentUrl) return <ReconsentPromptCard href={reconsentUrl} />App startup wiring (called once):
import { setApiBase, setAuthTokenProvider } from '@startsimpli/integrations'
import { getAccessToken } from '@startsimpli/auth/client'
setApiBase(process.env.NEXT_PUBLIC_API_BASE_URL!)
setAuthTokenProvider(() => getAccessToken())Verification
cd packages/integrations
pnpm vitest run
pnpm tsc --noEmitThe 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.
Shared-first
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.
