From 9e94045fa57f2448757813b9ac6b26870dccde1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 4 May 2026 11:26:34 +0200 Subject: [PATCH] feat(apps): generic OAuth provider support for app SDK (#20181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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__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 `/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 --- .../internal/twenty-linear/.oxlintrc.json | 19 + .../internal/twenty-linear/README.md | 68 + .../internal/twenty-linear/package.json | 32 + .../twenty-linear/public/linear-logomark.svg | 1 + .../twenty-linear/src/application.config.ts | 32 + .../connection-providers/linear-connection.ts | 22 + .../src/constants/universal-identifiers.ts | 19 + .../__tests__/create-linear-issue.test.ts | 126 ++ .../__tests__/list-linear-teams.test.ts | 56 + .../logic-functions/__tests__/test-utils.ts | 48 + .../issue-create-mutation.constant.ts | 8 + .../logic-functions/create-linear-issue.ts | 33 + .../handlers/create-linear-issue-handler.ts | 73 ++ .../handlers/list-linear-teams-handler.ts | 49 + .../src/logic-functions/list-linear-teams.ts | 18 + .../types/create-issue-input.type.ts | 5 + .../create-issue-mutation-result.type.ts | 11 + .../utils/call-linear-graphql.ts | 58 + .../utils/types/linear-graphql-result.type.ts | 4 + .../src/roles/default-function.role.ts | 22 + .../internal/twenty-linear/tsconfig.json | 30 + .../internal/twenty-linear/tsconfig.spec.json | 9 + .../twenty-linear/vitest.unit.config.ts | 43 + .../src/metadata/generated/schema.graphql | 25 +- .../src/metadata/generated/schema.ts | 72 +- .../src/metadata/generated/types.ts | 1163 +++++++++-------- .../developers/extend/apps/connections.mdx | 193 +++ .../src/generated-metadata/graphql.ts | 42 +- .../accounts/types/ConnectedAccount.ts | 6 + .../utils/hasMissingDraftEmailScopes.ts | 1 + .../graphql/queries/getMyConnectedAccounts.ts | 4 + .../accounts/hooks/useMyConnectedAccounts.ts | 32 +- .../findApplicationConnectionProviders.ts | 17 + ...puteApplicationContentForLayoutAndLogic.ts | 22 + .../field/display/components/ActorDisplay.tsx | 3 + .../SettingsApplicationDetails.tsx | 27 +- .../useFindApplicationConnectionProviders.ts | 28 + .../hooks/useMyAppConnectedAccounts.ts | 45 + .../applications/hooks/useTriggerAppOAuth.ts | 68 + .../SettingsApplicationConnectionsSection.tsx | 208 +++ .../SettingsApplicationDetailContentTab.tsx | 11 +- .../SettingsApplicationDetailSettingsTab.tsx | 30 +- .../FrontendApplicationConnectionProvider.ts | 18 + .../utils/normalize-manifest.util.ts | 1 + packages/twenty-sdk/src/cli/commands/add.ts | 83 ++ ...stub-twenty-sdk-define.plugin.spec.ts.snap | 1 + .../build/manifest/manifest-build.ts | 16 + .../build/manifest/manifest-extract-config.ts | 4 + .../dev-mode-orchestrator-state.ts | 1 + .../cli/utilities/dev/ui/dev-ui-constants.ts | 1 + .../entity-connection-provider-template.ts | 38 + .../append-server-variables.util.spec.ts | 166 +++ .../file/append-server-variables.util.ts | 147 +++ .../define/common/types/define-entity.type.ts | 2 + .../define-connection-provider.spec.ts | 85 ++ .../define-connection-provider.ts | 80 ++ packages/twenty-sdk/src/sdk/define/index.ts | 2 + .../find-connection-for-request.spec.ts | 91 ++ .../__tests__/get-connection.spec.ts | 103 ++ .../app-connection-auth-failed.error.ts | 21 + .../find-connection-for-request.ts | 36 + .../connections/get-connection.ts | 25 + .../connections/list-connections.ts | 26 + .../connections/types/app-connection.type.ts | 7 + .../utils/post-connections-endpoint.util.ts | 40 + .../src/sdk/logic-function/index.ts | 7 + ...nOAuthProviderAndConnectedAccountColumn.ts | 133 ++ .../application-manifest.module.ts | 2 + .../application-sync.service.ts | 8 + ...cation-oauth-provider-flow.service.spec.ts | 384 ++++++ ...application-oauth-provider.service.spec.ts | 142 ++ ...pplication-connection-provider.resolver.ts | 50 + ...tion-oauth-provider-exception-code.enum.ts | 9 + ...application-oauth-provider-flow.service.ts | 309 +++++ .../application-oauth-provider.controller.ts | 245 ++++ .../application-oauth-provider.entity.ts | 81 ++ .../application-oauth-provider.exception.ts | 43 + .../application-oauth-provider.module.ts | 39 + .../application-oauth-provider.service.ts | 285 ++++ ...plication-connections-list.service.spec.ts | 405 ++++++ .../application-connections.controller.ts | 87 ++ .../application-connections.module.ts | 30 + .../connections/dtos/app-connection.dto.ts | 7 + .../dtos/get-app-connection.dto.ts | 6 + .../dtos/list-app-connections.dto.ts | 18 + .../application-connections-list.service.ts | 249 ++++ .../application-connection-provider.dto.ts | 42 + .../refresh/app-oauth-refresh.module.ts | 18 + .../app-oauth-refresh-tokens.service.ts | 97 ++ .../services/app-oauth-revoke.service.ts | 71 + .../types/token-exchange-response.type.ts | 5 + .../exchange-code-for-token.util.spec.ts | 167 +++ .../utils/build-callback-url.util.ts | 5 + .../utils/compute-pkce-challenge.util.ts | 6 + .../utils/encode-oauth-body.util.ts | 15 + .../utils/exchange-code-for-token.util.ts | 36 + .../exchange-refresh-token-for-token.util.ts | 26 + .../utils/generate-pkce-verifier.util.ts | 6 + .../utils/parse-token-response.util.ts | 24 + .../utils/post-oauth-token-request.util.ts | 53 + .../engine/core-modules/auth/auth.module.ts | 6 + .../auth/services/auth.service.ts | 2 + .../auth/types/auth-context.type.ts | 21 +- .../triggers/route/route-trigger.service.ts | 7 +- .../build-logic-function-event.util.spec.ts | 11 + .../utils/build-logic-function-event.util.ts | 3 + .../connected-account-metadata.module.ts | 2 + .../connected-account-metadata.service.ts | 8 + .../dtos/connected-account.dto.ts | 22 + .../entities/connected-account.entity.ts | 40 + ...nected-account-refresh-tokens.exception.ts | 0 ...calendar-account-authentication.service.ts | 2 +- .../services/email-alias-manager.service.ts | 1 + ...d-account-refresh-tokens-manager.module.ts | 2 + .../google-api-refresh-tokens.service.ts | 2 +- .../utils/parse-google-oauth-error.util.ts | 2 +- .../microsoft-api-refresh-tokens.service.ts | 2 +- .../microsoft/utils/parse-msal-error.util.ts | 2 +- ...ted-account-refresh-tokens.service.spec.ts | 9 +- ...onnected-account-refresh-tokens.service.ts | 12 +- ...essaging-account-authentication.service.ts | 2 +- .../messaging-message-outbound.service.ts | 2 + .../src/application/appConnectionType.ts | 40 + .../connectionProviderManifestType.ts | 9 + .../src/application/connectionProviderType.ts | 5 + .../enums/syncable-entities.enum.ts | 1 + .../twenty-shared/src/application/index.ts | 5 + .../src/application/manifestType.ts | 2 + .../oauthConnectionProviderConfigType.ts | 13 + ...uthProviderTokenRequestContentType.type.ts | 1 + .../src/types/ConnectedAccountProvider.ts | 1 + .../src/types/LogicFunctionEvent.ts | 4 + 132 files changed, 6533 insertions(+), 595 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-linear/.oxlintrc.json create mode 100644 packages/twenty-apps/internal/twenty-linear/README.md create mode 100644 packages/twenty-apps/internal/twenty-linear/package.json create mode 100644 packages/twenty-apps/internal/twenty-linear/public/linear-logomark.svg create mode 100644 packages/twenty-apps/internal/twenty-linear/src/application.config.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/connection-providers/linear-connection.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/constants/universal-identifiers.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/create-linear-issue.test.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/list-linear-teams.test.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/test-utils.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/constants/issue-create-mutation.constant.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/create-linear-issue.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/create-linear-issue-handler.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-teams-handler.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-teams.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-input.type.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-mutation-result.type.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/call-linear-graphql.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/types/linear-graphql-result.type.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/src/roles/default-function.role.ts create mode 100644 packages/twenty-apps/internal/twenty-linear/tsconfig.json create mode 100644 packages/twenty-apps/internal/twenty-linear/tsconfig.spec.json create mode 100644 packages/twenty-apps/internal/twenty-linear/vitest.unit.config.ts create mode 100644 packages/twenty-docs/developers/extend/apps/connections.mdx create mode 100644 packages/twenty-front/src/modules/settings/applications/graphql/queries/findApplicationConnectionProviders.ts create mode 100644 packages/twenty-front/src/pages/settings/applications/hooks/useFindApplicationConnectionProviders.ts create mode 100644 packages/twenty-front/src/pages/settings/applications/hooks/useMyAppConnectedAccounts.ts create mode 100644 packages/twenty-front/src/pages/settings/applications/hooks/useTriggerAppOAuth.ts create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/types/FrontendApplicationConnectionProvider.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/entity/entity-connection-provider-template.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/file/__tests__/append-server-variables.util.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/file/append-server-variables.util.ts create mode 100644 packages/twenty-sdk/src/sdk/define/connection-providers/__tests__/define-connection-provider.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/define/connection-providers/define-connection-provider.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/connections/__tests__/find-connection-for-request.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/connections/__tests__/get-connection.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/connections/errors/app-connection-auth-failed.error.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/connections/find-connection-for-request.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/connections/get-connection.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/connections/list-connections.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/connections/types/app-connection.type.ts create mode 100644 packages/twenty-sdk/src/sdk/logic-function/connections/utils/post-connections-endpoint.util.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1777558657640-addApplicationOAuthProviderAndConnectedAccountColumn.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider-flow.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-connection-provider.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/__tests__/application-connections-list.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/get-app-connection.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/list-app-connections.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/dtos/application-connection-provider.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/__tests__/exchange-code-for-token.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/build-callback-url.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/compute-pkce-challenge.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/encode-oauth-body.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/generate-pkce-verifier.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/parse-token-response.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util.ts rename packages/twenty-server/src/{modules/connected-account/refresh-tokens-manager => engine/metadata-modules/connected-account}/exceptions/connected-account-refresh-tokens.exception.ts (100%) create mode 100644 packages/twenty-shared/src/application/appConnectionType.ts create mode 100644 packages/twenty-shared/src/application/connectionProviderManifestType.ts create mode 100644 packages/twenty-shared/src/application/connectionProviderType.ts create mode 100644 packages/twenty-shared/src/application/oauthConnectionProviderConfigType.ts create mode 100644 packages/twenty-shared/src/application/oauthProviderTokenRequestContentType.type.ts diff --git a/packages/twenty-apps/internal/twenty-linear/.oxlintrc.json b/packages/twenty-apps/internal/twenty-linear/.oxlintrc.json new file mode 100644 index 0000000000..87c62c5183 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/.oxlintrc.json @@ -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" + } +} diff --git a/packages/twenty-apps/internal/twenty-linear/README.md b/packages/twenty-apps/internal/twenty-linear/README.md new file mode 100644 index 0000000000..c44b3e4b13 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/README.md @@ -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 `/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. diff --git a/packages/twenty-apps/internal/twenty-linear/package.json b/packages/twenty-apps/internal/twenty-linear/package.json new file mode 100644 index 0000000000..c7a2690e93 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/package.json @@ -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" + } +} diff --git a/packages/twenty-apps/internal/twenty-linear/public/linear-logomark.svg b/packages/twenty-apps/internal/twenty-linear/public/linear-logomark.svg new file mode 100644 index 0000000000..08615dab3f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/public/linear-logomark.svg @@ -0,0 +1 @@ +Linear diff --git a/packages/twenty-apps/internal/twenty-linear/src/application.config.ts b/packages/twenty-apps/internal/twenty-linear/src/application.config.ts new file mode 100644 index 0000000000..8e72d387f2 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/application.config.ts @@ -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, + }, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-linear/src/connection-providers/linear-connection.ts b/packages/twenty-apps/internal/twenty-linear/src/connection-providers/linear-connection.ts new file mode 100644 index 0000000000..a108f239b8 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/connection-providers/linear-connection.ts @@ -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, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-linear/src/constants/universal-identifiers.ts b/packages/twenty-apps/internal/twenty-linear/src/constants/universal-identifiers.ts new file mode 100644 index 0000000000..1978757d56 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/constants/universal-identifiers.ts @@ -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'; diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/create-linear-issue.test.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/create-linear-issue.test.ts new file mode 100644 index 0000000000..09d70f4f15 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/create-linear-issue.test.ts @@ -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' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/list-linear-teams.test.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/list-linear-teams.test.ts new file mode 100644 index 0000000000..234fa289a6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/list-linear-teams.test.ts @@ -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' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/test-utils.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/test-utils.ts new file mode 100644 index 0000000000..d2e85fd473 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/test-utils.ts @@ -0,0 +1,48 @@ +import { vi } from 'vitest'; + +export const USER_WORKSPACE_ID = '11111111-1111-1111-1111-111111111111'; + +export const buildConnection = ( + overrides: Partial> = {}, +) => ({ + 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[], + 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; +}; diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/constants/issue-create-mutation.constant.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/constants/issue-create-mutation.constant.ts new file mode 100644 index 0000000000..832085f7be --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/constants/issue-create-mutation.constant.ts @@ -0,0 +1,8 @@ +export const ISSUE_CREATE_MUTATION = ` + mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { id identifier title url } + } + } +`; diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/create-linear-issue.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/create-linear-issue.ts new file mode 100644 index 0000000000..f8d3a709f9 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/create-linear-issue.ts @@ -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'], + }, +}); diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/create-linear-issue-handler.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/create-linear-issue-handler.ts new file mode 100644 index 0000000000..a30c585877 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/create-linear-issue-handler.ts @@ -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 => { + 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({ + 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 }; +}; diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-teams-handler.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-teams-handler.ts new file mode 100644 index 0000000000..7da1dcd972 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-teams-handler.ts @@ -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 => { + 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({ + 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 }; +}; diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-teams.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-teams.ts new file mode 100644 index 0000000000..72447bca06 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-teams.ts @@ -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: {}, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-input.type.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-input.type.ts new file mode 100644 index 0000000000..be319744df --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-input.type.ts @@ -0,0 +1,5 @@ +export type CreateIssueInput = { + teamId?: string; + title?: string; + description?: string; +}; diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-mutation-result.type.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-mutation-result.type.ts new file mode 100644 index 0000000000..257e753fcc --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-mutation-result.type.ts @@ -0,0 +1,11 @@ +export type CreateIssueMutationResult = { + issueCreate: { + success: boolean; + issue: { + id: string; + identifier: string; + title: string; + url: string; + } | null; + }; +}; diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/call-linear-graphql.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/call-linear-graphql.ts new file mode 100644 index 0000000000..678c67fb78 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/call-linear-graphql.ts @@ -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 ({ + accessToken, + query, + variables, +}: { + accessToken: string; + query: string; + variables?: Record; +}): Promise> => { + 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; + } catch (error) { + return { + errors: [ + { + message: `Linear API returned a non-JSON response: ${(error as Error).message}`, + }, + ], + }; + } +}; diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/types/linear-graphql-result.type.ts b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/types/linear-graphql-result.type.ts new file mode 100644 index 0000000000..742afd5abe --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/types/linear-graphql-result.type.ts @@ -0,0 +1,4 @@ +export type LinearGraphQLResult = { + data?: TData; + errors?: Array<{ message: string }>; +}; diff --git a/packages/twenty-apps/internal/twenty-linear/src/roles/default-function.role.ts b/packages/twenty-apps/internal/twenty-linear/src/roles/default-function.role.ts new file mode 100644 index 0000000000..c6346b4a89 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/src/roles/default-function.role.ts @@ -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: [], +}); diff --git a/packages/twenty-apps/internal/twenty-linear/tsconfig.json b/packages/twenty-apps/internal/twenty-linear/tsconfig.json new file mode 100644 index 0000000000..3e0e97756b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/tsconfig.json @@ -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"] +} diff --git a/packages/twenty-apps/internal/twenty-linear/tsconfig.spec.json b/packages/twenty-apps/internal/twenty-linear/tsconfig.spec.json new file mode 100644 index 0000000000..27a0249ae3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/twenty-apps/internal/twenty-linear/vitest.unit.config.ts b/packages/twenty-apps/internal/twenty-linear/vitest.unit.config.ts new file mode 100644 index 0000000000..57c5b5cd0f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-linear/vitest.unit.config.ts @@ -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'], + }, +}); diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 59b1f54452..404f91bb3e 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -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! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 358f109d0d..fb3692a3eb 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -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"') diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 2019bbd814..16392b13a3 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -39,47 +39,47 @@ export default { 98, 104, 118, - 127, - 128, 129, + 130, 131, - 139, - 150, - 153, + 133, + 141, + 152, 155, - 159, + 157, 161, - 165, - 166, - 173, - 176, - 179, - 202, - 217, - 253, - 254, - 264, - 265, - 291, - 300, - 301, + 163, + 167, + 168, + 175, + 178, + 181, + 204, + 219, + 255, + 256, + 266, + 267, + 293, 302, 303, + 304, 305, - 306, 307, 308, 309, 310, 311, + 312, 313, 315, - 325, - 332, - 339, - 375, - 450, - 462 + 317, + 327, + 334, + 341, + 377, + 452, + 464 ], "types": { "BillingProductDTO": { @@ -93,13 +93,13 @@ export default { 1 ], "metadata": [ - 126 + 128 ], "on_BillingLicensedProduct": [ - 135 + 137 ], "on_BillingMeteredProduct": [ - 136 + 138 ], "__typename": [ 1 @@ -849,10 +849,10 @@ export default { 3 ], "relation": [ - 201 + 203 ], "morphRelations": [ - 201 + 203 ], "object": [ 49 @@ -911,7 +911,7 @@ export default { 39 ], "objectMetadata": [ - 209, + 211, { "paging": [ 42, @@ -924,7 +924,7 @@ export default { } ], "indexFieldMetadatas": [ - 207, + 209, { "paging": [ 42, @@ -1169,7 +1169,7 @@ export default { 40 ], "fields": [ - 214, + 216, { "paging": [ 42, @@ -1182,7 +1182,7 @@ export default { } ], "indexMetadatas": [ - 212, + 214, { "paging": [ 42, @@ -1763,19 +1763,19 @@ export default { 52 ], "featureFlags": [ - 160 + 162 ], "billingSubscriptions": [ - 138 + 140 ], "installedApplications": [ 52 ], "currentBillingSubscription": [ - 138 + 140 ], "billingEntitlements": [ - 216 + 218 ], "hasValidEnterpriseKey": [ 6 @@ -1787,7 +1787,7 @@ export default { 6 ], "workspaceUrls": [ - 162 + 164 ], "workspaceCustomApplicationId": [ 1 @@ -1879,7 +1879,7 @@ export default { 20 ], "deletedWorkspaceMembers": [ - 200 + 202 ], "hasPassword": [ 6 @@ -1891,7 +1891,7 @@ export default { 17 ], "availableWorkspaces": [ - 199 + 201 ], "__typename": [ 1 @@ -2685,6 +2685,40 @@ export default { ] }, "PageLayoutType": {}, + "ApplicationConnectionProviderOAuthConfig": { + "scopes": [ + 1 + ], + "isClientCredentialsConfigured": [ + 6 + ], + "__typename": [ + 1 + ] + }, + "ApplicationConnectionProvider": { + "id": [ + 3 + ], + "applicationId": [ + 1 + ], + "type": [ + 1 + ], + "name": [ + 1 + ], + "displayName": [ + 1 + ], + "oauth": [ + 119 + ], + "__typename": [ + 1 + ] + }, "Analytics": { "success": [ 6 @@ -2789,7 +2823,7 @@ export default { 11 ], "items": [ - 124 + 126 ], "__typename": [ 1 @@ -2797,13 +2831,13 @@ export default { }, "BillingProductMetadata": { "planKey": [ - 127 + 129 ], "priceUsageBased": [ - 128 + 130 ], "productKey": [ - 129 + 131 ], "__typename": [ 1 @@ -2814,7 +2848,7 @@ export default { "BillingProductKey": {}, "BillingPriceLicensed": { "recurringInterval": [ - 131 + 133 ], "unitAmount": [ 11 @@ -2823,7 +2857,7 @@ export default { 1 ], "priceUsageType": [ - 128 + 130 ], "__typename": [ 1 @@ -2846,16 +2880,16 @@ export default { }, "BillingPriceMetered": { "tiers": [ - 132 + 134 ], "recurringInterval": [ - 131 + 133 ], "stripePriceId": [ 1 ], "priceUsageType": [ - 128 + 130 ], "__typename": [ 1 @@ -2872,7 +2906,7 @@ export default { 1 ], "metadata": [ - 126 + 128 ], "__typename": [ 1 @@ -2889,10 +2923,10 @@ export default { 1 ], "metadata": [ - 126 + 128 ], "prices": [ - 130 + 132 ], "__typename": [ 1 @@ -2909,10 +2943,10 @@ export default { 1 ], "metadata": [ - 126 + 128 ], "prices": [ - 133 + 135 ], "__typename": [ 1 @@ -2943,13 +2977,13 @@ export default { 3 ], "status": [ - 139 + 141 ], "interval": [ - 131 + 133 ], "billingSubscriptionItems": [ - 137 + 139 ], "currentPeriodEnd": [ 4 @@ -2958,7 +2992,7 @@ export default { 15 ], "phases": [ - 125 + 127 ], "__typename": [ 1 @@ -2967,7 +3001,7 @@ export default { "SubscriptionStatus": {}, "BillingEndTrialPeriod": { "status": [ - 139 + 141 ], "hasPaymentMethod": [ 6 @@ -2981,7 +3015,7 @@ export default { }, "BillingMeteredProductUsage": { "productKey": [ - 129 + 131 ], "periodStart": [ 4 @@ -3010,13 +3044,13 @@ export default { }, "BillingPlan": { "planKey": [ - 127 + 129 ], "licensedProducts": [ - 135 + 137 ], "meteredProducts": [ - 136 + 138 ], "__typename": [ 1 @@ -3032,10 +3066,10 @@ export default { }, "BillingUpdate": { "currentBillingSubscription": [ - 138 + 140 ], "billingSubscriptions": [ - 138 + 140 ], "__typename": [ 1 @@ -3074,7 +3108,7 @@ export default { 1 ], "result": [ - 146 + 148 ], "__typename": [ 1 @@ -3111,7 +3145,7 @@ export default { 3 ], "type": [ - 150 + 152 ], "name": [ 1 @@ -3144,7 +3178,7 @@ export default { 4 ], "targetRecordIdentifier": [ - 148 + 150 ], "__typename": [ 1 @@ -3170,7 +3204,7 @@ export default { }, "MetadataEvent": { "type": [ - 153 + 155 ], "metadataName": [ 1 @@ -3179,7 +3213,7 @@ export default { 1 ], "properties": [ - 151 + 153 ], "updatedCollectionHash": [ 1 @@ -3191,7 +3225,7 @@ export default { "MetadataEventAction": {}, "ObjectRecordEvent": { "action": [ - 155 + 157 ], "objectNameSingular": [ 1 @@ -3206,7 +3240,7 @@ export default { 1 ], "properties": [ - 151 + 153 ], "__typename": [ 1 @@ -3218,7 +3252,7 @@ export default { 1 ], "objectRecordEvent": [ - 154 + 156 ], "__typename": [ 1 @@ -3229,10 +3263,10 @@ export default { 1 ], "objectRecordEventsWithQueryIds": [ - 156 + 158 ], "metadataEvents": [ - 152 + 154 ], "__typename": [ 1 @@ -3249,7 +3283,7 @@ export default { 11 ], "status": [ - 159 + 161 ], "error": [ 15 @@ -3261,7 +3295,7 @@ export default { "LogicFunctionExecutionStatus": {}, "FeatureFlag": { "key": [ - 161 + 163 ], "value": [ 6 @@ -3301,10 +3335,10 @@ export default { 1 ], "type": [ - 165 + 167 ], "status": [ - 166 + 168 ], "issuer": [ 1 @@ -3317,7 +3351,7 @@ export default { "SSOIdentityProviderStatus": {}, "AuthProviders": { "sso": [ - 164 + 166 ], "google": [ 6 @@ -3354,10 +3388,10 @@ export default { 3 ], "authProviders": [ - 167 + 169 ], "authBypassProviders": [ - 168 + 170 ], "logo": [ 1 @@ -3366,7 +3400,7 @@ export default { 1 ], "workspaceUrls": [ - 162 + 164 ], "__typename": [ 1 @@ -3405,7 +3439,7 @@ export default { 1 ], "modelFamily": [ - 173 + 175 ], "modelFamilyLabel": [ 1 @@ -3420,7 +3454,7 @@ export default { 11 ], "nativeCapabilities": [ - 171 + 173 ], "isDeprecated": [ 6 @@ -3456,7 +3490,7 @@ export default { 1 ], "trialPeriods": [ - 163 + 165 ], "__typename": [ 1 @@ -3464,7 +3498,7 @@ export default { }, "Support": { "supportDriver": [ - 176 + 178 ], "supportFrontChatId": [ 1 @@ -3490,7 +3524,7 @@ export default { }, "Captcha": { "provider": [ - 179 + 181 ], "siteKey": [ 1 @@ -3524,10 +3558,10 @@ export default { }, "PublicFeatureFlag": { "key": [ - 161 + 163 ], "metadata": [ - 181 + 183 ], "__typename": [ 1 @@ -3552,13 +3586,13 @@ export default { 1 ], "authProviders": [ - 167 + 169 ], "billing": [ - 174 + 176 ], "aiModels": [ - 172 + 174 ], "signInPrefilled": [ 6 @@ -3579,25 +3613,25 @@ export default { 6 ], "support": [ - 175 + 177 ], "isAttachmentPreviewEnabled": [ 6 ], "sentry": [ - 177 + 179 ], "captcha": [ - 178 + 180 ], "api": [ - 180 + 182 ], "canManageFeatureFlags": [ 6 ], "publicFeatureFlags": [ - 182 + 184 ], "isMicrosoftMessagingEnabled": [ 6 @@ -3633,7 +3667,7 @@ export default { 6 ], "maintenance": [ - 183 + 185 ], "__typename": [ 1 @@ -3672,7 +3706,7 @@ export default { 1 ], "versionDistribution": [ - 186 + 188 ], "__typename": [ 1 @@ -3738,7 +3772,7 @@ export default { 3 ], "type": [ - 165 + 167 ], "issuer": [ 1 @@ -3747,7 +3781,7 @@ export default { 1 ], "status": [ - 166 + 168 ], "__typename": [ 1 @@ -3766,7 +3800,7 @@ export default { }, "FindAvailableSSOIDP": { "type": [ - 165 + 167 ], "id": [ 3 @@ -3778,10 +3812,10 @@ export default { 1 ], "status": [ - 166 + 168 ], "workspace": [ - 194 + 196 ], "__typename": [ 1 @@ -3792,7 +3826,7 @@ export default { 3 ], "type": [ - 165 + 167 ], "issuer": [ 1 @@ -3801,7 +3835,7 @@ export default { 1 ], "status": [ - 166 + 168 ], "__typename": [ 1 @@ -3809,7 +3843,7 @@ export default { }, "SSOConnection": { "type": [ - 165 + 167 ], "id": [ 3 @@ -3821,7 +3855,7 @@ export default { 1 ], "status": [ - 166 + 168 ], "__typename": [ 1 @@ -3844,13 +3878,13 @@ export default { 1 ], "workspaceUrls": [ - 162 + 164 ], "logo": [ 1 ], "sso": [ - 197 + 199 ], "__typename": [ 1 @@ -3858,10 +3892,10 @@ export default { }, "AvailableWorkspaces": { "availableWorkspacesForSignIn": [ - 198 + 200 ], "availableWorkspacesForSignUp": [ - 198 + 200 ], "__typename": [ 1 @@ -3889,7 +3923,7 @@ export default { }, "Relation": { "type": [ - 202 + 204 ], "sourceObjectMetadata": [ 49 @@ -3938,10 +3972,10 @@ export default { }, "IndexConnection": { "pageInfo": [ - 204 + 206 ], "edges": [ - 203 + 205 ], "__typename": [ 1 @@ -3960,10 +3994,10 @@ export default { }, "IndexIndexFieldMetadatasConnection": { "pageInfo": [ - 204 + 206 ], "edges": [ - 206 + 208 ], "__typename": [ 1 @@ -3982,10 +4016,10 @@ export default { }, "IndexObjectMetadataConnection": { "pageInfo": [ - 204 + 206 ], "edges": [ - 208 + 210 ], "__typename": [ 1 @@ -4004,10 +4038,10 @@ export default { }, "ObjectConnection": { "pageInfo": [ - 204 + 206 ], "edges": [ - 208 + 210 ], "__typename": [ 1 @@ -4015,10 +4049,10 @@ export default { }, "ObjectIndexMetadatasConnection": { "pageInfo": [ - 204 + 206 ], "edges": [ - 203 + 205 ], "__typename": [ 1 @@ -4037,10 +4071,10 @@ export default { }, "ObjectFieldsConnection": { "pageInfo": [ - 204 + 206 ], "edges": [ - 213 + 215 ], "__typename": [ 1 @@ -4048,10 +4082,10 @@ export default { }, "FieldConnection": { "pageInfo": [ - 204 + 206 ], "edges": [ - 213 + 215 ], "__typename": [ 1 @@ -4059,7 +4093,7 @@ export default { }, "BillingEntitlement": { "key": [ - 217 + 219 ], "value": [ 6 @@ -4097,7 +4131,7 @@ export default { 1 ], "records": [ - 218 + 220 ], "__typename": [ 1 @@ -4167,10 +4201,10 @@ export default { }, "AvailableWorkspacesAndAccessTokens": { "tokens": [ - 226 + 228 ], "availableWorkspaces": [ - 199 + 201 ], "__typename": [ 1 @@ -4208,7 +4242,7 @@ export default { }, "WorkspaceUrlsAndId": { "workspaceUrls": [ - 162 + 164 ], "id": [ 3 @@ -4222,7 +4256,7 @@ export default { 32 ], "workspace": [ - 231 + 233 ], "__typename": [ 1 @@ -4255,7 +4289,7 @@ export default { 32 ], "workspaceUrls": [ - 162 + 164 ], "__typename": [ 1 @@ -4271,7 +4305,7 @@ export default { }, "AuthTokens": { "tokens": [ - 226 + 228 ], "__typename": [ 1 @@ -4312,7 +4346,7 @@ export default { 32 ], "workspace": [ - 231 + 233 ], "__typename": [ 1 @@ -4334,7 +4368,7 @@ export default { 1 ], "dailyUsage": [ - 242 + 244 ], "__typename": [ 1 @@ -4342,16 +4376,16 @@ export default { }, "UsageAnalytics": { "usageByUser": [ - 185 + 187 ], "usageByOperationType": [ - 185 + 187 ], "usageByModel": [ - 185 + 187 ], "timeSeries": [ - 242 + 244 ], "periodStart": [ 4 @@ -4360,7 +4394,7 @@ export default { 4 ], "userDailyUsage": [ - 243 + 245 ], "__typename": [ 1 @@ -4514,13 +4548,13 @@ export default { 1 ], "driver": [ - 253 + 255 ], "status": [ - 254 + 256 ], "verificationRecords": [ - 251 + 253 ], "verifiedAt": [ 4 @@ -4570,7 +4604,7 @@ export default { 1 ], "location": [ - 256 + 258 ], "__typename": [ 1 @@ -4598,13 +4632,13 @@ export default { }, "ImapSmtpCaldavConnectionParameters": { "IMAP": [ - 258 + 260 ], "SMTP": [ - 258 + 260 ], "CALDAV": [ - 258 + 260 ], "__typename": [ 1 @@ -4624,7 +4658,7 @@ export default { 3 ], "connectionParameters": [ - 259 + 261 ], "__typename": [ 1 @@ -4672,7 +4706,7 @@ export default { 34 ], "engineComponentKey": [ - 264 + 266 ], "label": [ 1 @@ -4690,10 +4724,10 @@ export default { 6 ], "availabilityType": [ - 265 + 267 ], "payload": [ - 266 + 268 ], "hotKeys": [ 1 @@ -4724,10 +4758,10 @@ export default { "CommandMenuItemAvailabilityType": {}, "CommandMenuItemPayload": { "on_PathCommandMenuItemPayload": [ - 267 + 269 ], "on_ObjectMetadataCommandMenuItemPayload": [ - 268 + 270 ], "__typename": [ 1 @@ -4888,7 +4922,7 @@ export default { 1 ], "series": [ - 272 + 274 ], "xAxisLabel": [ 1 @@ -4937,7 +4971,7 @@ export default { 1 ], "data": [ - 274 + 276 ], "__typename": [ 1 @@ -4945,7 +4979,7 @@ export default { }, "LineChartData": { "series": [ - 275 + 277 ], "xAxisLabel": [ 1 @@ -4982,7 +5016,7 @@ export default { }, "PieChartData": { "data": [ - 277 + 279 ], "showLegend": [ 6 @@ -5049,7 +5083,7 @@ export default { 1 ], "connectionParameters": [ - 259 + 261 ], "lastSignedInAt": [ 4 @@ -5057,6 +5091,18 @@ export default { "userWorkspaceId": [ 3 ], + "applicationConnectionProviderId": [ + 3 + ], + "applicationId": [ + 3 + ], + "name": [ + 1 + ], + "visibility": [ + 1 + ], "createdAt": [ 4 ], @@ -5086,13 +5132,13 @@ export default { }, "PublicImapSmtpCaldavConnectionParameters": { "IMAP": [ - 281 + 283 ], "SMTP": [ - 281 + 283 ], "CALDAV": [ - 281 + 283 ], "__typename": [ 1 @@ -5126,6 +5172,18 @@ export default { "userWorkspaceId": [ 3 ], + "applicationConnectionProviderId": [ + 3 + ], + "applicationId": [ + 3 + ], + "name": [ + 1 + ], + "visibility": [ + 1 + ], "createdAt": [ 4 ], @@ -5133,7 +5191,7 @@ export default { 4 ], "connectionParameters": [ - 282 + 284 ], "__typename": [ 1 @@ -5189,13 +5247,13 @@ export default { }, "EventLogQueryResult": { "records": [ - 285 + 287 ], "totalCount": [ 21 ], "pageInfo": [ - 286 + 288 ], "__typename": [ 1 @@ -5259,7 +5317,7 @@ export default { 1 ], "parts": [ - 270 + 272 ], "processedAt": [ 4 @@ -5273,7 +5331,7 @@ export default { }, "AgentChatThread": { "id": [ - 291 + 293 ], "title": [ 1 @@ -5329,7 +5387,7 @@ export default { }, "AiSystemPromptPreview": { "sections": [ - 292 + 294 ], "estimatedTokenCount": [ 21 @@ -5405,10 +5463,10 @@ export default { 3 ], "evaluations": [ - 297 + 299 ], "messages": [ - 289 + 291 ], "createdAt": [ 4 @@ -5425,19 +5483,19 @@ export default { 1 ], "syncStatus": [ - 300 + 302 ], "syncStage": [ - 301 + 303 ], "visibility": [ - 302 + 304 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 303 + 305 ], "isSyncEnabled": [ 6 @@ -5473,22 +5531,22 @@ export default { 3 ], "visibility": [ - 305 + 307 ], "handle": [ 1 ], "type": [ - 306 + 308 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 307 + 309 ], "messageFolderImportPolicy": [ - 308 + 310 ], "excludeNonProfessionalEmails": [ 6 @@ -5497,7 +5555,7 @@ export default { 6 ], "pendingGroupEmailsAction": [ - 309 + 311 ], "isSyncEnabled": [ 6 @@ -5506,10 +5564,10 @@ export default { 4 ], "syncStatus": [ - 310 + 312 ], "syncStage": [ - 311 + 313 ], "syncStageStartedAt": [ 4 @@ -5560,7 +5618,7 @@ export default { 1 ], "pendingSyncAction": [ - 313 + 315 ], "messageChannelId": [ 3 @@ -5578,7 +5636,7 @@ export default { "MessageFolderPendingSyncAction": {}, "CollectionHash": { "collectionName": [ - 315 + 317 ], "hash": [ 1 @@ -5645,13 +5703,13 @@ export default { }, "MinimalMetadata": { "objectMetadataItems": [ - 316 + 318 ], "views": [ - 317 + 319 ], "collectionHashes": [ - 314 + 316 ], "__typename": [ 1 @@ -5691,10 +5749,10 @@ export default { }, "Query": { "navigationMenuItems": [ - 149 + 151 ], "navigationMenuItem": [ - 149, + 151, { "id": [ 3, @@ -5817,7 +5875,7 @@ export default { 2, { "input": [ - 321, + 323, "GetApiKeyInput!" ] } @@ -5839,10 +5897,10 @@ export default { } ], "enterpriseSubscriptionStatus": [ - 123 + 125 ], "billingPortalSession": [ - 143, + 145, { "returnUrlPath": [ 1 @@ -5850,16 +5908,16 @@ export default { } ], "listPlans": [ - 142 + 144 ], "getMeteredProductsUsage": [ - 141 + 143 ], "findWorkspaceInvitations": [ - 146 + 148 ], "getApprovedAccessDomains": [ - 120 + 122 ], "getPageLayoutTabs": [ 116, @@ -5899,6 +5957,15 @@ export default { ] } ], + "applicationConnectionProviders": [ + 120, + { + "applicationId": [ + 3, + "UUID!" + ] + } + ], "getPageLayoutWidgets": [ 78, { @@ -5921,7 +5988,7 @@ export default { 35, { "input": [ - 322, + 324, "LogicFunctionIdInput!" ] } @@ -5933,7 +6000,7 @@ export default { 15, { "input": [ - 322, + 324, "LogicFunctionIdInput!" ] } @@ -5942,16 +6009,16 @@ export default { 1, { "input": [ - 322, + 324, "LogicFunctionIdInput!" ] } ], "commandMenuItems": [ - 263 + 265 ], "commandMenuItem": [ - 263, + 265, { "id": [ 3, @@ -5972,7 +6039,7 @@ export default { } ], "objectRecordCounts": [ - 210 + 212 ], "object": [ 49, @@ -5984,7 +6051,7 @@ export default { } ], "objects": [ - 211, + 213, { "paging": [ 42, @@ -6006,7 +6073,7 @@ export default { } ], "indexMetadatas": [ - 205, + 207, { "paging": [ 42, @@ -6025,7 +6092,7 @@ export default { 25, { "input": [ - 323, + 325, "AgentIdInput!" ] } @@ -6034,7 +6101,7 @@ export default { 29 ], "getToolIndex": [ - 269 + 271 ], "getToolInputSchema": [ 15, @@ -6055,7 +6122,7 @@ export default { } ], "fields": [ - 215, + 217, { "paging": [ 42, @@ -6085,7 +6152,7 @@ export default { } ], "myMessageFolders": [ - 312, + 314, { "messageChannelId": [ 3 @@ -6093,7 +6160,7 @@ export default { } ], "myMessageChannels": [ - 304, + 306, { "connectedAccountId": [ 3 @@ -6101,10 +6168,10 @@ export default { } ], "myConnectedAccounts": [ - 280 + 282 ], "connectedAccountById": [ - 283, + 285, { "id": [ 3, @@ -6113,10 +6180,10 @@ export default { } ], "connectedAccounts": [ - 280 + 282 ], "myCalendarChannels": [ - 299, + 301, { "connectedAccountId": [ 3 @@ -6124,10 +6191,10 @@ export default { } ], "webhooks": [ - 319 + 321 ], "webhook": [ - 319, + 321, { "id": [ 3, @@ -6136,13 +6203,13 @@ export default { } ], "minimalMetadata": [ - 318 + 320 ], "chatThreads": [ - 290 + 292 ], "chatThread": [ - 290, + 292, { "id": [ 3, @@ -6151,7 +6218,7 @@ export default { } ], "chatMessages": [ - 289, + 291, { "threadId": [ 3, @@ -6160,7 +6227,7 @@ export default { } ], "chatStreamCatchupChunks": [ - 294, + 296, { "threadId": [ 3, @@ -6169,13 +6236,13 @@ export default { } ], "getAiSystemPromptPreview": [ - 293 + 295 ], "skills": [ - 288 + 290 ], "skill": [ - 288, + 290, { "id": [ 3, @@ -6184,7 +6251,7 @@ export default { } ], "agentTurns": [ - 298, + 300, { "agentId": [ 3, @@ -6193,7 +6260,7 @@ export default { } ], "checkUserExists": [ - 239, + 241, { "email": [ 1, @@ -6205,7 +6272,7 @@ export default { } ], "checkWorkspaceInviteHashIsValid": [ - 240, + 242, { "inviteHash": [ 1, @@ -6223,7 +6290,7 @@ export default { } ], "validatePasswordResetToken": [ - 234, + 236, { "passwordResetToken": [ 1, @@ -6232,7 +6299,7 @@ export default { } ], "findApplicationRegistrationByClientId": [ - 189, + 191, { "clientId": [ 1, @@ -6262,7 +6329,7 @@ export default { } ], "findApplicationRegistrationStats": [ - 187, + 189, { "id": [ 1, @@ -6295,7 +6362,7 @@ export default { 69 ], "getPublicWorkspaceDataByDomain": [ - 169, + 171, { "origin": [ 1 @@ -6303,7 +6370,7 @@ export default { } ], "getPublicWorkspaceDataById": [ - 170, + 172, { "id": [ 3, @@ -6326,46 +6393,46 @@ export default { } ], "getSSOIdentityProviders": [ - 195 + 197 ], "eventLogs": [ - 287, + 289, { "input": [ - 324, + 326, "EventLogQueryInput!" ] } ], "pieChartData": [ - 278, + 280, { "input": [ - 328, + 330, "PieChartDataInput!" ] } ], "lineChartData": [ - 276, + 278, { "input": [ - 329, + 331, "LineChartDataInput!" ] } ], "barChartData": [ - 273, + 275, { "input": [ - 330, + 332, "BarChartDataInput!" ] } ], "getConnectedImapSmtpCaldavAccount": [ - 260, + 262, { "id": [ 3, @@ -6374,7 +6441,7 @@ export default { } ], "getAutoCompleteAddress": [ - 255, + 257, { "address": [ 1, @@ -6393,7 +6460,7 @@ export default { } ], "getAddressDetails": [ - 257, + 259, { "placeId": [ 1, @@ -6406,27 +6473,27 @@ export default { } ], "getUsageAnalytics": [ - 244, + 246, { "input": [ - 331 + 333 ] } ], "getPostgresCredentials": [ - 262 + 264 ], "findManyPublicDomains": [ - 250 - ], - "getEmailingDomains": [ 252 ], + "getEmailingDomains": [ + 254 + ], "findManyMarketplaceApps": [ - 248 + 250 ], "findMarketplaceAppDetail": [ - 249, + 251, { "universalIdentifier": [ 1, @@ -6448,7 +6515,7 @@ export default { }, "LogicFunctionIdInput": { "id": [ - 291 + 293 ], "__typename": [ 1 @@ -6464,10 +6531,10 @@ export default { }, "EventLogQueryInput": { "table": [ - 325 + 327 ], "filters": [ - 326 + 328 ], "first": [ 21 @@ -6488,7 +6555,7 @@ export default { 1 ], "dateRange": [ - 327 + 329 ], "recordId": [ 1 @@ -6555,7 +6622,7 @@ export default { 1 ], "operationTypes": [ - 332 + 334 ], "__typename": [ 1 @@ -6567,7 +6634,7 @@ export default { 6, { "input": [ - 334, + 336, "AddQuerySubscriptionInput!" ] } @@ -6576,49 +6643,49 @@ export default { 6, { "input": [ - 335, + 337, "RemoveQueryFromEventStreamInput!" ] } ], "createManyNavigationMenuItems": [ - 149, + 151, { "inputs": [ - 336, + 338, "[CreateNavigationMenuItemInput!]!" ] } ], "createNavigationMenuItem": [ - 149, + 151, { "input": [ - 336, + 338, "CreateNavigationMenuItemInput!" ] } ], "updateManyNavigationMenuItems": [ - 149, + 151, { "inputs": [ - 337, + 339, "[UpdateOneNavigationMenuItemInput!]!" ] } ], "updateNavigationMenuItem": [ - 149, + 151, { "input": [ - 337, + 339, "UpdateOneNavigationMenuItemInput!" ] } ], "deleteManyNavigationMenuItems": [ - 149, + 151, { "ids": [ 3, @@ -6627,7 +6694,7 @@ export default { } ], "deleteNavigationMenuItem": [ - 149, + 151, { "id": [ 3, @@ -6636,55 +6703,55 @@ export default { } ], "uploadEmailAttachmentFile": [ - 121, + 123, { "file": [ - 339, + 341, "Upload!" ] } ], "uploadAiChatFile": [ - 121, + 123, { "file": [ - 339, + 341, "Upload!" ] } ], "uploadWorkflowFile": [ - 121, + 123, { "file": [ - 339, + 341, "Upload!" ] } ], "uploadWorkspaceLogo": [ - 121, + 123, { "file": [ - 339, + 341, "Upload!" ] } ], "uploadWorkspaceMemberProfilePicture": [ - 121, + 123, { "file": [ - 339, + 341, "Upload!" ] } ], "uploadFilesFieldFile": [ - 121, + 123, { "file": [ - 339, + 341, "Upload!" ], "fieldMetadataId": [ @@ -6694,10 +6761,10 @@ export default { } ], "uploadFilesFieldFileByUniversalIdentifier": [ - 121, + 123, { "file": [ - 339, + 341, "Upload!" ], "fieldMetadataUniversalIdentifier": [ @@ -6710,7 +6777,7 @@ export default { 55, { "input": [ - 340, + 342, "CreateViewFilterGroupInput!" ] } @@ -6723,7 +6790,7 @@ export default { "String!" ], "input": [ - 341, + 343, "UpdateViewFilterGroupInput!" ] } @@ -6750,7 +6817,7 @@ export default { 57, { "input": [ - 342, + 344, "CreateViewFilterInput!" ] } @@ -6759,7 +6826,7 @@ export default { 57, { "input": [ - 343, + 345, "UpdateViewFilterInput!" ] } @@ -6768,7 +6835,7 @@ export default { 57, { "input": [ - 345, + 347, "DeleteViewFilterInput!" ] } @@ -6777,7 +6844,7 @@ export default { 57, { "input": [ - 346, + 348, "DestroyViewFilterInput!" ] } @@ -6786,7 +6853,7 @@ export default { 63, { "input": [ - 347, + 349, "CreateViewInput!" ] } @@ -6799,7 +6866,7 @@ export default { "String!" ], "input": [ - 348, + 350, "UpdateViewInput!" ] } @@ -6826,7 +6893,7 @@ export default { 63, { "input": [ - 349, + 351, "UpsertViewWidgetInput!" ] } @@ -6835,7 +6902,7 @@ export default { 60, { "input": [ - 354, + 356, "CreateViewSortInput!" ] } @@ -6844,7 +6911,7 @@ export default { 60, { "input": [ - 355, + 357, "UpdateViewSortInput!" ] } @@ -6853,7 +6920,7 @@ export default { 6, { "input": [ - 357, + 359, "DeleteViewSortInput!" ] } @@ -6862,7 +6929,7 @@ export default { 6, { "input": [ - 358, + 360, "DestroyViewSortInput!" ] } @@ -6871,7 +6938,7 @@ export default { 53, { "input": [ - 359, + 361, "UpdateViewFieldInput!" ] } @@ -6880,7 +6947,7 @@ export default { 53, { "input": [ - 361, + 363, "CreateViewFieldInput!" ] } @@ -6889,7 +6956,7 @@ export default { 53, { "inputs": [ - 361, + 363, "[CreateViewFieldInput!]!" ] } @@ -6898,7 +6965,7 @@ export default { 53, { "input": [ - 362, + 364, "DeleteViewFieldInput!" ] } @@ -6907,7 +6974,7 @@ export default { 53, { "input": [ - 363, + 365, "DestroyViewFieldInput!" ] } @@ -6916,7 +6983,7 @@ export default { 62, { "input": [ - 364, + 366, "UpdateViewFieldGroupInput!" ] } @@ -6925,7 +6992,7 @@ export default { 62, { "input": [ - 366, + 368, "CreateViewFieldGroupInput!" ] } @@ -6934,7 +7001,7 @@ export default { 62, { "inputs": [ - 366, + 368, "[CreateViewFieldGroupInput!]!" ] } @@ -6943,7 +7010,7 @@ export default { 62, { "input": [ - 367, + 369, "DeleteViewFieldGroupInput!" ] } @@ -6952,7 +7019,7 @@ export default { 62, { "input": [ - 368, + 370, "DestroyViewFieldGroupInput!" ] } @@ -6961,7 +7028,7 @@ export default { 63, { "input": [ - 369, + 371, "UpsertFieldsWidgetInput!" ] } @@ -6970,7 +7037,7 @@ export default { 2, { "input": [ - 372, + 374, "CreateApiKeyInput!" ] } @@ -6979,7 +7046,7 @@ export default { 2, { "input": [ - 373, + 375, "UpdateApiKeyInput!" ] } @@ -6988,7 +7055,7 @@ export default { 2, { "input": [ - 374, + 376, "RevokeApiKeyInput!" ] } @@ -7007,7 +7074,7 @@ export default { } ], "createObjectEvent": [ - 119, + 121, { "event": [ 1, @@ -7027,10 +7094,10 @@ export default { } ], "trackAnalytics": [ - 119, + 121, { "type": [ - 375, + 377, "AnalyticsType!" ], "name": [ @@ -7048,7 +7115,7 @@ export default { 6 ], "setEnterpriseKey": [ - 122, + 124, { "enterpriseKey": [ 1, @@ -7057,20 +7124,20 @@ export default { } ], "skipSyncEmailOnboardingStep": [ - 145 + 147 ], "skipBookOnboardingStep": [ - 145 + 147 ], "checkoutSession": [ - 143, + 145, { "recurringInterval": [ - 131, + 133, "SubscriptionInterval!" ], "plan": [ - 127, + 129, "BillingPlanKey!" ], "requirePaymentMethod": [ @@ -7083,19 +7150,19 @@ export default { } ], "switchSubscriptionInterval": [ - 144 + 146 ], "switchBillingPlan": [ - 144 + 146 ], "cancelSwitchBillingPlan": [ - 144 + 146 ], "cancelSwitchBillingInterval": [ - 144 + 146 ], "setMeteredSubscriptionPrice": [ - 144, + 146, { "priceId": [ 1, @@ -7104,10 +7171,10 @@ export default { } ], "endSubscriptionTrialPeriod": [ - 140 + 142 ], "cancelSwitchMeteredPrice": [ - 144 + 146 ], "deleteWorkspaceInvitation": [ 1, @@ -7119,7 +7186,7 @@ export default { } ], "resendWorkspaceInvitation": [ - 147, + 149, { "appTokenId": [ 1, @@ -7128,7 +7195,7 @@ export default { } ], "sendInvitations": [ - 147, + 149, { "emails": [ 1, @@ -7140,10 +7207,10 @@ export default { } ], "createApprovedAccessDomain": [ - 120, + 122, { "input": [ - 376, + 378, "CreateApprovedAccessDomainInput!" ] } @@ -7152,16 +7219,16 @@ export default { 6, { "input": [ - 377, + 379, "DeleteApprovedAccessDomainInput!" ] } ], "validateApprovedAccessDomain": [ - 120, + 122, { "input": [ - 378, + 380, "ValidateApprovedAccessDomainInput!" ] } @@ -7170,7 +7237,7 @@ export default { 116, { "input": [ - 379, + 381, "CreatePageLayoutTabInput!" ] } @@ -7183,7 +7250,7 @@ export default { "String!" ], "input": [ - 380, + 382, "UpdatePageLayoutTabInput!" ] } @@ -7201,7 +7268,7 @@ export default { 117, { "input": [ - 381, + 383, "CreatePageLayoutInput!" ] } @@ -7214,7 +7281,7 @@ export default { "String!" ], "input": [ - 382, + 384, "UpdatePageLayoutInput!" ] } @@ -7236,7 +7303,7 @@ export default { "String!" ], "input": [ - 383, + 385, "UpdatePageLayoutWithTabsInput!" ] } @@ -7268,11 +7335,28 @@ export default { ] } ], + "updateOneApplicationVariable": [ + 6, + { + "key": [ + 1, + "String!" + ], + "value": [ + 1, + "String!" + ], + "applicationId": [ + 3, + "UUID!" + ] + } + ], "createPageLayoutWidget": [ 78, { "input": [ - 387, + 389, "CreatePageLayoutWidgetInput!" ] } @@ -7285,7 +7369,7 @@ export default { "String!" ], "input": [ - 388, + 390, "UpdatePageLayoutWidgetInput!" ] } @@ -7303,7 +7387,7 @@ export default { 35, { "input": [ - 322, + 324, "LogicFunctionIdInput!" ] } @@ -7312,16 +7396,16 @@ export default { 35, { "input": [ - 389, + 391, "CreateLogicFunctionFromSourceInput!" ] } ], "executeOneLogicFunction": [ - 158, + 160, { "input": [ - 390, + 392, "ExecuteOneLogicFunctionInput!" ] } @@ -7330,31 +7414,31 @@ export default { 6, { "input": [ - 391, + 393, "UpdateLogicFunctionFromSourceInput!" ] } ], "createCommandMenuItem": [ - 263, + 265, { "input": [ - 393, + 395, "CreateCommandMenuItemInput!" ] } ], "updateCommandMenuItem": [ - 263, + 265, { "input": [ - 394, + 396, "UpdateCommandMenuItemInput!" ] } ], "deleteCommandMenuItem": [ - 263, + 265, { "id": [ 3, @@ -7366,7 +7450,7 @@ export default { 34, { "input": [ - 395, + 397, "CreateFrontComponentInput!" ] } @@ -7375,7 +7459,7 @@ export default { 34, { "input": [ - 396, + 398, "UpdateFrontComponentInput!" ] } @@ -7393,7 +7477,7 @@ export default { 49, { "input": [ - 398, + 400, "CreateOneObjectInput!" ] } @@ -7402,7 +7486,7 @@ export default { 49, { "input": [ - 400, + 402, "DeleteOneObjectInput!" ] } @@ -7411,7 +7495,7 @@ export default { 49, { "input": [ - 401, + 403, "UpdateOneObjectInput!" ] } @@ -7420,7 +7504,7 @@ export default { 25, { "input": [ - 403, + 405, "CreateAgentInput!" ] } @@ -7429,7 +7513,7 @@ export default { 25, { "input": [ - 404, + 406, "UpdateAgentInput!" ] } @@ -7438,7 +7522,7 @@ export default { 25, { "input": [ - 323, + 325, "AgentIdInput!" ] } @@ -7460,7 +7544,7 @@ export default { 29, { "createRoleInput": [ - 405, + 407, "CreateRoleInput!" ] } @@ -7469,7 +7553,7 @@ export default { 29, { "updateRoleInput": [ - 406, + 408, "UpdateRoleInput!" ] } @@ -7487,7 +7571,7 @@ export default { 16, { "upsertObjectPermissionsInput": [ - 408, + 410, "UpsertObjectPermissionsInput!" ] } @@ -7496,7 +7580,7 @@ export default { 27, { "upsertPermissionFlagsInput": [ - 410, + 412, "UpsertPermissionFlagsInput!" ] } @@ -7505,16 +7589,16 @@ export default { 26, { "upsertFieldPermissionsInput": [ - 411, + 413, "UpsertFieldPermissionsInput!" ] } ], "upsertRowLevelPermissionPredicates": [ - 220, + 222, { "input": [ - 413, + 415, "UpsertRowLevelPermissionPredicatesInput!" ] } @@ -7545,7 +7629,7 @@ export default { 37, { "input": [ - 416, + 418, "CreateOneFieldMetadataInput!" ] } @@ -7554,7 +7638,7 @@ export default { 37, { "input": [ - 418, + 420, "UpdateOneFieldMetadataInput!" ] } @@ -7563,7 +7647,7 @@ export default { 37, { "input": [ - 420, + 422, "DeleteOneFieldInput!" ] } @@ -7572,7 +7656,7 @@ export default { 59, { "input": [ - 421, + 423, "CreateViewGroupInput!" ] } @@ -7581,7 +7665,7 @@ export default { 59, { "inputs": [ - 421, + 423, "[CreateViewGroupInput!]!" ] } @@ -7590,7 +7674,7 @@ export default { 59, { "input": [ - 422, + 424, "UpdateViewGroupInput!" ] } @@ -7599,7 +7683,7 @@ export default { 59, { "inputs": [ - 422, + 424, "[UpdateViewGroupInput!]!" ] } @@ -7608,7 +7692,7 @@ export default { 59, { "input": [ - 424, + 426, "DeleteViewGroupInput!" ] } @@ -7617,40 +7701,40 @@ export default { 59, { "input": [ - 425, + 427, "DestroyViewGroupInput!" ] } ], "updateMessageFolder": [ - 312, + 314, { "input": [ - 426, + 428, "UpdateMessageFolderInput!" ] } ], "updateMessageFolders": [ - 312, + 314, { "input": [ - 428, + 430, "UpdateMessageFoldersInput!" ] } ], "updateMessageChannel": [ - 304, + 306, { "input": [ - 429, + 431, "UpdateMessageChannelInput!" ] } ], "deleteConnectedAccount": [ - 280, + 282, { "id": [ 3, @@ -7659,34 +7743,34 @@ export default { } ], "updateCalendarChannel": [ - 299, + 301, { "input": [ - 431, + 433, "UpdateCalendarChannelInput!" ] } ], "createWebhook": [ - 319, + 321, { "input": [ - 433, + 435, "CreateWebhookInput!" ] } ], "updateWebhook": [ - 319, + 321, { "input": [ - 434, + 436, "UpdateWebhookInput!" ] } ], "deleteWebhook": [ - 319, + 321, { "id": [ 3, @@ -7695,10 +7779,10 @@ export default { } ], "createChatThread": [ - 290 + 292 ], "sendChatMessage": [ - 295, + 297, { "threadId": [ 3, @@ -7734,7 +7818,7 @@ export default { } ], "renameChatThread": [ - 290, + 292, { "id": [ 3, @@ -7747,7 +7831,7 @@ export default { } ], "archiveChatThread": [ - 290, + 292, { "id": [ 3, @@ -7756,7 +7840,7 @@ export default { } ], "unarchiveChatThread": [ - 290, + 292, { "id": [ 3, @@ -7783,25 +7867,25 @@ export default { } ], "createSkill": [ - 288, + 290, { "input": [ - 436, + 438, "CreateSkillInput!" ] } ], "updateSkill": [ - 288, + 290, { "input": [ - 437, + 439, "UpdateSkillInput!" ] } ], "deleteSkill": [ - 288, + 290, { "id": [ 3, @@ -7810,7 +7894,7 @@ export default { } ], "activateSkill": [ - 288, + 290, { "id": [ 3, @@ -7819,7 +7903,7 @@ export default { } ], "deactivateSkill": [ - 288, + 290, { "id": [ 3, @@ -7828,7 +7912,7 @@ export default { } ], "evaluateAgentTurn": [ - 297, + 299, { "turnId": [ 3, @@ -7837,7 +7921,7 @@ export default { } ], "runEvaluationInput": [ - 298, + 300, { "agentId": [ 3, @@ -7850,16 +7934,16 @@ export default { } ], "getAuthorizationUrlForSSO": [ - 229, + 231, { "input": [ - 438, + 440, "GetAuthorizationUrlForSSOInput!" ] } ], "getLoginTokenFromCredentials": [ - 238, + 240, { "email": [ 1, @@ -7885,7 +7969,7 @@ export default { } ], "signIn": [ - 227, + 229, { "email": [ 1, @@ -7907,7 +7991,7 @@ export default { } ], "verifyEmailAndGetLoginToken": [ - 235, + 237, { "emailVerificationToken": [ 1, @@ -7927,7 +8011,7 @@ export default { } ], "verifyEmailAndGetWorkspaceAgnosticToken": [ - 227, + 229, { "emailVerificationToken": [ 1, @@ -7943,7 +8027,7 @@ export default { } ], "getAuthTokensFromOTP": [ - 237, + 239, { "otp": [ 1, @@ -7963,7 +8047,7 @@ export default { } ], "signUp": [ - 227, + 229, { "email": [ 1, @@ -7985,7 +8069,7 @@ export default { } ], "signUpInWorkspace": [ - 232, + 234, { "email": [ 1, @@ -8016,13 +8100,13 @@ export default { } ], "signUpInNewWorkspace": [ - 232 + 234 ], "generateTransientToken": [ - 233 + 235 ], "getAuthTokensFromLoginToken": [ - 237, + 239, { "loginToken": [ 1, @@ -8035,7 +8119,7 @@ export default { } ], "authorizeApp": [ - 225, + 227, { "clientId": [ 1, @@ -8057,7 +8141,7 @@ export default { } ], "renewToken": [ - 237, + 239, { "appToken": [ 1, @@ -8066,7 +8150,7 @@ export default { } ], "generateApiKeyToken": [ - 236, + 238, { "apiKeyId": [ 3, @@ -8079,7 +8163,7 @@ export default { } ], "emailPasswordResetLink": [ - 228, + 230, { "email": [ 1, @@ -8091,7 +8175,7 @@ export default { } ], "updatePasswordViaResetToken": [ - 230, + 232, { "passwordResetToken": [ 1, @@ -8104,10 +8188,10 @@ export default { } ], "createApplicationRegistration": [ - 188, + 190, { "input": [ - 439, + 441, "CreateApplicationRegistrationInput!" ] } @@ -8116,7 +8200,7 @@ export default { 7, { "input": [ - 440, + 442, "UpdateApplicationRegistrationInput!" ] } @@ -8131,7 +8215,7 @@ export default { } ], "rotateApplicationRegistrationClientSecret": [ - 190, + 192, { "id": [ 1, @@ -8143,7 +8227,7 @@ export default { 5, { "input": [ - 442, + 444, "CreateApplicationRegistrationVariableInput!" ] } @@ -8152,7 +8236,7 @@ export default { 5, { "input": [ - 443, + 445, "UpdateApplicationRegistrationVariableInput!" ] } @@ -8170,7 +8254,7 @@ export default { 7, { "file": [ - 339, + 341, "Upload!" ], "universalIdentifier": [ @@ -8192,7 +8276,7 @@ export default { } ], "initiateOTPProvisioning": [ - 223, + 225, { "loginToken": [ 1, @@ -8205,10 +8289,10 @@ export default { } ], "initiateOTPProvisioningForAuthenticatedUser": [ - 223 + 225 ], "deleteTwoFactorAuthenticationMethod": [ - 222, + 224, { "twoFactorAuthenticationMethodId": [ 3, @@ -8217,7 +8301,7 @@ export default { } ], "verifyTwoFactorAuthenticationMethodForAuthenticatedUser": [ - 224, + 226, { "otp": [ 1, @@ -8241,7 +8325,7 @@ export default { 6, { "input": [ - 445, + 447, "UpdateWorkspaceMemberSettingsInput!" ] } @@ -8259,7 +8343,7 @@ export default { } ], "resendEmailVerificationToken": [ - 191, + 193, { "email": [ 1, @@ -8275,7 +8359,7 @@ export default { 69, { "data": [ - 446, + 448, "ActivateWorkspaceInput!" ] } @@ -8284,7 +8368,7 @@ export default { 69, { "data": [ - 447, + 449, "UpdateWorkspaceInput!" ] } @@ -8293,7 +8377,7 @@ export default { 69 ], "checkCustomDomainValidRecords": [ - 219 + 221 ], "installApplication": [ 6, @@ -8311,7 +8395,7 @@ export default { 6, { "workspaceMigration": [ - 448, + 450, "WorkspaceMigrationInput!" ] } @@ -8325,61 +8409,44 @@ export default { ] } ], - "updateOneApplicationVariable": [ - 6, - { - "key": [ - 1, - "String!" - ], - "value": [ - 1, - "String!" - ], - "applicationId": [ - 3, - "UUID!" - ] - } - ], "createOIDCIdentityProvider": [ - 196, + 198, { "input": [ - 451, + 453, "SetupOIDCSsoInput!" ] } ], "createSAMLIdentityProvider": [ - 196, + 198, { "input": [ - 452, + 454, "SetupSAMLSsoInput!" ] } ], "deleteSSOIdentityProvider": [ - 192, + 194, { "input": [ - 453, + 455, "DeleteSsoInput!" ] } ], "editSSOIdentityProvider": [ - 193, + 195, { "input": [ - 454, + 456, "EditSsoInput!" ] } ], "duplicateDashboard": [ - 279, + 281, { "id": [ 3, @@ -8388,7 +8455,7 @@ export default { } ], "impersonate": [ - 241, + 243, { "userId": [ 3, @@ -8401,16 +8468,16 @@ export default { } ], "sendEmail": [ - 284, + 286, { "input": [ - 455, + 457, "SendEmailInput!" ] } ], "startChannelSync": [ - 271, + 273, { "connectedAccountId": [ 3, @@ -8419,7 +8486,7 @@ export default { } ], "saveImapSmtpCaldavAccount": [ - 261, + 263, { "accountOwnerId": [ 3, @@ -8430,7 +8497,7 @@ export default { "String!" ], "connectionParameters": [ - 457, + 459, "EmailAccountConnectionParameters!" ], "id": [ @@ -8439,22 +8506,22 @@ export default { } ], "updateLabPublicFeatureFlag": [ - 160, + 162, { "input": [ - 459, + 461, "UpdateLabPublicFeatureFlagInput!" ] } ], "enablePostgresProxy": [ - 262 + 264 ], "disablePostgresProxy": [ - 262 + 264 ], "createPublicDomain": [ - 250, + 252, { "domain": [ 1, @@ -8472,7 +8539,7 @@ export default { } ], "checkPublicDomainValidRecords": [ - 219, + 221, { "domain": [ 1, @@ -8481,14 +8548,14 @@ export default { } ], "createEmailingDomain": [ - 252, + 254, { "domain": [ 1, "String!" ], "driver": [ - 253, + 255, "EmailingDomainDriver!" ] } @@ -8503,7 +8570,7 @@ export default { } ], "verifyEmailingDomain": [ - 252, + 254, { "id": [ 1, @@ -8515,7 +8582,7 @@ export default { 71, { "input": [ - 460, + 462, "CreateOneAppTokenInput!" ] } @@ -8536,7 +8603,7 @@ export default { 6 ], "createDevelopmentApplication": [ - 245, + 247, { "universalIdentifier": [ 1, @@ -8558,7 +8625,7 @@ export default { } ], "syncApplication": [ - 246, + 248, { "manifest": [ 15, @@ -8567,10 +8634,10 @@ export default { } ], "uploadApplicationFile": [ - 247, + 249, { "file": [ - 339, + 341, "Upload!" ], "applicationUniversalIdentifier": [ @@ -8578,7 +8645,7 @@ export default { "String!" ], "fileFolder": [ - 462, + 464, "FileFolder!" ], "filePath": [ @@ -8655,7 +8722,7 @@ export default { 3 ], "type": [ - 150 + 152 ], "name": [ 1 @@ -8687,7 +8754,7 @@ export default { 3 ], "update": [ - 338 + 340 ], "__typename": [ 1 @@ -8794,7 +8861,7 @@ export default { 3 ], "update": [ - 344 + 346 ], "__typename": [ 1 @@ -8950,17 +9017,17 @@ export default { 3 ], "viewFields": [ - 350 - ], - "viewFilters": [ - 351 - ], - "viewFilterGroups": [ 352 ], - "viewSorts": [ + "viewFilters": [ 353 ], + "viewFilterGroups": [ + 354 + ], + "viewSorts": [ + 355 + ], "__typename": [ 1 ] @@ -9064,7 +9131,7 @@ export default { 3 ], "update": [ - 356 + 358 ], "__typename": [ 1 @@ -9099,7 +9166,7 @@ export default { 3 ], "update": [ - 360 + 362 ], "__typename": [ 1 @@ -9175,7 +9242,7 @@ export default { 3 ], "update": [ - 365 + 367 ], "__typename": [ 1 @@ -9239,10 +9306,10 @@ export default { 3 ], "groups": [ - 370 + 372 ], "fields": [ - 371 + 373 ], "__typename": [ 1 @@ -9262,7 +9329,7 @@ export default { 6 ], "fields": [ - 371 + 373 ], "__typename": [ 1 @@ -9431,7 +9498,7 @@ export default { 3 ], "tabs": [ - 384 + 386 ], "__typename": [ 1 @@ -9454,7 +9521,7 @@ export default { 82 ], "widgets": [ - 385 + 387 ], "__typename": [ 1 @@ -9477,7 +9544,7 @@ export default { 3 ], "gridPosition": [ - 386 + 388 ], "position": [ 15 @@ -9526,7 +9593,7 @@ export default { 3 ], "gridPosition": [ - 386 + 388 ], "position": [ 15 @@ -9552,7 +9619,7 @@ export default { 3 ], "gridPosition": [ - 386 + 388 ], "position": [ 15 @@ -9624,7 +9691,7 @@ export default { 3 ], "update": [ - 392 + 394 ], "__typename": [ 1 @@ -9676,7 +9743,7 @@ export default { 3 ], "engineComponentKey": [ - 264 + 266 ], "label": [ 1 @@ -9694,7 +9761,7 @@ export default { 6 ], "availabilityType": [ - 265 + 267 ], "hotKeys": [ 1 @@ -9735,13 +9802,13 @@ export default { 6 ], "availabilityType": [ - 265 + 267 ], "availabilityObjectMetadataId": [ 3 ], "engineComponentKey": [ - 264 + 266 ], "hotKeys": [ 1 @@ -9784,7 +9851,7 @@ export default { 3 ], "update": [ - 397 + 399 ], "__typename": [ 1 @@ -9803,7 +9870,7 @@ export default { }, "CreateOneObjectInput": { "object": [ - 399 + 401 ], "__typename": [ 1 @@ -9863,7 +9930,7 @@ export default { }, "UpdateOneObjectInput": { "update": [ - 402 + 404 ], "id": [ 3 @@ -10035,7 +10102,7 @@ export default { }, "UpdateRoleInput": { "update": [ - 407 + 409 ], "id": [ 3 @@ -10090,7 +10157,7 @@ export default { 3 ], "objectPermissions": [ - 409 + 411 ], "__typename": [ 1 @@ -10132,7 +10199,7 @@ export default { 3 ], "fieldPermissions": [ - 412 + 414 ], "__typename": [ 1 @@ -10163,10 +10230,10 @@ export default { 3 ], "predicates": [ - 414 + 416 ], "predicateGroups": [ - 415 + 417 ], "__typename": [ 1 @@ -10226,7 +10293,7 @@ export default { }, "CreateOneFieldMetadataInput": { "field": [ - 417 + 419 ], "__typename": [ 1 @@ -10299,7 +10366,7 @@ export default { 3 ], "update": [ - 419 + 421 ], "__typename": [ 1 @@ -10391,7 +10458,7 @@ export default { 3 ], "update": [ - 423 + 425 ], "__typename": [ 1 @@ -10435,7 +10502,7 @@ export default { 3 ], "update": [ - 427 + 429 ], "__typename": [ 1 @@ -10454,7 +10521,7 @@ export default { 3 ], "update": [ - 427 + 429 ], "__typename": [ 1 @@ -10465,7 +10532,7 @@ export default { 3 ], "update": [ - 430 + 432 ], "__typename": [ 1 @@ -10473,16 +10540,16 @@ export default { }, "UpdateMessageChannelInputUpdates": { "visibility": [ - 305 + 307 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 307 + 309 ], "messageFolderImportPolicy": [ - 308 + 310 ], "isSyncEnabled": [ 6 @@ -10502,7 +10569,7 @@ export default { 3 ], "update": [ - 432 + 434 ], "__typename": [ 1 @@ -10510,13 +10577,13 @@ export default { }, "UpdateCalendarChannelInputUpdates": { "visibility": [ - 302 + 304 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 303 + 305 ], "isSyncEnabled": [ 6 @@ -10550,7 +10617,7 @@ export default { 3 ], "update": [ - 435 + 437 ], "__typename": [ 1 @@ -10655,7 +10722,7 @@ export default { 1 ], "update": [ - 441 + 443 ], "__typename": [ 1 @@ -10703,7 +10770,7 @@ export default { 1 ], "update": [ - 444 + 446 ], "__typename": [ 1 @@ -10818,7 +10885,7 @@ export default { }, "WorkspaceMigrationInput": { "actions": [ - 449 + 451 ], "__typename": [ 1 @@ -10826,10 +10893,10 @@ export default { }, "WorkspaceMigrationDeleteActionInput": { "type": [ - 450 + 452 ], "metadataName": [ - 315 + 317 ], "universalIdentifier": [ 1 @@ -10892,7 +10959,7 @@ export default { 3 ], "status": [ - 166 + 168 ], "__typename": [ 1 @@ -10921,7 +10988,7 @@ export default { 1 ], "files": [ - 456 + 458 ], "__typename": [ 1 @@ -10940,13 +11007,13 @@ export default { }, "EmailAccountConnectionParameters": { "IMAP": [ - 458 + 460 ], "SMTP": [ - 458 + 460 ], "CALDAV": [ - 458 + 460 ], "__typename": [ 1 @@ -10985,7 +11052,7 @@ export default { }, "CreateOneAppTokenInput": { "appToken": [ - 461 + 463 ], "__typename": [ 1 @@ -11002,7 +11069,7 @@ export default { "FileFolder": {}, "Subscription": { "onEventSubscription": [ - 157, + 159, { "eventStreamId": [ 1, @@ -11011,16 +11078,16 @@ export default { } ], "logicFunctionLogs": [ - 221, + 223, { "input": [ - 464, + 466, "LogicFunctionLogsInput!" ] } ], "onAgentChatEvent": [ - 296, + 298, { "threadId": [ 3, diff --git a/packages/twenty-docs/developers/extend/apps/connections.mdx b/packages/twenty-docs/developers/extend/apps/connections.mdx new file mode 100644 index 0000000000..41960e885b --- /dev/null +++ b/packages/twenty-docs/developers/extend/apps/connections.mdx @@ -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. + + + + + +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:///apps/oauth/callback +``` + + + + + +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. + + + + + +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. + + + + + +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 `/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. + + + + diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 04461fa796..b76672d4bf 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -288,6 +288,22 @@ export type Application = { yarnLockFileId?: Maybe; }; +export type ApplicationConnectionProvider = { + __typename?: 'ApplicationConnectionProvider'; + applicationId: Scalars['String']; + displayName: Scalars['String']; + id: Scalars['UUID']; + name: Scalars['String']; + oauth?: Maybe; + type: Scalars['String']; +}; + +export type ApplicationConnectionProviderOAuthConfig = { + __typename?: 'ApplicationConnectionProviderOAuthConfig'; + isClientCredentialsConfigured: Scalars['Boolean']; + scopes: Array; +}; + export type ApplicationRegistration = { __typename?: 'ApplicationRegistration'; createdAt: Scalars['DateTime']; @@ -880,6 +896,8 @@ export type CommandMenuItemPayload = ObjectMetadataCommandMenuItemPayload | Path export type ConnectedAccountDto = { __typename?: 'ConnectedAccountDTO'; + applicationConnectionProviderId?: Maybe; + applicationId?: Maybe; authFailedAt?: Maybe; connectionParameters?: Maybe; createdAt: Scalars['DateTime']; @@ -888,14 +906,18 @@ export type ConnectedAccountDto = { id: Scalars['UUID']; lastCredentialsRefreshedAt?: Maybe; lastSignedInAt?: Maybe; + name?: Maybe; provider: Scalars['String']; scopes?: Maybe>; updatedAt: Scalars['DateTime']; userWorkspaceId: Scalars['UUID']; + visibility: Scalars['String']; }; export type ConnectedAccountPublicDto = { __typename?: 'ConnectedAccountPublicDTO'; + applicationConnectionProviderId?: Maybe; + applicationId?: Maybe; authFailedAt?: Maybe; connectionParameters?: Maybe; createdAt: Scalars['DateTime']; @@ -904,10 +926,12 @@ export type ConnectedAccountPublicDto = { id: Scalars['UUID']; lastCredentialsRefreshedAt?: Maybe; lastSignedInAt?: Maybe; + name?: Maybe; provider: Scalars['String']; scopes?: Maybe>; updatedAt: Scalars['DateTime']; userWorkspaceId: Scalars['UUID']; + visibility: Scalars['String']; }; export type ConnectedImapSmtpCaldavAccount = { @@ -4030,6 +4054,7 @@ export type Query = { agentTurns: Array; apiKey?: Maybe; apiKeys: Array; + applicationConnectionProviders: Array; applicationRegistrationTarballUrl?: Maybe; 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 | null, handleAliases?: Array | 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 | null, handleAliases?: Array | 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; @@ -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, 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; 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; 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; -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; +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; 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; 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; 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; @@ -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; 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; 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; +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; 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; 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; 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; diff --git a/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts b/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts index 9c494d5d9a..ac28e9a68f 100644 --- a/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts +++ b/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts @@ -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; diff --git a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts index 0d503ed2e4..19741478f1 100644 --- a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts +++ b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts @@ -27,6 +27,7 @@ export const getMissingDraftEmailScopes = ( case ConnectedAccountProvider.IMAP_SMTP_CALDAV: case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: + case ConnectedAccountProvider.APP: return []; default: assertUnreachable( diff --git a/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyConnectedAccounts.ts b/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyConnectedAccounts.ts index 17067453bf..515dc9e20d 100644 --- a/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyConnectedAccounts.ts +++ b/packages/twenty-front/src/modules/settings/accounts/graphql/queries/getMyConnectedAccounts.ts @@ -11,6 +11,10 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql` handleAliases lastSignedInAt userWorkspaceId + applicationConnectionProviderId + name + visibility + lastCredentialsRefreshedAt connectionParameters { IMAP { host diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useMyConnectedAccounts.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useMyConnectedAccounts.ts index 33d17fba6a..0578dac26b 100644 --- a/packages/twenty-front/src/modules/settings/accounts/hooks/useMyConnectedAccounts.ts +++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useMyConnectedAccounts.ts @@ -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 = + 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 { diff --git a/packages/twenty-front/src/modules/settings/applications/graphql/queries/findApplicationConnectionProviders.ts b/packages/twenty-front/src/modules/settings/applications/graphql/queries/findApplicationConnectionProviders.ts new file mode 100644 index 0000000000..677505d4e3 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/applications/graphql/queries/findApplicationConnectionProviders.ts @@ -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 + } + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic.ts b/packages/twenty-front/src/modules/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic.ts index eac715da95..71c993e522 100644 --- a/packages/twenty-front/src/modules/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic.ts +++ b/packages/twenty-front/src/modules/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic.ts @@ -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, }; }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx index 4b14a0dd60..c3c9fb1e9b 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx @@ -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: { diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx index 504a21bb86..09991c6e44 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx @@ -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 }] : []), diff --git a/packages/twenty-front/src/pages/settings/applications/hooks/useFindApplicationConnectionProviders.ts b/packages/twenty-front/src/pages/settings/applications/hooks/useFindApplicationConnectionProviders.ts new file mode 100644 index 0000000000..f006c7cd63 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/hooks/useFindApplicationConnectionProviders.ts @@ -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( + 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, + }; +}; diff --git a/packages/twenty-front/src/pages/settings/applications/hooks/useMyAppConnectedAccounts.ts b/packages/twenty-front/src/pages/settings/applications/hooks/useMyAppConnectedAccounts.ts new file mode 100644 index 0000000000..efc9be0635 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/hooks/useMyAppConnectedAccounts.ts @@ -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 }; diff --git a/packages/twenty-front/src/pages/settings/applications/hooks/useTriggerAppOAuth.ts b/packages/twenty-front/src/pages/settings/applications/hooks/useTriggerAppOAuth.ts new file mode 100644 index 0000000000..3bc5d5556e --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/hooks/useTriggerAppOAuth.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx new file mode 100644 index 0000000000..43ecae5083 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx @@ -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 ( + + } + dropdownComponents={ + + + handleSelect('user')} + /> + handleSelect('workspace')} + /> + + + } + /> + ); +}; + +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 ( +
+ + {isOAuth && !isClientCredentialsConfigured && ( + + )} + {providerConnections.length > 0 && ( + ({ + 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 }) => ( + + + {item.authFailedAt && ( + + )} + {item.authFailedAt && ( +
+ ); + })} + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx index 825c64255b..ccf645cc18 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx @@ -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} /> + )} diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx index 081d6710f8..3824fb5c0a 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx @@ -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 ( - - application?.id - ? updateOneApplicationVariable({ - key, - value, - applicationId: application.id, - }) - : null - } - /> + <> + {application?.id && ( + + )} + + application?.id + ? updateOneApplicationVariable({ + key, + value, + applicationId: application.id, + }) + : null + } + /> + ); }; diff --git a/packages/twenty-front/src/pages/settings/applications/types/FrontendApplicationConnectionProvider.ts b/packages/twenty-front/src/pages/settings/applications/types/FrontendApplicationConnectionProvider.ts new file mode 100644 index 0000000000..5722e23154 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/types/FrontendApplicationConnectionProvider.ts @@ -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; +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts index 8037caeed8..78ace5eb5c 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts @@ -28,6 +28,7 @@ export const normalizeManifestForComparison = ( 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), diff --git a/packages/twenty-sdk/src/cli/commands/add.ts b/packages/twenty-sdk/src/cli/commands/add.ts index 874a56b78b..581ed9866d 100644 --- a/packages/twenty-sdk/src/cli/commands/add.ts +++ b/packages/twenty-sdk/src/cli/commands/add.ts @@ -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 (`_CLIENT_ID` + // and `_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 { + 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, ) { diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap index 791e0e96b4..434a8845fb 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap +++ b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap @@ -33,6 +33,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1 "createValidationResult", "defineAgent", "defineApplication", + "defineConnectionProvider", "defineField", "defineFrontComponent", "defineLogicFunction", diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index bd8e4ab413..da92d01155 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -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({ + appPath, + filePath, + }); + connectionProviders.push(extract.config); + errors.push(...extract.errors); + connectionProvidersFilePaths.push(relativePath); + break; + } case ManifestEntityKey.LogicFunctions: { const extract = await extractManifestFromFile({ 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, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts index 66c034eb58..b6779b4832 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts @@ -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]: diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts index 670a074bf6..8cf45bb053 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts @@ -71,6 +71,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record = { frontComponents: SyncableEntity.FrontComponent, roles: SyncableEntity.Role, skills: SyncableEntity.Skill, + connectionProviders: SyncableEntity.ConnectionProvider, views: SyncableEntity.View, navigationMenuItems: SyncableEntity.NavigationMenuItem, pageLayouts: SyncableEntity.PageLayout, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts index db9a57db1f..659245fa81 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts @@ -104,6 +104,7 @@ export const ENTITY_LABELS: Record = { [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[]; diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-connection-provider-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-connection-provider-template.ts new file mode 100644 index 0000000000..95499d7dc0 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-connection-provider-template.ts @@ -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', + }, +}); +`; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/file/__tests__/append-server-variables.util.spec.ts b/packages/twenty-sdk/src/cli/utilities/file/__tests__/append-server-variables.util.spec.ts new file mode 100644 index 0000000000..c6aab9fb56 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/file/__tests__/append-server-variables.util.spec.ts @@ -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' }); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/file/append-server-variables.util.ts b/packages/twenty-sdk/src/cli/utilities/file/append-server-variables.util.ts new file mode 100644 index 0000000000..bd9f037129 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/file/append-server-variables.util.ts @@ -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 => { + 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 => { + 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}},`; +}; diff --git a/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts b/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts index 0216e5c576..49a6eea95c 100644 --- a/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts +++ b/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts @@ -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 diff --git a/packages/twenty-sdk/src/sdk/define/connection-providers/__tests__/define-connection-provider.spec.ts b/packages/twenty-sdk/src/sdk/define/connection-providers/__tests__/define-connection-provider.spec.ts new file mode 100644 index 0000000000..b376597f17 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/connection-providers/__tests__/define-connection-provider.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/define/connection-providers/define-connection-provider.ts b/packages/twenty-sdk/src/sdk/define/connection-providers/define-connection-provider.ts new file mode 100644 index 0000000000..fd9af5f1c3 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/connection-providers/define-connection-provider.ts @@ -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 }); +}; diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts index 2ca830bd04..677642e4c9 100644 --- a/packages/twenty-sdk/src/sdk/define/index.ts +++ b/packages/twenty-sdk/src/sdk/define/index.ts @@ -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'; diff --git a/packages/twenty-sdk/src/sdk/logic-function/connections/__tests__/find-connection-for-request.spec.ts b/packages/twenty-sdk/src/sdk/logic-function/connections/__tests__/find-connection-for-request.spec.ts new file mode 100644 index 0000000000..f2e6f0d1c4 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/connections/__tests__/find-connection-for-request.spec.ts @@ -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 => ({ + 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); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/logic-function/connections/__tests__/get-connection.spec.ts b/packages/twenty-sdk/src/sdk/logic-function/connections/__tests__/get-connection.spec.ts new file mode 100644 index 0000000000..7174f2a80b --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/connections/__tests__/get-connection.spec.ts @@ -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 => ({ + 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; + + 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(); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/logic-function/connections/errors/app-connection-auth-failed.error.ts b/packages/twenty-sdk/src/sdk/logic-function/connections/errors/app-connection-auth-failed.error.ts new file mode 100644 index 0000000000..0872bc24ca --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/connections/errors/app-connection-auth-failed.error.ts @@ -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; + } +} diff --git a/packages/twenty-sdk/src/sdk/logic-function/connections/find-connection-for-request.ts b/packages/twenty-sdk/src/sdk/logic-function/connections/find-connection-for-request.ts new file mode 100644 index 0000000000..fc1c755c7a --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/connections/find-connection-for-request.ts @@ -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; +}; diff --git a/packages/twenty-sdk/src/sdk/logic-function/connections/get-connection.ts b/packages/twenty-sdk/src/sdk/logic-function/connections/get-connection.ts new file mode 100644 index 0000000000..010bce0027 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/connections/get-connection.ts @@ -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 => { + const connection = await postConnectionsEndpoint< + { id: string }, + AppConnection + >('get', { id }); + + if (connection.authFailedAt !== null) { + throw new AppConnectionAuthFailedError(connection.id); + } + + return connection; +}; diff --git a/packages/twenty-sdk/src/sdk/logic-function/connections/list-connections.ts b/packages/twenty-sdk/src/sdk/logic-function/connections/list-connections.ts new file mode 100644 index 0000000000..1ae16be23f --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/connections/list-connections.ts @@ -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 => + postConnectionsEndpoint( + 'list', + filter, + ); diff --git a/packages/twenty-sdk/src/sdk/logic-function/connections/types/app-connection.type.ts b/packages/twenty-sdk/src/sdk/logic-function/connections/types/app-connection.type.ts new file mode 100644 index 0000000000..b51afa62f6 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/connections/types/app-connection.type.ts @@ -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'; diff --git a/packages/twenty-sdk/src/sdk/logic-function/connections/utils/post-connections-endpoint.util.ts b/packages/twenty-sdk/src/sdk/logic-function/connections/utils/post-connections-endpoint.util.ts new file mode 100644 index 0000000000..845d9ba012 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/connections/utils/post-connections-endpoint.util.ts @@ -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 ( + path: 'list' | 'get', + body: TBody, +): Promise => { + 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; +}; diff --git a/packages/twenty-sdk/src/sdk/logic-function/index.ts b/packages/twenty-sdk/src/sdk/logic-function/index.ts index 4b238575cc..812605b566 100644 --- a/packages/twenty-sdk/src/sdk/logic-function/index.ts +++ b/packages/twenty-sdk/src/sdk/logic-function/index.ts @@ -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'; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1777558657640-addApplicationOAuthProviderAndConnectedAccountColumn.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1777558657640-addApplicationOAuthProviderAndConnectedAccountColumn.ts new file mode 100644 index 0000000000..9ba1441d64 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1777558657640-addApplicationOAuthProviderAndConnectedAccountColumn.ts @@ -0,0 +1,133 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddApplicationOAuthProviderAndConnectedAccountColumn1777558657640 + implements MigrationInterface +{ + name = 'AddApplicationOAuthProviderAndConnectedAccountColumn1777558657640'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts index 1d3d9752a1..f85d2f02f6 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts index 65ac2d0196..bf3804f2a4 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider-flow.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider-flow.service.spec.ts new file mode 100644 index 0000000000..cbc76a811d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider-flow.service.spec.ts @@ -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/); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider.service.spec.ts new file mode 100644 index 0000000000..33a176b58a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider.service.spec.ts @@ -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 => + ({ + 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', + }), + ]); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-connection-provider.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-connection-provider.resolver.ts new file mode 100644 index 0000000000..b6d6adf684 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-connection-provider.resolver.ts @@ -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 { + 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, + }, + })); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum.ts new file mode 100644 index 0000000000..4d67969f86 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum.ts @@ -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', +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service.ts new file mode 100644 index 0000000000..bf2ba93ad3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service.ts @@ -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, + ) {} + + 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 { + 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 { + 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); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.controller.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.controller.ts new file mode 100644 index 0000000000..fb7922e410 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.controller.ts @@ -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, + @InjectRepository(UserWorkspaceEntity) + private readonly userWorkspaceRepository: Repository, + ) {} + + // 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), + }), + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity.ts new file mode 100644 index 0000000000..8dabb4dd62 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity.ts @@ -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; + + @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 | 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; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception.ts new file mode 100644 index 0000000000..c5085c71e4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception.ts @@ -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 { + constructor( + message: string, + code: ApplicationOAuthProviderExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getApplicationOAuthProviderExceptionUserFriendlyMessage(code), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module.ts new file mode 100644 index 0000000000..6270a076d5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service.ts new file mode 100644 index 0000000000..704d19b871 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service.ts @@ -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, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + @InjectRepository(ApplicationRegistrationVariableEntity) + private readonly registrationVariableRepository: Repository, + 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 { + const result = await this.areClientCredentialsConfiguredBatch([provider]); + + return result.get(provider.id) ?? false; + } + + async areClientCredentialsConfiguredBatch( + providers: ApplicationOAuthProviderEntity[], + ): Promise> { + const result = new Map(); + + 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>(); + + for (const variable of variables) { + if (variable.encryptedValue === '') continue; + const set = + filledKeysByRegistrationId.get(variable.applicationRegistrationId) ?? + new Set(); + + 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 { + return this.oauthProviderRepository.findOne({ + where: { applicationId, name }, + }); + } + + async findOneByIdOrThrow( + id: string, + ): Promise { + 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 { + 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 { + 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[] = + 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 }, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/__tests__/application-connections-list.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/__tests__/application-connections-list.service.spec.ts new file mode 100644 index 0000000000..2674319354 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/__tests__/application-connections-list.service.spec.ts @@ -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 => + ({ + 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 => + ({ + 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); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.controller.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.controller.ts new file mode 100644 index 0000000000..28b3e9b13e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.controller.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.module.ts new file mode 100644 index 0000000000..2b6f5752c9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto.ts new file mode 100644 index 0000000000..06648dc626 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/get-app-connection.dto.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/get-app-connection.dto.ts new file mode 100644 index 0000000000..d8c8b1b5e6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/get-app-connection.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class GetAppConnectionDto { + @IsUUID() + id: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/list-app-connections.dto.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/list-app-connections.dto.ts new file mode 100644 index 0000000000..d100b421b6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/list-app-connections.dto.ts @@ -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'; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service.ts new file mode 100644 index 0000000000..45c7e8030a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service.ts @@ -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, + @InjectRepository(ApplicationOAuthProviderEntity) + private readonly oauthProviderRepository: Repository, + ) {} + + async list({ + applicationId, + workspaceId, + requestUserWorkspaceId, + filter, + }: ListArgs): Promise { + 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 = { + 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 { + 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, + requestUserWorkspaceId: string | null, + visibilityFilter: 'user' | 'workspace' | undefined, + ): + | FindOptionsWhere + | FindOptionsWhere[] { + // 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, + ): Promise { + 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; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/dtos/application-connection-provider.dto.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/dtos/application-connection-provider.dto.ts new file mode 100644 index 0000000000..730ceccb64 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/dtos/application-connection-provider.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module.ts new file mode 100644 index 0000000000..ef4851d2fb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service.ts new file mode 100644 index 0000000000..d26bdc43e3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service.ts @@ -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 { + 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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service.ts new file mode 100644 index 0000000000..c81d83ea66 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service.ts @@ -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 { + 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}`, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type.ts new file mode 100644 index 0000000000..9afa70ae50 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type.ts @@ -0,0 +1,5 @@ +export type TokenExchangeResponse = { + accessToken: string; + refreshToken: string | null; + scopes: string[] | null; +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/__tests__/exchange-code-for-token.util.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/__tests__/exchange-code-for-token.util.spec.ts new file mode 100644 index 0000000000..49c8441091 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/__tests__/exchange-code-for-token.util.spec.ts @@ -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; 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; 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(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/build-callback-url.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/build-callback-url.util.ts new file mode 100644 index 0000000000..7bcb5aa026 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/build-callback-url.util.ts @@ -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(); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/compute-pkce-challenge.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/compute-pkce-challenge.util.ts new file mode 100644 index 0000000000..25afd61f3c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/compute-pkce-challenge.util.ts @@ -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()); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/encode-oauth-body.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/encode-oauth-body.util.ts new file mode 100644 index 0000000000..bc2132f69c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/encode-oauth-body.util.ts @@ -0,0 +1,15 @@ +import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application'; + +export const encodeOAuthBody = ( + contentType: OAuthProviderTokenRequestContentType, + params: Record, +): { body: string; contentTypeHeader: string } => + contentType === 'json' + ? { + body: JSON.stringify(params), + contentTypeHeader: 'application/json', + } + : { + body: new URLSearchParams(params).toString(), + contentTypeHeader: 'application/x-www-form-urlencoded', + }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util.ts new file mode 100644 index 0000000000..bba71e4039 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util.ts @@ -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 => { + const params: Record = { + 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, + }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util.ts new file mode 100644 index 0000000000..ff7804408d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util.ts @@ -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 => + 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, + }, + }); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/generate-pkce-verifier.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/generate-pkce-verifier.util.ts new file mode 100644 index 0000000000..8f14af4169 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/generate-pkce-verifier.util.ts @@ -0,0 +1,6 @@ +import { randomBytes } from 'crypto'; + +import { base64UrlEncode } from 'twenty-shared/utils'; + +export const generatePkceVerifier = (): string => + base64UrlEncode(randomBytes(32)); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/parse-token-response.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/parse-token-response.util.ts new file mode 100644 index 0000000000..9b988de00e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/parse-token-response.util.ts @@ -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, +): 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, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util.ts new file mode 100644 index 0000000000..33a767afd8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util.ts @@ -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; +}): Promise => { + 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); +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index bb1d743f9a..2f9159d04a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -8,6 +8,9 @@ import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.enti import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { ApplicationOAuthProviderModule } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module'; +import { ApplicationOAuthProviderController } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.controller'; +import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/application-oauth-provider/connections/application-connections.module'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller'; import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller'; @@ -115,6 +118,8 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; DomainServerConfigModule, ApplicationRegistrationModule, ApplicationModule, + ApplicationOAuthProviderModule, + ApplicationConnectionsModule, WorkspaceCacheModule, CoreEntityCacheModule, SecureHttpClientModule, @@ -128,6 +133,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; MicrosoftAPIsAuthController, OAuthPropagatorController, SSOAuthController, + ApplicationOAuthProviderController, ], providers: [ SignInUpService, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 53f0c1db45..18a9b9057c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -1140,6 +1140,8 @@ export class AuthService { return []; case ConnectedAccountProvider.IMAP_SMTP_CALDAV: return []; + case ConnectedAccountProvider.APP: + return []; default: throw new Error( `Unsupported connected account provider: ${provider satisfies never}`, diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index a4f817b1e3..ab099f04fd 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -48,6 +48,7 @@ export enum JwtTokenTypeEnum { KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY', APPLICATION_ACCESS = 'APPLICATION_ACCESS', APPLICATION_REFRESH = 'APPLICATION_REFRESH', + APP_OAUTH_STATE = 'APP_OAUTH_STATE', } type CommonPropertiesJwtPayload = { @@ -141,6 +142,23 @@ export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & { type: JwtTokenTypeEnum.POSTGRES_PROXY; }; +export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.APP_OAUTH_STATE; + workspaceId: string; + applicationOAuthProviderId: string; + userId: string; + userWorkspaceId: string; + // 'user' = the resulting credential is private to userWorkspaceId. + // 'workspace' = visible to anyone in the workspace. + // Named `visibility` to disambiguate from OAuth `scopes` on the row. + visibility: 'user' | 'workspace'; + // If set, the callback updates this existing connectedAccount row instead + // of creating a new one (used by the UI's "Reconnect" action). + reconnectingConnectedAccountId: string | null; + redirectLocation: string | null; + codeVerifier: string | null; +}; + export type JwtPayload = | AccessTokenJwtPayload | ApiKeyTokenJwtPayload @@ -152,4 +170,5 @@ export type JwtPayload = | RefreshTokenJwtPayload | FileTokenJwtPayload | FileTokenJwtPayloadLegacy - | PostgresProxyTokenJwtPayload; + | PostgresProxyTokenJwtPayload + | AppOAuthStateJwtPayload; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts index 77daadb375..daf98cbc07 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts @@ -166,17 +166,22 @@ export class RouteTriggerService { const httpRouteSettings = logicFunction.httpRouteTriggerSettings; + let userWorkspaceId: string | null = null; + if (httpRouteSettings?.isAuthRequired) { - await this.validateWorkspaceFromRequest({ + const authContext = await this.validateWorkspaceFromRequest({ request, workspaceId: logicFunction.workspaceId, }); + + userWorkspaceId = authContext.userWorkspaceId ?? null; } const event = buildLogicFunctionEvent({ request, pathParameters: pathParams, forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [], + userWorkspaceId, }); let result; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts index 46d31a9451..4760cde23c 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/__tests__/build-logic-function-event.util.spec.ts @@ -332,6 +332,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: { id: '123' }, forwardedRequestHeaders: ['content-type', 'authorization'], + userWorkspaceId: 'uws-1', }); expect(result).toEqual({ @@ -349,6 +350,7 @@ describe('buildLogicFunctionEvent', () => { path: '/s/users/123', }, }, + userWorkspaceId: 'uws-1', }); }); @@ -361,6 +363,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: {}, forwardedRequestHeaders: [], + userWorkspaceId: null, }); expect(result.requestContext.http.path).toBe('/s/api/users'); @@ -375,6 +378,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: {}, forwardedRequestHeaders: [], + userWorkspaceId: null, }); expect(result.requestContext.http.path).toBe('/api/users'); @@ -391,6 +395,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: {}, forwardedRequestHeaders: [], + userWorkspaceId: null, }); expect(result.body).toBeNull(); @@ -407,6 +412,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: { userId: '456' }, forwardedRequestHeaders: [], + userWorkspaceId: null, }); expect(result.requestContext.http.method).toBe('DELETE'); @@ -427,6 +433,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: {}, forwardedRequestHeaders: ['x-api-key'], + userWorkspaceId: null, }); expect(result.headers).toEqual({ @@ -443,6 +450,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: {}, forwardedRequestHeaders: [], + userWorkspaceId: null, }); expect(result.isBase64Encoded).toBe(false); @@ -464,6 +472,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: {}, forwardedRequestHeaders: [], + userWorkspaceId: null, }); expect(result.rawBody).toBe(original); @@ -480,6 +489,7 @@ describe('buildLogicFunctionEvent', () => { request, pathParameters: {}, forwardedRequestHeaders: [], + userWorkspaceId: null, }); expect(result.rawBody).toBeUndefined(); @@ -498,6 +508,7 @@ describe('buildLogicFunctionEvent', () => { userId: 'user1', }, forwardedRequestHeaders: [], + userWorkspaceId: null, }); expect(result.pathParameters).toEqual({ diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts index 80cbf71399..af641c3b81 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts @@ -117,10 +117,12 @@ export const buildLogicFunctionEvent = ({ request, pathParameters, forwardedRequestHeaders, + userWorkspaceId, }: { request: Request; pathParameters: Record; forwardedRequestHeaders: string[]; + userWorkspaceId: string | null; }): LogicFunctionEvent => { const rawBody = extractRawBody(request); @@ -140,5 +142,6 @@ export const buildLogicFunctionEvent = ({ path: request.path, }, }, + userWorkspaceId, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.module.ts index eb0138dd4c..c800662cb1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service'; @@ -19,6 +20,7 @@ import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/ CalendarChannelEntity, MessageChannelEntity, ]), + AppOAuthRefreshModule, FeatureFlagModule, PermissionsModule, WorkspaceEventEmitterModule, diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.service.ts index 90e3f29d18..6ca1fa8397 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/connected-account-metadata.service.ts @@ -5,6 +5,7 @@ import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { In, Repository } from 'typeorm'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { AppOAuthRevokeService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service'; import { ConnectedAccountException, ConnectedAccountExceptionCode, @@ -33,6 +34,7 @@ export class ConnectedAccountMetadataService { private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly appOAuthRevokeService: AppOAuthRevokeService, ) {} async findAll(workspaceId: string): Promise { @@ -182,6 +184,12 @@ export class ConnectedAccountMetadataService { `WorkspaceId: ${workspaceId} Deleting connected account ${id} with ${messageChannels.length} message channel(s) and ${calendarChannels.length} calendar channel(s)`, ); + // Best-effort revocation against the provider's revokeEndpoint (no-op + // for non-app providers and for app providers without a revokeEndpoint + // declared). We don't want a slow or failing provider to block the + // local disconnect, so any error is swallowed inside the service. + await this.appOAuthRevokeService.revokeIfApp(connectedAccount); + await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => { await this.repository.delete({ id, diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/dtos/connected-account.dto.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/dtos/connected-account.dto.ts index 7fa46795dd..460e15f768 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/dtos/connected-account.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/dtos/connected-account.dto.ts @@ -72,6 +72,28 @@ export class ConnectedAccountDTO { @Field(() => UUIDScalarType) userWorkspaceId: string; + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + applicationConnectionProviderId: string | null; + + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + applicationId: string | null; + + @IsString() + @IsOptional() + @Field(() => String, { nullable: true }) + name: string | null; + + // 'user' = private to the connecting user. + // 'workspace' = shared with all members. + // Named `visibility` to disambiguate from the OAuth `scopes` array. + @IsString() + @Field(() => String) + visibility: string; + @HideField() workspaceId: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts index 1343d6017f..20ce2b2c40 100644 --- a/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/connected-account/entities/connected-account.entity.ts @@ -2,6 +2,9 @@ import { Column, CreateDateColumn, Entity, + Index, + JoinColumn, + ManyToOne, OneToMany, PrimaryGeneratedColumn, type Relation, @@ -10,12 +13,23 @@ import { import { type ConnectedAccountProvider } from 'twenty-shared/types'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity'; import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type'; import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity'; +// Distinguishes who can use this credential. Named `visibility` (not +// `scope`) so it doesn't clash with the OAuth `scopes` array on the same +// row — those are unrelated concepts that used to differ by one letter. +export type ConnectedAccountVisibility = 'user' | 'workspace'; + @Entity({ name: 'connectedAccount', schema: 'core' }) +@Index('IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID', [ + 'applicationConnectionProviderId', +]) +@Index('IDX_CONNECTED_ACCOUNT_APPLICATION_ID', ['applicationId']) export class ConnectedAccountEntity extends WorkspaceRelatedEntity { @PrimaryGeneratedColumn('uuid') id: string; @@ -56,6 +70,32 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity { @Column({ type: 'uuid', nullable: false }) userWorkspaceId: string; + @Column({ type: 'uuid', nullable: true, name: 'applicationOAuthProviderId' }) + applicationConnectionProviderId: string | null; + + @ManyToOne(() => ApplicationOAuthProviderEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'applicationOAuthProviderId' }) + applicationConnectionProvider: Relation | null; + + @Column({ type: 'uuid', nullable: true }) + applicationId: string | null; + + @ManyToOne(() => ApplicationEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'applicationId' }) + application: Relation | null; + + @Column({ type: 'varchar', nullable: true }) + name: string | null; + + @Column({ type: 'varchar', nullable: false, default: 'user' }) + visibility: ConnectedAccountVisibility; + @OneToMany( 'MessageChannelEntity', (messageChannel: MessageChannelEntity) => messageChannel.connectedAccount, diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception.ts b/packages/twenty-server/src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception.ts similarity index 100% rename from packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception.ts rename to packages/twenty-server/src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception.ts diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-account-authentication.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-account-authentication.service.ts index 108e887670..5b5aa23f81 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-account-authentication.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-account-authentication.service.ts @@ -7,7 +7,7 @@ import { CalendarEventImportDriverException, CalendarEventImportDriverExceptionCode, } from 'src/modules/calendar/calendar-event-import-manager/drivers/exceptions/calendar-event-import-driver.exception'; -import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; +import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; import { ConnectedAccountRefreshTokensService, type ConnectedAccountTokens, diff --git a/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts b/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts index 4741748859..0b756a837f 100644 --- a/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts +++ b/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts @@ -43,6 +43,7 @@ export class EmailAliasManagerService { case ConnectedAccountProvider.IMAP_SMTP_CALDAV: case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: + case ConnectedAccountProvider.APP: handleAliases = []; break; default: diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module.ts index dacea180e0..a59cc6c3d6 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/connected-account-refresh-tokens-manager.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/google-api-refresh-access-token.module'; @@ -13,6 +14,7 @@ import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-acco TypeOrmModule.forFeature([ConnectedAccountEntity]), GoogleAPIRefreshAccessTokenModule, MicrosoftAPIRefreshAccessTokenModule, + AppOAuthRefreshModule, ], providers: [ConnectedAccountRefreshTokensService], exports: [ConnectedAccountRefreshTokensService], diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service.ts index b8792e2614..6f28cd8d68 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service.ts @@ -7,7 +7,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, -} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; +} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; import { parseGoogleOAuthError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util'; diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util.ts index bef8b9a9cd..5ac15a4f43 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/google/utils/parse-google-oauth-error.util.ts @@ -3,7 +3,7 @@ import { type GaxiosError } from 'gaxios'; import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, -} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; +} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; import { isGmailNetworkError } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/is-gmail-network-error.util'; export const parseGoogleOAuthError = ( diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts index 4c95a6b50e..fc30e1380b 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service.ts @@ -6,7 +6,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, -} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; +} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; import type { ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service'; import { parseMsalError } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util'; diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util.ts index 7c245bc01e..b1a275ff64 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/utils/parse-msal-error.util.ts @@ -7,7 +7,7 @@ import { import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, -} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; +} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; /** * @see https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts index f4055943a0..53541c5db8 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ConnectedAccountProvider } from 'twenty-shared/types'; +import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service'; @@ -10,7 +11,7 @@ import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-acc import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, -} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; +} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; import { ConnectedAccountRefreshTokensService } from './connected-account-refresh-tokens.service'; @@ -42,6 +43,12 @@ describe('ConnectedAccountRefreshTokensService', () => { refreshTokens: jest.fn(), }, }, + { + provide: AppOAuthRefreshAccessTokenService, + useValue: { + refreshTokens: jest.fn(), + }, + }, { provide: GlobalWorkspaceOrmManager, useValue: { diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts index 2f39f496ad..a2ae60840c 100644 --- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts +++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts @@ -5,6 +5,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; +import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -13,7 +14,7 @@ import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-acc import { ConnectedAccountRefreshAccessTokenException, ConnectedAccountRefreshAccessTokenExceptionCode, -} from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; +} from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; export type ConnectedAccountTokens = { accessToken: string; @@ -31,6 +32,7 @@ export class ConnectedAccountRefreshTokensService { constructor( private readonly googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService, private readonly microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService, + private readonly appOAuthRefreshAccessTokenService: AppOAuthRefreshAccessTokenService, private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, @InjectRepository(ConnectedAccountEntity) private readonly connectedAccountRepository: Repository, @@ -99,7 +101,8 @@ export class ConnectedAccountRefreshTokensService { ): Promise { switch (connectedAccount.provider) { case ConnectedAccountProvider.GOOGLE: - case ConnectedAccountProvider.MICROSOFT: { + case ConnectedAccountProvider.MICROSOFT: + case ConnectedAccountProvider.APP: { if (!connectedAccount.lastCredentialsRefreshedAt) { return false; } @@ -141,6 +144,11 @@ export class ConnectedAccountRefreshTokensService { return await this.microsoftAPIRefreshAccessTokenService.refreshTokens( refreshToken, ); + case ConnectedAccountProvider.APP: + return await this.appOAuthRefreshAccessTokenService.refreshTokens( + connectedAccount, + refreshToken, + ); case ConnectedAccountProvider.IMAP_SMTP_CALDAV: case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-account-authentication.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-account-authentication.service.ts index 28031e0dd5..4bbf55f245 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-account-authentication.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-account-authentication.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'class-validator'; import { ConnectedAccountProvider } from 'twenty-shared/types'; -import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/modules/connected-account/refresh-tokens-manager/exceptions/connected-account-refresh-tokens.exception'; +import { ConnectedAccountRefreshAccessTokenExceptionCode } from 'src/engine/metadata-modules/connected-account/exceptions/connected-account-refresh-tokens.exception'; import { ConnectedAccountRefreshTokensService, type ConnectedAccountTokens, diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts index 85ca92e18d..7f9b9f2249 100644 --- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts @@ -40,6 +40,7 @@ export class MessagingMessageOutboundService { ); case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: + case ConnectedAccountProvider.APP: throw new Error( `Provider ${connectedAccount.provider} does not support sending messages`, ); @@ -73,6 +74,7 @@ export class MessagingMessageOutboundService { ); case ConnectedAccountProvider.OIDC: case ConnectedAccountProvider.SAML: + case ConnectedAccountProvider.APP: throw new Error( `Provider ${connectedAccount.provider} does not support creating drafts`, ); diff --git a/packages/twenty-shared/src/application/appConnectionType.ts b/packages/twenty-shared/src/application/appConnectionType.ts new file mode 100644 index 0000000000..716947c479 --- /dev/null +++ b/packages/twenty-shared/src/application/appConnectionType.ts @@ -0,0 +1,40 @@ +// Wire shape exchanged between an app's logic-function runtime and the +// `/apps/connections/list` and `/apps/connections/get` endpoints. +// +// Lives in twenty-shared so both sides — the SDK helpers (`listConnections`, +// `getConnection`) and the server controller / DTO — pick from one source +// of truth. Adding a field on either side without updating this type will +// fail typecheck. +export type AppConnection = { + id: string; + // The app-developer-facing provider name (e.g. "linear"), as declared on + // `defineConnectionProvider({ name })`. Useful when listing across providers + // without a filter. + providerName: string; + // User-given (or auto-derived from the OAuth handle) display label. + // Falls back to `handle` when the user never set one. Suitable for logs + // and end-user UI. + name: string; + // OAuth-derived identifier (typically email or login). Stays stable across + // reconnects of the same upstream account. + handle: string; + // Connection-row visibility: + // 'user' = visible only to the user who created it. + // 'workspace' = visible to every user in the workspace. + // Named `visibility` (not `scope`) to disambiguate from the `scopes` + // array below, which is the unrelated set of OAuth permissions granted + // by the upstream provider. + visibility: 'user' | 'workspace'; + // The userWorkspace that originally created the credential (also the owner + // for `scope: 'user'` credentials). Match against `event.userWorkspaceId` + // to resolve the request user's connection. + userWorkspaceId: string; + accessToken: string; + // OAuth scopes actually granted by the upstream provider on the most recent + // token issuance (may be a subset of what the app requested). + scopes: string[]; + // Set when the most recent refresh attempt failed permanently + // (4xx invalid_grant); the user must reconnect from the app's settings tab. + // Apps should surface this so users know to take action. + authFailedAt: string | null; +}; diff --git a/packages/twenty-shared/src/application/connectionProviderManifestType.ts b/packages/twenty-shared/src/application/connectionProviderManifestType.ts new file mode 100644 index 0000000000..e45d44efa0 --- /dev/null +++ b/packages/twenty-shared/src/application/connectionProviderManifestType.ts @@ -0,0 +1,9 @@ +import { type OAuthConnectionProviderConfig } from '@/application/oauthConnectionProviderConfigType'; +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; + +export type ConnectionProviderManifest = SyncableEntityOptions & { + name: string; + displayName: string; + type: 'oauth'; + oauth: OAuthConnectionProviderConfig; +}; diff --git a/packages/twenty-shared/src/application/connectionProviderType.ts b/packages/twenty-shared/src/application/connectionProviderType.ts new file mode 100644 index 0000000000..3363cd85f5 --- /dev/null +++ b/packages/twenty-shared/src/application/connectionProviderType.ts @@ -0,0 +1,5 @@ +// Discriminator over how a connection's credentials are obtained. Today only +// `oauth` is supported. Future credential types (PATs, API keys, basic auth) +// add new `type` values + their own sub-config block alongside `oauth` — +// purely additive, no breaking change for app developers. +export type ConnectionProviderType = 'oauth'; diff --git a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts index 2bd3300ed1..6797192fdc 100644 --- a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts +++ b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts @@ -6,6 +6,7 @@ export enum SyncableEntity { Role = 'role', Skill = 'skill', Agent = 'agent', + ConnectionProvider = 'connectionProvider', View = 'view', NavigationMenuItem = 'navigationMenuItem', PageLayout = 'pageLayout', diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 26705702ee..0f3d4e3a66 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -8,9 +8,12 @@ */ export type { AgentManifest } from './agentManifestType'; +export type { AppConnection } from './appConnectionType'; export type { ApplicationManifest } from './applicationType'; export type { ApplicationVariables } from './applicationVariablesType'; export type { AssetManifest } from './assetManifestType'; +export type { ConnectionProviderManifest } from './connectionProviderManifestType'; +export type { ConnectionProviderType } from './connectionProviderType'; export { ASSETS_DIR } from './constants/AssetDirectory'; export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName'; export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName'; @@ -39,6 +42,8 @@ export type { } from './logicFunctionManifestType'; export type { Manifest } from './manifestType'; export type { NavigationMenuItemManifest } from './navigationMenuItemManifestType'; +export type { OAuthConnectionProviderConfig } from './oauthConnectionProviderConfigType'; +export type { OAuthProviderTokenRequestContentType } from './oauthProviderTokenRequestContentType.type'; export type { ObjectFieldManifest } from './objectFieldManifest.type'; export type { ObjectManifest } from './objectManifestType'; export type { diff --git a/packages/twenty-shared/src/application/manifestType.ts b/packages/twenty-shared/src/application/manifestType.ts index 2f98805720..4efb9f4f4b 100644 --- a/packages/twenty-shared/src/application/manifestType.ts +++ b/packages/twenty-shared/src/application/manifestType.ts @@ -1,6 +1,7 @@ import { type AgentManifest } from './agentManifestType'; import { type ApplicationManifest } from './applicationType'; import { type AssetManifest } from './assetManifestType'; +import { type ConnectionProviderManifest } from './connectionProviderManifestType'; import { type FieldManifest } from './fieldManifestType'; import { type FrontComponentManifest } from './frontComponentManifestType'; import { type LogicFunctionManifest } from './logicFunctionManifestType'; @@ -23,6 +24,7 @@ export type Manifest = { roles: RoleManifest[]; skills: SkillManifest[]; agents: AgentManifest[]; + connectionProviders?: ConnectionProviderManifest[]; publicAssets: AssetManifest[]; views: ViewManifest[]; navigationMenuItems: NavigationMenuItemManifest[]; diff --git a/packages/twenty-shared/src/application/oauthConnectionProviderConfigType.ts b/packages/twenty-shared/src/application/oauthConnectionProviderConfigType.ts new file mode 100644 index 0000000000..c47777c345 --- /dev/null +++ b/packages/twenty-shared/src/application/oauthConnectionProviderConfigType.ts @@ -0,0 +1,13 @@ +import { type OAuthProviderTokenRequestContentType } from '@/application/oauthProviderTokenRequestContentType.type'; + +export type OAuthConnectionProviderConfig = { + authorizationEndpoint: string; + tokenEndpoint: string; + revokeEndpoint?: string; + scopes: string[]; + clientIdVariable: string; + clientSecretVariable: string; + authorizationParams?: Record; + tokenRequestContentType?: OAuthProviderTokenRequestContentType; + usePkce?: boolean; +}; diff --git a/packages/twenty-shared/src/application/oauthProviderTokenRequestContentType.type.ts b/packages/twenty-shared/src/application/oauthProviderTokenRequestContentType.type.ts new file mode 100644 index 0000000000..46b3fa545b --- /dev/null +++ b/packages/twenty-shared/src/application/oauthProviderTokenRequestContentType.type.ts @@ -0,0 +1 @@ +export type OAuthProviderTokenRequestContentType = 'json' | 'form-urlencoded'; diff --git a/packages/twenty-shared/src/types/ConnectedAccountProvider.ts b/packages/twenty-shared/src/types/ConnectedAccountProvider.ts index 68627ad2b3..33c6f219d4 100644 --- a/packages/twenty-shared/src/types/ConnectedAccountProvider.ts +++ b/packages/twenty-shared/src/types/ConnectedAccountProvider.ts @@ -4,4 +4,5 @@ export enum ConnectedAccountProvider { IMAP_SMTP_CALDAV = 'imap_smtp_caldav', OIDC = 'oidc', SAML = 'saml', + APP = 'app', } diff --git a/packages/twenty-shared/src/types/LogicFunctionEvent.ts b/packages/twenty-shared/src/types/LogicFunctionEvent.ts index 8e76532bf6..7fc87b823e 100644 --- a/packages/twenty-shared/src/types/LogicFunctionEvent.ts +++ b/packages/twenty-shared/src/types/LogicFunctionEvent.ts @@ -11,4 +11,8 @@ export type LogicFunctionEvent = { path: string; }; }; + // Populated for HTTP-route triggers with `isAuthRequired: true`. null + // when the trigger fires without a user (cron, database events) or when + // auth is disabled. + userWorkspaceId: string | null; };