9e94045fa5
## 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>
207 lines
6.7 KiB
TypeScript
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,
|
|
};
|
|
};
|