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>
This commit is contained in:
Félix Malfait
2026-05-04 11:26:34 +02:00
committed by GitHub
parent ff22988caf
commit 9e94045fa5
132 changed files with 6533 additions and 595 deletions
@@ -0,0 +1,19 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -0,0 +1,68 @@
# Linear for Twenty
Connect your Linear account to Twenty to create issues and look up teams
straight from your workflows or the AI chat.
## What you can do
Once installed and connected, two tools become available:
- **Create Linear issue** — from the AI chat, ask something like
*"create a Linear issue in the Engineering team titled 'Fix login bug'"*
and the AI will file it for you. From a workflow, add it as a step
with `teamId` + `title` (and optional `description`).
- **List Linear teams** — discovers the teams in your Linear workspace,
useful when you need to pick a `teamId` for the create-issue step.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Linear** in the available apps and click **Install**.
3. Open the app, go to the **Connections** tab, and click **Add connection**.
4. Choose **Just for me** (your personal Linear account) or
**Workspace shared** (a team-managed Linear account anyone in this
workspace can act through), then complete the Linear sign-in.
That's it — you can now use the tools above.
> If you see a "Linear OAuth is not yet set up by your server administrator"
> notice on the Connections tab, ask your Twenty admin to follow the
> **Self-hosting setup** below — they need to provide the OAuth credentials
> before connections can be added.
---
## Self-hosting setup
This section is for Twenty server admins. If you're on Twenty Cloud, skip
this — the OAuth credentials are already configured.
### 1. Register an OAuth app in Linear
1. Visit https://linear.app/settings/api/applications/new.
2. Set the **Redirect URI** to `<SERVER_URL>/apps/oauth/callback` (for
local dev: `http://localhost:3000/apps/oauth/callback`).
3. Copy the generated **Client ID** and **Client Secret**.
### 2. Wire the credentials into Twenty
1. In **Settings → Applications**, find **Linear**, click into it, and go
to the **Application registration** tab (admin-only).
2. Paste your Linear **Client ID** into `LINEAR_CLIENT_ID` and the
**Client Secret** into `LINEAR_CLIENT_SECRET`.
Workspace users will now be able to add Linear connections from the
**Connections** tab as described above.
### 3. (Developers only) Building the app from source
If you're working on this app rather than installing the published version:
```bash
cd packages/twenty-apps/internal/twenty-linear
yarn twenty deploy
```
This serves as the reference implementation for Twenty's
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
when adding OAuth integrations for other providers.
@@ -0,0 +1,32 @@
{
"name": "twenty-linear",
"version": "0.1.5",
"description": "Linear integration for Twenty. Connect a user's Linear account and create issues from logic functions.",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run --config vitest.unit.config.ts",
"test:watch": "vitest --config vitest.unit.config.ts"
},
"dependencies": {
"twenty-sdk": "2.1.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"oxlint": "^0.16.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^3.1.1"
}
}
@@ -0,0 +1 @@
<svg fill="#5E6AD2" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Linear</title><path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"/></svg>

After

Width:  |  Height:  |  Size: 469 B

@@ -0,0 +1,32 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Linear',
description:
'Connect Linear to Twenty. Each workspace member connects their own Linear account; logic functions can then create issues and read team data on their behalf.',
logoUrl: 'public/linear-logomark.svg',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
// OAuth client_id/secret live at the registration level (one OAuth app per
// Twenty server, configured by the server admin) — not per-workspace —
// so they're declared as serverVariables, not applicationVariables.
serverVariables: {
LINEAR_CLIENT_ID: {
description:
'OAuth client ID from your Linear OAuth application (linear.app/settings/api/applications).',
isSecret: false,
isRequired: true,
},
LINEAR_CLIENT_SECRET: {
description:
'OAuth client secret from your Linear OAuth application. Stored encrypted; never exposed in API responses.',
isSecret: true,
isRequired: true,
},
},
});
@@ -0,0 +1,22 @@
import { defineConnectionProvider } from 'twenty-sdk/define';
import { LINEAR_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineConnectionProvider({
universalIdentifier: LINEAR_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
tokenRequestContentType: 'form-urlencoded',
// Linear supports PKCE but doesn't require it for confidential clients.
// Disabled to keep the test surface minimal.
usePkce: false,
},
});
@@ -0,0 +1,19 @@
// Group all universal identifiers in a single file. Per the codebase
// convention (see twenty-for-twenty), closely-related constants live
// together so the rest of the app's source files can stay at one
// `export default` per file.
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'6f4e7c2a-3d8e-4a91-b2cf-9e0b8d5f4a2e';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b6c33347-e41c-4b90-8a37-7b3c49baa85a';
export const LINEAR_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
'9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f';
export const CREATE_LINEAR_ISSUE_UNIVERSAL_IDENTIFIER =
'01f829c9-1661-41fa-9ee1-b67e64716c2e';
export const LIST_LINEAR_TEAMS_UNIVERSAL_IDENTIFIER =
'15824bbc-9c64-4f97-b45f-d0a44b402bb8';
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createLinearIssueHandler } from '../handlers/create-linear-issue-handler';
import { buildConnection, stubConnectionsThenLinear } from './test-utils';
const SAVED_ENV = { ...process.env };
describe('createLinearIssueHandler', () => {
beforeEach(() => {
process.env.TWENTY_API_URL = 'http://api.test';
process.env.TWENTY_APP_ACCESS_TOKEN = 'app-token';
});
afterEach(() => {
process.env = { ...SAVED_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns an error when required input fields are missing', async () => {
const result = await createLinearIssueHandler({ title: 'no team' });
expect(result).toMatchObject({
success: false,
error: expect.stringContaining('teamId'),
});
});
it('returns an error when no Linear connection exists', async () => {
stubConnectionsThenLinear([], {});
const result = await createLinearIssueHandler({
teamId: 'team_1',
title: 'hi',
});
expect(result).toMatchObject({
success: false,
error: expect.stringContaining('not connected'),
});
});
it('calls Linear with the only available connection and returns the issue', async () => {
const issue = {
id: 'issue_1',
identifier: 'TEAM-1',
title: 'Hello from Twenty',
url: 'https://linear.app/twenty/issue/TEAM-1',
};
const fetchMock = stubConnectionsThenLinear([buildConnection()], {
data: { issueCreate: { success: true, issue } },
});
const result = await createLinearIssueHandler({
teamId: 'team_1',
title: 'Hello from Twenty',
description: 'Body',
});
expect(result).toEqual({ success: true, issue });
const [url, init] = fetchMock.mock.calls[1];
expect(url).toBe('https://api.linear.app/graphql');
expect(init.headers.Authorization).toBe('Bearer lin_test_access_token');
expect(JSON.parse(init.body as string).variables.input).toEqual({
teamId: 'team_1',
title: 'Hello from Twenty',
description: 'Body',
});
});
it('prefers a workspace-shared connection over a user-visibility one', async () => {
const userConnection = buildConnection({
id: 'conn_user',
accessToken: 'lin_user',
});
const sharedConnection = buildConnection({
id: 'conn_shared',
visibility: 'workspace',
accessToken: 'lin_shared',
});
const fetchMock = stubConnectionsThenLinear(
[userConnection, sharedConnection],
{
data: {
issueCreate: {
success: true,
issue: {
id: 'issue_2',
identifier: 'T-2',
title: 'Hi',
url: 'https://linear.app/x/T-2',
},
},
},
},
);
const result = await createLinearIssueHandler({
teamId: 'team_1',
title: 'Hi',
});
expect(result.success).toBe(true);
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(
'Bearer lin_shared',
);
});
it('surfaces Linear GraphQL errors as the handler error', async () => {
stubConnectionsThenLinear([buildConnection()], {
errors: [{ message: 'Invalid teamId' }],
});
const result = await createLinearIssueHandler({
teamId: 'bogus',
title: 'hi',
});
expect(result).toEqual({ success: false, error: 'Invalid teamId' });
});
});
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { listLinearTeamsHandler } from '../handlers/list-linear-teams-handler';
import { buildConnection, stubConnectionsThenLinear } from './test-utils';
const SAVED_ENV = { ...process.env };
describe('listLinearTeamsHandler', () => {
beforeEach(() => {
process.env.TWENTY_API_URL = 'http://api.test';
process.env.TWENTY_APP_ACCESS_TOKEN = 'app-token';
});
afterEach(() => {
process.env = { ...SAVED_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns the teams when the Linear query succeeds', async () => {
const teams = [
{ id: 'team_1', name: 'Engineering', key: 'ENG' },
{ id: 'team_2', name: 'Design', key: 'DES' },
];
const fetchMock = stubConnectionsThenLinear([buildConnection()], {
data: { teams: { nodes: teams } },
});
const result = await listLinearTeamsHandler();
expect(result).toEqual({ success: true, teams });
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(
'Bearer lin_test_access_token',
);
});
it('returns success=false when no Linear connection exists', async () => {
stubConnectionsThenLinear([], { data: { teams: { nodes: [] } } });
const result = await listLinearTeamsHandler();
expect(result.success).toBe(false);
});
it('surfaces Linear errors', async () => {
stubConnectionsThenLinear([buildConnection()], {
errors: [{ message: 'rate limited' }],
});
const result = await listLinearTeamsHandler();
expect(result).toEqual({ success: false, error: 'rate limited' });
});
});
@@ -0,0 +1,48 @@
import { vi } from 'vitest';
export const USER_WORKSPACE_ID = '11111111-1111-1111-1111-111111111111';
export const buildConnection = (
overrides: Partial<Record<string, unknown>> = {},
) => ({
id: 'conn_1',
name: 'octocat@example.com',
visibility: 'user' as const,
providerName: 'linear',
userWorkspaceId: USER_WORKSPACE_ID,
accessToken: 'lin_test_access_token',
scopes: ['read', 'write'],
handle: 'octocat@example.com',
lastRefreshedAt: '2024-01-01T00:00:00.000Z',
authFailedAt: null,
...overrides,
});
// Stubs `fetch` to first answer the SDK's `/apps/connections/list` call, then
// the handler's downstream Linear GraphQL request.
export const stubConnectionsThenLinear = (
connections: ReturnType<typeof buildConnection>[],
linearJson: unknown,
) => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/apps/connections/list')) {
return {
ok: true,
status: 200,
json: async () => connections,
text: async () => JSON.stringify(connections),
};
}
return {
ok: true,
status: 200,
json: async () => linearJson,
text: async () => JSON.stringify(linearJson),
};
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
};
@@ -0,0 +1,8 @@
export const ISSUE_CREATE_MUTATION = `
mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue { id identifier title url }
}
}
`;
@@ -0,0 +1,33 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { CREATE_LINEAR_ISSUE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { createLinearIssueHandler } from 'src/logic-functions/handlers/create-linear-issue-handler';
export default defineLogicFunction({
universalIdentifier: CREATE_LINEAR_ISSUE_UNIVERSAL_IDENTIFIER,
name: 'create-linear-issue',
description:
'Create a Linear issue on behalf of the connected user. Requires a teamId (call list-linear-teams to discover one) and a title.',
timeoutSeconds: 30,
handler: createLinearIssueHandler,
isTool: true,
toolInputSchema: {
type: 'object',
properties: {
teamId: {
type: 'string',
description:
'The Linear team ID to create the issue in. Use list-linear-teams to discover available teams.',
},
title: {
type: 'string',
description: 'The issue title.',
},
description: {
type: 'string',
description: 'Optional issue description (Markdown supported).',
},
},
required: ['teamId', 'title'],
},
});
@@ -0,0 +1,73 @@
import { listConnections } from 'twenty-sdk/logic-function';
import { ISSUE_CREATE_MUTATION } from 'src/logic-functions/constants/issue-create-mutation.constant';
import { type CreateIssueInput } from 'src/logic-functions/types/create-issue-input.type';
import { type CreateIssueMutationResult } from 'src/logic-functions/types/create-issue-mutation-result.type';
import { callLinearGraphQL } from 'src/logic-functions/utils/call-linear-graphql';
type HandlerResult =
| {
success: true;
issue: {
id: string;
identifier: string;
title: string;
url: string;
};
}
| { success: false; error: string };
export const createLinearIssueHandler = async (
input: CreateIssueInput,
): Promise<HandlerResult> => {
if (!input.teamId || !input.title) {
return {
success: false,
error: 'Both `teamId` and `title` are required.',
};
}
const connections = await listConnections({ providerName: 'linear' });
// Workspace-shared credentials win when present (a team-managed service
// account); otherwise fall back to the first user-scoped connection.
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection" first.',
};
}
const result = await callLinearGraphQL<CreateIssueMutationResult>({
accessToken: connection.accessToken,
query: ISSUE_CREATE_MUTATION,
variables: {
input: {
teamId: input.teamId,
title: input.title,
description: input.description,
},
},
});
if (result.errors || !result.data) {
return {
success: false,
error: result.errors?.[0]?.message ?? 'Unknown Linear API error',
};
}
const { success, issue } = result.data.issueCreate;
if (!success || !issue) {
return {
success: false,
error: 'Linear reported the mutation as unsuccessful.',
};
}
return { success: true, issue };
};
@@ -0,0 +1,49 @@
import { listConnections } from 'twenty-sdk/logic-function';
import { callLinearGraphQL } from 'src/logic-functions/utils/call-linear-graphql';
type LinearTeam = {
id: string;
name: string;
key: string;
};
type TeamsQueryResult = {
teams: { nodes: LinearTeam[] };
};
type HandlerResult =
| { success: true; teams: LinearTeam[] }
| { success: false; error: string };
export const listLinearTeamsHandler = async (): Promise<HandlerResult> => {
const connections = await listConnections({ providerName: 'linear' });
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection" first.',
};
}
const result = await callLinearGraphQL<TeamsQueryResult>({
accessToken: connection.accessToken,
query: `
query Teams {
teams { nodes { id name key } }
}
`,
});
if (result.errors || !result.data) {
return {
success: false,
error: result.errors?.[0]?.message ?? 'Unknown Linear API error',
};
}
return { success: true, teams: result.data.teams.nodes };
};
@@ -0,0 +1,18 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { LIST_LINEAR_TEAMS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { listLinearTeamsHandler } from 'src/logic-functions/handlers/list-linear-teams-handler';
export default defineLogicFunction({
universalIdentifier: LIST_LINEAR_TEAMS_UNIVERSAL_IDENTIFIER,
name: 'list-linear-teams',
description:
"Returns the connected user's Linear teams. Useful for picking a teamId to pass to create-linear-issue.",
timeoutSeconds: 15,
handler: listLinearTeamsHandler,
isTool: true,
toolInputSchema: {
type: 'object',
properties: {},
},
});
@@ -0,0 +1,5 @@
export type CreateIssueInput = {
teamId?: string;
title?: string;
description?: string;
};
@@ -0,0 +1,11 @@
export type CreateIssueMutationResult = {
issueCreate: {
success: boolean;
issue: {
id: string;
identifier: string;
title: string;
url: string;
} | null;
};
};
@@ -0,0 +1,58 @@
import { type LinearGraphQLResult } from 'src/logic-functions/utils/types/linear-graphql-result.type';
const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql';
export const callLinearGraphQL = async <TData>({
accessToken,
query,
variables,
}: {
accessToken: string;
query: string;
variables?: Record<string, unknown>;
}): Promise<LinearGraphQLResult<TData>> => {
let response: Response;
try {
response = await fetch(LINEAR_GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ query, variables }),
});
} catch (error) {
return {
errors: [
{
message: `Linear API request failed: ${(error as Error).message}`,
},
],
};
}
if (!response.ok) {
const text = await response.text().catch(() => '');
return {
errors: [
{
message: `Linear API responded with ${response.status}: ${text.slice(0, 500)}`,
},
],
};
}
try {
return (await response.json()) as LinearGraphQLResult<TData>;
} catch (error) {
return {
errors: [
{
message: `Linear API returned a non-JSON response: ${(error as Error).message}`,
},
],
};
}
};
@@ -0,0 +1,4 @@
export type LinearGraphQLResult<TData> = {
data?: TData;
errors?: Array<{ message: string }>;
};
@@ -0,0 +1,22 @@
import { defineRole } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
// The Linear logic functions never read workspace data — they only call
// Linear's GraphQL API on behalf of the connected user.
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Linear function role',
description: 'No-op role for Linear logic functions',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [],
fieldPermissions: [],
permissionFlags: [],
});
@@ -0,0 +1,30 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2020",
"module": "esnext",
"lib": ["es2020"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,43 @@
import path from 'node:path';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_SDK_SRC = path.resolve(
__dirname,
'../../../twenty-sdk/src/sdk',
);
// twenty-sdk's `exports` map points at compiled `./dist/*`. Aliasing the
// subpaths to source keeps unit tests self-contained — no `yarn build` in
// twenty-sdk required before running them.
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
resolve: {
alias: [
{
find: 'twenty-sdk/logic-function',
replacement: path.join(TWENTY_SDK_SRC, 'logic-function/index.ts'),
},
{
find: 'twenty-sdk/define',
replacement: path.join(TWENTY_SDK_SRC, 'define/index.ts'),
},
// The SDK source uses `@/*` to refer to its own `src/`. Vitest
// doesn't pick up the SDK's tsconfig path mapping when resolving
// a different package, so map the alias here.
{
find: /^@\/(.*)$/,
replacement: path.resolve(__dirname, '../../../twenty-sdk/src/$1'),
},
],
},
test: {
include: ['src/**/*.test.ts'],
},
});
@@ -1293,6 +1293,20 @@ enum PageLayoutType {
STANDALONE_PAGE
}
type ApplicationConnectionProviderOAuthConfig {
scopes: [String!]!
isClientCredentialsConfigured: Boolean!
}
type ApplicationConnectionProvider {
id: UUID!
applicationId: String!
type: String!
name: String!
displayName: String!
oauth: ApplicationConnectionProviderOAuthConfig
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
@@ -2537,6 +2551,10 @@ type ConnectedAccountDTO {
connectionParameters: ImapSmtpCaldavConnectionParameters
lastSignedInAt: DateTime
userWorkspaceId: UUID!
applicationConnectionProviderId: UUID
applicationId: UUID
name: String
visibility: String!
createdAt: DateTime!
updatedAt: DateTime!
}
@@ -2564,6 +2582,10 @@ type ConnectedAccountPublicDTO {
scopes: [String!]
lastSignedInAt: DateTime
userWorkspaceId: UUID!
applicationConnectionProviderId: UUID
applicationId: UUID
name: String
visibility: String!
createdAt: DateTime!
updatedAt: DateTime!
connectionParameters: PublicImapSmtpCaldavConnectionParameters
@@ -2919,6 +2941,7 @@ type Query {
getPageLayoutTab(id: String!): PageLayoutTab!
getPageLayouts(objectMetadataId: String, pageLayoutType: PageLayoutType): [PageLayout!]!
getPageLayout(id: String!): PageLayout
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
getPageLayoutWidget(id: String!): PageLayoutWidget!
findOneLogicFunction(input: LogicFunctionIdInput!): LogicFunction!
@@ -3170,6 +3193,7 @@ type Mutation {
resetPageLayoutToDefault(id: String!): PageLayout!
resetPageLayoutWidgetToDefault(id: String!): PageLayoutWidget!
resetPageLayoutTabToDefault(id: String!): PageLayoutTab!
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
destroyPageLayoutWidget(id: String!): Boolean!
@@ -3272,7 +3296,6 @@ type Mutation {
installApplication(appRegistrationId: String!, version: String): Boolean!
runWorkspaceMigration(workspaceMigration: WorkspaceMigrationInput!): Boolean!
uninstallApplication(universalIdentifier: String!): Boolean!
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
createOIDCIdentityProvider(input: SetupOIDCSsoInput!): SetupSso!
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
@@ -1019,6 +1019,22 @@ export interface PageLayout {
export type PageLayoutType = 'RECORD_INDEX' | 'RECORD_PAGE' | 'DASHBOARD' | 'STANDALONE_PAGE'
export interface ApplicationConnectionProviderOAuthConfig {
scopes: Scalars['String'][]
isClientCredentialsConfigured: Scalars['Boolean']
__typename: 'ApplicationConnectionProviderOAuthConfig'
}
export interface ApplicationConnectionProvider {
id: Scalars['UUID']
applicationId: Scalars['String']
type: Scalars['String']
name: Scalars['String']
displayName: Scalars['String']
oauth?: ApplicationConnectionProviderOAuthConfig
__typename: 'ApplicationConnectionProvider'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
@@ -2223,6 +2239,10 @@ export interface ConnectedAccountDTO {
connectionParameters?: ImapSmtpCaldavConnectionParameters
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
applicationConnectionProviderId?: Scalars['UUID']
applicationId?: Scalars['UUID']
name?: Scalars['String']
visibility: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ConnectedAccountDTO'
@@ -2253,6 +2273,10 @@ export interface ConnectedAccountPublicDTO {
scopes?: Scalars['String'][]
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
applicationConnectionProviderId?: Scalars['UUID']
applicationId?: Scalars['UUID']
name?: Scalars['String']
visibility: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
connectionParameters?: PublicImapSmtpCaldavConnectionParameters
@@ -2544,6 +2568,7 @@ export interface Query {
getPageLayoutTab: PageLayoutTab
getPageLayouts: PageLayout[]
getPageLayout?: PageLayout
applicationConnectionProviders: ApplicationConnectionProvider[]
getPageLayoutWidgets: PageLayoutWidget[]
getPageLayoutWidget: PageLayoutWidget
findOneLogicFunction: LogicFunction
@@ -2701,6 +2726,7 @@ export interface Mutation {
resetPageLayoutToDefault: PageLayout
resetPageLayoutWidgetToDefault: PageLayoutWidget
resetPageLayoutTabToDefault: PageLayoutTab
updateOneApplicationVariable: Scalars['Boolean']
createPageLayoutWidget: PageLayoutWidget
updatePageLayoutWidget: PageLayoutWidget
destroyPageLayoutWidget: Scalars['Boolean']
@@ -2803,7 +2829,6 @@ export interface Mutation {
installApplication: Scalars['Boolean']
runWorkspaceMigration: Scalars['Boolean']
uninstallApplication: Scalars['Boolean']
updateOneApplicationVariable: Scalars['Boolean']
createOIDCIdentityProvider: SetupSso
createSAMLIdentityProvider: SetupSso
deleteSSOIdentityProvider: DeleteSso
@@ -3916,6 +3941,24 @@ export interface PageLayoutGenqlSelection{
__scalar?: boolean | number
}
export interface ApplicationConnectionProviderOAuthConfigGenqlSelection{
scopes?: boolean | number
isClientCredentialsConfigured?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApplicationConnectionProviderGenqlSelection{
id?: boolean | number
applicationId?: boolean | number
type?: boolean | number
name?: boolean | number
displayName?: boolean | number
oauth?: ApplicationConnectionProviderOAuthConfigGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
@@ -5209,6 +5252,10 @@ export interface ConnectedAccountDTOGenqlSelection{
connectionParameters?: ImapSmtpCaldavConnectionParametersGenqlSelection
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
applicationConnectionProviderId?: boolean | number
applicationId?: boolean | number
name?: boolean | number
visibility?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
@@ -5242,6 +5289,10 @@ export interface ConnectedAccountPublicDTOGenqlSelection{
scopes?: boolean | number
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
applicationConnectionProviderId?: boolean | number
applicationId?: boolean | number
name?: boolean | number
visibility?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
connectionParameters?: PublicImapSmtpCaldavConnectionParametersGenqlSelection
@@ -5530,6 +5581,7 @@ export interface QueryGenqlSelection{
getPageLayoutTab?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
getPageLayouts?: (PageLayoutGenqlSelection & { __args?: {objectMetadataId?: (Scalars['String'] | null), pageLayoutType?: (PageLayoutType | null)} })
getPageLayout?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
getPageLayoutWidgets?: (PageLayoutWidgetGenqlSelection & { __args: {pageLayoutTabId: Scalars['String']} })
getPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
findOneLogicFunction?: (LogicFunctionGenqlSelection & { __args: {input: LogicFunctionIdInput} })
@@ -5726,6 +5778,7 @@ export interface MutationGenqlSelection{
resetPageLayoutToDefault?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutWidgetToDefault?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutTabToDefault?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
createPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {input: CreatePageLayoutWidgetInput} })
updatePageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String'], input: UpdatePageLayoutWidgetInput} })
destroyPageLayoutWidget?: { __args: {id: Scalars['String']} }
@@ -5828,7 +5881,6 @@ export interface MutationGenqlSelection{
installApplication?: { __args: {appRegistrationId: Scalars['String'], version?: (Scalars['String'] | null)} }
runWorkspaceMigration?: { __args: {workspaceMigration: WorkspaceMigrationInput} }
uninstallApplication?: { __args: {universalIdentifier: Scalars['String']} }
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
createOIDCIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupOIDCSsoInput} })
createSAMLIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupSAMLSsoInput} })
deleteSSOIdentityProvider?: (DeleteSsoGenqlSelection & { __args: {input: DeleteSsoInput} })
@@ -6807,6 +6859,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ApplicationConnectionProviderOAuthConfig_possibleTypes: string[] = ['ApplicationConnectionProviderOAuthConfig']
export const isApplicationConnectionProviderOAuthConfig = (obj?: { __typename?: any } | null): obj is ApplicationConnectionProviderOAuthConfig => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationConnectionProviderOAuthConfig"')
return ApplicationConnectionProviderOAuthConfig_possibleTypes.includes(obj.__typename)
}
const ApplicationConnectionProvider_possibleTypes: string[] = ['ApplicationConnectionProvider']
export const isApplicationConnectionProvider = (obj?: { __typename?: any } | null): obj is ApplicationConnectionProvider => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationConnectionProvider"')
return ApplicationConnectionProvider_possibleTypes.includes(obj.__typename)
}
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
---
title: Connections
description: Let your app act on a user's behalf in third-party services via OAuth.
icon: 'plug'
---
Connections are credentials a user holds for an external service (Linear, GitHub, Slack, ...). Your app declares **how** those credentials are obtained — a **connection provider** — and consumes them at runtime to make authenticated calls to the third-party API.
Today only OAuth 2.0 is supported. Future credential types (personal access tokens, API keys, basic auth) will plug into the same surface — apps already using `defineConnectionProvider({ type: 'oauth', ... })` won't need to migrate.
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="Declare how your app's connections are obtained">
A connection provider describes the OAuth handshake your app needs. The user clicks "Add connection" in your app's settings, completes the provider's consent screen, and a `ConnectedAccount` row is created in their workspace.
A working setup needs **two files** — the connection provider, and a matching `serverVariables` declaration on `defineApplication` that holds the OAuth client credentials.
```ts src/connection-providers/linear-connection.ts
import { defineConnectionProvider } from 'twenty-sdk/define';
export default defineConnectionProvider({
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
name: 'linear',
displayName: 'Linear',
icon: 'IconBrandLinear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
// These must match keys in `defineApplication.serverVariables` below.
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
// 'form-urlencoded' for the token request.
tokenRequestContentType: 'form-urlencoded',
// Optional: defaults to true. Disable only if the provider rejects PKCE.
usePkce: false,
// Optional: extra query params on the authorize URL.
// authorizationParams: { prompt: 'consent' },
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
// revokeEndpoint: 'https://example.com/oauth/revoke',
},
});
```
```ts src/application.config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
displayName: 'Linear',
description: 'Connect Linear to Twenty.',
defaultRoleUniversalIdentifier: '...',
// OAuth client credentials live on the app registration (one OAuth app per
// Twenty server, configured by the admin) — not per-workspace. Declare them
// as serverVariables so the admin can fill them in once for all installs.
serverVariables: {
LINEAR_CLIENT_ID: {
description: 'OAuth client ID from your Linear OAuth application.',
isSecret: false,
isRequired: true,
},
LINEAR_CLIENT_SECRET: {
description: 'OAuth client secret from your Linear OAuth application.',
isSecret: true,
isRequired: true,
},
},
});
```
Key points:
- `name` is the unique identifier string used in `listConnections({ providerName })` (kebab-case, must match `^[a-z][a-z0-9-]*$`).
- `displayName` shows in the per-app settings tab and in the AI tool list.
- `clientIdVariable` / `clientSecretVariable` are **names**, not values — they must match keys declared in `defineApplication.serverVariables`. The actual `client_id` and `client_secret` are entered by the server admin through the app registration UI, never committed to your repo.
- Use `serverVariables` (not `applicationVariables`) — OAuth credentials are server-wide and one OAuth app per Twenty server.
- Until both `serverVariables` are filled in, the per-app settings tab shows a "needs server admin" hint and the "Add connection" button is disabled.
- `type: 'oauth'` is the only supported value today. The discriminator is forward-compatible: future types (`'pat'`, `'api-key'`, ...) will add new sub-config blocks alongside `oauth`.
The OAuth callback URL your provider needs to whitelist is:
```
https://<your-twenty-server>/apps/oauth/callback
```
</Accordion>
<Accordion title="listConnections / getConnection" description="Use connections from a logic function">
Inside a logic function handler, `listConnections({ providerName })` returns this app's `ConnectedAccount` rows for the given provider, with refreshed access tokens.
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
import { listConnections } from 'twenty-sdk/logic-function';
export const createLinearIssueHandler = async (input: {
teamId?: string;
title?: string;
}) => {
if (!input.teamId || !input.title) {
return { success: false, error: 'teamId and title are required' };
}
const connections = await listConnections({ providerName: 'linear' });
// Workspace-shared credentials win when present; fall back to the first
// user-visibility one. For HTTP-route triggers you typically pick the
// request user's connection via event.userWorkspaceId instead.
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection".',
};
}
// Use connection.accessToken to call the third-party API.
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
}),
});
return { success: response.ok };
};
```
Each connection has:
| Field | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------- |
| `id` | Unique row id; pass to `getConnection(id)` to refetch a single one |
| `visibility` | `'user'` (private to one workspace member) or `'workspace'` (shared with all members) |
| `scopes` | OAuth permissions granted by the upstream provider (distinct from `visibility` — those are unrelated) |
| `userWorkspaceId` | The owner's userWorkspace id — useful for picking "the request user's connection" in HTTP-route triggers |
| `accessToken` | Fresh OAuth access token (refreshed automatically if expired) |
| `name` / `handle` | The connection's display name (auto-derived at OAuth callback, user-renameable) |
| `authFailedAt` | Set when the most recent refresh failed; the user must reconnect |
Key points:
- Pass `{ providerName }` to filter by provider; omit it to get all connections this app owns across all providers.
- The server transparently refreshes the access token before returning. Your handler always sees a usable token (or `authFailedAt` set).
- `getConnection(id)` is the single-row equivalent.
</Accordion>
<Accordion title="Per-user vs workspace-shared visibility" description="How users choose between private and shared credentials">
When a user clicks "Add connection," they're prompted to pick a visibility:
- **Just for me** — the credential is private to the connecting user. Any logic function called on their behalf (HTTP-route trigger with `isAuthRequired: true`) sees it; cron triggers and database events do not.
- **Workspace shared** — any workspace member can use the credential. Cron / database triggers also see it, since they have no request user.
Use the right one for each handler:
```ts
// HTTP-route trigger — prefer the request user's own connection.
const conn =
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
connections.find((c) => c.visibility === 'workspace');
// Cron trigger — no request user; only shared credentials are sensible.
const conn = connections.find((c) => c.visibility === 'workspace');
```
Multiple connections per (user, provider) are allowed, so the same user can hold "Personal Linear" and "Work Linear" side by side.
</Accordion>
<Accordion title="One-time provider setup" description="Register your OAuth app with the third-party service">
For each connection provider, the server admin needs to register an OAuth app at the third party first.
1. Go to the provider's developer settings (e.g. https://linear.app/settings/api/applications/new).
2. Set the **Redirect URI** to `<SERVER_URL>/apps/oauth/callback`.
3. Copy the generated **Client ID** and **Client Secret**.
4. Open the installed app in Twenty as a server admin → set the values on the corresponding `serverVariables`.
5. Workspace members can then add connections from the per-app **Connections** section.
</Accordion>
</AccordionGroup>
@@ -288,6 +288,22 @@ export type Application = {
yarnLockFileId?: Maybe<Scalars['UUID']>;
};
export type ApplicationConnectionProvider = {
__typename?: 'ApplicationConnectionProvider';
applicationId: Scalars['String'];
displayName: Scalars['String'];
id: Scalars['UUID'];
name: Scalars['String'];
oauth?: Maybe<ApplicationConnectionProviderOAuthConfig>;
type: Scalars['String'];
};
export type ApplicationConnectionProviderOAuthConfig = {
__typename?: 'ApplicationConnectionProviderOAuthConfig';
isClientCredentialsConfigured: Scalars['Boolean'];
scopes: Array<Scalars['String']>;
};
export type ApplicationRegistration = {
__typename?: 'ApplicationRegistration';
createdAt: Scalars['DateTime'];
@@ -880,6 +896,8 @@ export type CommandMenuItemPayload = ObjectMetadataCommandMenuItemPayload | Path
export type ConnectedAccountDto = {
__typename?: 'ConnectedAccountDTO';
applicationConnectionProviderId?: Maybe<Scalars['UUID']>;
applicationId?: Maybe<Scalars['UUID']>;
authFailedAt?: Maybe<Scalars['DateTime']>;
connectionParameters?: Maybe<ImapSmtpCaldavConnectionParameters>;
createdAt: Scalars['DateTime'];
@@ -888,14 +906,18 @@ export type ConnectedAccountDto = {
id: Scalars['UUID'];
lastCredentialsRefreshedAt?: Maybe<Scalars['DateTime']>;
lastSignedInAt?: Maybe<Scalars['DateTime']>;
name?: Maybe<Scalars['String']>;
provider: Scalars['String'];
scopes?: Maybe<Array<Scalars['String']>>;
updatedAt: Scalars['DateTime'];
userWorkspaceId: Scalars['UUID'];
visibility: Scalars['String'];
};
export type ConnectedAccountPublicDto = {
__typename?: 'ConnectedAccountPublicDTO';
applicationConnectionProviderId?: Maybe<Scalars['UUID']>;
applicationId?: Maybe<Scalars['UUID']>;
authFailedAt?: Maybe<Scalars['DateTime']>;
connectionParameters?: Maybe<PublicImapSmtpCaldavConnectionParameters>;
createdAt: Scalars['DateTime'];
@@ -904,10 +926,12 @@ export type ConnectedAccountPublicDto = {
id: Scalars['UUID'];
lastCredentialsRefreshedAt?: Maybe<Scalars['DateTime']>;
lastSignedInAt?: Maybe<Scalars['DateTime']>;
name?: Maybe<Scalars['String']>;
provider: Scalars['String'];
scopes?: Maybe<Array<Scalars['String']>>;
updatedAt: Scalars['DateTime'];
userWorkspaceId: Scalars['UUID'];
visibility: Scalars['String'];
};
export type ConnectedImapSmtpCaldavAccount = {
@@ -4030,6 +4054,7 @@ export type Query = {
agentTurns: Array<AgentTurn>;
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
applicationConnectionProviders: Array<ApplicationConnectionProvider>;
applicationRegistrationTarballUrl?: Maybe<Scalars['String']>;
barChartData: BarChartData;
billingPortalSession: BillingSession;
@@ -4140,6 +4165,11 @@ export type QueryApiKeyArgs = {
};
export type QueryApplicationConnectionProvidersArgs = {
applicationId: Scalars['UUID'];
};
export type QueryApplicationRegistrationTarballUrlArgs = {
id: Scalars['String'];
};
@@ -6849,7 +6879,7 @@ export type MyCalendarChannelsQuery = { __typename?: 'Query', myCalendarChannels
export type MyConnectedAccountsQueryVariables = Exact<{ [key: string]: never; }>;
export type MyConnectedAccountsQuery = { __typename?: 'Query', myConnectedAccounts: Array<{ __typename?: 'ConnectedAccountDTO', id: string, handle: string, provider: string, authFailedAt?: string | null, scopes?: Array<string> | null, handleAliases?: Array<string> | null, lastSignedInAt?: string | null, userWorkspaceId: string, createdAt: string, updatedAt: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, password: string } | null } | null }> };
export type MyConnectedAccountsQuery = { __typename?: 'Query', myConnectedAccounts: Array<{ __typename?: 'ConnectedAccountDTO', id: string, handle: string, provider: string, authFailedAt?: string | null, scopes?: Array<string> | null, handleAliases?: Array<string> | null, lastSignedInAt?: string | null, userWorkspaceId: string, applicationConnectionProviderId?: string | null, name?: string | null, visibility: string, lastCredentialsRefreshedAt?: string | null, createdAt: string, updatedAt: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, password: string } | null } | null }> };
export type MyMessageChannelsQueryVariables = Exact<{
connectedAccountId?: InputMaybe<Scalars['UUID']>;
@@ -6952,6 +6982,13 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{
export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean };
export type ApplicationConnectionProvidersQueryVariables = Exact<{
applicationId: Scalars['UUID'];
}>;
export type ApplicationConnectionProvidersQuery = { __typename?: 'Query', applicationConnectionProviders: Array<{ __typename?: 'ApplicationConnectionProvider', id: string, applicationId: string, type: string, name: string, displayName: string, oauth?: { __typename?: 'ApplicationConnectionProviderOAuthConfig', scopes: Array<string>, isClientCredentialsConfigured: boolean } | null }> };
export type BillingPriceLicensedFragmentFragment = { __typename?: 'BillingPriceLicensed', stripePriceId: string, unitAmount: number, recurringInterval: SubscriptionInterval, priceUsageType: BillingUsageType };
export type BillingPriceMeteredFragmentFragment = { __typename?: 'BillingPriceMetered', priceUsageType: BillingUsageType, recurringInterval: SubscriptionInterval, stripePriceId: string, tiers: Array<{ __typename?: 'BillingPriceTier', flatAmount?: number | null, unitAmount?: number | null, upTo?: number | null }> };
@@ -7979,7 +8016,7 @@ export const UpdateMessageFoldersDocument = {"kind":"Document","definitions":[{"
export const ConnectedAccountByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectedAccountById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectedAccountById"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PublicConnectionParams"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PublicConnectionParametersOutput"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]} as unknown as DocumentNode<ConnectedAccountByIdQuery, ConnectedAccountByIdQueryVariables>;
export const GetConnectedImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectedImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConnectedImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCalendarChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCalendarChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyCalendarChannelsQuery, MyCalendarChannelsQueryVariables>;
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"applicationConnectionProviderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"lastCredentialsRefreshedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
export const MyMessageChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageChannelsQuery, MyMessageChannelsQueryVariables>;
export const MyMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"messageChannelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}},{"kind":"Field","name":{"kind":"Name","value":"isSentFolder"}},{"kind":"Field","name":{"kind":"Name","value":"parentFolderId"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"messageChannelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageFoldersQuery, MyMessageFoldersQueryVariables>;
export const DeleteApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode<DeleteApplicationRegistrationMutation, DeleteApplicationRegistrationMutationVariables>;
@@ -7994,6 +8031,7 @@ export const FindManyApplicationRegistrationsDocument = {"kind":"Document","defi
export const FindOneApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneApplicationRegistrationQuery, FindOneApplicationRegistrationQueryVariables>;
export const UninstallApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UninstallApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uninstallApplication"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"universalIdentifier"},"value":{"kind":"Variable","name":{"kind":"Name","value":"universalIdentifier"}}}]}]}}]} as unknown as DocumentNode<UninstallApplicationMutation, UninstallApplicationMutationVariables>;
export const UpdateOneApplicationVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOneApplicationVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOneApplicationVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}},{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}]}]}}]} as unknown as DocumentNode<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>;
export const ApplicationConnectionProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApplicationConnectionProviders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationConnectionProviders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"oauth"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"isClientCredentialsConfigured"}}]}}]}}]}}]} as unknown as DocumentNode<ApplicationConnectionProvidersQuery, ApplicationConnectionProvidersQueryVariables>;
export const CancelSwitchBillingIntervalDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchBillingInterval"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchBillingInterval"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<CancelSwitchBillingIntervalMutation, CancelSwitchBillingIntervalMutationVariables>;
export const CancelSwitchMeteredPriceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchMeteredPrice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchMeteredPrice"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<CancelSwitchMeteredPriceMutation, CancelSwitchMeteredPriceMutationVariables>;
export const CancelSwitchBillingPlanDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelSwitchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSwitchBillingPlan"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentBillingSubscription"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"price"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhase"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start_date"}},{"kind":"Field","name":{"kind":"Name","value":"end_date"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseItemFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CurrentBillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingSubscriptionItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hasReachedCurrentPeriodCap"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"billingProduct"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productKey"}},{"kind":"Field","name":{"kind":"Name","value":"planKey"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageBased"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingSubscriptionFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingSubscription"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"}},{"kind":"Field","name":{"kind":"Name","value":"phases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BillingSubscriptionSchedulePhaseFragment"}}]}}]}}]} as unknown as DocumentNode<CancelSwitchBillingPlanMutation, CancelSwitchBillingPlanMutationVariables>;
@@ -12,6 +12,12 @@ export type ConnectedAccount = {
handleAliases: string[] | null;
lastSignedInAt: string | null;
userWorkspaceId: string;
applicationConnectionProviderId: string | null;
name: string | null;
// Connection-row visibility — distinct from the `scopes` array above
// (those are upstream-granted OAuth permissions).
visibility: 'user' | 'workspace';
lastCredentialsRefreshedAt: string | null;
connectionParameters: ImapSmtpCaldavAccount | null;
createdAt: string;
updatedAt: string;
@@ -27,6 +27,7 @@ export const getMissingDraftEmailScopes = (
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.APP:
return [];
default:
assertUnreachable(
@@ -11,6 +11,10 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql`
handleAliases
lastSignedInAt
userWorkspaceId
applicationConnectionProviderId
name
visibility
lastCredentialsRefreshedAt
connectionParameters {
IMAP {
host
@@ -4,12 +4,24 @@ import { useMyCalendarChannels } from '@/settings/accounts/hooks/useMyCalendarCh
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { ConnectedAccountProvider } from 'twenty-shared/types';
type CoreConnectedAccount = Omit<
ConnectedAccount,
'messageChannels' | 'calendarChannels'
>;
const EMAIL_AND_CALENDAR_PROVIDERS: ReadonlySet<ConnectedAccountProvider> =
new Set([
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
]);
// The personal accounts page is for email/calendar credentials only. SSO
// providers (OIDC, SAML) and app-managed OAuth (APP) also live in
// connectedAccount, but they're surfaced elsewhere — keep them off this
// page by filtering to the email/calendar provider set.
export const useMyConnectedAccounts = () => {
const apolloClient = useApolloClient();
@@ -29,15 +41,17 @@ export const useMyConnectedAccounts = () => {
return [];
}
return data.myConnectedAccounts.map((account) => ({
...account,
messageChannels: messageChannels.filter(
(channel) => channel.connectedAccountId === account.id,
),
calendarChannels: calendarChannels.filter(
(channel) => channel.connectedAccountId === account.id,
),
}));
return data.myConnectedAccounts
.filter((account) => EMAIL_AND_CALENDAR_PROVIDERS.has(account.provider))
.map((account) => ({
...account,
messageChannels: messageChannels.filter(
(channel) => channel.connectedAccountId === account.id,
),
calendarChannels: calendarChannels.filter(
(channel) => channel.connectedAccountId === account.id,
),
}));
}, [data, messageChannels, calendarChannels]);
return {
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const FIND_APPLICATION_CONNECTION_PROVIDERS = gql`
query ApplicationConnectionProviders($applicationId: UUID!) {
applicationConnectionProviders(applicationId: $applicationId) {
id
applicationId
type
name
displayName
oauth {
scopes
isClientCredentialsConfigured
}
}
}
`;
@@ -173,6 +173,27 @@ export const useComputeApplicationContentForLayoutAndLogic = ({
}),
);
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,
@@ -180,5 +201,6 @@ export const useComputeApplicationContentForLayoutAndLogic = ({
agentRows,
skillRows,
roleRows,
connectionProviderRows,
};
};
@@ -30,6 +30,9 @@ const PROVIDERS_ICON_MAPPING = {
[ConnectedAccountProvider.IMAP_SMTP_CALDAV]: IconMail,
[ConnectedAccountProvider.OIDC]: IconMail,
[ConnectedAccountProvider.SAML]: IconMail,
// App-managed connections aren't email accounts; this case is unreachable
// for the EMAIL source but the lookup type still requires every provider.
[ConnectedAccountProvider.APP]: IconMail,
default: IconMail,
},
CALENDAR: {
@@ -44,6 +44,7 @@ import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSec
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
import { CUSTOM_APPLICATION_ILLUSTRATIONS } from '~/pages/settings/applications/constants/CustomApplicationIllustrations';
import { STANDARD_APPLICATION_ILLUSTRATIONS } from '~/pages/settings/applications/constants/StandardApplicationIllustrations';
import { useFindApplicationConnectionProviders } from '~/pages/settings/applications/hooks/useFindApplicationConnectionProviders';
import { SettingsApplicationCustomTab } from '~/pages/settings/applications/tabs/SettingsApplicationCustomTab';
import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab';
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
@@ -68,6 +69,9 @@ export const SettingsApplicationDetails = () => {
const application = data?.findOneApplication;
const { connectionProviders } =
useFindApplicationConnectionProviders(applicationId);
const { data: detailData } = useQuery(FindMarketplaceAppDetailDocument, {
variables: { universalIdentifier: application?.universalIdentifier ?? '' },
skip: !application?.universalIdentifier,
@@ -227,16 +231,21 @@ export const SettingsApplicationDetails = () => {
: undefined,
disabled: !isDefined(application?.defaultRoleId),
},
{
id: 'settings',
title: t`Settings`,
Icon: IconSettings,
tooltipContent:
(application?.applicationVariables ?? []).length === 0
? t`No variables to set for this application`
(() => {
const hasVariables = (application?.applicationVariables ?? []).length > 0;
const hasConnectionProviders = connectionProviders.length > 0;
const hasNothingToConfigure = !hasVariables && !hasConnectionProviders;
return {
id: 'settings',
title: t`Settings`,
Icon: IconSettings,
tooltipContent: hasNothingToConfigure
? t`Nothing to configure for this application`
: undefined,
disabled: (application?.applicationVariables ?? []).length === 0,
},
disabled: hasNothingToConfigure,
};
})(),
...(isDefined(settingsCustomTabFrontComponentId)
? [{ id: 'custom', title: t`Custom`, Icon: IconApps }]
: []),
@@ -0,0 +1,28 @@
import { useQuery } from '@apollo/client/react';
import { FIND_APPLICATION_CONNECTION_PROVIDERS } from '@/settings/applications/graphql/queries/findApplicationConnectionProviders';
import { type FrontendApplicationConnectionProvider } from '~/pages/settings/applications/types/FrontendApplicationConnectionProvider';
type QueryResult = {
applicationConnectionProviders: FrontendApplicationConnectionProvider[];
};
export const useFindApplicationConnectionProviders = (
applicationId?: string,
) => {
const { data, loading, refetch } = useQuery<QueryResult>(
FIND_APPLICATION_CONNECTION_PROVIDERS,
{
skip: !applicationId,
variables: { applicationId: applicationId ?? '' },
// Provider list only changes on app install/update; cache is fine.
fetchPolicy: 'cache-first',
},
);
return {
connectionProviders: data?.applicationConnectionProviders ?? [],
loading,
refetch,
};
};
@@ -0,0 +1,45 @@
import { useApolloClient, useQuery } from '@apollo/client/react';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import {
MyConnectedAccountsDocument,
type MyConnectedAccountsQuery,
} from '~/generated-metadata/graphql';
// App OAuth connections only — i.e. ConnectedAccount rows with
// `provider = 'app'`. The email/calendar `useMyConnectedAccounts` filters
// these out; this hook is the inverse, used by the per-app settings tab.
//
// We keep the legacy `gql\`...\`` document around in
// `getMyConnectedAccounts.ts` because other consumers (and the mutation
// `refetchQueries` list) still reference it by identity. Internally,
// though, we type the query result against the codegen-generated shape so
// new fields surface here automatically.
export type AppConnectedAccount =
MyConnectedAccountsQuery['myConnectedAccounts'][number];
export const useMyAppConnectedAccounts = () => {
const apolloClient = useApolloClient();
const { data, loading, refetch } = useQuery(GET_MY_CONNECTED_ACCOUNTS, {
client: apolloClient,
fetchPolicy: 'cache-and-network',
});
// Cast to the codegen-typed query shape: the inline `gql` document above
// is structurally identical to `MyConnectedAccountsDocument`, but Apollo
// can't unify the two without help.
const accounts = (
(data as MyConnectedAccountsQuery | undefined)?.myConnectedAccounts ?? []
).filter(
(account): account is AppConnectedAccount =>
account.provider === ConnectedAccountProvider.APP,
);
return { accounts, loading, refetch };
};
// Re-export the generated document so callers that need to track a single
// source of truth can use it directly without looking up the path.
export { MyConnectedAccountsDocument };
@@ -0,0 +1,68 @@
import { useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { GenerateTransientTokenDocument } from '~/generated-metadata/graphql';
// Mints a transient token then redirects to the generic app OAuth endpoint.
// Mirrors `useTriggerApisOAuth` for Google/Microsoft, but the URL template is
// /apps/oauth/authorize and works for any app-declared OAuth provider.
export const useTriggerAppOAuth = () => {
const [generateTransientToken] = useMutation(GenerateTransientTokenDocument);
const { redirect } = useRedirect();
const triggerAppOAuth = useCallback(
async ({
applicationId,
providerName,
visibility,
reconnectingConnectedAccountId,
redirectLocation,
}: {
applicationId: string;
providerName: string;
// Connection-row visibility:
// 'user' = personal credential, only the creator can use it.
// 'workspace' = shared with all members of the workspace.
// Distinct from the OAuth `scopes` granted by the upstream provider.
visibility: 'user' | 'workspace';
// Set to update an existing connectedAccount row rather than creating
// a new one (the "Reconnect" action on a failed credential).
reconnectingConnectedAccountId?: string;
redirectLocation?: string;
}) => {
const transient = await generateTransientToken();
const token = transient.data?.generateTransientToken.transientToken.token;
if (!token) {
return;
}
const params = new URLSearchParams({
applicationId,
providerName,
transientToken: token,
visibility,
});
if (reconnectingConnectedAccountId) {
params.set(
'reconnectingConnectedAccountId',
reconnectingConnectedAccountId,
);
}
if (redirectLocation) {
params.set('redirectLocation', redirectLocation);
}
redirect(
`${REACT_APP_SERVER_BASE_URL}/apps/oauth/authorize?${params.toString()}`,
);
},
[generateTransientToken, redirect],
);
return { triggerAppOAuth };
};
@@ -0,0 +1,208 @@
import { useMutation } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import {
H2Title,
IconPlus,
IconUser,
IconUsers,
Info,
Status,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MenuItem } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { DeleteConnectedAccountDocument } from '~/generated-metadata/graphql';
import { useFindApplicationConnectionProviders } from '~/pages/settings/applications/hooks/useFindApplicationConnectionProviders';
import { useMyAppConnectedAccounts } from '~/pages/settings/applications/hooks/useMyAppConnectedAccounts';
import { useTriggerAppOAuth } from '~/pages/settings/applications/hooks/useTriggerAppOAuth';
import { type FrontendApplicationConnectionProvider } from '~/pages/settings/applications/types/FrontendApplicationConnectionProvider';
const StyledRowRightContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledFooter = styled.div`
display: flex;
justify-content: flex-start;
margin-top: ${themeCssVariables.spacing[2]};
`;
// Inline split button: a "Add connection" CTA whose click opens a small
// Dropdown menu with the two visibility choices ("Just for me" / "Workspace
// shared"). Replaces an earlier full-screen modal that didn't match the
// rest of the settings UI.
const AddConnectionDropdown = ({
provider,
onPick,
}: {
provider: FrontendApplicationConnectionProvider;
onPick: (visibility: 'user' | 'workspace') => void;
}) => {
const { t } = useLingui();
const dropdownId = `app-connection-add-${provider.id}`;
const { closeDropdown } = useCloseDropdown();
const handleSelect = (visibility: 'user' | 'workspace') => {
closeDropdown(dropdownId);
onPick(visibility);
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponent={
<Button
title={t`Add connection`}
Icon={IconPlus}
variant="secondary"
accent="default"
size="small"
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
text={t`Just for me`}
LeftIcon={IconUser}
onClick={() => handleSelect('user')}
/>
<MenuItem
text={t`Workspace shared`}
LeftIcon={IconUsers}
onClick={() => handleSelect('workspace')}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
export const SettingsApplicationConnectionsSection = ({
applicationId,
}: {
applicationId: string;
}) => {
const { t } = useLingui();
const { triggerAppOAuth } = useTriggerAppOAuth();
const { connectionProviders, loading } =
useFindApplicationConnectionProviders(applicationId);
const { accounts: connectedAccounts } = useMyAppConnectedAccounts();
const [deleteConnectedAccount] = useMutation(DeleteConnectedAccountDocument, {
refetchQueries: [{ query: GET_MY_CONNECTED_ACCOUNTS }],
});
if (loading || connectionProviders.length === 0) {
return null;
}
return (
<>
{connectionProviders.map((provider) => {
const isOAuth = provider.type === 'oauth';
const isClientCredentialsConfigured =
provider.oauth?.isClientCredentialsConfigured ?? false;
const providerConnections = connectedAccounts.filter(
(account) => account.applicationConnectionProviderId === provider.id,
);
return (
<Section key={provider.id}>
<H2Title
title={provider.displayName}
description={t`Manage connections used by this app to call ${provider.displayName}.`}
/>
{isOAuth && !isClientCredentialsConfigured && (
<Info
accent="danger"
text={t`${provider.displayName} OAuth is not yet set up by your server administrator. They need to fill in the OAuth client ID and secret on the application registration before you can add a connection.`}
/>
)}
{providerConnections.length > 0 && (
<SettingsListCard
items={providerConnections.map((connection) => ({
id: connection.id,
label: connection.name ?? connection.handle,
// GraphQL types `visibility` as `string`; the column is
// constrained to one of these two values at write time.
visibility: connection.visibility as 'user' | 'workspace',
authFailedAt: connection.authFailedAt,
providerName: provider.name,
}))}
getItemLabel={(item) => item.label}
RowRightComponent={({ item }) => (
<StyledRowRightContainer>
<Status
color={item.visibility === 'workspace' ? 'blue' : 'gray'}
text={
item.visibility === 'workspace'
? t`Workspace`
: t`Just me`
}
/>
{item.authFailedAt && (
<Status color="red" text={t`Reconnect needed`} />
)}
{item.authFailedAt && (
<Button
title={t`Reconnect`}
variant="secondary"
accent="blue"
size="small"
onClick={() =>
triggerAppOAuth({
applicationId,
providerName: item.providerName,
visibility: item.visibility,
reconnectingConnectedAccountId: item.id,
})
}
/>
)}
<Button
title={t`Delete`}
variant="secondary"
accent="danger"
size="small"
onClick={() =>
deleteConnectedAccount({ variables: { id: item.id } })
}
/>
</StyledRowRightContainer>
)}
/>
)}
{isClientCredentialsConfigured && (
<StyledFooter>
<AddConnectionDropdown
provider={provider}
onPick={(visibility) =>
triggerAppOAuth({
applicationId,
providerName: provider.name,
visibility,
})
}
/>
</StyledFooter>
)}
</Section>
);
})}
</>
);
};
@@ -68,6 +68,7 @@ export const SettingsApplicationDetailContentTab = ({
agentRows,
skillRows,
roleRows,
connectionProviderRows,
} = useComputeApplicationContentForLayoutAndLogic({
installedApplication,
manifestContent,
@@ -137,6 +138,7 @@ export const SettingsApplicationDetailContentTab = ({
agents: filterRows(agentRows, normalizedSearch),
skills: filterRows(skillRows, normalizedSearch),
roles: filterRows(roleRows, normalizedSearch),
connectionProviders: filterRows(connectionProviderRows, normalizedSearch),
};
const hasData = filtered.objects.length > 0 || filtered.fields.length > 0;
@@ -149,7 +151,8 @@ export const SettingsApplicationDetailContentTab = ({
filtered.logicFunctions.length > 0 ||
filtered.agents.length > 0 ||
filtered.skills.length > 0 ||
filtered.roles.length > 0;
filtered.roles.length > 0 ||
filtered.connectionProviders.length > 0;
if (!hasData && !hasLayout && !hasLogic && normalizedSearch === '') {
return null;
@@ -254,6 +257,12 @@ export const SettingsApplicationDetailContentTab = ({
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Connection providers`}
rows={filtered.connectionProviders}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
</Table>
</Section>
)}
@@ -1,5 +1,6 @@
import { type Application } from '~/generated-metadata/graphql';
import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable';
import { SettingsApplicationConnectionsSection } from '~/pages/settings/applications/tabs/SettingsApplicationConnectionsSection';
import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable';
export const SettingsApplicationDetailSettingsTab = ({
@@ -17,17 +18,22 @@ export const SettingsApplicationDetailSettingsTab = ({
);
return (
<SettingsApplicationDetailEnvironmentVariablesTable
envVariables={envVariables}
onUpdate={({ key, value }) =>
application?.id
? updateOneApplicationVariable({
key,
value,
applicationId: application.id,
})
: null
}
/>
<>
{application?.id && (
<SettingsApplicationConnectionsSection applicationId={application.id} />
)}
<SettingsApplicationDetailEnvironmentVariablesTable
envVariables={envVariables}
onUpdate={({ key, value }) =>
application?.id
? updateOneApplicationVariable({
key,
value,
applicationId: application.id,
})
: null
}
/>
</>
);
};
@@ -0,0 +1,18 @@
import { type ConnectionProviderType } from 'twenty-shared/application';
export type FrontendApplicationConnectionProviderOAuthConfig = {
scopes: string[];
// false when the server admin hasn't filled in the OAuth client_id /
// client_secret on the application registration. Surface a hint to the
// user and disable "Add connection" in that case.
isClientCredentialsConfigured: boolean;
};
export type FrontendApplicationConnectionProvider = {
id: string;
applicationId: string;
type: ConnectionProviderType;
name: string;
displayName: string;
oauth: FrontendApplicationConnectionProviderOAuthConfig | null;
};
@@ -28,6 +28,7 @@ export const normalizeManifestForComparison = <T extends Manifest>(
roles: sortById(manifest.roles),
skills: sortById(manifest.skills),
agents: sortById(manifest.agents),
connectionProviders: sortById(manifest.connectionProviders ?? []),
views: sortById(manifest.views),
navigationMenuItems: sortById(manifest.navigationMenuItems),
pageLayouts: sortById(manifest.pageLayouts),
@@ -9,6 +9,7 @@ import { v4 } from 'uuid';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { convertToLabel } from '@/cli/utilities/entity/entity-label';
import { appendServerVariablesToAppConfig } from '@/cli/utilities/file/append-server-variables.util';
import { getFieldBaseFile } from '@/cli/utilities/entity/entity-field-template';
import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-component-template';
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
@@ -19,11 +20,14 @@ import { getPageLayoutTabBaseFile } from '@/cli/utilities/entity/entity-page-lay
import { getRecordPageLayoutBaseFile } from '@/cli/utilities/entity/entity-record-page-layout-template';
import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template';
import { getAgentBaseFile } from '@/cli/utilities/entity/entity-agent-template';
import { getConnectionProviderBaseFile } from '@/cli/utilities/entity/entity-connection-provider-template';
import { getSkillBaseFile } from '@/cli/utilities/entity/entity-skill-template';
import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template';
import { ensureDir, pathExists } from '@/cli/utilities/file/fs-utils';
import { kebabCase } from '@/cli/utilities/string/kebab-case';
const APP_CONFIG_HINT_PATH = 'src/application.config.ts';
const APP_FOLDER = 'src';
export class EntityAddCommand {
@@ -64,6 +68,10 @@ export class EntityAddCommand {
if (entity === SyncableEntity.Object) {
await this.promptAndCreateObjectCompanions(name, path);
}
if (entity === SyncableEntity.ConnectionProvider) {
await this.registerConnectionProviderServerVariables(name);
}
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
@@ -159,6 +167,16 @@ export class EntityAddCommand {
return { name, file };
}
case SyncableEntity.ConnectionProvider: {
const name = await this.getEntityName(entity);
const file = getConnectionProviderBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.View: {
const entityData = await this.getViewData();
@@ -381,6 +399,71 @@ export class EntityAddCommand {
);
}
// Connection providers reference two serverVariables (`<NAME>_CLIENT_ID`
// and `<NAME>_CLIENT_SECRET`) that the dev needs to declare on
// `defineApplication.serverVariables`. Auto-append them so the dev
// doesn't have to remember the wiring after `twenty add connection-provider`.
// The util is best-effort: it handles the common file shapes and falls
// back to a printed snippet for anything it can't safely modify.
private async registerConnectionProviderServerVariables(
name: string,
): Promise<void> {
const upperKey = kebabCase(name).toUpperCase().replace(/-/g, '_');
const variables = [
{
name: `${upperKey}_CLIENT_ID`,
description:
'OAuth client ID issued by the third-party provider. Filled in once by the server admin on the application registration.',
isSecret: false,
},
{
name: `${upperKey}_CLIENT_SECRET`,
description:
'OAuth client secret issued by the third-party provider. Stored encrypted; never echoed in API responses.',
isSecret: true,
},
];
const result = await appendServerVariablesToAppConfig({
projectRoot: CURRENT_EXECUTION_DIRECTORY,
variables,
});
switch (result.status) {
case 'appended':
case 'created': {
console.log(
chalk.green(
`✓ Added ${variables.map((v) => v.name).join(' + ')} to defineApplication.serverVariables:`,
),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, result.file)),
);
break;
}
case 'skipped-existing':
console.log(
chalk.dim(
` (${variables.map((v) => v.name).join(' / ')} already declared on defineApplication.serverVariables)`,
),
);
break;
case 'skipped-no-config':
case 'skipped-no-app-call':
console.log(
chalk.yellow(
`! Couldn't auto-update ${APP_CONFIG_HINT_PATH}. Add these manually to defineApplication.serverVariables:\n` +
variables
.map(
(v) =>
` ${v.name}: { description: '...', isSecret: ${v.isSecret}, isRequired: true },`,
)
.join('\n'),
),
);
break;
}
}
private buildRecordPageFieldsViewFields(
nameFieldUniversalIdentifier: string | undefined,
) {
@@ -33,6 +33,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"createValidationResult",
"defineAgent",
"defineApplication",
"defineConnectionProvider",
"defineField",
"defineFrontComponent",
"defineLogicFunction",
@@ -21,6 +21,7 @@ import {
type ApplicationManifest,
type AssetManifest,
ASSETS_DIR,
type ConnectionProviderManifest,
type FieldManifest,
type FrontComponentCommandManifest,
type FrontComponentManifest,
@@ -77,6 +78,7 @@ export const buildManifest = async (
const roles: RoleManifest[] = [];
const skills: SkillManifest[] = [];
const agents: AgentManifest[] = [];
const connectionProviders: ConnectionProviderManifest[] = [];
const logicFunctions: LogicFunctionManifest[] = [];
const frontComponents: FrontComponentManifest[] = [];
const publicAssets: AssetManifest[] = [];
@@ -94,6 +96,7 @@ export const buildManifest = async (
const rolesFilePaths: string[] = [];
const skillsFilePaths: string[] = [];
const agentsFilePaths: string[] = [];
const connectionProvidersFilePaths: string[] = [];
const logicFunctionsFilePaths: string[] = [];
const frontComponentsFilePaths: string[] = [];
const publicAssetsFilePaths: string[] = [];
@@ -208,6 +211,17 @@ export const buildManifest = async (
agentsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.ConnectionProviders: {
const extract =
await extractManifestFromFile<ConnectionProviderManifest>({
appPath,
filePath,
});
connectionProviders.push(extract.config);
errors.push(...extract.errors);
connectionProvidersFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.LogicFunctions: {
const extract = await extractManifestFromFile<LogicFunctionConfig>({
appPath,
@@ -420,6 +434,7 @@ export const buildManifest = async (
roles: roles.sort(byId),
skills: skills.sort(byId),
agents: agents.sort(byId),
connectionProviders: connectionProviders.sort(byId),
logicFunctions: logicFunctions.sort(byId),
frontComponents: frontComponents.sort(byId),
publicAssets: publicAssets.sort(byPath),
@@ -436,6 +451,7 @@ export const buildManifest = async (
roles: rolesFilePaths,
skills: skillsFilePaths,
agents: agentsFilePaths,
connectionProviders: connectionProvidersFilePaths,
logicFunctions: logicFunctionsFilePaths,
frontComponents: frontComponentsFilePaths,
publicAssets: publicAssetsFilePaths,
@@ -10,6 +10,7 @@ export enum TargetFunction {
DefineRole = 'defineRole',
DefineSkill = 'defineSkill',
DefineAgent = 'defineAgent',
DefineConnectionProvider = 'defineConnectionProvider',
DefineFrontComponent = 'defineFrontComponent',
DefineView = 'defineView',
DefineNavigationMenuItem = 'defineNavigationMenuItem',
@@ -25,6 +26,7 @@ export enum ManifestEntityKey {
Roles = 'roles',
Skills = 'skills',
Agents = 'agents',
ConnectionProviders = 'connectionProviders',
FrontComponents = 'frontComponents',
PublicAssets = 'publicAssets',
Views = 'views',
@@ -50,6 +52,8 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
[TargetFunction.DefineRole]: ManifestEntityKey.Roles,
[TargetFunction.DefineSkill]: ManifestEntityKey.Skills,
[TargetFunction.DefineAgent]: ManifestEntityKey.Agents,
[TargetFunction.DefineConnectionProvider]:
ManifestEntityKey.ConnectionProviders,
[TargetFunction.DefineFrontComponent]: ManifestEntityKey.FrontComponents,
[TargetFunction.DefineView]: ManifestEntityKey.Views,
[TargetFunction.DefineNavigationMenuItem]:
@@ -71,6 +71,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
frontComponents: SyncableEntity.FrontComponent,
roles: SyncableEntity.Role,
skills: SyncableEntity.Skill,
connectionProviders: SyncableEntity.ConnectionProvider,
views: SyncableEntity.View,
navigationMenuItems: SyncableEntity.NavigationMenuItem,
pageLayouts: SyncableEntity.PageLayout,
@@ -104,6 +104,7 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.PageLayout]: 'Page layouts',
[SyncableEntity.PageLayoutTab]: 'Page layout tabs',
[SyncableEntity.Agent]: 'Agents',
[SyncableEntity.ConnectionProvider]: 'Connection providers',
};
export const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[];
@@ -0,0 +1,38 @@
import { kebabCase } from '@/cli/utilities/string/kebab-case';
import { v4 } from 'uuid';
export const getConnectionProviderBaseFile = ({
name,
universalIdentifier = v4(),
}: {
name: string;
universalIdentifier?: string;
}) => {
const kebabCaseName = kebabCase(name);
const upperKey = kebabCaseName.toUpperCase().replace(/-/g, '_');
// Escape backslashes and single quotes so a name like "Bob's app" produces
// a valid TS literal in the generated file.
const escapedDisplayName = name.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `import { defineConnectionProvider } from 'twenty-sdk/define';
export const ${upperKey}_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineConnectionProvider({
universalIdentifier: ${upperKey}_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
name: '${kebabCaseName}',
displayName: '${escapedDisplayName}',
type: 'oauth',
oauth: {
// Replace with the OAuth provider's endpoints.
authorizationEndpoint: 'https://example.com/oauth/authorize',
tokenEndpoint: 'https://example.com/oauth/access_token',
scopes: [],
// Names of serverVariables declared on this app's defineApplication.
clientIdVariable: '${upperKey}_CLIENT_ID',
clientSecretVariable: '${upperKey}_CLIENT_SECRET',
},
});
`;
};
@@ -0,0 +1,166 @@
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appendServerVariablesToAppConfig } from '@/cli/utilities/file/append-server-variables.util';
const VARIABLES = [
{
name: 'LINEAR_CLIENT_ID',
description: 'OAuth client ID from Linear.',
isSecret: false,
},
{
name: 'LINEAR_CLIENT_SECRET',
description: 'OAuth client secret from Linear.',
isSecret: true,
},
];
describe('appendServerVariablesToAppConfig', () => {
let projectRoot: string;
let configPath: string;
beforeEach(async () => {
projectRoot = await mkdtemp(join(tmpdir(), 'twenty-cli-spec-'));
await mkdir(join(projectRoot, 'src'), { recursive: true });
configPath = join(projectRoot, 'src/application.config.ts');
});
afterEach(async () => {
// Tests get fresh tmpdirs; OS handles cleanup. Keeping the dirs around
// helps debug failures.
});
it('appends entries inside an existing serverVariables block', async () => {
await writeFile(
configPath,
`import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
serverVariables: {
EXISTING_VAR: { description: '...', isSecret: false, isRequired: true },
},
});
`,
'utf8',
);
const result = await appendServerVariablesToAppConfig({
projectRoot,
variables: VARIABLES,
});
expect(result).toEqual({ status: 'appended', file: configPath });
const updated = await readFile(configPath, 'utf8');
expect(updated).toContain('LINEAR_CLIENT_ID');
expect(updated).toContain('LINEAR_CLIENT_SECRET');
// Existing entry survives.
expect(updated).toContain('EXISTING_VAR');
// New entries land inside the existing block, not in a new one.
expect(updated.match(/serverVariables\s*:\s*\{/g)?.length).toBe(1);
});
it('creates a fresh serverVariables block when none exists', async () => {
await writeFile(
configPath,
`import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
displayName: 'My App',
});
`,
'utf8',
);
const result = await appendServerVariablesToAppConfig({
projectRoot,
variables: VARIABLES,
});
expect(result).toEqual({ status: 'created', file: configPath });
const updated = await readFile(configPath, 'utf8');
expect(updated).toContain('serverVariables: {');
expect(updated).toContain('LINEAR_CLIENT_ID');
expect(updated).toContain('LINEAR_CLIENT_SECRET');
// The defineApplication() closing should still be there.
expect(updated).toMatch(/\}\)\s*;?\s*$/);
});
it('skips variables that are already declared (idempotent re-runs)', async () => {
await writeFile(
configPath,
`import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
serverVariables: {
LINEAR_CLIENT_ID: { description: '...', isSecret: false, isRequired: true },
LINEAR_CLIENT_SECRET: { description: '...', isSecret: true, isRequired: true },
},
});
`,
'utf8',
);
const result = await appendServerVariablesToAppConfig({
projectRoot,
variables: VARIABLES,
});
expect(result).toEqual({ status: 'skipped-existing' });
});
it('returns skipped-no-config when no application.config.ts exists', async () => {
const result = await appendServerVariablesToAppConfig({
projectRoot,
variables: VARIABLES,
});
expect(result).toEqual({ status: 'skipped-no-config' });
});
it('finds the alternative application-config.ts filename', async () => {
const altPath = join(projectRoot, 'src/application-config.ts');
await writeFile(
altPath,
`import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
});
`,
'utf8',
);
const result = await appendServerVariablesToAppConfig({
projectRoot,
variables: VARIABLES,
});
expect(result).toEqual({ status: 'created', file: altPath });
});
it('returns skipped-no-app-call when the file lacks defineApplication', async () => {
await writeFile(
configPath,
`// not an app config\nexport const FOO = 1;\n`,
'utf8',
);
const result = await appendServerVariablesToAppConfig({
projectRoot,
variables: VARIABLES,
});
expect(result).toEqual({ status: 'skipped-no-app-call' });
});
});
@@ -0,0 +1,147 @@
import { readFile, writeFile } from 'node:fs/promises';
import { pathExists } from '@/cli/utilities/file/fs-utils';
// One name + description pair to add to defineApplication.serverVariables.
// `isSecret` defaults to false; set true on credentials that should be
// stored encrypted and never echoed to the dev (e.g. client secrets).
export type ServerVariableSpec = {
name: string;
description: string;
isSecret: boolean;
};
const APP_CONFIG_CANDIDATES = [
'src/application.config.ts',
'src/application-config.ts',
'src/applicationConfig.ts',
];
const SERVER_VARIABLES_PATTERN = /serverVariables\s*:\s*\{/;
const DEFINE_APPLICATION_PATTERN = /defineApplication\s*\(\s*\{/;
// Auto-appends OAuth client_id / client_secret entries to the dev's
// `defineApplication({ serverVariables: { ... } })` block so they don't
// have to remember the wiring after `twenty add connection-provider`.
//
// Returns one of:
// - { status: 'appended', file } — wrote new entries to an existing block
// - { status: 'created', file } — added a fresh serverVariables block
// - { status: 'skipped-existing' } — all variables already declared
// - { status: 'skipped-no-config' } — couldn't find application.config.ts
// - { status: 'skipped-no-app-call' } — file exists but lacks defineApplication(
//
// On `skipped-*`, the caller should print a manual snippet so the dev still
// knows what to paste — the helper is best-effort, not authoritative.
export type AppendResult =
| { status: 'appended'; file: string }
| { status: 'created'; file: string }
| { status: 'skipped-existing' }
| { status: 'skipped-no-config' }
| { status: 'skipped-no-app-call' };
export const appendServerVariablesToAppConfig = async ({
projectRoot,
variables,
}: {
projectRoot: string;
variables: ServerVariableSpec[];
}): Promise<AppendResult> => {
const configPath = await findAppConfigPath(projectRoot);
if (configPath === null) {
return { status: 'skipped-no-config' };
}
const original = await readFile(configPath, 'utf8');
// Skip variables that are already declared anywhere in the file — a
// crude check (key presence) but enough to avoid duplicate entries.
const newVariables = variables.filter(
(variable) => !new RegExp(`\\b${variable.name}\\s*:`).test(original),
);
if (newVariables.length === 0) {
return { status: 'skipped-existing' };
}
const block = renderServerVariableEntries(newVariables);
if (SERVER_VARIABLES_PATTERN.test(original)) {
// Insert immediately after the opening `{` of the existing block.
const updated = original.replace(
SERVER_VARIABLES_PATTERN,
(match) => `${match}\n${block}`,
);
await writeFile(configPath, updated, 'utf8');
return { status: 'appended', file: configPath };
}
if (!DEFINE_APPLICATION_PATTERN.test(original)) {
return { status: 'skipped-no-app-call' };
}
// Inject a fresh `serverVariables: { … },` property right before the
// closing `})` of the defineApplication call. The closing pattern is
// intentionally narrow (closing brace of the object literal followed by
// the closing paren) to avoid eating other nested blocks.
const updated = original.replace(/\n(\s*)\}\s*\)\s*;?\s*$/, (_, indent) => {
const renderedBlock = renderFreshServerVariablesBlock(newVariables, indent);
return `\n${indent}${renderedBlock}\n${indent}});\n`;
});
if (updated === original) {
return { status: 'skipped-no-app-call' };
}
await writeFile(configPath, updated, 'utf8');
return { status: 'created', file: configPath };
};
const findAppConfigPath = async (
projectRoot: string,
): Promise<string | null> => {
for (const candidate of APP_CONFIG_CANDIDATES) {
const absolute = `${projectRoot}/${candidate}`;
if (await pathExists(absolute)) {
return absolute;
}
}
return null;
};
const renderServerVariableEntries = (variables: ServerVariableSpec[]): string =>
variables
.map(
({ name, description, isSecret }) =>
` ${name}: {\n` +
` description: ${JSON.stringify(description)},\n` +
` isSecret: ${isSecret},\n` +
` isRequired: true,\n` +
` },`,
)
.join('\n');
const renderFreshServerVariablesBlock = (
variables: ServerVariableSpec[],
indent: string,
): string => {
const entries = variables
.map(
({ name, description, isSecret }) =>
`${indent} ${name}: {\n` +
`${indent} description: ${JSON.stringify(description)},\n` +
`${indent} isSecret: ${isSecret},\n` +
`${indent} isRequired: true,\n` +
`${indent} },`,
)
.join('\n');
return `serverVariables: {\n${entries}\n${indent}},`;
};
@@ -10,6 +10,7 @@ import { type PreInstallLogicFunctionConfig } from '@/sdk/define/logic-functions
import { type RoleConfig } from '@/sdk/define/roles/role-config';
import {
type AgentManifest,
type ConnectionProviderManifest,
type FieldManifest,
type NavigationMenuItemManifest,
type SkillManifest,
@@ -30,6 +31,7 @@ export type DefinableEntity =
| PostInstallLogicFunctionConfig
| PreInstallLogicFunctionConfig
| AgentManifest
| ConnectionProviderManifest
| RoleConfig
| SkillManifest
| ViewConfig
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { defineConnectionProvider } from '@/sdk/define';
const baseValidConfig = {
universalIdentifier: '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa',
name: 'linear',
displayName: 'Linear',
type: 'oauth' as const,
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
};
describe('defineConnectionProvider', () => {
it('returns success for a valid config', () => {
const result = defineConnectionProvider(baseValidConfig);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});
it('reports a missing universalIdentifier', () => {
const result = defineConnectionProvider({
...baseValidConfig,
universalIdentifier: '',
});
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Connection provider must have a universalIdentifier',
);
});
it('rejects a non-UUID universalIdentifier', () => {
// Catches the most common mistake: copy-pasting a slug or generated id
// string instead of running `uuidgen`. Server-side throws too — this
// arm of the check just shifts the error to `twenty deploy` time.
const result = defineConnectionProvider({
...baseValidConfig,
universalIdentifier: 'linear-provider',
});
expect(result.success).toBe(false);
expect(
result.errors.some((error) => error.includes('must be a UUID')),
).toBe(true);
});
it('accepts UUID v1, v4, and v5', () => {
// Postgres `uuid` is version-agnostic — make sure the SDK regex matches.
const versions = [
// v1
'b648f87b-1d26-1961-b974-0908fd991061',
// v4
'b648f87b-1d26-4961-b974-0908fd991061',
// v5
'b648f87b-1d26-5961-b974-0908fd991061',
// Nil UUID
'00000000-0000-0000-0000-000000000000',
];
for (const universalIdentifier of versions) {
const result = defineConnectionProvider({
...baseValidConfig,
universalIdentifier,
});
expect(result.success).toBe(true);
}
});
it('rejects a UUID with surrounding whitespace', () => {
const result = defineConnectionProvider({
...baseValidConfig,
universalIdentifier: ' b648f87b-1d26-4961-b974-0908fd991061 ',
});
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,80 @@
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
import { type ConnectionProviderManifest } from 'twenty-shared/application';
const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
// Matches UUID v1v5 (and the `00000000-…` Nil UUID). Mirrors the server-side
// check that runs against the `uuid` Postgres column; catching this at SDK
// build time means a typo in `defineConnectionProvider({ universalIdentifier })`
// fails the dev's `twenty deploy` rather than blowing up at install time.
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SUPPORTED_TYPES = ['oauth'] as const;
export const defineConnectionProvider: DefineEntity<
ConnectionProviderManifest
> = (config) => {
const errors: string[] = [];
if (!config.universalIdentifier) {
errors.push('Connection provider must have a universalIdentifier');
} else if (!UUID_PATTERN.test(config.universalIdentifier)) {
errors.push(
`Connection provider universalIdentifier "${config.universalIdentifier}" must be a UUID. Generate one with \`uuidgen\` or any UUID v4 tool.`,
);
}
if (!config.name) {
errors.push('Connection provider must have a name');
} else if (!PROVIDER_NAME_PATTERN.test(config.name)) {
errors.push(
`Connection provider name "${config.name}" must match ${PROVIDER_NAME_PATTERN} (used in URLs)`,
);
}
if (!config.displayName) {
errors.push('Connection provider must have a displayName');
}
if (!config.type) {
errors.push("Connection provider must declare a `type` (e.g. 'oauth')");
} else if (!(SUPPORTED_TYPES as readonly string[]).includes(config.type)) {
errors.push(
`Connection provider type "${config.type}" is not supported. Supported types: ${SUPPORTED_TYPES.join(', ')}.`,
);
}
if (config.type === 'oauth') {
const oauth = config.oauth;
if (!oauth) {
errors.push(
"Connection provider with type 'oauth' must declare an `oauth` config block",
);
} else {
if (!oauth.authorizationEndpoint) {
errors.push(
'OAuth connection provider must have an authorizationEndpoint',
);
}
if (!oauth.tokenEndpoint) {
errors.push('OAuth connection provider must have a tokenEndpoint');
}
if (!oauth.clientIdVariable) {
errors.push(
'OAuth connection provider must reference a clientIdVariable (key of a serverVariable on defineApplication)',
);
}
if (!oauth.clientSecretVariable) {
errors.push(
'OAuth connection provider must reference a clientSecretVariable (key of a serverVariable on defineApplication)',
);
}
if (!Array.isArray(oauth.scopes)) {
errors.push('OAuth connection provider must declare a scopes array');
}
}
}
return createValidationResult({ config, errors });
};
@@ -60,6 +60,8 @@ export type {
export type { RoutePayload } from '@/sdk/define/logic-functions/triggers/route-payload-type';
export type { InputJsonSchema } from 'twenty-shared/logic-function';
export { defineConnectionProvider } from '@/sdk/define/connection-providers/define-connection-provider';
export { defineNavigationMenuItem } from '@/sdk/define/navigation-menu-items/define-navigation-menu-item';
export { defineObject } from '@/sdk/define/objects/define-object';
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { findConnectionForRequest } from '@/sdk/logic-function/connections/find-connection-for-request';
import { type AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
const buildConnection = (
overrides: Partial<AppConnection> = {},
): AppConnection => ({
id: 'c-1',
providerName: 'linear',
name: 'Linear #1',
handle: 'octocat@example.com',
visibility: 'user',
userWorkspaceId: 'uws-me',
accessToken: 'token-fresh',
scopes: ['read'],
authFailedAt: null,
...overrides,
});
describe('findConnectionForRequest', () => {
it('returns the request users personal credential when one exists', () => {
const personal = buildConnection({ id: 'mine', userWorkspaceId: 'uws-me' });
const someoneElses = buildConnection({
id: 'theirs',
userWorkspaceId: 'uws-other',
});
const shared = buildConnection({ id: 'shared', visibility: 'workspace' });
expect(
findConnectionForRequest([someoneElses, shared, personal], {
userWorkspaceId: 'uws-me',
}),
).toBe(personal);
});
it('falls back to a workspace-shared credential when no personal one exists', () => {
const someoneElses = buildConnection({
id: 'theirs',
userWorkspaceId: 'uws-other',
});
const shared = buildConnection({ id: 'shared', visibility: 'workspace' });
expect(
findConnectionForRequest([someoneElses, shared], {
userWorkspaceId: 'uws-me',
}),
).toBe(shared);
});
it('returns workspace-shared even when event.userWorkspaceId is null', () => {
// Cron / DB-event triggers carry no user context. The handler should still
// be able to lean on a workspace-shared credential.
const shared = buildConnection({ id: 'shared', visibility: 'workspace' });
expect(findConnectionForRequest([shared], { userWorkspaceId: null })).toBe(
shared,
);
});
it('returns null when nothing matches', () => {
expect(
findConnectionForRequest([], { userWorkspaceId: 'uws-me' }),
).toBeNull();
const onlyOtherUser = buildConnection({
id: 'theirs',
userWorkspaceId: 'uws-other',
});
expect(
findConnectionForRequest([onlyOtherUser], {
userWorkspaceId: 'uws-me',
}),
).toBeNull();
});
it('does not pick a workspace-shared connection over a personal one', () => {
// Even when the workspace-shared row appears first in the array, the
// personal one wins — we never want a request user to silently fall
// through to a service-account credential.
const shared = buildConnection({ id: 'shared', visibility: 'workspace' });
const personal = buildConnection({ id: 'mine', userWorkspaceId: 'uws-me' });
expect(
findConnectionForRequest([shared, personal], {
userWorkspaceId: 'uws-me',
}),
).toBe(personal);
});
});
@@ -0,0 +1,103 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error';
import { getConnection } from '@/sdk/logic-function/connections/get-connection';
import { type AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
const buildConnection = (
overrides: Partial<AppConnection> = {},
): AppConnection => ({
id: 'c-1',
providerName: 'linear',
name: 'Linear #1',
handle: 'octocat@example.com',
visibility: 'user',
userWorkspaceId: 'uws-me',
accessToken: 'fresh',
scopes: ['read'],
authFailedAt: null,
...overrides,
});
describe('getConnection', () => {
let fetchSpy: MockInstance<typeof fetch>;
beforeEach(() => {
process.env.TWENTY_API_URL = 'https://api.test';
process.env.TWENTY_APP_ACCESS_TOKEN = 'app-token';
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
delete process.env.TWENTY_API_URL;
delete process.env.TWENTY_APP_ACCESS_TOKEN;
fetchSpy.mockRestore();
});
it('POSTs the id to /apps/connections/get and returns the response', async () => {
const connection = buildConnection({ id: 'persisted' });
fetchSpy.mockResolvedValue(
new Response(JSON.stringify(connection), { status: 200 }),
);
const result = await getConnection('persisted');
expect(result).toEqual(connection);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.test/apps/connections/get',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ id: 'persisted' }),
headers: expect.objectContaining({
Authorization: 'Bearer app-token',
}),
}),
);
});
it('throws AppConnectionAuthFailedError when the connection needs reconnect', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify(
buildConnection({
id: 'broken',
authFailedAt: '2024-01-02T00:00:00.000Z',
}),
),
{ status: 200 },
),
);
const error = await getConnection('broken').catch((caught) => caught);
expect(error).toBeInstanceOf(AppConnectionAuthFailedError);
expect((error as AppConnectionAuthFailedError).connectionId).toBe('broken');
});
it('surfaces non-2xx HTTP responses as a regular Error', async () => {
fetchSpy.mockResolvedValue(
new Response('not found', { status: 404, statusText: 'Not Found' }),
);
await expect(getConnection('missing')).rejects.toThrow(/HTTP 404/);
});
it('throws when the runtime env vars are missing', async () => {
delete process.env.TWENTY_API_URL;
await expect(getConnection('any')).rejects.toThrow(
/requires the app runtime env vars/,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,21 @@
// Thrown when the platform asks the SDK to operate on a connection whose
// OAuth refresh failed permanently (`authFailedAt` is set). The end user
// must reconnect from the app's settings tab — the app cannot recover on
// its own.
//
// `listConnections` filters these out by default (the user can't act on
// them anyway). `getConnection` throws this when the looked-up connection
// is in this state, so a stored connection id can be safely retried until
// it works again.
export class AppConnectionAuthFailedError extends Error {
readonly connectionId: string;
constructor(connectionId: string) {
super(
`App connection ${connectionId} requires the user to reconnect ` +
`(authFailedAt is set). Surface a "Reconnect" prompt in your UI.`,
);
this.name = 'AppConnectionAuthFailedError';
this.connectionId = connectionId;
}
}
@@ -0,0 +1,36 @@
import { type AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
// Resolution rule for an HTTP-route handler that wants "the request user's
// connection, or fall back to a workspace-shared one." Pure function — no
// network. Pass it the result of `listConnections` and the trigger event.
//
// The plan-of-record for picking a connection is documented in the v3 plan
// notes; this utility encodes the most common case so handlers don't have
// to repeat the same `find(...) ?? find(...)` chain.
//
// Returns `null` when no candidate exists — caller decides whether that's
// a 4xx for the end user or a hard error.
export const findConnectionForRequest = (
connections: AppConnection[],
event: { userWorkspaceId: string | null },
): AppConnection | null => {
// 1. Personal credential of the request user (highest specificity).
if (event.userWorkspaceId !== null) {
const personal = connections.find(
(connection) =>
connection.visibility === 'user' &&
connection.userWorkspaceId === event.userWorkspaceId,
);
if (personal) {
return personal;
}
}
// 2. Any workspace-shared credential (team-managed service account).
const workspaceShared = connections.find(
(connection) => connection.visibility === 'workspace',
);
return workspaceShared ?? null;
};
@@ -0,0 +1,25 @@
import { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error';
import { type AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
import { postConnectionsEndpoint } from '@/sdk/logic-function/connections/utils/post-connections-endpoint.util';
// Look up a single connection by id. The id is stable across reconnects
// (the row keeps its id when the user clicks "Reconnect"), so apps can
// safely persist it in their own data and call this helper on each
// invocation to retrieve a fresh access token.
//
// Throws `AppConnectionAuthFailedError` if the credential is in a
// permanent-failure state (the user must reconnect from the app's
// settings tab). Throws a regular `Error` for any other failure
// (network, not-found, transient refresh failure).
export const getConnection = async (id: string): Promise<AppConnection> => {
const connection = await postConnectionsEndpoint<
{ id: string },
AppConnection
>('get', { id });
if (connection.authFailedAt !== null) {
throw new AppConnectionAuthFailedError(connection.id);
}
return connection;
};
@@ -0,0 +1,26 @@
import { type AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
import { postConnectionsEndpoint } from '@/sdk/logic-function/connections/utils/post-connections-endpoint.util';
export type ListConnectionsFilter = {
// Provider name as declared on `defineConnectionProvider({ name })`.
providerName?: string;
// Restrict to credentials owned by a specific user. Useful in cron
// triggers when picking a service-account user via app config.
userWorkspaceId?: string;
// Restrict by row visibility — 'user' (private) or 'workspace' (shared).
visibility?: 'user' | 'workspace';
};
// Returns every connection owned by the running app, optionally filtered.
// The server refreshes each access token on read, so the returned values
// are usable immediately. When the running execution carries a user
// context (HTTP-route trigger with `isAuthRequired`, tool calls, etc.),
// `scope: 'user'` connections belonging to other users are filtered out
// server-side. Cron and database-event triggers see all connections.
export const listConnections = async (
filter: ListConnectionsFilter = {},
): Promise<AppConnection[]> =>
postConnectionsEndpoint<ListConnectionsFilter, AppConnection[]>(
'list',
filter,
);
@@ -0,0 +1,7 @@
// One credential the running app owns. Returned from `listConnections` and
// `getConnection`. The server refreshes `accessToken` on read, so the value
// is always usable at the moment the helper resolves.
//
// The shape lives in twenty-shared so this re-export and the server-side
// `AppConnectionDto` always agree — changes propagate to both at once.
export type { AppConnection } from 'twenty-shared/application';
@@ -0,0 +1,40 @@
import {
DEFAULT_API_URL_NAME,
DEFAULT_APP_ACCESS_TOKEN_NAME,
} from 'twenty-shared/application';
// Shared transport for `/apps/connections/*` endpoints. Centralises the
// env-var check, auth header, and HTTP error translation so each helper
// stays focused on its own input/output shape.
export const postConnectionsEndpoint = async <TBody, TResponse>(
path: 'list' | 'get',
body: TBody,
): Promise<TResponse> => {
const apiUrl = process.env[DEFAULT_API_URL_NAME];
const accessToken = process.env[DEFAULT_APP_ACCESS_TOKEN_NAME];
if (!apiUrl || !accessToken) {
throw new Error(
`${path === 'list' ? 'listConnections' : 'getConnection'}() requires the app runtime env vars ` +
`${DEFAULT_API_URL_NAME} and ${DEFAULT_APP_ACCESS_TOKEN_NAME}.`,
);
}
const response = await fetch(`${apiUrl}/apps/connections/${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`${path === 'list' ? 'listConnections' : 'getConnection'}() failed: ` +
`HTTP ${response.status} ${response.statusText}`,
);
}
return (await response.json()) as TResponse;
};
@@ -37,3 +37,10 @@ export type {
export type { RoutePayload } from '@/sdk/define/logic-functions/triggers/route-payload-type';
export type { InputJsonSchema } from 'twenty-shared/logic-function';
export { getConnection } from '@/sdk/logic-function/connections/get-connection';
export { listConnections } from '@/sdk/logic-function/connections/list-connections';
export type { ListConnectionsFilter } from '@/sdk/logic-function/connections/list-connections';
export { findConnectionForRequest } from '@/sdk/logic-function/connections/find-connection-for-request';
export { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error';
export type { AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
@@ -0,0 +1,133 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddApplicationOAuthProviderAndConnectedAccountColumn1777558657640
implements MigrationInterface
{
name = 'AddApplicationOAuthProviderAndConnectedAccountColumn1777558657640';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."applicationOAuthProvider" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workspaceId" uuid NOT NULL,
"applicationId" uuid NOT NULL,
"universalIdentifier" uuid NOT NULL,
"name" varchar NOT NULL,
"displayName" varchar NOT NULL,
"authorizationEndpoint" varchar NOT NULL,
"tokenEndpoint" varchar NOT NULL,
"revokeEndpoint" varchar,
"scopes" varchar array NOT NULL DEFAULT '{}',
"clientIdVariable" varchar NOT NULL,
"clientSecretVariable" varchar NOT NULL,
"authorizationParams" jsonb,
"tokenRequestContentType" varchar NOT NULL DEFAULT 'json',
"usePkce" boolean NOT NULL DEFAULT true,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "IDX_APP_OAUTH_PROVIDER_NAME_APPLICATION_UNIQUE" UNIQUE ("name", "applicationId"),
CONSTRAINT "IDX_APP_OAUTH_PROVIDER_UNIVERSAL_ID_APPLICATION_UNIQUE" UNIQUE ("universalIdentifier", "applicationId"),
CONSTRAINT "PK_applicationOAuthProvider_id" PRIMARY KEY ("id")
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_APP_OAUTH_PROVIDER_APPLICATION_ID" ON "core"."applicationOAuthProvider" ("applicationId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID" ON "core"."applicationOAuthProvider" ("workspaceId")`,
);
// FK constraint names match the hashes that TypeORM auto-generates from
// the @ManyToOne decorators in the entities. Keeping them in sync here
// avoids a "pending migration" diff on every CI run.
await queryRunner.query(
`ALTER TABLE "core"."applicationOAuthProvider"
ADD CONSTRAINT "FK_c63de8b90514de1798876c30f2e"
FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationOAuthProvider"
ADD CONSTRAINT "FK_2d01320998547c2f5059d8b09d6"
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD COLUMN "applicationOAuthProviderId" uuid,
ADD COLUMN "applicationId" uuid,
ADD COLUMN "name" varchar,
ADD COLUMN "visibility" varchar NOT NULL DEFAULT 'user'`,
);
await queryRunner.query(
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID" ON "core"."connectedAccount" ("applicationOAuthProviderId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_APPLICATION_ID" ON "core"."connectedAccount" ("applicationId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD CONSTRAINT "FK_344a905bc2041c998d5b57f9bde"
FOREIGN KEY ("applicationOAuthProviderId") REFERENCES "core"."applicationOAuthProvider"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_344a905bc2041c998d5b57f9bde"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APPLICATION_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
DROP COLUMN "visibility",
DROP COLUMN "name",
DROP COLUMN "applicationId",
DROP COLUMN "applicationOAuthProviderId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationOAuthProvider" DROP CONSTRAINT "FK_2d01320998547c2f5059d8b09d6"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationOAuthProvider" DROP CONSTRAINT "FK_c63de8b90514de1798876c30f2e"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_OAUTH_PROVIDER_APPLICATION_ID"`,
);
await queryRunner.query(`DROP TABLE "core"."applicationOAuthProvider"`);
}
}
@@ -4,6 +4,7 @@ import { ApplicationModule } from 'src/engine/core-modules/application/applicati
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationOAuthProviderModule } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
@@ -16,6 +17,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
ApplicationModule,
ApplicationOAuthProviderModule,
ApplicationVariableEntityModule,
FeatureFlagModule,
FileStorageModule,
@@ -12,6 +12,7 @@ import {
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
@@ -33,6 +34,7 @@ export class ApplicationSyncService {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationVariableService: ApplicationVariableEntityService,
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly applicationManifestMigrationService: ApplicationManifestMigrationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
@@ -153,6 +155,12 @@ export class ApplicationSyncService {
},
);
await this.applicationOAuthProviderService.upsertManyFromManifest({
connectionProviders: manifest.connectionProviders,
applicationId: application.id,
workspaceId,
});
const resolvedRegistrationId =
applicationRegistrationId ?? application.applicationRegistrationId;
@@ -0,0 +1,384 @@
// SecureHttpClientService transitively depends on `@lifeomic/axios-fetch`,
// which is an optional native-binding dep that's flaky in some test envs.
// We never use the real implementation here (the test always injects a
// mock via `useValue`), so stub the module to avoid loading the dep at all.
jest.mock(
'src/engine/core-modules/secure-http-client/secure-http-client.service',
() => ({
SecureHttpClientService: class {},
}),
);
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { type ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
describe('ApplicationOAuthProviderFlowService', () => {
let service: ApplicationOAuthProviderFlowService;
let oauthProviderService: {
findOneByIdOrThrow: jest.Mock;
getClientCredentials: jest.Mock;
};
let jwtWrapperService: {
sign: jest.Mock;
verifyJwtToken: jest.Mock;
generateAppSecret: jest.Mock;
};
let secureHttpClientService: { createSsrfSafeFetch: jest.Mock };
let connectedAccountRepository: {
count: jest.Mock;
update: jest.Mock;
create: jest.Mock;
save: jest.Mock;
findOne: jest.Mock;
findOneByOrFail: jest.Mock;
};
const baseProvider: ApplicationOAuthProviderEntity = {
id: 'provider-1',
universalIdentifier: 'provider-uid',
applicationId: 'app-1',
workspaceId: 'workspace-1',
name: 'linear',
displayName: 'Linear',
icon: null,
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: null,
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: null,
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as ApplicationOAuthProviderEntity;
beforeEach(async () => {
oauthProviderService = {
findOneByIdOrThrow: jest.fn(),
getClientCredentials: jest.fn(async () => ({
clientId: 'lin_client_id',
clientSecret: 'lin_client_secret',
})),
};
jwtWrapperService = {
sign: jest.fn(),
verifyJwtToken: jest.fn(),
generateAppSecret: jest.fn(() => 'derived-secret'),
};
secureHttpClientService = { createSsrfSafeFetch: jest.fn() };
connectedAccountRepository = {
count: jest.fn(async () => 0),
update: jest.fn(),
create: jest.fn((entity) => entity),
save: jest.fn(async (entity) => ({ ...entity, id: 'new-account-id' })),
findOne: jest.fn(async () => null),
findOneByOrFail: jest.fn(async ({ id }) => ({
id,
provider: ConnectedAccountProvider.APP,
})),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationOAuthProviderFlowService,
{
provide: ApplicationOAuthProviderService,
useValue: oauthProviderService,
},
{ provide: JwtWrapperService, useValue: jwtWrapperService },
{ provide: SecureHttpClientService, useValue: secureHttpClientService },
{
provide: TwentyConfigService,
useValue: { get: jest.fn(() => 'https://api.example.com') },
},
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: connectedAccountRepository,
},
],
}).compile();
service = module.get(ApplicationOAuthProviderFlowService);
});
afterEach(() => jest.clearAllMocks());
describe('startAuthorizationFlow', () => {
it('builds the provider authorization URL with the workspace + visibility context signed into state', async () => {
jwtWrapperService.sign.mockReturnValue('signed-state-token');
const { authorizationUrl } = await service.startAuthorizationFlow({
applicationOAuthProvider: baseProvider,
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
redirectLocation: null,
});
const url = new URL(authorizationUrl);
expect(url.origin + url.pathname).toBe(
'https://linear.app/oauth/authorize',
);
expect(url.searchParams.get('client_id')).toBe('lin_client_id');
expect(url.searchParams.get('response_type')).toBe('code');
// OAuth-standard `scope` (plural meaning) — these are the upstream
// permissions we're requesting, unrelated to the row-visibility field.
expect(url.searchParams.get('scope')).toBe('read write');
expect(url.searchParams.get('state')).toBe('signed-state-token');
expect(url.searchParams.get('redirect_uri')).toBe(
'https://api.example.com/apps/oauth/callback',
);
expect(url.searchParams.has('code_challenge')).toBe(false);
// signed payload carries workspace identity for the callback to use
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
workspaceId: 'workspace-1',
applicationOAuthProviderId: 'provider-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
}),
expect.objectContaining({ secret: 'derived-secret' }),
);
});
it('emits PKCE challenge params when usePkce is enabled', async () => {
jwtWrapperService.sign.mockReturnValue('signed-state');
const { authorizationUrl } = await service.startAuthorizationFlow({
applicationOAuthProvider: { ...baseProvider, usePkce: true },
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
redirectLocation: null,
});
const url = new URL(authorizationUrl);
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('code_challenge')).toMatch(/^[\w-]+$/);
});
describe('reconnect target validation', () => {
// Cross-workspace reconnect was a real bug: the persist UPDATE filtered
// by (id, workspaceId) so it wrote nothing, but the subsequent
// findOneByOrFail({ id }) returned the foreign-workspace row with stale
// tokens, making the reconnect look successful. Catch it at authorize
// time before the upstream OAuth round-trip.
const validateArgs = {
applicationOAuthProvider: baseProvider,
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user' as const,
redirectLocation: null,
};
it('throws FORBIDDEN when reconnecting an id that lives in another workspace', async () => {
connectedAccountRepository.findOne.mockResolvedValue(null);
const error = await service
.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'foreign-account-id',
})
.catch((caught) => caught);
expect(error).toMatchObject({
code: 'FORBIDDEN',
});
expect(error.message).toContain('foreign-account-id');
expect(connectedAccountRepository.findOne).toHaveBeenCalledWith({
where: {
id: 'foreign-account-id',
workspaceId: 'workspace-1',
applicationConnectionProviderId: 'provider-1',
},
});
// No state JWT signed, no upstream URL built.
expect(jwtWrapperService.sign).not.toHaveBeenCalled();
});
it('throws FORBIDDEN when reconnecting an id that belongs to a different provider in the same workspace', async () => {
// findOne with the provider filter returns null even though the row
// exists in this workspace under a different provider.
connectedAccountRepository.findOne.mockResolvedValue(null);
await expect(
service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'wrong-provider-account-id',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('proceeds when the reconnect target matches workspace and provider', async () => {
connectedAccountRepository.findOne.mockResolvedValue({
id: 'existing-account-id',
workspaceId: 'workspace-1',
applicationConnectionProviderId: 'provider-1',
});
jwtWrapperService.sign.mockReturnValue('state');
const { authorizationUrl } = await service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'existing-account-id',
});
expect(new URL(authorizationUrl).searchParams.get('state')).toBe(
'state',
);
expect(jwtWrapperService.sign).toHaveBeenCalled();
});
it('skips the lookup entirely when reconnectingConnectedAccountId is null', async () => {
jwtWrapperService.sign.mockReturnValue('state');
await service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: null,
});
expect(connectedAccountRepository.findOne).not.toHaveBeenCalled();
});
});
});
describe('completeAuthorizationFlow', () => {
const stateClaims = {
sub: 'provider-1',
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
applicationOAuthProviderId: 'provider-1',
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user' as const,
reconnectingConnectedAccountId: null,
redirectLocation: null,
codeVerifier: null,
};
const successfulTokenResponse = {
ok: true,
status: 200,
json: async () => ({
access_token: 'new_access',
refresh_token: 'new_refresh',
scope: 'read write',
}),
text: async () => '',
};
beforeEach(() => {
jwtWrapperService.verifyJwtToken.mockReturnValue(stateClaims);
oauthProviderService.findOneByIdOrThrow.mockResolvedValue(baseProvider);
secureHttpClientService.createSsrfSafeFetch.mockReturnValue(
jest.fn(async () => successfulTokenResponse),
);
});
it('always creates a new ConnectedAccount when no reconnect id is supplied', async () => {
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(result.connectedAccountId).toBe('new-account-id');
expect(result.workspaceId).toBe('workspace-1');
expect(result.applicationId).toBe('app-1');
expect(connectedAccountRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
provider: ConnectedAccountProvider.APP,
accessToken: 'new_access',
refreshToken: 'new_refresh',
applicationConnectionProviderId: 'provider-1',
applicationId: 'app-1',
workspaceId: 'workspace-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
}),
);
expect(connectedAccountRepository.save).toHaveBeenCalled();
expect(connectedAccountRepository.update).not.toHaveBeenCalled();
});
it('updates the existing ConnectedAccount when reconnectingConnectedAccountId is supplied', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
...stateClaims,
reconnectingConnectedAccountId: 'existing-account-id',
});
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(result.connectedAccountId).toBe('existing-account-id');
expect(connectedAccountRepository.update).toHaveBeenCalledWith(
{ id: 'existing-account-id', workspaceId: 'workspace-1' },
expect.objectContaining({
accessToken: 'new_access',
refreshToken: 'new_refresh',
authFailedAt: null,
}),
);
// Defense-in-depth: the post-update read MUST also be workspace-scoped,
// otherwise a foreign-id that slipped past the authorize-time guard
// would still surface stale fields from another workspace.
expect(connectedAccountRepository.findOneByOrFail).toHaveBeenCalledWith({
id: 'existing-account-id',
workspaceId: 'workspace-1',
});
expect(connectedAccountRepository.create).not.toHaveBeenCalled();
});
it('persists the workspace visibility when state asks for it', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
...stateClaims,
visibility: 'workspace',
});
await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(connectedAccountRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ visibility: 'workspace' }),
);
});
it('rejects an invalid state', async () => {
jwtWrapperService.verifyJwtToken.mockImplementation(() => {
throw new Error('JWT expired');
});
await expect(
service.completeAuthorizationFlow({
code: 'auth_code',
state: 'bad-state',
}),
).rejects.toThrow(/state/);
});
});
});
@@ -0,0 +1,142 @@
jest.mock(
'src/engine/core-modules/secret-encryption/secret-encryption.service',
() => ({
SecretEncryptionService: class {},
}),
);
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
const APP_ID = 'a8a8a8a8-a8a8-4a8a-a8a8-a8a8a8a8a8a8';
const WORKSPACE_ID = 'b8b8b8b8-b8b8-4b8b-b8b8-b8b8b8b8b8b8';
const buildOAuthManifest = (
overrides: Partial<ConnectionProviderManifest> = {},
): ConnectionProviderManifest =>
({
universalIdentifier: '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa',
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
...overrides,
}) as ConnectionProviderManifest;
describe('ApplicationOAuthProviderService', () => {
let service: ApplicationOAuthProviderService;
let oauthProviderRepository: {
find: jest.Mock;
save: jest.Mock;
delete: jest.Mock;
};
beforeEach(async () => {
oauthProviderRepository = {
find: jest.fn().mockResolvedValue([]),
save: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationOAuthProviderService,
{
provide: getRepositoryToken(ApplicationOAuthProviderEntity),
useValue: oauthProviderRepository,
},
{
provide: getRepositoryToken(ApplicationEntity),
useValue: { findOneBy: jest.fn() },
},
{
provide: getRepositoryToken(ApplicationRegistrationVariableEntity),
useValue: { find: jest.fn() },
},
{ provide: SecretEncryptionService, useValue: {} },
],
}).compile();
service = module.get(ApplicationOAuthProviderService);
});
afterEach(() => jest.clearAllMocks());
describe('upsertManyFromManifest', () => {
it('rejects a manifest whose connection provider has a non-UUID universalIdentifier', async () => {
const manifestWithBadId = buildOAuthManifest({
universalIdentifier: 'linear-provider',
});
const error = await service
.upsertManyFromManifest({
connectionProviders: [manifestWithBadId],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
})
.catch((caught) => caught);
expect(error).toBeInstanceOf(ApplicationOAuthProviderException);
expect(error.code).toBe(
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
expect(error.message).toContain('linear');
expect(error.message).toContain('linear-provider');
// Crucially: the failing validation must run before any DB write.
expect(oauthProviderRepository.save).not.toHaveBeenCalled();
expect(oauthProviderRepository.delete).not.toHaveBeenCalled();
});
it('points at the first invalid provider when multiple are wrong', async () => {
const error = await service
.upsertManyFromManifest({
connectionProviders: [
buildOAuthManifest({
name: 'first-bad',
universalIdentifier: 'not-a-uuid',
}),
buildOAuthManifest({
name: 'second-bad',
universalIdentifier: 'also-bad',
}),
],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
})
.catch((caught) => caught);
expect(error.message).toContain('first-bad');
});
it('accepts a valid UUID and persists the provider', async () => {
await service.upsertManyFromManifest({
connectionProviders: [buildOAuthManifest()],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
});
expect(oauthProviderRepository.save).toHaveBeenCalledWith([
expect.objectContaining({
universalIdentifier: '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa',
name: 'linear',
}),
]);
});
});
});
@@ -0,0 +1,50 @@
import { UseGuards } from '@nestjs/common';
import { Args, Query } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationConnectionProviderDTO } from 'src/engine/core-modules/application/application-oauth-provider/dtos/application-connection-provider.dto';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(WorkspaceAuthGuard)
@MetadataResolver(() => ApplicationConnectionProviderDTO)
export class ApplicationConnectionProviderResolver {
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
) {}
@Query(() => [ApplicationConnectionProviderDTO])
@UseGuards(NoPermissionGuard)
async applicationConnectionProviders(
@Args('applicationId', { type: () => UUIDScalarType })
applicationId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ApplicationConnectionProviderDTO[]> {
const providers = await this.oauthProviderService.findManyByApplication({
applicationId,
workspaceId: workspace.id,
});
const credentialsConfiguredByProviderId =
await this.oauthProviderService.areClientCredentialsConfiguredBatch(
providers,
);
return providers.map((provider) => ({
id: provider.id,
applicationId: provider.applicationId,
type: 'oauth',
name: provider.name,
displayName: provider.displayName,
oauth: {
scopes: provider.scopes,
isClientCredentialsConfigured:
credentialsConfiguredByProviderId.get(provider.id) ?? false,
},
}));
}
}
@@ -0,0 +1,9 @@
export enum ApplicationOAuthProviderExceptionCode {
PROVIDER_NOT_FOUND = 'PROVIDER_NOT_FOUND',
CLIENT_CREDENTIALS_NOT_CONFIGURED = 'CLIENT_CREDENTIALS_NOT_CONFIGURED',
TOKEN_EXCHANGE_FAILED = 'TOKEN_EXCHANGE_FAILED',
REFRESH_FAILED = 'REFRESH_FAILED',
INVALID_STATE = 'INVALID_STATE',
INVALID_REQUEST = 'INVALID_REQUEST',
FORBIDDEN = 'FORBIDDEN',
}
@@ -0,0 +1,309 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/application-oauth-provider/utils/build-callback-url.util';
import { computePkceChallenge } from 'src/engine/core-modules/application/application-oauth-provider/utils/compute-pkce-challenge.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util';
import { generatePkceVerifier } from 'src/engine/core-modules/application/application-oauth-provider/utils/generate-pkce-verifier.util';
import {
type AppOAuthStateJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
const STATE_JWT_EXPIRES_IN = '10m';
type AuthorizeArgs = {
applicationOAuthProvider: ApplicationOAuthProviderEntity;
workspaceId: string;
userId: string;
userWorkspaceId: string;
// Connection-row visibility: 'user' = private to userWorkspaceId,
// 'workspace' = shared with all members. Distinct from OAuth `scopes`
// on the row, which are the upstream-granted permissions.
visibility: 'user' | 'workspace';
reconnectingConnectedAccountId: string | null;
redirectLocation: string | null;
};
type CallbackArgs = {
code: string;
state: string;
};
type CallbackResult = {
connectedAccountId: string;
workspaceId: string;
applicationId: string;
redirectLocation: string | null;
};
@Injectable()
export class ApplicationOAuthProviderFlowService {
private readonly logger = new Logger(
ApplicationOAuthProviderFlowService.name,
);
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
) {}
async startAuthorizationFlow(
args: AuthorizeArgs,
): Promise<{ authorizationUrl: string }> {
const { applicationOAuthProvider, workspaceId, userId, userWorkspaceId } =
args;
// Reconnect can only target a row that lives in the requesting workspace
// *and* belongs to the same provider. Without this check, a caller could
// pass any connectedAccount id from any workspace; persist() filters its
// UPDATE by workspaceId so nothing would be written, but the subsequent
// findOneByOrFail (and the redirect URL we build from it) would happily
// surface stale fields from the foreign row. Fail fast at authorize time
// so the user sees the error before the upstream OAuth round-trip.
if (isDefined(args.reconnectingConnectedAccountId)) {
const target = await this.connectedAccountRepository.findOne({
where: {
id: args.reconnectingConnectedAccountId,
workspaceId,
applicationConnectionProviderId: applicationOAuthProvider.id,
},
});
if (!isDefined(target)) {
throw new ApplicationOAuthProviderException(
`Cannot reconnect connectedAccount ${args.reconnectingConnectedAccountId}: not found in this workspace for the requested provider.`,
ApplicationOAuthProviderExceptionCode.FORBIDDEN,
);
}
}
const { clientId } = await this.oauthProviderService.getClientCredentials(
applicationOAuthProvider,
);
const codeVerifier = applicationOAuthProvider.usePkce
? generatePkceVerifier()
: null;
const state = this.signState({
sub: applicationOAuthProvider.id,
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
applicationOAuthProviderId: applicationOAuthProvider.id,
workspaceId,
userId,
userWorkspaceId,
visibility: args.visibility,
reconnectingConnectedAccountId: args.reconnectingConnectedAccountId,
redirectLocation: args.redirectLocation,
codeVerifier,
});
const callbackUrl = buildAppOAuthCallbackUrl(this.getServerUrl());
const authorizationUrl = new URL(
applicationOAuthProvider.authorizationEndpoint,
);
authorizationUrl.searchParams.set('client_id', clientId);
authorizationUrl.searchParams.set('redirect_uri', callbackUrl);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set(
'scope',
applicationOAuthProvider.scopes.join(' '),
);
authorizationUrl.searchParams.set('state', state);
if (codeVerifier) {
authorizationUrl.searchParams.set(
'code_challenge',
computePkceChallenge(codeVerifier),
);
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
}
for (const [key, value] of Object.entries(
applicationOAuthProvider.authorizationParams ?? {},
)) {
authorizationUrl.searchParams.set(key, value);
}
return { authorizationUrl: authorizationUrl.toString() };
}
async completeAuthorizationFlow(args: CallbackArgs): Promise<CallbackResult> {
const statePayload = this.verifyState(args.state);
const provider = await this.oauthProviderService.findOneByIdOrThrow(
statePayload.applicationOAuthProviderId,
);
const { clientId, clientSecret } =
await this.oauthProviderService.getClientCredentials(provider);
const callbackUrl = buildAppOAuthCallbackUrl(this.getServerUrl());
let tokenResponse: TokenExchangeResponse;
try {
tokenResponse = await exchangeCodeForToken({
fetchFn: this.secureHttpClientService.createSsrfSafeFetch(),
tokenEndpoint: provider.tokenEndpoint,
clientId,
clientSecret,
code: args.code,
redirectUri: callbackUrl,
codeVerifier: statePayload.codeVerifier,
contentType: provider.tokenRequestContentType,
});
} catch (error) {
this.logger.error(
`OAuth token exchange failed for provider ${provider.id}: ${(error as Error).message}`,
);
throw new ApplicationOAuthProviderException(
(error as Error).message,
ApplicationOAuthProviderExceptionCode.TOKEN_EXCHANGE_FAILED,
);
}
const connectedAccount = await this.persistConnectedAccount({
provider,
tokenResponse,
workspaceId: statePayload.workspaceId,
userWorkspaceId: statePayload.userWorkspaceId,
visibility: statePayload.visibility,
reconnectingConnectedAccountId:
statePayload.reconnectingConnectedAccountId,
});
return {
connectedAccountId: connectedAccount.id,
workspaceId: statePayload.workspaceId,
applicationId: provider.applicationId,
redirectLocation: statePayload.redirectLocation,
};
}
private signState(payload: AppOAuthStateJwtPayload): string {
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.APP_OAUTH_STATE,
payload.workspaceId,
);
return this.jwtWrapperService.sign(payload, {
secret,
expiresIn: STATE_JWT_EXPIRES_IN,
});
}
private verifyState(state: string): AppOAuthStateJwtPayload {
try {
const verified = this.jwtWrapperService.verifyJwtToken(
state,
) as AppOAuthStateJwtPayload;
if (verified.type !== JwtTokenTypeEnum.APP_OAUTH_STATE) {
throw new Error('Wrong JWT type for OAuth state');
}
return verified;
} catch (error) {
this.logger.warn(
`Rejected OAuth state: ${(error as Error).message ?? 'unknown reason'}`,
);
throw new ApplicationOAuthProviderException(
'OAuth state signature invalid or expired',
ApplicationOAuthProviderExceptionCode.INVALID_STATE,
);
}
}
private getServerUrl(): string {
return this.twentyConfigService.get('SERVER_URL');
}
// Reconnect updates an existing row (preserves the id so logic-function
// bindings via id keep working). New connections always create — multiple
// credentials per (user, provider) are now allowed and intentional.
private async persistConnectedAccount({
provider,
tokenResponse,
workspaceId,
userWorkspaceId,
visibility,
reconnectingConnectedAccountId,
}: {
provider: ApplicationOAuthProviderEntity;
tokenResponse: TokenExchangeResponse;
workspaceId: string;
userWorkspaceId: string;
visibility: 'user' | 'workspace';
reconnectingConnectedAccountId: string | null;
}): Promise<ConnectedAccountEntity> {
const sharedFields = {
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
scopes: tokenResponse.scopes ?? provider.scopes,
lastCredentialsRefreshedAt: new Date(),
authFailedAt: null,
};
if (isDefined(reconnectingConnectedAccountId)) {
// Workspace-scope BOTH the update and the read — a foreign-id passed
// through here (the authorize-time guard should have caught it) would
// otherwise update zero rows but still return the foreign row from
// findOneByOrFail({ id }), making a silently-failed reconnect look
// successful.
await this.connectedAccountRepository.update(
{ id: reconnectingConnectedAccountId, workspaceId },
sharedFields,
);
return this.connectedAccountRepository.findOneByOrFail({
id: reconnectingConnectedAccountId,
workspaceId,
});
}
const existingCount = await this.connectedAccountRepository.count({
where: { applicationConnectionProviderId: provider.id, workspaceId },
});
// Auto-generated default — the user can rename from the app settings tab.
const name = `${provider.displayName} #${existingCount + 1}`;
const created = this.connectedAccountRepository.create({
...sharedFields,
handle: name,
name,
visibility,
provider: ConnectedAccountProvider.APP,
workspaceId,
applicationId: provider.applicationId,
applicationConnectionProviderId: provider.id,
userWorkspaceId,
});
return this.connectedAccountRepository.save(created);
}
}
@@ -0,0 +1,245 @@
import { Controller, Get, Logger, Query, Res, UseGuards } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type Response } from 'express';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('apps/oauth')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
export class ApplicationOAuthProviderController {
private readonly logger = new Logger(ApplicationOAuthProviderController.name);
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly oauthProviderFlowService: ApplicationOAuthProviderFlowService,
private readonly transientTokenService: TransientTokenService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly guardRedirectService: GuardRedirectService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
// Public endpoint — the transient token carries workspace + user context
// so we don't need a session cookie here.
@Get('authorize')
async authorize(
@Query('applicationId') applicationId: string,
@Query('providerName') providerName: string,
@Query('transientToken') transientToken: string,
@Query('visibility') visibility: string | undefined,
@Query('reconnectingConnectedAccountId')
reconnectingConnectedAccountId: string | undefined,
@Query('redirectLocation') redirectLocation: string | undefined,
@Res() res: Response,
) {
// Captured early so the error-redirect lands on the user's own
// subdomain (different cookie domain otherwise = de-facto logout).
let workspace: WorkspaceEntity | null = null;
try {
if (!applicationId || !providerName || !transientToken) {
throw new ApplicationOAuthProviderException(
'Missing required query parameters: applicationId, providerName, transientToken',
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
}
if (
visibility !== undefined &&
visibility !== 'user' &&
visibility !== 'workspace'
) {
throw new ApplicationOAuthProviderException(
`Invalid visibility "${visibility}" — must be 'user' or 'workspace'`,
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
}
const { userId, workspaceId } =
await this.transientTokenService.verifyTransientToken(transientToken);
if (!workspaceId || !userId) {
throw new AuthException(
'Workspace or user not found in transient token',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new AuthException(
`Workspace ${workspaceId} not found`,
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const provider =
await this.oauthProviderService.findOneByApplicationAndName({
applicationId,
name: providerName,
});
if (!provider) {
throw new ApplicationOAuthProviderException(
`OAuth provider "${providerName}" not found for application ${applicationId}`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
if (provider.workspaceId !== workspaceId) {
throw new ApplicationOAuthProviderException(
'OAuth provider does not belong to the requesting workspace',
ApplicationOAuthProviderExceptionCode.FORBIDDEN,
);
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { userId, workspaceId },
});
if (!isDefined(userWorkspace)) {
throw new AuthException(
`UserWorkspace not found for user ${userId} in workspace ${workspaceId}`,
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const { authorizationUrl } =
await this.oauthProviderFlowService.startAuthorizationFlow({
applicationOAuthProvider: provider,
workspaceId,
userId,
userWorkspaceId: userWorkspace.id,
visibility:
(visibility as 'user' | 'workspace' | undefined) ?? 'user',
reconnectingConnectedAccountId:
reconnectingConnectedAccountId ?? null,
redirectLocation: redirectLocation ?? null,
});
return res.redirect(authorizationUrl);
} catch (error) {
// Without an explicit log, CustomException would 500 silently
// (it doesn't extend HttpException, so Nest's default filter swallows it).
this.logger.error(
`OAuth authorize failed (applicationId=${applicationId}, providerName=${providerName}): ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
return this.redirectToError(res, error, workspace);
}
}
@Get('callback')
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Query('error') errorParam: string | undefined,
@Query('error_description') errorDescription: string | undefined,
@Res() res: Response,
) {
let workspace: WorkspaceEntity | null = null;
if (errorParam) {
return this.redirectToError(
res,
new Error(
`OAuth provider returned error: ${errorParam}${errorDescription ? `: ${errorDescription}` : ''}`,
),
workspace,
);
}
if (!code || !state) {
return this.redirectToError(
res,
new Error(
'OAuth callback is missing the `code` or `state` query parameter',
),
workspace,
);
}
try {
const { workspaceId, applicationId, redirectLocation } =
await this.oauthProviderFlowService.completeAuthorizationFlow({
code,
state,
});
workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new ApplicationOAuthProviderException(
`Workspace ${workspaceId} not found after OAuth callback`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
const pathname =
redirectLocation ||
getSettingsPath(SettingsPath.ApplicationDetail, { applicationId });
const url = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname,
});
// Frontend tab list reads the URL hash to pick the active tab.
if (!redirectLocation) {
url.hash = 'settings';
}
return res.redirect(url.toString());
} catch (error) {
return this.redirectToError(res, error, workspace);
}
}
private redirectToError(
res: Response,
error: unknown,
workspace: WorkspaceEntity | null,
) {
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error: error instanceof Error ? error : new Error(String(error)),
workspace: {
id: workspace?.id,
subdomain:
workspace?.subdomain ??
this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
customDomain: workspace?.customDomain ?? null,
},
pathname: getSettingsPath(SettingsPath.Accounts),
}),
);
}
}
@@ -0,0 +1,81 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'applicationOAuthProvider', schema: 'core' })
@Unique('IDX_APP_OAUTH_PROVIDER_NAME_APPLICATION_UNIQUE', [
'name',
'applicationId',
])
@Unique('IDX_APP_OAUTH_PROVIDER_UNIVERSAL_ID_APPLICATION_UNIQUE', [
'universalIdentifier',
'applicationId',
])
@Index('IDX_APP_OAUTH_PROVIDER_APPLICATION_ID', ['applicationId'])
@Index('IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID', ['workspaceId'])
export class ApplicationOAuthProviderEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Column({ nullable: false, type: 'uuid' })
applicationId: string;
@ManyToOne(() => ApplicationEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'applicationId' })
application: Relation<ApplicationEntity>;
@Column({ nullable: false, type: 'varchar' })
name: string;
@Column({ nullable: false, type: 'varchar' })
displayName: string;
@Column({ nullable: false, type: 'varchar' })
authorizationEndpoint: string;
@Column({ nullable: false, type: 'varchar' })
tokenEndpoint: string;
@Column({ nullable: true, type: 'varchar' })
revokeEndpoint: string | null;
@Column({ type: 'varchar', array: true, nullable: false, default: '{}' })
scopes: string[];
@Column({ nullable: false, type: 'varchar' })
clientIdVariable: string;
@Column({ nullable: false, type: 'varchar' })
clientSecretVariable: string;
@Column({ nullable: true, type: 'jsonb' })
authorizationParams: Record<string, string> | null;
@Column({ nullable: false, type: 'varchar', default: 'json' })
tokenRequestContentType: OAuthProviderTokenRequestContentType;
@Column({ nullable: false, type: 'boolean', default: true })
usePkce: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,43 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { CustomException } from 'src/utils/custom-exception';
const getApplicationOAuthProviderExceptionUserFriendlyMessage = (
code: ApplicationOAuthProviderExceptionCode,
) => {
switch (code) {
case ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND:
return msg`OAuth provider not found.`;
case ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED:
return msg`Client credentials are not configured for this OAuth provider.`;
case ApplicationOAuthProviderExceptionCode.TOKEN_EXCHANGE_FAILED:
return msg`Failed to exchange the authorization code for an access token.`;
case ApplicationOAuthProviderExceptionCode.REFRESH_FAILED:
return msg`Failed to refresh the access token.`;
case ApplicationOAuthProviderExceptionCode.INVALID_STATE:
return msg`The OAuth state parameter is invalid or expired.`;
case ApplicationOAuthProviderExceptionCode.INVALID_REQUEST:
return msg`The OAuth request is missing required parameters.`;
case ApplicationOAuthProviderExceptionCode.FORBIDDEN:
return msg`Not authorized to access this OAuth provider.`;
default:
assertUnreachable(code);
}
};
export class ApplicationOAuthProviderException extends CustomException<ApplicationOAuthProviderExceptionCode> {
constructor(
message: string,
code: ApplicationOAuthProviderExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getApplicationOAuthProviderExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationConnectionProviderResolver } from 'src/engine/core-modules/application/application-oauth-provider/application-connection-provider.resolver';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationOAuthProviderEntity,
ApplicationEntity,
ApplicationRegistrationVariableEntity,
ConnectedAccountEntity,
]),
JwtModule,
SecretEncryptionModule,
SecureHttpClientModule,
TwentyConfigModule,
],
providers: [
ApplicationOAuthProviderService,
ApplicationOAuthProviderFlowService,
ApplicationConnectionProviderResolver,
],
exports: [
ApplicationOAuthProviderService,
ApplicationOAuthProviderFlowService,
],
})
export class ApplicationOAuthProviderModule {}
@@ -0,0 +1,285 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isUUID } from 'class-validator';
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, Repository } from 'typeorm';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
export class ApplicationOAuthProviderService {
constructor(
@InjectRepository(ApplicationOAuthProviderEntity)
private readonly oauthProviderRepository: Repository<ApplicationOAuthProviderEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly registrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
// Stored on the registration (one OAuth app per Twenty server, set by
// the server admin) — not per-workspace.
async getClientCredentials(
provider: ApplicationOAuthProviderEntity,
): Promise<{ clientId: string; clientSecret: string }> {
const application = await this.applicationRepository.findOneBy({
id: provider.applicationId,
});
if (!isDefined(application?.applicationRegistrationId)) {
throw new ApplicationOAuthProviderException(
`Application ${provider.applicationId} has no registration; OAuth client credentials cannot be resolved`,
ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: application.applicationRegistrationId,
key: In([provider.clientIdVariable, provider.clientSecretVariable]),
},
});
const valuesByKey = new Map(
variables.map((v) => [
v.key,
v.encryptedValue
? this.secretEncryptionService.decrypt(v.encryptedValue)
: '',
]),
);
const clientId = valuesByKey.get(provider.clientIdVariable) ?? '';
const clientSecret = valuesByKey.get(provider.clientSecretVariable) ?? '';
if (!clientId || !clientSecret) {
throw new ApplicationOAuthProviderException(
`OAuth client credentials are not configured for provider "${provider.name}". The server administrator needs to fill in "${provider.clientIdVariable}" and "${provider.clientSecretVariable}" on the application registration.`,
ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
return { clientId, clientSecret };
}
// For batched calls (e.g. the resolver listing path) prefer
// `areClientCredentialsConfiguredBatch` to avoid N+1.
async areClientCredentialsConfigured(
provider: ApplicationOAuthProviderEntity,
): Promise<boolean> {
const result = await this.areClientCredentialsConfiguredBatch([provider]);
return result.get(provider.id) ?? false;
}
async areClientCredentialsConfiguredBatch(
providers: ApplicationOAuthProviderEntity[],
): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
if (providers.length === 0) {
return result;
}
const applicationIds = [...new Set(providers.map((p) => p.applicationId))];
const applications = await this.applicationRepository.find({
where: { id: In(applicationIds) },
});
const registrationIdByApplicationId = new Map(
applications.map((app) => [app.id, app.applicationRegistrationId]),
);
const registrationIds = [
...new Set(
applications
.map((app) => app.applicationRegistrationId)
.filter(isDefined),
),
];
if (registrationIds.length === 0) {
providers.forEach((p) => result.set(p.id, false));
return result;
}
const allKeys = providers.flatMap((p) => [
p.clientIdVariable,
p.clientSecretVariable,
]);
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: In(registrationIds),
key: In(allKeys),
},
});
const filledKeysByRegistrationId = new Map<string, Set<string>>();
for (const variable of variables) {
if (variable.encryptedValue === '') continue;
const set =
filledKeysByRegistrationId.get(variable.applicationRegistrationId) ??
new Set<string>();
set.add(variable.key);
filledKeysByRegistrationId.set(variable.applicationRegistrationId, set);
}
for (const provider of providers) {
const registrationId = registrationIdByApplicationId.get(
provider.applicationId,
);
if (!isDefined(registrationId)) {
result.set(provider.id, false);
continue;
}
const filled = filledKeysByRegistrationId.get(registrationId);
result.set(
provider.id,
filled?.has(provider.clientIdVariable) === true &&
filled?.has(provider.clientSecretVariable) === true,
);
}
return result;
}
async findOneByApplicationAndName({
applicationId,
name,
}: {
applicationId: string;
name: string;
}): Promise<ApplicationOAuthProviderEntity | null> {
return this.oauthProviderRepository.findOne({
where: { applicationId, name },
});
}
async findOneByIdOrThrow(
id: string,
): Promise<ApplicationOAuthProviderEntity> {
const provider = await this.oauthProviderRepository.findOne({
where: { id },
});
if (!isDefined(provider)) {
throw new ApplicationOAuthProviderException(
`OAuth provider with id "${id}" not found`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
return provider;
}
async findManyByApplication({
applicationId,
workspaceId,
}: {
applicationId: string;
workspaceId: string;
}): Promise<ApplicationOAuthProviderEntity[]> {
return this.oauthProviderRepository.find({
where: { applicationId, workspaceId },
});
}
// Persists OAuth-typed entries only. Other connection-provider types get
// their own sibling persistence helpers when added.
async upsertManyFromManifest({
connectionProviders,
applicationId,
workspaceId,
}: {
connectionProviders?: ConnectionProviderManifest[];
applicationId: string;
workspaceId: string;
}): Promise<void> {
const oauthProviders = (connectionProviders ?? []).filter(
(provider) => provider.type === 'oauth',
);
// The DB column is `uuid NOT NULL`. The manifest type is just `string`
// because manifests are dev-supplied and TS can't enforce UUID at the
// type level. Validate up-front so we throw a typed exception instead
// of letting Postgres reject the insert with an opaque type error.
for (const provider of oauthProviders) {
if (!isUUID(provider.universalIdentifier)) {
throw new ApplicationOAuthProviderException(
`Connection provider "${provider.name}" has an invalid universalIdentifier "${provider.universalIdentifier}" — must be a UUID.`,
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
}
}
const existing = await this.oauthProviderRepository.find({
where: { applicationId, workspaceId },
});
if (oauthProviders.length === 0 && existing.length === 0) {
return;
}
const existingByUniversalIdentifier = new Map(
existing.map((p) => [p.universalIdentifier, p]),
);
const toSave: Partial<ApplicationOAuthProviderEntity>[] =
oauthProviders.map((manifest) => {
const fields = {
applicationId,
workspaceId,
universalIdentifier: manifest.universalIdentifier,
name: manifest.name,
displayName: manifest.displayName,
authorizationEndpoint: manifest.oauth.authorizationEndpoint,
tokenEndpoint: manifest.oauth.tokenEndpoint,
revokeEndpoint: manifest.oauth.revokeEndpoint ?? null,
scopes: manifest.oauth.scopes,
clientIdVariable: manifest.oauth.clientIdVariable,
clientSecretVariable: manifest.oauth.clientSecretVariable,
authorizationParams: manifest.oauth.authorizationParams ?? null,
tokenRequestContentType:
manifest.oauth.tokenRequestContentType ?? 'json',
usePkce: manifest.oauth.usePkce ?? true,
};
const existingEntity = existingByUniversalIdentifier.get(
manifest.universalIdentifier,
);
return isDefined(existingEntity)
? { id: existingEntity.id, ...fields }
: fields;
});
if (toSave.length > 0) {
await this.oauthProviderRepository.save(toSave);
}
await this.oauthProviderRepository.delete(
oauthProviders.length > 0
? {
applicationId,
workspaceId,
universalIdentifier: Not(
In(oauthProviders.map((p) => p.universalIdentifier)),
),
}
: { applicationId, workspaceId },
);
}
}
@@ -0,0 +1,405 @@
// SecureHttpClientService transitively depends on `@lifeomic/axios-fetch`,
// which is an optional native-binding dep that's flaky in some test envs.
// The list service uses ConnectedAccountRefreshTokensService (which pulls in
// the SSRF-safe HTTP client), so stub the module to avoid loading the dep.
// We never use the real implementation here — the test always injects a mock.
jest.mock(
'src/engine/core-modules/secure-http-client/secure-http-client.service',
() => ({
SecureHttpClientService: class {},
}),
);
import { NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
const APP_ID = 'app-1';
const WORKSPACE_ID = 'workspace-1';
const REQUEST_USER_WORKSPACE_ID = 'uws-request';
const OTHER_USER_WORKSPACE_ID = 'uws-other';
const PROVIDER_ID = 'provider-1';
const buildProvider = (
overrides: Partial<ApplicationOAuthProviderEntity> = {},
): ApplicationOAuthProviderEntity =>
({
id: PROVIDER_ID,
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
name: 'linear',
displayName: 'Linear',
scopes: ['read', 'write'],
...overrides,
}) as unknown as ApplicationOAuthProviderEntity;
const buildAccount = (
overrides: Partial<ConnectedAccountEntity> = {},
): ConnectedAccountEntity =>
({
id: 'conn-1',
name: 'Linear #1',
handle: 'octocat@example.com',
visibility: 'user',
applicationId: APP_ID,
applicationConnectionProviderId: PROVIDER_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
provider: ConnectedAccountProvider.APP,
accessToken: 'enc',
refreshToken: 'enc',
// OAuth scopes granted by the upstream provider — distinct from the
// row-level `visibility` field above.
scopes: ['read', 'write'],
lastCredentialsRefreshedAt: new Date('2024-01-01T00:00:00Z'),
authFailedAt: null,
...overrides,
}) as unknown as ConnectedAccountEntity;
describe('ApplicationConnectionsListService', () => {
let service: ApplicationConnectionsListService;
let connectedAccountRepository: { find: jest.Mock; findOne: jest.Mock };
let oauthProviderRepository: {
find: jest.Mock;
findOneByOrFail: jest.Mock;
};
let refreshTokensService: { refreshAndSaveTokens: jest.Mock };
beforeEach(async () => {
connectedAccountRepository = { find: jest.fn(), findOne: jest.fn() };
oauthProviderRepository = {
find: jest.fn().mockResolvedValue([buildProvider()]),
findOneByOrFail: jest.fn().mockResolvedValue(buildProvider()),
};
refreshTokensService = {
refreshAndSaveTokens: jest.fn(async () => ({
accessToken: 'fresh-access',
refreshToken: 'fresh-refresh',
})),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationConnectionsListService,
{
provide: ConnectedAccountRefreshTokensService,
useValue: refreshTokensService,
},
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: connectedAccountRepository,
},
{
provide: getRepositoryToken(ApplicationOAuthProviderEntity),
useValue: oauthProviderRepository,
},
],
}).compile();
service = module.get(ApplicationConnectionsListService);
});
afterEach(() => jest.clearAllMocks());
describe('list', () => {
it('asks SQL to OR (visibility = workspace) with (visibility = user AND userWorkspaceId = me) when there is a request user', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'mine' }),
buildAccount({
id: 'shared',
visibility: 'workspace',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result.map((c) => c.id).sort()).toEqual(['mine', 'shared']);
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: [
expect.objectContaining({ visibility: 'workspace' }),
expect.objectContaining({
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
}),
],
});
});
it('skips the privacy OR clause when no request user is provided (cron)', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'mine' }),
buildAccount({
id: 'theirs',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
filter: {},
});
expect(result.map((c) => c.id).sort()).toEqual(['mine', 'theirs']);
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.not.objectContaining({ visibility: expect.anything() }),
});
});
it('respects filter.visibility=user under request-user privacy (regression)', async () => {
// Bug guard: an earlier version OR'd { visibility: 'workspace' } into
// the privacy where regardless of the caller's filter, so requesting
// user-visibility only would silently leak workspace-shared rows back.
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { visibility: 'user' },
});
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.objectContaining({
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
}),
});
// Specifically not the OR shape — single AND object.
const passed = connectedAccountRepository.find.mock.calls[0][0];
expect(Array.isArray(passed.where)).toBe(false);
});
it('respects filter.visibility=workspace under request-user privacy', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { visibility: 'workspace' },
});
const passed = connectedAccountRepository.find.mock.calls[0][0];
expect(passed.where).toEqual(
expect.objectContaining({ visibility: 'workspace' }),
);
expect(passed.where).not.toHaveProperty('userWorkspaceId');
expect(Array.isArray(passed.where)).toBe(false);
});
it('passes filter.visibility through unchanged in cron context', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
filter: { visibility: 'user' },
});
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.objectContaining({ visibility: 'user' }),
});
});
it('returns empty list when filter.providerName matches no provider for this app', async () => {
oauthProviderRepository.find.mockResolvedValue([]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { providerName: 'unknown-provider' },
});
expect(result).toEqual([]);
expect(connectedAccountRepository.find).not.toHaveBeenCalled();
});
it('refreshes the access token before returning', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(refreshTokensService.refreshAndSaveTokens).toHaveBeenCalledTimes(
1,
);
expect(result[0].accessToken).toBe('fresh-access');
});
it('exposes provider name and other public fields in the DTO', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
const [connection] = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(connection).toEqual({
id: 'conn-1',
providerName: 'linear',
name: 'Linear #1',
handle: 'octocat@example.com',
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
accessToken: 'fresh-access',
scopes: ['read', 'write'],
authFailedAt: null,
});
});
it('falls back to handle when name is null', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ name: null }),
]);
const [connection] = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(connection.name).toBe('octocat@example.com');
});
it('skips a connection when the refresh fails', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'good' }),
buildAccount({ id: 'broken' }),
]);
refreshTokensService.refreshAndSaveTokens
.mockResolvedValueOnce({ accessToken: 'fresh', refreshToken: 'r' })
.mockRejectedValueOnce(new Error('refresh failed'));
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result.map((c) => c.id)).toEqual(['good']);
});
it('skips a connection whose provider was deleted (orphan)', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({
id: 'orphan',
applicationConnectionProviderId: 'gone-provider',
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result).toEqual([]);
});
});
describe('getOne', () => {
it('returns the connection when the request user owns it', async () => {
connectedAccountRepository.findOne.mockResolvedValue(buildAccount());
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
});
expect(result.id).toBe('conn-1');
expect(result.providerName).toBe('linear');
expect(result.accessToken).toBe('fresh-access');
});
it('returns the connection when visibility is workspace, regardless of owner', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({
visibility: 'workspace',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
);
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
});
expect(result.id).toBe('conn-1');
});
it('throws NotFound when the connection does not exist', async () => {
connectedAccountRepository.findOne.mockResolvedValue(null);
await expect(
service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'missing',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('throws NotFound when a request user asks for another user-visibility connection', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({ userWorkspaceId: OTHER_USER_WORKSPACE_ID }),
);
await expect(
service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns another user-visibility connection in cron context (no request user)', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({ userWorkspaceId: OTHER_USER_WORKSPACE_ID }),
);
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
id: 'conn-1',
});
expect(result.userWorkspaceId).toBe(OTHER_USER_WORKSPACE_ID);
});
});
});
@@ -0,0 +1,87 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
Req,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { type AppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto';
import { GetAppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/get-app-connection.dto';
import { ListAppConnectionsDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/list-app-connections.dto';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
// On-demand connection lookup for app logic functions. Authenticated via the
// application access token (already injected into the function runtime as
// TWENTY_APP_ACCESS_TOKEN). Apps can only list their own connections.
@Controller('apps/connections')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
export class ApplicationConnectionsController {
constructor(
private readonly listService: ApplicationConnectionsListService,
) {}
@Post('list')
@HttpCode(HttpStatus.OK)
async list(
@Req() request: Request,
@Body() filter: ListAppConnectionsDto,
): Promise<AppConnectionDto[]> {
const { applicationId, workspaceId, requestUserWorkspaceId } =
this.requireAppContext(request);
return this.listService.list({
applicationId,
workspaceId,
requestUserWorkspaceId,
filter,
});
}
@Post('get')
@HttpCode(HttpStatus.OK)
async get(
@Req() request: Request,
@Body() body: GetAppConnectionDto,
): Promise<AppConnectionDto> {
const { applicationId, workspaceId, requestUserWorkspaceId } =
this.requireAppContext(request);
return this.listService.getOne({
applicationId,
workspaceId,
requestUserWorkspaceId,
id: body.id,
});
}
private requireAppContext(request: Request): {
applicationId: string;
workspaceId: string;
requestUserWorkspaceId: string | null;
} {
if (!isDefined(request.application) || !isDefined(request.workspace)) {
throw new ForbiddenException(
'This endpoint requires an APPLICATION_ACCESS token.',
);
}
return {
applicationId: request.application.id,
workspaceId: request.workspace.id,
requestUserWorkspaceId: request.userWorkspaceId ?? null,
};
}
}
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationConnectionsController } from 'src/engine/core-modules/application/application-oauth-provider/connections/application-connections.controller';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { RefreshTokensManagerModule } from 'src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module';
// Top-level consumer: depends on RefreshTokensManagerModule (which itself
// imports the engine-side AppOAuthRefreshModule). Kept separate from
// ApplicationOAuthProviderModule to avoid the import cycle. TokenModule +
// WorkspaceCacheStorageModule are pulled in for the controller's JwtAuthGuard.
@Module({
imports: [
TypeOrmModule.forFeature([
ConnectedAccountEntity,
ApplicationOAuthProviderEntity,
]),
TokenModule,
WorkspaceCacheStorageModule,
RefreshTokensManagerModule,
],
providers: [ApplicationConnectionsListService],
controllers: [ApplicationConnectionsController],
exports: [ApplicationConnectionsListService],
})
export class ApplicationConnectionsModule {}
@@ -0,0 +1,7 @@
import { type AppConnection } from 'twenty-shared/application';
// Wire shape returned to apps from POST /apps/connections/list and /get.
// Re-exported under the `…Dto` suffix to follow the server-side naming
// convention; the canonical shape lives in twenty-shared so the SDK and
// the server stay in lock-step (changes that drift would fail typecheck).
export type AppConnectionDto = AppConnection;
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class GetAppConnectionDto {
@IsUUID()
id: string;
}
@@ -0,0 +1,18 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class ListAppConnectionsDto {
@IsString()
@IsOptional()
providerName?: string;
// Optional UUID filter — when set, only credentials owned by this user are
// returned. The privacy filter on the server still applies on top of this.
@IsUUID()
@IsOptional()
userWorkspaceId?: string;
// Optional visibility filter — narrow to 'user' or 'workspace' only.
@IsString()
@IsOptional()
visibility?: 'user' | 'workspace';
}
@@ -0,0 +1,249 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type FindOptionsWhere, In, Repository } from 'typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { type AppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
type ListArgs = {
applicationId: string;
workspaceId: string;
// The userWorkspaceId of the request initiator, when known. null for
// cron / database-event triggers (the app is trusted to use its own
// criteria when picking among workspace credentials).
requestUserWorkspaceId: string | null;
filter: {
providerName?: string;
userWorkspaceId?: string;
visibility?: 'user' | 'workspace';
};
};
type GetArgs = {
applicationId: string;
workspaceId: string;
requestUserWorkspaceId: string | null;
id: string;
};
@Injectable()
export class ApplicationConnectionsListService {
private readonly logger = new Logger(ApplicationConnectionsListService.name);
constructor(
private readonly refreshTokensService: ConnectedAccountRefreshTokensService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(ApplicationOAuthProviderEntity)
private readonly oauthProviderRepository: Repository<ApplicationOAuthProviderEntity>,
) {}
async list({
applicationId,
workspaceId,
requestUserWorkspaceId,
filter,
}: ListArgs): Promise<AppConnectionDto[]> {
const providers = await this.oauthProviderRepository.find({
where: { applicationId, workspaceId },
});
const providerById = new Map(providers.map((p) => [p.id, p]));
let providerIds: string[] | undefined;
if (isDefined(filter.providerName)) {
const matching = providers.find((p) => p.name === filter.providerName);
if (!matching) {
return [];
}
providerIds = [matching.id];
}
const baseWhere: FindOptionsWhere<ConnectedAccountEntity> = {
applicationId,
workspaceId,
provider: ConnectedAccountProvider.APP,
...(isDefined(providerIds)
? { applicationConnectionProviderId: In(providerIds) }
: {}),
...(isDefined(filter.userWorkspaceId)
? { userWorkspaceId: filter.userWorkspaceId }
: {}),
};
const accounts = await this.connectedAccountRepository.find({
where: this.buildPrivacyWhere(
baseWhere,
requestUserWorkspaceId,
filter.visibility,
),
});
const refreshed = await Promise.all(
accounts.map((account) =>
this.refreshAndMap(account, workspaceId, providerById),
),
);
return refreshed.filter(isDefined);
}
async getOne({
applicationId,
workspaceId,
requestUserWorkspaceId,
id,
}: GetArgs): Promise<AppConnectionDto> {
const account = await this.connectedAccountRepository.findOne({
where: {
id,
applicationId,
workspaceId,
provider: ConnectedAccountProvider.APP,
},
});
if (!isDefined(account)) {
throw new NotFoundException(`Connection ${id} not found`);
}
// Same privacy rule as list(): a request-user can only see their own
// user-visibility credentials. Workspace-shared ones are visible to
// anyone in the workspace. Cron has no request user — sees all.
if (
isDefined(requestUserWorkspaceId) &&
account.visibility === 'user' &&
account.userWorkspaceId !== requestUserWorkspaceId
) {
throw new NotFoundException(`Connection ${id} not found`);
}
if (!isDefined(account.applicationConnectionProviderId)) {
throw new NotFoundException(`Connection ${id} has no provider`);
}
const provider = await this.oauthProviderRepository.findOneByOrFail({
id: account.applicationConnectionProviderId,
workspaceId,
});
const dto = await this.refreshAndMap(
account,
workspaceId,
new Map([[provider.id, provider]]),
);
if (!isDefined(dto)) {
throw new NotFoundException(
`Connection ${id} could not be refreshed; ask the user to reconnect`,
);
}
return dto;
}
// Composes the caller's `visibility` filter with the per-request privacy
// rule. Always returns a TypeORM where (single object = AND, array = OR)
// so the caller doesn't have to branch.
//
// The earlier inline version OR'd `{ ...baseWhere, visibility: 'workspace' }`
// with `{ ...baseWhere, userWorkspaceId: me }` regardless of caller intent,
// which silently overrode an explicit `filter.visibility: 'user'` (the
// first OR branch always returned workspace-shared rows).
private buildPrivacyWhere(
baseWhere: FindOptionsWhere<ConnectedAccountEntity>,
requestUserWorkspaceId: string | null,
visibilityFilter: 'user' | 'workspace' | undefined,
):
| FindOptionsWhere<ConnectedAccountEntity>
| FindOptionsWhere<ConnectedAccountEntity>[] {
// Cron / DB-event triggers carry no user — the app is trusted to use
// its own criteria, so honour the visibility filter as-is.
if (!isDefined(requestUserWorkspaceId)) {
return isDefined(visibilityFilter)
? { ...baseWhere, visibility: visibilityFilter }
: baseWhere;
}
// Caller asked for user-visibility only → must be theirs.
if (visibilityFilter === 'user') {
return {
...baseWhere,
visibility: 'user',
userWorkspaceId: requestUserWorkspaceId,
};
}
// Caller asked for workspace-shared only → no per-user restriction
// (workspace-shared credentials are visible to everyone in the workspace).
if (visibilityFilter === 'workspace') {
return { ...baseWhere, visibility: 'workspace' };
}
// No visibility filter → return both: every workspace-shared row, plus
// the request user's own user-visibility rows.
return [
{ ...baseWhere, visibility: 'workspace' },
{
...baseWhere,
visibility: 'user',
userWorkspaceId: requestUserWorkspaceId,
},
];
}
private async refreshAndMap(
account: ConnectedAccountEntity,
workspaceId: string,
providerById: Map<string, ApplicationOAuthProviderEntity>,
): Promise<AppConnectionDto | null> {
const provider = isDefined(account.applicationConnectionProviderId)
? providerById.get(account.applicationConnectionProviderId)
: undefined;
// Connections without a resolvable provider can't be refreshed and the
// app has no way to use them — drop them from the response so the dev
// doesn't see ghost rows. The upstream cleanup happens via the FK
// ON DELETE CASCADE when the provider is removed.
if (!isDefined(provider)) {
this.logger.warn(
`Connection ${account.id} references missing provider ${account.applicationConnectionProviderId}`,
);
return null;
}
try {
const tokens = await this.refreshTokensService.refreshAndSaveTokens(
account,
workspaceId,
);
return {
id: account.id,
providerName: provider.name,
name: account.name ?? account.handle,
handle: account.handle,
visibility: account.visibility as 'user' | 'workspace',
userWorkspaceId: account.userWorkspaceId,
accessToken: tokens.accessToken,
scopes: account.scopes ?? provider.scopes,
authFailedAt: account.authFailedAt?.toISOString() ?? null,
};
} catch (error) {
this.logger.warn(
`Failed to refresh tokens for connection ${account.id}: ${(error as Error).message}`,
);
return null;
}
}
}
@@ -0,0 +1,42 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { type ConnectionProviderType } from 'twenty-shared/application';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ApplicationConnectionProviderOAuthConfig')
export class ApplicationConnectionProviderOAuthConfigDTO {
@Field(() => [String])
scopes: string[];
// false when the server admin hasn't filled in the OAuth client_id /
// client_secret on the application registration. The frontend uses it to
// disable "Add connection" and surface a "needs server admin" hint.
@Field()
isClientCredentialsConfigured: boolean;
}
@ObjectType('ApplicationConnectionProvider')
export class ApplicationConnectionProviderDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field()
applicationId: string;
// Explicit String type because @nestjs/graphql can't infer GraphQL types
// from TS string unions. The TS type is the source of truth for the union.
@Field(() => String)
type: ConnectionProviderType;
@Field()
name: string;
@Field()
displayName: string;
@Field(() => ApplicationConnectionProviderOAuthConfigDTO, { nullable: true })
oauth: ApplicationConnectionProviderOAuthConfigDTO | null;
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { ApplicationOAuthProviderModule } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
@Module({
imports: [
ApplicationOAuthProviderModule,
ApplicationVariableEntityModule,
SecureHttpClientModule,
],
providers: [AppOAuthRefreshAccessTokenService, AppOAuthRevokeService],
exports: [AppOAuthRefreshAccessTokenService, AppOAuthRevokeService],
})
export class AppOAuthRefreshModule {}
@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util';
import { OAuthTokenEndpointError } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
ConnectedAccountRefreshAccessTokenException,
ConnectedAccountRefreshAccessTokenExceptionCode,
} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception';
@Injectable()
export class AppOAuthRefreshAccessTokenService {
private readonly logger = new Logger(AppOAuthRefreshAccessTokenService.name);
constructor(
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
async refreshTokens(
connectedAccount: ConnectedAccountEntity,
refreshToken: string,
): Promise<ConnectedAccountTokens> {
if (!isDefined(connectedAccount.applicationConnectionProviderId)) {
throw new ConnectedAccountRefreshAccessTokenException(
`Connected account ${connectedAccount.id} has no applicationConnectionProviderId`,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
let provider, clientId, clientSecret;
try {
provider = await this.applicationOAuthProviderService.findOneByIdOrThrow(
connectedAccount.applicationConnectionProviderId,
);
({ clientId, clientSecret } =
await this.applicationOAuthProviderService.getClientCredentials(
provider,
));
} catch (error) {
// Provider lookup or credential resolution failed (provider deleted,
// server admin hasn't filled in client_id/secret). Translate so callers
// see one exception class regardless of provider.
if (error instanceof ApplicationOAuthProviderException) {
throw new ConnectedAccountRefreshAccessTokenException(
error.message,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
throw error;
}
try {
const tokenResponse = await exchangeRefreshTokenForToken({
fetchFn: this.secureHttpClientService.createSsrfSafeFetch(),
tokenEndpoint: provider.tokenEndpoint,
clientId,
clientSecret,
refreshToken,
contentType: provider.tokenRequestContentType,
});
return {
accessToken: tokenResponse.accessToken,
// Some providers (e.g. Google) keep the refresh token stable across
// refreshes; others rotate. Fall back to the original when the
// response omits one.
refreshToken: tokenResponse.refreshToken ?? refreshToken,
};
} catch (error) {
this.logger.warn(
`App OAuth refresh failed for connected account ${connectedAccount.id}: ${(error as Error).message}`,
);
// 5xx and network/transport errors are transient — don't mark the
// credential as permanently invalid. Only 4xx responses from the
// token endpoint (esp. invalid_grant) imply the user must reconnect.
const isTransient =
!(error instanceof OAuthTokenEndpointError) || error.status >= 500;
throw new ConnectedAccountRefreshAccessTokenException(
`App OAuth refresh failed: ${(error as Error).message}`,
isTransient
? ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR
: ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,
);
}
}
}
@@ -0,0 +1,71 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Injectable()
export class AppOAuthRevokeService {
private readonly logger = new Logger(AppOAuthRevokeService.name);
constructor(
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
// Best-effort revoke against the provider's `revokeEndpoint` if declared
// in the manifest. Failures are swallowed (logged as warnings) so a
// disconnect always succeeds locally even when the provider is down or
// doesn't support revocation. RFC 7009 form-urlencoded body is the
// de-facto standard.
async revokeIfApp(connectedAccount: ConnectedAccountEntity): Promise<void> {
if (
!isDefined(connectedAccount.applicationConnectionProviderId) ||
!isDefined(connectedAccount.accessToken)
) {
return;
}
let provider;
try {
provider = await this.applicationOAuthProviderService.findOneByIdOrThrow(
connectedAccount.applicationConnectionProviderId,
);
} catch {
return;
}
if (!provider.revokeEndpoint) {
return;
}
try {
const response = await this.secureHttpClientService.createSsrfSafeFetch()(
provider.revokeEndpoint,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
token: connectedAccount.accessToken,
token_type_hint: 'access_token',
}).toString(),
},
);
if (!response.ok) {
this.logger.warn(
`Provider ${provider.id} revoke endpoint responded with ${response.status} for connected account ${connectedAccount.id}`,
);
}
} catch (error) {
this.logger.warn(
`Provider revoke call failed for connected account ${connectedAccount.id}: ${(error as Error).message}`,
);
}
}
}
@@ -0,0 +1,5 @@
export type TokenExchangeResponse = {
accessToken: string;
refreshToken: string | null;
scopes: string[] | null;
};
@@ -0,0 +1,167 @@
import { exchangeCodeForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util';
const buildResponse = (
json: unknown,
options: { ok?: boolean; status?: number } = {},
): Response =>
({
ok: options.ok ?? true,
status: options.status ?? 200,
json: async () => json,
text: async () => JSON.stringify(json),
}) as Response;
const baseExchangeArgs = {
tokenEndpoint: 'https://example.com/token',
contentType: 'form-urlencoded' as const,
clientId: 'cid',
clientSecret: 'csec',
code: 'c',
redirectUri: 'https://example.com/cb',
codeVerifier: null,
};
describe('exchangeCodeForToken', () => {
it('POSTs form-urlencoded with the OAuth2 standard fields and parses the response', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({
access_token: 'lin_access',
refresh_token: 'lin_refresh',
expires_in: 315360000,
scope: 'read write',
}),
);
const result = await exchangeCodeForToken({
...baseExchangeArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
codeVerifier: 'verifier_123',
});
expect(result).toEqual({
accessToken: 'lin_access',
refreshToken: 'lin_refresh',
scopes: ['read', 'write'],
});
const init = (
fetchFn.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string>; body: string },
]
)[1];
expect(init.headers['Content-Type']).toBe(
'application/x-www-form-urlencoded',
);
const params = new URLSearchParams(init.body);
expect(params.get('grant_type')).toBe('authorization_code');
expect(params.get('code')).toBe('c');
expect(params.get('client_id')).toBe('cid');
expect(params.get('client_secret')).toBe('csec');
expect(params.get('code_verifier')).toBe('verifier_123');
});
it('POSTs JSON when contentType is json', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({ access_token: 'a', refresh_token: 'r' }),
);
await exchangeCodeForToken({
...baseExchangeArgs,
contentType: 'json',
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
});
const init = (
fetchFn.mock.calls[0] as unknown as [
string,
{ headers: Record<string, string>; body: string },
]
)[1];
expect(init.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(init.body)).toMatchObject({
grant_type: 'authorization_code',
code: 'c',
});
});
it('throws on non-2xx response', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({ error: 'invalid_grant' }, { ok: false, status: 400 }),
);
await expect(
exchangeCodeForToken({
...baseExchangeArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow(/400/);
});
it('throws when 200 response is missing access_token', async () => {
const fetchFn = jest.fn(async () => buildResponse({ refresh_token: 'r' }));
await expect(
exchangeCodeForToken({
...baseExchangeArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
}),
).rejects.toThrow(/access_token/);
});
});
describe('exchangeRefreshTokenForToken', () => {
const baseRefreshArgs = {
tokenEndpoint: 'https://example.com/token',
contentType: 'form-urlencoded' as const,
clientId: 'cid',
clientSecret: 'csec',
refreshToken: 'old_refresh',
};
it('uses grant_type=refresh_token and returns the rotated tokens', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({
access_token: 'new_access',
refresh_token: 'new_refresh',
}),
);
const result = await exchangeRefreshTokenForToken({
...baseRefreshArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
});
expect(result).toEqual({
accessToken: 'new_access',
refreshToken: 'new_refresh',
scopes: null,
});
const init = (
fetchFn.mock.calls[0] as unknown as [string, { body: string }]
)[1];
const params = new URLSearchParams(init.body);
expect(params.get('grant_type')).toBe('refresh_token');
expect(params.get('refresh_token')).toBe('old_refresh');
});
it('returns refreshToken=null when the provider omits it (caller applies fallback)', async () => {
const fetchFn = jest.fn(async () =>
buildResponse({ access_token: 'new_access' }),
);
const result = await exchangeRefreshTokenForToken({
...baseRefreshArgs,
fetchFn: fetchFn as unknown as typeof globalThis.fetch,
});
expect(result.refreshToken).toBeNull();
});
});
@@ -0,0 +1,5 @@
// Workspace-agnostic by design: the workspace identity travels in the
// signed `state` parameter, so a single redirect URL configured at the
// OAuth provider serves every workspace.
export const buildAppOAuthCallbackUrl = (serverUrl: string): string =>
new URL('/apps/oauth/callback', serverUrl).toString();
@@ -0,0 +1,6 @@
import { createHash } from 'crypto';
import { base64UrlEncode } from 'twenty-shared/utils';
export const computePkceChallenge = (verifier: string): string =>
base64UrlEncode(createHash('sha256').update(verifier).digest());
@@ -0,0 +1,15 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
export const encodeOAuthBody = (
contentType: OAuthProviderTokenRequestContentType,
params: Record<string, string>,
): { body: string; contentTypeHeader: string } =>
contentType === 'json'
? {
body: JSON.stringify(params),
contentTypeHeader: 'application/json',
}
: {
body: new URLSearchParams(params).toString(),
contentTypeHeader: 'application/x-www-form-urlencoded',
};
@@ -0,0 +1,36 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
type FetchFn = typeof globalThis.fetch;
export const exchangeCodeForToken = (args: {
fetchFn: FetchFn;
tokenEndpoint: string;
contentType: OAuthProviderTokenRequestContentType;
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
codeVerifier: string | null;
}): Promise<TokenExchangeResponse> => {
const params: Record<string, string> = {
grant_type: 'authorization_code',
code: args.code,
redirect_uri: args.redirectUri,
client_id: args.clientId,
client_secret: args.clientSecret,
};
if (args.codeVerifier) {
params.code_verifier = args.codeVerifier;
}
return postOAuthTokenRequest({
fetchFn: args.fetchFn,
tokenEndpoint: args.tokenEndpoint,
contentType: args.contentType,
params,
});
};
@@ -0,0 +1,26 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
type FetchFn = typeof globalThis.fetch;
export const exchangeRefreshTokenForToken = (args: {
fetchFn: FetchFn;
tokenEndpoint: string;
contentType: OAuthProviderTokenRequestContentType;
clientId: string;
clientSecret: string;
refreshToken: string;
}): Promise<TokenExchangeResponse> =>
postOAuthTokenRequest({
fetchFn: args.fetchFn,
tokenEndpoint: args.tokenEndpoint,
contentType: args.contentType,
params: {
grant_type: 'refresh_token',
refresh_token: args.refreshToken,
client_id: args.clientId,
client_secret: args.clientSecret,
},
});
@@ -0,0 +1,6 @@
import { randomBytes } from 'crypto';
import { base64UrlEncode } from 'twenty-shared/utils';
export const generatePkceVerifier = (): string =>
base64UrlEncode(randomBytes(32));
@@ -0,0 +1,24 @@
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
export const parseTokenResponse = (
json: Record<string, unknown>,
): TokenExchangeResponse => {
const accessToken =
typeof json.access_token === 'string' ? json.access_token : null;
if (!accessToken) {
throw new Error(
`Token endpoint did not return an access_token. Response keys: ${Object.keys(json).join(', ')}`,
);
}
return {
accessToken,
refreshToken:
typeof json.refresh_token === 'string' ? json.refresh_token : null,
scopes:
typeof json.scope === 'string'
? json.scope.split(/[\s,]+/).filter(Boolean)
: null,
};
};
@@ -0,0 +1,53 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { encodeOAuthBody } from 'src/engine/core-modules/application/application-oauth-provider/utils/encode-oauth-body.util';
import { parseTokenResponse } from 'src/engine/core-modules/application/application-oauth-provider/utils/parse-token-response.util';
type FetchFn = typeof globalThis.fetch;
// Carries the HTTP status alongside the message so callers can distinguish
// transient (5xx, network) from permanent (4xx) failures.
export class OAuthTokenEndpointError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'OAuthTokenEndpointError';
}
}
export const postOAuthTokenRequest = async (args: {
fetchFn: FetchFn;
tokenEndpoint: string;
contentType: OAuthProviderTokenRequestContentType;
params: Record<string, string>;
}): Promise<TokenExchangeResponse> => {
const { body, contentTypeHeader } = encodeOAuthBody(
args.contentType,
args.params,
);
const response = await args.fetchFn(args.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': contentTypeHeader,
// Many providers (notably GitHub) default to URL-encoded responses
// unless we explicitly ask for JSON.
Accept: 'application/json',
},
body,
});
if (!response.ok) {
const text = await response.text();
throw new OAuthTokenEndpointError(
`Token endpoint responded with ${response.status}: ${text.slice(0, 500)}`,
response.status,
);
}
return parseTokenResponse((await response.json()) as Record<string, unknown>);
};

Some files were not shown because too many files have changed in this diff Show More