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:
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
+22
@@ -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';
|
||||
+126
@@ -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' });
|
||||
});
|
||||
});
|
||||
+56
@@ -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' });
|
||||
});
|
||||
});
|
||||
+48
@@ -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;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export const ISSUE_CREATE_MUTATION = `
|
||||
mutation IssueCreate($input: IssueCreateInput!) {
|
||||
issueCreate(input: $input) {
|
||||
success
|
||||
issue { id identifier title url }
|
||||
}
|
||||
}
|
||||
`;
|
||||
+33
@@ -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'],
|
||||
},
|
||||
});
|
||||
+73
@@ -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 };
|
||||
};
|
||||
+49
@@ -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: {},
|
||||
},
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type CreateIssueInput = {
|
||||
teamId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export type CreateIssueMutationResult = {
|
||||
issueCreate: {
|
||||
success: boolean;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
+58
@@ -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}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
+4
@@ -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(
|
||||
|
||||
+4
@@ -11,6 +11,10 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql`
|
||||
handleAliases
|
||||
lastSignedInAt
|
||||
userWorkspaceId
|
||||
applicationConnectionProviderId
|
||||
name
|
||||
visibility
|
||||
lastCredentialsRefreshedAt
|
||||
connectionParameters {
|
||||
IMAP {
|
||||
host
|
||||
|
||||
+23
-9
@@ -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 {
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+22
@@ -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: {
|
||||
|
||||
+18
-9
@@ -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 }]
|
||||
: []),
|
||||
|
||||
+28
@@ -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,
|
||||
};
|
||||
};
|
||||
+45
@@ -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 };
|
||||
};
|
||||
+208
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+10
-1
@@ -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>
|
||||
)}
|
||||
|
||||
+18
-12
@@ -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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+18
@@ -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,
|
||||
) {
|
||||
|
||||
+1
@@ -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',
|
||||
},
|
||||
});
|
||||
`;
|
||||
};
|
||||
+166
@@ -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
|
||||
|
||||
+85
@@ -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 v1–v5 (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';
|
||||
|
||||
+91
@@ -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 user’s 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);
|
||||
});
|
||||
});
|
||||
+103
@@ -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();
|
||||
});
|
||||
});
|
||||
+21
@@ -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';
|
||||
+40
@@ -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';
|
||||
|
||||
+133
@@ -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"`);
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
|
||||
+384
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
+142
@@ -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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+50
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
+9
@@ -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',
|
||||
}
|
||||
+309
@@ -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);
|
||||
}
|
||||
}
|
||||
+245
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+81
@@ -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;
|
||||
}
|
||||
+43
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+39
@@ -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 {}
|
||||
+285
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+405
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+87
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+30
@@ -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 {}
|
||||
+7
@@ -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;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class GetAppConnectionDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
+18
@@ -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';
|
||||
}
|
||||
+249
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
+18
@@ -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 {}
|
||||
+97
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type TokenExchangeResponse = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
scopes: string[] | null;
|
||||
};
|
||||
+167
@@ -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();
|
||||
});
|
||||
});
|
||||
+5
@@ -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();
|
||||
+6
@@ -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());
|
||||
+15
@@ -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',
|
||||
};
|
||||
+36
@@ -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,
|
||||
});
|
||||
};
|
||||
+26
@@ -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,
|
||||
},
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { base64UrlEncode } from 'twenty-shared/utils';
|
||||
|
||||
export const generatePkceVerifier = (): string =>
|
||||
base64UrlEncode(randomBytes(32));
|
||||
+24
@@ -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,
|
||||
};
|
||||
};
|
||||
+53
@@ -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
Reference in New Issue
Block a user