Files
twenty/packages/twenty-front/src/modules/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic.ts
T
Félix Malfait 9e94045fa5 feat(apps): generic OAuth provider support for app SDK (#20181)
## Summary

App developers can now declare third-party OAuth integrations (GitHub,
Linear, Slack, etc.) in their manifest and the platform handles the full
authorize → callback → token-exchange → refresh → injection lifecycle.
The dev writes ~10 lines of config and reads tokens via
`useOAuth('linear')` inside any logic function.

```ts
// app/src/oauth-providers/linear.ts
export default defineOAuthProvider({
  universalIdentifier: '...',
  name: 'linear',
  displayName: 'Linear',
  authorizationEndpoint: 'https://linear.app/oauth/authorize',
  tokenEndpoint: 'https://api.linear.app/oauth/token',
  scopes: ['read', 'write'],
  connectionMode: 'per-user',
  clientIdVariable: 'LINEAR_CLIENT_ID',
  clientSecretVariable: 'LINEAR_CLIENT_SECRET',
  tokenRequestContentType: 'form-urlencoded',
});

// app/src/logic-functions/handlers/...
const { accessToken } = useOAuth('linear'); // throws OAuthNotConnectedError if missing
```

## Architecture

- **Storage**: extends the existing `connectedAccount` table — new
nullable `applicationOAuthProviderId` FK + new `app` value on the
`ConnectedAccountProvider` enum. Existing Google/Microsoft flows are
untouched.
- **OAuth flow**: a single `/apps/oauth/authorize` +
`/apps/oauth/callback` controller pair handles every app provider. State
travels in a JWT signed via the existing `JwtWrapperService` (new
`APP_OAUTH_STATE` token type).
- **Token exchange**: goes through
`SecureHttpClientService.createSsrfSafeFetch()` (so an installed app
can't point `tokenEndpoint` at internal hosts).
- **Refresh**: piggybacks on the existing
`ConnectedAccountRefreshTokensService` dispatch — Google/Microsoft
drivers untouched, new app driver lives engine-side under
`application-oauth-provider/refresh/`.
- **Injection**: the executor injects refreshed tokens as env vars
(`OAUTH_<NAME>_ACCESS_TOKEN`, `_HANDLE`, `_SCOPES`, `_CONNECTED`); the
SDK helpers `useOAuth` / `useOptionalOAuth` read them.
- **Frontend**: auto-rendered "OAuth Connections" section under each
app's settings tab (no custom front component needed). App-managed
connections are filtered out of `/settings/accounts` so the
email/calendar page stays focused.
- **Disconnect**: best-effort revoke against the manifest's
`revokeEndpoint` before deleting the row.

## Reference app

`packages/twenty-apps/internal/twenty-linear/` exercises the full
pipeline:

- `defineOAuthProvider` for Linear
- `POST /linear/create-issue` and `GET /linear/teams` HTTP-route logic
functions
- Vitest tests for the handlers

## Tests

- 14 server-side Jest tests: token-exchange util (form-urlencoded vs
JSON, PKCE, error paths), flow service (authorize URL shape, state
binding, ConnectedAccount upsert on first/reconnect, per-workspace mode,
invalid state)
- 8 app-level Vitest tests: handler error paths, GraphQL request shape,
Linear error propagation
- All 4 packages clean: `npx nx lint:diff-with-main` and `npx tsc
--noEmit`

## Test plan

- [ ] Apply migration on a dev DB: `npx nx run
twenty-server:database:migrate:prod`
- [ ] Regenerate frontend types: `npx nx run
twenty-front:graphql:generate --configuration=metadata`
- [ ] Create a Linear OAuth app at
https://linear.app/settings/api/applications/new with redirect URI
`<SERVER_URL>/apps/oauth/callback`
- [ ] Deploy + install `twenty-linear` on a workspace, paste the Linear
client id/secret into the app's variables
- [ ] Click "Connect Linear" in the app's settings tab → complete OAuth
→ verify `connectedAccount` row created with `provider = 'app'`
- [ ] Trigger `POST /linear/create-issue` with a valid teamId → verify
issue lands in Linear
- [ ] Disconnect → verify the row is deleted and (if Linear's revoke
endpoint is configured in the manifest) the revoke call fires
- [ ] Verify `/settings/accounts` does NOT show the Linear connection —
it appears only under the Linear app's settings tab

## Out of scope (deliberately)

- **Cron + per-user providers**: a cron-triggered function with a
per-user OAuth provider currently returns `CONNECTED=false` (no user
context). The follow-up design is `useOAuthForUser(name,
userWorkspaceId)` paired with a `POST /apps/oauth/connection-token`
endpoint, deferred to keep this PR focused.
- **Token encryption at rest**: tokens stored as plain `varchar`
matching the existing Google/Microsoft pattern. Worth a separate
cross-cutting PR.
- **Manifest endpoint pinning**: a malicious app upgrade could change
`tokenEndpoint` silently. Same trust model as logic-function source code
(which already runs arbitrary server-side); worth tightening across the
whole upgrade pipeline rather than just OAuth.
- **CLI helpers** (`twenty oauth show-callback-url`, `twenty oauth
connect`): manual setup for v1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 11:26:34 +02:00

207 lines
6.7 KiB
TypeScript

import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { capitalize, getSettingsPath, isDefined } from 'twenty-shared/utils';
import { type Application } from '~/generated-metadata/graphql';
import { type ApplicationContentRow } from '~/pages/settings/applications/components/SettingsApplicationContentSubtable';
type InstalledApplicationForContent = Pick<Application, 'agents' | 'id'>;
export const useComputeApplicationContentForLayoutAndLogic = ({
installedApplication,
manifestContent,
}: {
installedApplication?: InstalledApplicationForContent;
manifestContent?: Manifest;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const installedAppId = installedApplication?.id;
// Workspace metadata covers standard + installed-app objects; the manifest
// fallback only matters when previewing an uninstalled marketplace app.
const resolveLabel = (uid: string | undefined | null) => {
if (!isDefined(uid)) return undefined;
return (
objectMetadataItems.find((o) => o.universalIdentifier === uid)
?.labelSingular ??
manifestContent?.objects.find((o) => o.universalIdentifier === uid)
?.labelSingular
);
};
const pageLayoutRows: ApplicationContentRow[] = (
manifestContent?.pageLayouts ?? []
).map((layout) => {
const objectLabel = resolveLabel(layout.objectUniversalIdentifier);
const tabCount = layout.tabs?.length ?? 0;
const parts: string[] = [];
if (isDefined(objectLabel)) parts.push(t`for ${objectLabel}`);
if (tabCount > 0) {
parts.push(tabCount === 1 ? t`1 tab` : t`${tabCount} tabs`);
}
return {
key: layout.universalIdentifier,
name: layout.name,
secondary: parts.length > 0 ? parts.join(' · ') : undefined,
link: isDefined(installedAppId)
? getSettingsPath(SettingsPath.ApplicationPageLayoutDetail, {
applicationId: installedAppId,
pageLayoutUniversalIdentifier: layout.universalIdentifier,
})
: undefined,
};
});
const viewRows: ApplicationContentRow[] = (manifestContent?.views ?? []).map(
(view) => {
const objectLabel = resolveLabel(view.objectUniversalIdentifier);
const formattedType = capitalize((view.type ?? 'TABLE').toLowerCase());
return {
key: view.universalIdentifier,
name: view.name,
icon: view.icon ?? undefined,
secondary: isDefined(objectLabel)
? t`${formattedType} of ${objectLabel}`
: formattedType,
link: isDefined(installedAppId)
? getSettingsPath(SettingsPath.ApplicationViewDetail, {
applicationId: installedAppId,
viewUniversalIdentifier: view.universalIdentifier,
})
: undefined,
};
},
);
const navigationMenuItemRows: ApplicationContentRow[] = (
manifestContent?.navigationMenuItems ?? []
).map((item) => {
const destination = (() => {
switch (item.type) {
case 'FOLDER':
return { label: t`Folder`, displayName: t`Folder` };
case 'LINK': {
const link = item.link ?? t`Link`;
return { label: link, displayName: link };
}
case 'OBJECT': {
const label = resolveLabel(item.targetObjectUniversalIdentifier);
return {
label: isDefined(label) ? t`${label} list` : t`Object`,
displayName: label,
};
}
case 'PAGE_LAYOUT': {
const layout = manifestContent?.pageLayouts?.find(
(pl) =>
pl.universalIdentifier === item.pageLayoutUniversalIdentifier,
);
return {
label: isDefined(layout)
? t`${layout.name} layout`
: t`Page layout`,
displayName: layout?.name,
};
}
case 'VIEW': {
const view = manifestContent?.views?.find(
(v) => v.universalIdentifier === item.viewUniversalIdentifier,
);
return {
label: isDefined(view) ? t`${view.name} view` : t`View`,
displayName: view?.name,
};
}
case 'RECORD':
return { label: t`Record`, displayName: t`Record` };
default:
return { label: undefined, displayName: undefined };
}
})();
const displayName =
isDefined(item.name) && item.name !== ''
? item.name
: (destination.displayName ?? item.type);
return {
key: item.universalIdentifier,
name: displayName,
icon: item.icon ?? undefined,
secondary: destination.label,
};
});
const agentRows: ApplicationContentRow[] = isDefined(installedApplication)
? (installedApplication.agents ?? []).map((agent) => ({
key: agent.id,
name: agent.label,
icon: agent.icon ?? undefined,
secondary: agent.description ?? undefined,
link: getSettingsPath(SettingsPath.AiAgentDetail, {
agentId: agent.id,
}),
}))
: (manifestContent?.agents ?? []).map((agent) => ({
key: agent.universalIdentifier,
name: agent.label,
icon: agent.icon ?? undefined,
secondary: agent.description ?? undefined,
}));
const skillRows: ApplicationContentRow[] = (
manifestContent?.skills ?? []
).map((skill) => ({
key: skill.universalIdentifier,
name: skill.label,
icon: skill.icon ?? undefined,
secondary: skill.description ?? undefined,
}));
const roleRows: ApplicationContentRow[] = (manifestContent?.roles ?? []).map(
(role) => ({
key: role.universalIdentifier,
name: role.label,
icon: role.icon ?? undefined,
secondary: role.description ?? undefined,
}),
);
const connectionProviderRows: ApplicationContentRow[] = (
manifestContent?.connectionProviders ?? []
).map((provider) => {
const parts: string[] = [];
if (provider.type === 'oauth') {
parts.push(t`OAuth 2.0`);
const scopeCount = provider.oauth.scopes.length;
if (scopeCount > 0) {
parts.push(scopeCount === 1 ? t`1 scope` : t`${scopeCount} scopes`);
}
}
return {
key: provider.universalIdentifier,
name: provider.displayName,
icon: undefined,
secondary: parts.length > 0 ? parts.join(' · ') : undefined,
};
});
return {
pageLayoutRows,
viewRows,
navigationMenuItemRows,
agentRows,
skillRows,
roleRows,
connectionProviderRows,
};
};