From e3be1f497166d5be07e0c4f3885388b5c1b5eef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 5 May 2026 14:04:15 +0200 Subject: [PATCH] Make ConnectionProvider a true SyncableEntity (#20232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary PR #20181 left `ConnectionProvider` in the `SyncableEntity` enum but bypassing the standard sync pipeline — manifest sync called the bespoke `ApplicationOAuthProviderService.upsertManyFromManifest()` instead of going through the workspace-migration orchestrator like every other SyncableEntity. Anything that assumed *"all SyncableEntity values flow through the same pipeline"* (dev UI sync tracking, verification tooling) was wrong about ConnectionProvider — that's the inconsistency this PR closes. This PR follows the `.cursor/skills/syncable-entity-*` guides religiously, all six steps. ## What changes **Step 1 — Types & Constants** (`@syncable-entity-types-and-constants`) - Add `connectionProvider` to `ALL_METADATA_NAME` (twenty-shared) - Make `ApplicationOAuthProviderEntity` extend `SyncableEntity` (drops the ad-hoc columns since the base class provides them, adds `deletedAt`, drops the old `(applicationId, universalIdentifier)` unique in favour of SyncableEntity's `(workspaceId, universalIdentifier)`) - `FlatConnectionProvider`, `FlatConnectionProviderMaps`, `FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES`, `UniversalFlatConnectionProvider`, six action types - Register in **all** the central registries: `AllFlatEntityTypesByMetadataName`, `ALL_METADATA_ENTITY_BY_METADATA_NAME`, `ALL_ENTITY_PROPERTIES_CONFIGURATION`, `ALL_MANY_TO_ONE_*`, `ALL_ONE_TO_MANY_*`, `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION`, `ALL_METADATA_SERIALIZED_RELATION`, `ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION`, `WORKSPACE_CACHE_KEYS_V2` (`flatConnectionProviderMaps`), `METADATA_EVENTS_TO_EMIT` - `case 'connectionProvider':` in seven discriminated-union switches (`derive-metadata-events-*`, `optimistically-apply-*`, `enrich-create-*`) **Step 2 — Cache & Transform** (`@syncable-entity-cache-and-transform`) - `WorkspaceFlatConnectionProviderMapCacheService` (extends `WorkspaceCacheProvider`, decorated with `@WorkspaceCache`, soft-delete-aware) - `fromConnectionProviderEntityToFlatConnectionProvider` util - `fromConnectionProviderManifestToUniversalFlatConnectionProvider` util - `FlatConnectionProviderModule` wires the cache service - Wired the manifest converter into `compute-application-manifest-all-universal-flat-entity-maps` **Step 3 — Builder & Validation** (`@syncable-entity-builder-and-validation`) - `FlatConnectionProviderValidatorService` — never throws, returns error arrays; uses indexed `byUniversalIdentifier` for the (name, applicationUniversalIdentifier) uniqueness check (no `Object.values().find()` on the hot path) - `WorkspaceMigrationConnectionProviderActionsBuilderService` - Registered in both validators-module + builder-module - **Wired into the orchestrator** (the most-commonly-forgotten step per the rule) — constructor inject, destructure `flatConnectionProviderMaps`, `validateAndBuild`, append actions to the final migration **Step 4 — Runner & Actions** (`@syncable-entity-runner-and-actions`) - Three handlers (create / update / delete) using the canonical `WorkspaceMigrationRunnerActionHandler` mixin - Registered in `WorkspaceSchemaMigrationRunnerActionHandlersModule` **Step 5 — Integration** (`@syncable-entity-integration`) - Delete the `upsertManyFromManifest` bypass on `ApplicationOAuthProviderService` - Remove the bypass call from `ApplicationSyncService` — manifest sync now flows through the standard pipeline - Drop `ApplicationOAuthProviderModule` from `ApplicationManifestModule` (no longer needed) - Import `FlatConnectionProviderModule` from `ApplicationOAuthProviderModule` to keep the cache discoverable - 3 new exception codes: `INVALID_CONNECTION_PROVIDER_INPUT`, `CONNECTION_PROVIDER_NOT_FOUND`, `CONNECTION_PROVIDER_NAME_ALREADY_EXISTS` **Migration** - Generated via `database:migrate:generate` (instance command `1777896012579`): drops the old `(applicationId, universalIdentifier)` unique constraint, adds `deletedAt` column, adds the `(workspaceId, universalIdentifier)` unique index that `SyncableEntity` requires. - Verified clean — a second `migrate:generate` pass produces zero drift. **Step 6 — Tests** (`@syncable-entity-testing`) - 3 new specs for the manifest converter (defaults, optional fields, all-fields) - All 32 existing OAuth-provider tests still pass - ConnectionProvider has no end-user GraphQL CRUD (it's manifest-driven only), so the GraphQL integration suite that other SyncableEntities ship doesn't apply here **Codegen** - Regenerated GraphQL artifacts (twenty-front + twenty-client-sdk) against the live schema ## Why this matters Before: - `ConnectionProvider` claimed to be a `SyncableEntity` (in the enum) - But the entity didn't extend `SyncableEntity` - And the manifest sync bypassed the standard pipeline - → Verification tooling, dev UI sync tracking, anything iterating over `ALL_METADATA_NAME` got inconsistent behaviour After: - `ConnectionProvider` is a `SyncableEntity` end-to-end - Single sync path through the workspace-migration orchestrator (same as `agent`, `skill`, `frontComponent`, `webhook`, …) - One mental model ## Out of scope (deliberate) - **Renaming the table** from `applicationOAuthProvider` to `connectionProvider` — the `metadataName` is `connectionProvider` (what consumers see in code); the table name is internal. A rename would balloon this PR with mechanical churn unrelated to the sync-pipeline wiring. Worth doing as a follow-up. - **`applicationVariable` SyncableEntity conversion** — the other manifest-sync holdout. Tracked in #20215. ## Test plan - [ ] Migration up/down clean against fresh DB - [ ] Install an app whose manifest declares connection providers — providers appear in the workspace - [ ] Re-deploy the app with one provider added, one removed, one renamed → all reconciled correctly via the sync pipeline - [ ] Verify the dev-UI sync-tracking page shows ConnectionProvider entries the same way it shows agents/skills/etc - [ ] OAuth flow still works (existing connections, new connections, reconnect, list/get from SDK) — should be unchanged since the runtime code path didn't move 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/metadata/generated/schema.graphql | 5 +- .../src/metadata/generated/schema.ts | 13 +- .../src/metadata/generated/types.ts | 4 +- .../src/generated-metadata/graphql.ts | 9 +- .../accounts/types/ConnectedAccount.ts | 2 +- .../hooks/useMetadataErrorHandler.ts | 1 + .../graphql/queries/getMyConnectedAccounts.ts | 2 +- .../SettingsApplicationConnectionsSection.tsx | 2 +- ...579-connection-provider-syncable-entity.ts | 117 +++++ .../instance-commands.constant.ts | 2 + ...nOAuthProviderAndConnectedAccountColumn.ts | 133 ------ .../application-manifest.module.ts | 2 - .../application-sync.service.ts | 8 - ...rsal-flat-connection-provider.util.spec.ts | 97 +++++ ...universal-flat-connection-provider.util.ts | 49 +++ ...est-all-universal-flat-entity-maps.util.ts | 14 + ...cation-oauth-provider-flow.service.spec.ts | 384 ----------------- ...application-oauth-provider.service.spec.ts | 142 ------ .../application-oauth-provider.entity.ts | 81 ---- .../application-oauth-provider.exception.ts | 43 -- .../application-oauth-provider.service.ts | 285 ------------ ...plication-connections-list.service.spec.ts | 405 ------------------ ...pplication-connection-provider.resolver.ts | 21 +- ...onnection-provider-exception-code.enum.ts} | 5 +- ...connection-provider-oauth-flow.service.ts} | 120 +++--- .../connection-provider-oauth.controller.ts} | 38 +- .../connection-provider.entity.ts | 47 ++ .../connection-provider.exception.ts | 49 +++ .../connection-provider.module.ts} | 23 +- .../connection-provider.service.ts | 208 +++++++++ .../application-connections.controller.ts | 8 +- .../application-connections.module.ts | 10 +- .../connections/dtos/app-connection.dto.ts | 0 .../dtos/get-app-connection.dto.ts | 0 .../dtos/list-app-connections.dto.ts | 0 .../application-connections-list.service.ts | 39 +- .../application-connection-provider.dto.ts | 0 .../refresh/app-oauth-refresh.module.ts | 8 +- .../app-oauth-refresh-tokens.service.ts | 80 ++-- .../services/app-oauth-revoke.service.ts | 22 +- .../types/token-exchange-response.type.ts | 0 .../exchange-code-for-token.util.spec.ts | 4 +- .../utils/assert-oauth-provider.util.ts | 22 + .../utils/build-callback-url.util.ts | 0 .../utils/compute-pkce-challenge.util.ts | 0 .../utils/encode-oauth-body.util.ts | 0 .../utils/exchange-code-for-token.util.ts | 4 +- .../exchange-refresh-token-for-token.util.ts | 4 +- .../utils/generate-pkce-verifier.util.ts | 0 .../utils/parse-token-response.util.ts | 2 +- .../utils/post-oauth-token-request.util.ts | 6 +- .../engine/core-modules/auth/auth.module.ts | 10 +- .../auth/types/auth-context.type.ts | 2 +- .../connected-account-metadata.module.ts | 2 +- .../connected-account-metadata.service.ts | 2 +- .../dtos/connected-account.dto.ts | 2 +- .../entities/connected-account.entity.ts | 19 +- ...n-provider-editable-properties.constant.ts | 7 + .../flat-connection-provider.module.ts | 17 + ...t-connection-provider-map-cache.service.ts | 62 +++ .../flat-connection-provider-maps.type.ts | 4 + .../types/flat-connection-provider.type.ts | 4 + ...entity-to-flat-connection-provider.util.ts | 39 ++ ...ompare-and-stringify.constant.spec.ts.snap | 11 + ...configuration-by-metadata-name.constant.ts | 24 ++ ...ny-to-one-metadata-foreign-key.constant.ts | 4 + ...many-to-one-metadata-relations.constant.ts | 4 + ...tadata-entity-by-metadata-name.constant.ts | 2 + ...quired-metadata-for-validation.constant.ts | 1 + ...l-metadata-serialized-relation.constant.ts | 1 + ...one-to-many-metadata-relations.constant.ts | 1 + .../all-flat-entity-types-by-metadata-name.ts | 27 ++ ...a-related-metadata-names.util.spec.ts.snap | 4 +- ...ata-names-children-first.util.spec.ts.snap | 1 + .../types/workspace-cache-key.type.ts | 1 + ...orkspace-migration-action-with-ids.util.ts | 3 +- ...ce-migration-build-orchestrator.service.ts | 37 ++ ...ggregator-properties.constant.spec.ts.snap | 3 +- ...ized-relation-by-metadata-name.constant.ts | 1 + ...universal-flat-connection-provider.type.ts | 7 + ...gration-connection-provider-action.type.ts | 24 ++ ...ection-provider-actions-builder.service.ts | 122 ++++++ ...t-connection-provider-validator.service.ts | 173 ++++++++ ...ace-migration-builder-validators.module.ts | 3 + .../workspace-migration-builder.module.ts | 3 + ...nection-provider-action-handler.service.ts | 65 +++ ...nection-provider-action-handler.service.ts | 51 +++ ...nection-provider-action-handler.service.ts | 68 +++ ...migration-runner-action-handlers.module.ts | 6 + .../metadata-event-to-emit.constant.ts | 1 + ...metadata-events-from-create-action.util.ts | 3 +- ...metadata-events-from-delete-action.util.ts | 3 +- ...metadata-events-from-update-action.util.ts | 3 +- ...ate-action-on-all-flat-entity-maps.util.ts | 3 +- ...ete-action-on-all-flat-entity-maps.util.ts | 3 +- ...ate-action-on-all-flat-entity-maps.util.ts | 3 +- ...d-account-refresh-tokens-manager.module.ts | 2 +- ...ted-account-refresh-tokens.service.spec.ts | 2 +- ...onnected-account-refresh-tokens.service.ts | 2 +- ...te-connection-provider.integration-spec.ts | 268 ++++++++++++ ...tion-provider-resolver.integration-spec.ts | 132 ++++++ ...onnection-providers-by-application.util.ts | 27 ++ .../src/application/connectionProviderType.ts | 15 +- .../twenty-shared/src/application/index.ts | 1 + ...storedOAuthConnectionProviderConfigType.ts | 16 + .../constants/all-metadata-name.constant.ts | 1 + 106 files changed, 2076 insertions(+), 1746 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity.ts delete 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-manifest/converters/__tests__/from-connection-provider-manifest-to-universal-flat-connection-provider.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider-flow.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/__tests__/application-connections-list.service.spec.ts rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/application-connection-provider.resolver.ts (73%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider/application-oauth-provider-exception-code.enum.ts => connection-provider/connection-provider-exception-code.enum.ts} (53%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider/application-oauth-provider-flow.service.ts => connection-provider/connection-provider-oauth-flow.service.ts} (63%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider/application-oauth-provider.controller.ts => connection-provider/connection-provider-oauth.controller.ts} (83%) create mode 100644 packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.exception.ts rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider/application-oauth-provider.module.ts => connection-provider/connection-provider.module.ts} (58%) create mode 100644 packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.service.ts rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/connections/application-connections.controller.ts (88%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/connections/application-connections.module.ts (73%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/connections/dtos/app-connection.dto.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/connections/dtos/get-app-connection.dto.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/connections/dtos/list-app-connections.dto.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/connections/services/application-connections-list.service.ts (77%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/dtos/application-connection-provider.dto.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/refresh/app-oauth-refresh.module.ts (65%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/refresh/services/app-oauth-refresh-tokens.service.ts (58%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/refresh/services/app-oauth-revoke.service.ts (65%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/types/token-exchange-response.type.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/__tests__/exchange-code-for-token.util.spec.ts (96%) create mode 100644 packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util.ts rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/build-callback-url.util.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/compute-pkce-challenge.util.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/encode-oauth-body.util.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/exchange-code-for-token.util.ts (86%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/exchange-refresh-token-for-token.util.ts (83%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/generate-pkce-verifier.util.ts (100%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/parse-token-response.util.ts (89%) rename packages/twenty-server/src/engine/core-modules/application/{application-oauth-provider => connection-provider}/utils/post-oauth-token-request.util.ts (88%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/constants/flat-connection-provider-editable-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/services/workspace-flat-connection-provider-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider-maps.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/utils/from-connection-provider-entity-to-flat-connection-provider.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/create-connection-provider-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/delete-connection-provider-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/update-connection-provider-action-handler.service.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-connection-provider.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/connection-provider/connection-provider-resolver.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/connection-provider/utils/find-connection-providers-by-application.util.ts create mode 100644 packages/twenty-shared/src/application/storedOAuthConnectionProviderConfigType.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 814444f70e..f298d7985b 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -2550,7 +2550,7 @@ type ConnectedAccountDTO { connectionParameters: ImapSmtpCaldavConnectionParameters lastSignedInAt: DateTime userWorkspaceId: UUID! - applicationConnectionProviderId: UUID + connectionProviderId: UUID applicationId: UUID name: String visibility: String! @@ -2581,7 +2581,7 @@ type ConnectedAccountPublicDTO { scopes: [String!] lastSignedInAt: DateTime userWorkspaceId: UUID! - applicationConnectionProviderId: UUID + connectionProviderId: UUID applicationId: UUID name: String visibility: String! @@ -2870,6 +2870,7 @@ enum AllMetadataName { fieldPermission frontComponent webhook + connectionProvider } type MinimalObjectMetadata { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 748387e124..e280a39725 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2238,7 +2238,7 @@ export interface ConnectedAccountDTO { connectionParameters?: ImapSmtpCaldavConnectionParameters lastSignedInAt?: Scalars['DateTime'] userWorkspaceId: Scalars['UUID'] - applicationConnectionProviderId?: Scalars['UUID'] + connectionProviderId?: Scalars['UUID'] applicationId?: Scalars['UUID'] name?: Scalars['String'] visibility: Scalars['String'] @@ -2272,7 +2272,7 @@ export interface ConnectedAccountPublicDTO { scopes?: Scalars['String'][] lastSignedInAt?: Scalars['DateTime'] userWorkspaceId: Scalars['UUID'] - applicationConnectionProviderId?: Scalars['UUID'] + connectionProviderId?: Scalars['UUID'] applicationId?: Scalars['UUID'] name?: Scalars['String'] visibility: Scalars['String'] @@ -2493,7 +2493,7 @@ export interface CollectionHash { __typename: 'CollectionHash' } -export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' +export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' | 'connectionProvider' export interface MinimalObjectMetadata { id: Scalars['UUID'] @@ -5250,7 +5250,7 @@ export interface ConnectedAccountDTOGenqlSelection{ connectionParameters?: ImapSmtpCaldavConnectionParametersGenqlSelection lastSignedInAt?: boolean | number userWorkspaceId?: boolean | number - applicationConnectionProviderId?: boolean | number + connectionProviderId?: boolean | number applicationId?: boolean | number name?: boolean | number visibility?: boolean | number @@ -5287,7 +5287,7 @@ export interface ConnectedAccountPublicDTOGenqlSelection{ scopes?: boolean | number lastSignedInAt?: boolean | number userWorkspaceId?: boolean | number - applicationConnectionProviderId?: boolean | number + connectionProviderId?: boolean | number applicationId?: boolean | number name?: boolean | number visibility?: boolean | number @@ -8849,7 +8849,8 @@ export const enumAllMetadataName = { objectPermission: 'objectPermission' as const, fieldPermission: 'fieldPermission' as const, frontComponent: 'frontComponent' as const, - webhook: 'webhook' as const + webhook: 'webhook' as const, + connectionProvider: 'connectionProvider' as const } export const enumEventLogTable = { diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 8de449535f..454f71b6d6 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -5088,7 +5088,7 @@ export default { "userWorkspaceId": [ 3 ], - "applicationConnectionProviderId": [ + "connectionProviderId": [ 3 ], "applicationId": [ @@ -5169,7 +5169,7 @@ export default { "userWorkspaceId": [ 3 ], - "applicationConnectionProviderId": [ + "connectionProviderId": [ 3 ], "applicationId": [ diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 1f05f2f534..6a6eac176a 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -186,6 +186,7 @@ export type AiSystemPromptSection = { export enum AllMetadataName { agent = 'agent', commandMenuItem = 'commandMenuItem', + connectionProvider = 'connectionProvider', fieldMetadata = 'fieldMetadata', fieldPermission = 'fieldPermission', frontComponent = 'frontComponent', @@ -896,10 +897,10 @@ export type CommandMenuItemPayload = ObjectMetadataCommandMenuItemPayload | Path export type ConnectedAccountDto = { __typename?: 'ConnectedAccountDTO'; - applicationConnectionProviderId?: Maybe; applicationId?: Maybe; authFailedAt?: Maybe; connectionParameters?: Maybe; + connectionProviderId?: Maybe; createdAt: Scalars['DateTime']; handle: Scalars['String']; handleAliases?: Maybe>; @@ -916,10 +917,10 @@ export type ConnectedAccountDto = { export type ConnectedAccountPublicDto = { __typename?: 'ConnectedAccountPublicDTO'; - applicationConnectionProviderId?: Maybe; applicationId?: Maybe; authFailedAt?: Maybe; connectionParameters?: Maybe; + connectionProviderId?: Maybe; createdAt: Scalars['DateTime']; handle: Scalars['String']; handleAliases?: Maybe>; @@ -6878,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, 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 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, connectionProviderId?: 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; @@ -8015,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":"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 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":"connectionProviderId"}},{"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; diff --git a/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts b/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts index ac28e9a68f..2b9a4b440c 100644 --- a/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts +++ b/packages/twenty-front/src/modules/accounts/types/ConnectedAccount.ts @@ -12,7 +12,7 @@ export type ConnectedAccount = { handleAliases: string[] | null; lastSignedInAt: string | null; userWorkspaceId: string; - applicationConnectionProviderId: string | null; + connectionProviderId: string | null; name: string | null; // Connection-row visibility — distinct from the `scopes` array above // (those are upstream-granted OAuth permissions). diff --git a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts index 8cb091ce8a..432c45c2c2 100644 --- a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts +++ b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts @@ -48,6 +48,7 @@ export const useMetadataErrorHandler = () => { navigationMenuItem: t`navigation menu item`, webhook: t`webhook`, viewSort: t`view sort`, + connectionProvider: t`connection provider`, } as const satisfies Record; const handleMetadataError = ( 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 515dc9e20d..e1d69c0fb8 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,7 +11,7 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql` handleAliases lastSignedInAt userWorkspaceId - applicationConnectionProviderId + connectionProviderId name visibility lastCredentialsRefreshedAt diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx index 43ecae5083..ab735a7bd1 100644 --- a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationConnectionsSection.tsx @@ -117,7 +117,7 @@ export const SettingsApplicationConnectionsSection = ({ provider.oauth?.isClientCredentialsConfigured ?? false; const providerConnections = connectedAccounts.filter( - (account) => account.applicationConnectionProviderId === provider.id, + (account) => account.connectionProviderId === provider.id, ); return ( diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity.ts new file mode 100644 index 0000000000..6e0b035ae6 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity.ts @@ -0,0 +1,117 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.3.0', 1777896012579) +export class ConnectionProviderSyncableEntityFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."connectionProvider" ( + "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, + "type" varchar NOT NULL, + "oauthConfig" jsonb, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "IDX_CONNECTION_PROVIDER_NAME_APPLICATION_UNIQUE" UNIQUE ("name", "applicationId"), + CONSTRAINT "PK_connectionProvider_id" PRIMARY KEY ("id") + )`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_CONNECTION_PROVIDER_APPLICATION_ID" ON "core"."connectionProvider" ("applicationId")`, + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_44a4fc17a91603c38daabfd4d8" ON "core"."connectionProvider" ("workspaceId", "universalIdentifier")`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."connectionProvider" + ADD CONSTRAINT "FK_16d8e4d029dd986268d759a2257" + FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") + ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."connectionProvider" + ADD CONSTRAINT "FK_a2553b431536a5b93211012f984" + FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") + ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."connectedAccount" + ADD COLUMN "connectionProviderId" 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_CONNECTION_PROVIDER_ID" ON "core"."connectedAccount" ("connectionProviderId")`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_CONNECTED_ACCOUNT_APPLICATION_ID" ON "core"."connectedAccount" ("applicationId")`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."connectedAccount" + ADD CONSTRAINT "FK_40de45e67a285dafb84e510cdc6" + FOREIGN KEY ("connectionProviderId") REFERENCES "core"."connectionProvider"("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_40de45e67a285dafb84e510cdc6"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APPLICATION_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_CONNECTION_PROVIDER_ID"`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."connectedAccount" + DROP COLUMN "visibility", + DROP COLUMN "name", + DROP COLUMN "applicationId", + DROP COLUMN "connectionProviderId"`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."connectionProvider" DROP CONSTRAINT "FK_a2553b431536a5b93211012f984"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."connectionProvider" DROP CONSTRAINT "FK_16d8e4d029dd986268d759a2257"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_44a4fc17a91603c38daabfd4d8"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_CONNECTION_PROVIDER_APPLICATION_ID"`, + ); + await queryRunner.query(`DROP TABLE "core"."connectionProvider"`); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index db50b39235..3b4c780e0d 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -22,6 +22,7 @@ import { AddUpgradeMigrationWorkspaceIdIndexFastInstanceCommand } from 'src/data import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread'; import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application'; import { AddDeletedAtToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777682000000-add-deleted-at-to-agent-chat-thread'; +import { ConnectionProviderSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity'; import { RemoveUserDefaultAvatarUrlFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777915958318-remove-user-default-avatar-url'; export const INSTANCE_COMMANDS = [ @@ -47,5 +48,6 @@ export const INSTANCE_COMMANDS = [ AddCacheTokensToAgentChatThreadFastInstanceCommand, AddLogoToApplicationFastInstanceCommand, AddDeletedAtToAgentChatThreadFastInstanceCommand, + ConnectionProviderSyncableEntityFastInstanceCommand, RemoveUserDefaultAvatarUrlFastInstanceCommand, ]; 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 deleted file mode 100644 index 9ba1441d64..0000000000 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1777558657640-addApplicationOAuthProviderAndConnectedAccountColumn.ts +++ /dev/null @@ -1,133 +0,0 @@ -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 f85d2f02f6..1d3d9752a1 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,7 +4,6 @@ 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'; @@ -17,7 +16,6 @@ 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 bf3804f2a4..65ac2d0196 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,7 +12,6 @@ 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'; @@ -34,7 +33,6 @@ 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, @@ -155,12 +153,6 @@ 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-manifest/converters/__tests__/from-connection-provider-manifest-to-universal-flat-connection-provider.util.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/__tests__/from-connection-provider-manifest-to-universal-flat-connection-provider.util.spec.ts new file mode 100644 index 0000000000..112e656784 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/__tests__/from-connection-provider-manifest-to-universal-flat-connection-provider.util.spec.ts @@ -0,0 +1,97 @@ +import { type ConnectionProviderManifest } from 'twenty-shared/application'; + +import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util'; + +const APP_UID = 'a8a8a8a8-a8a8-4a8a-a8a8-a8a8a8a8a8a8'; +const PROVIDER_UID = '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa'; +const NOW = '2026-05-04T00:00:00.000Z'; + +const buildManifest = ( + overrides: Partial = {}, +): ConnectionProviderManifest => + ({ + universalIdentifier: PROVIDER_UID, + 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('fromConnectionProviderManifestToUniversalFlatConnectionProvider', () => { + it('moves OAuth manifest fields into the resolved oauthConfig blob with defaults filled', () => { + const result = + fromConnectionProviderManifestToUniversalFlatConnectionProvider({ + connectionProviderManifest: buildManifest(), + applicationUniversalIdentifier: APP_UID, + now: NOW, + }); + + expect(result).toEqual({ + universalIdentifier: PROVIDER_UID, + applicationUniversalIdentifier: APP_UID, + name: 'linear', + displayName: 'Linear', + type: 'oauth', + oauthConfig: { + 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: 'json', + usePkce: true, + }, + createdAt: NOW, + updatedAt: NOW, + }); + }); + + it('passes through optional oauth config when provided', () => { + const result = + fromConnectionProviderManifestToUniversalFlatConnectionProvider({ + connectionProviderManifest: buildManifest({ + 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', + authorizationParams: { prompt: 'consent' }, + tokenRequestContentType: 'form-urlencoded', + usePkce: false, + }, + }), + applicationUniversalIdentifier: APP_UID, + now: NOW, + }); + + expect(result.oauthConfig).toMatchObject({ + revokeEndpoint: 'https://api.linear.app/oauth/revoke', + authorizationParams: { prompt: 'consent' }, + tokenRequestContentType: 'form-urlencoded', + usePkce: false, + }); + }); + + it('defaults to json content-type and PKCE-on when oauth config omits them', () => { + const result = + fromConnectionProviderManifestToUniversalFlatConnectionProvider({ + connectionProviderManifest: buildManifest(), + applicationUniversalIdentifier: APP_UID, + now: NOW, + }); + + expect(result.oauthConfig?.tokenRequestContentType).toBe('json'); + expect(result.oauthConfig?.usePkce).toBe(true); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util.ts new file mode 100644 index 0000000000..4d0f280a02 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util.ts @@ -0,0 +1,49 @@ +import { + type ConnectionProviderManifest, + type StoredOAuthConnectionProviderConfig, +} from 'twenty-shared/application'; + +import { type UniversalFlatConnectionProvider } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type'; + +export const fromConnectionProviderManifestToUniversalFlatConnectionProvider = + ({ + connectionProviderManifest, + applicationUniversalIdentifier, + now, + }: { + connectionProviderManifest: ConnectionProviderManifest; + applicationUniversalIdentifier: string; + now: string; + }): UniversalFlatConnectionProvider => { + const oauthConfig: StoredOAuthConnectionProviderConfig | null = + connectionProviderManifest.type === 'oauth' + ? { + authorizationEndpoint: + connectionProviderManifest.oauth.authorizationEndpoint, + tokenEndpoint: connectionProviderManifest.oauth.tokenEndpoint, + revokeEndpoint: + connectionProviderManifest.oauth.revokeEndpoint ?? null, + scopes: connectionProviderManifest.oauth.scopes, + clientIdVariable: connectionProviderManifest.oauth.clientIdVariable, + clientSecretVariable: + connectionProviderManifest.oauth.clientSecretVariable, + authorizationParams: + connectionProviderManifest.oauth.authorizationParams ?? null, + tokenRequestContentType: + connectionProviderManifest.oauth.tokenRequestContentType ?? + 'json', + usePkce: connectionProviderManifest.oauth.usePkce ?? true, + } + : null; + + return { + universalIdentifier: connectionProviderManifest.universalIdentifier, + applicationUniversalIdentifier, + name: connectionProviderManifest.name, + displayName: connectionProviderManifest.displayName, + type: connectionProviderManifest.type, + oauthConfig, + createdAt: now, + updatedAt: now, + }; + }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts index ddd1016eb2..545679b7ef 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts @@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils'; import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util'; import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util'; +import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util'; import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util'; import { fromFieldPermissionManifestToUniversalFlatFieldPermission } from 'src/engine/core-modules/application/application-manifest/converters/from-field-permission-manifest-to-universal-flat-field-permission.util'; import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util'; @@ -176,6 +177,19 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({ } } + for (const connectionProviderManifest of manifest.connectionProviders ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromConnectionProviderManifestToUniversalFlatConnectionProvider({ + connectionProviderManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatConnectionProviderMaps, + }); + } + for (const roleManifest of manifest.roles) { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ universalFlatEntity: fromRoleManifestToUniversalFlatRole({ 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 deleted file mode 100644 index cbc76a811d..0000000000 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider-flow.service.spec.ts +++ /dev/null @@ -1,384 +0,0 @@ -// 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 deleted file mode 100644 index 33a176b58a..0000000000 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/__tests__/application-oauth-provider.service.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -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-oauth-provider.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity.ts deleted file mode 100644 index 8dabb4dd62..0000000000 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index c5085c71e4..0000000000 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception.ts +++ /dev/null @@ -1,43 +0,0 @@ -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.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service.ts deleted file mode 100644 index 704d19b871..0000000000 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service.ts +++ /dev/null @@ -1,285 +0,0 @@ -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 deleted file mode 100644 index 2674319354..0000000000 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/__tests__/application-connections-list.service.spec.ts +++ /dev/null @@ -1,405 +0,0 @@ -// 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/application-connection-provider.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/application-connection-provider.resolver.ts similarity index 73% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-connection-provider.resolver.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/application-connection-provider.resolver.ts index b6d6adf684..0d5bfb9988 100644 --- 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/connection-provider/application-connection-provider.resolver.ts @@ -3,8 +3,8 @@ 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 { ApplicationConnectionProviderDTO } from 'src/engine/core-modules/application/connection-provider/dtos/application-connection-provider.dto'; +import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-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'; @@ -14,7 +14,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @MetadataResolver(() => ApplicationConnectionProviderDTO) export class ApplicationConnectionProviderResolver { constructor( - private readonly oauthProviderService: ApplicationOAuthProviderService, + private readonly oauthProviderService: ConnectionProviderService, ) {} @Query(() => [ApplicationConnectionProviderDTO]) @@ -37,14 +37,17 @@ export class ApplicationConnectionProviderResolver { return providers.map((provider) => ({ id: provider.id, applicationId: provider.applicationId, - type: 'oauth', + type: provider.type, name: provider.name, displayName: provider.displayName, - oauth: { - scopes: provider.scopes, - isClientCredentialsConfigured: - credentialsConfiguredByProviderId.get(provider.id) ?? false, - }, + oauth: + provider.type === 'oauth' && provider.oauthConfig + ? { + scopes: provider.oauthConfig.scopes, + isClientCredentialsConfigured: + credentialsConfiguredByProviderId.get(provider.id) ?? false, + } + : null, })); } } 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/connection-provider/connection-provider-exception-code.enum.ts similarity index 53% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum.ts index 4d67969f86..52828cadcc 100644 --- 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/connection-provider/connection-provider-exception-code.enum.ts @@ -1,4 +1,4 @@ -export enum ApplicationOAuthProviderExceptionCode { +export enum ConnectionProviderExceptionCode { PROVIDER_NOT_FOUND = 'PROVIDER_NOT_FOUND', CLIENT_CREDENTIALS_NOT_CONFIGURED = 'CLIENT_CREDENTIALS_NOT_CONFIGURED', TOKEN_EXCHANGE_FAILED = 'TOKEN_EXCHANGE_FAILED', @@ -6,4 +6,7 @@ export enum ApplicationOAuthProviderExceptionCode { INVALID_STATE = 'INVALID_STATE', INVALID_REQUEST = 'INVALID_REQUEST', FORBIDDEN = 'FORBIDDEN', + INVALID_CONNECTION_PROVIDER_INPUT = 'INVALID_CONNECTION_PROVIDER_INPUT', + CONNECTION_PROVIDER_NOT_FOUND = 'CONNECTION_PROVIDER_NOT_FOUND', + CONNECTION_PROVIDER_NAME_ALREADY_EXISTS = 'CONNECTION_PROVIDER_NAME_ALREADY_EXISTS', } 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/connection-provider/connection-provider-oauth-flow.service.ts similarity index 63% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service.ts index bf2ba93ad3..b3a4d1d4af 100644 --- 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/connection-provider/connection-provider-oauth-flow.service.ts @@ -6,15 +6,19 @@ 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 ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; +import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; +import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service'; +import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type'; +import { + assertOAuthProvider, + type OAuthConnectionProvider, +} from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util'; +import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/connection-provider/utils/build-callback-url.util'; +import { computePkceChallenge } from 'src/engine/core-modules/application/connection-provider/utils/compute-pkce-challenge.util'; +import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util'; +import { generatePkceVerifier } from 'src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util'; import { type AppOAuthStateJwtPayload, JwtTokenTypeEnum, @@ -27,13 +31,10 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac const STATE_JWT_EXPIRES_IN = '10m'; type AuthorizeArgs = { - applicationOAuthProvider: ApplicationOAuthProviderEntity; + connectionProvider: ConnectionProviderEntity; 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; @@ -52,13 +53,11 @@ type CallbackResult = { }; @Injectable() -export class ApplicationOAuthProviderFlowService { - private readonly logger = new Logger( - ApplicationOAuthProviderFlowService.name, - ); +export class ConnectionProviderOAuthFlowService { + private readonly logger = new Logger(ConnectionProviderOAuthFlowService.name); constructor( - private readonly oauthProviderService: ApplicationOAuthProviderService, + private readonly oauthProviderService: ConnectionProviderService, private readonly jwtWrapperService: JwtWrapperService, private readonly secureHttpClientService: SecureHttpClientService, private readonly twentyConfigService: TwentyConfigService, @@ -69,45 +68,42 @@ export class ApplicationOAuthProviderFlowService { async startAuthorizationFlow( args: AuthorizeArgs, ): Promise<{ authorizationUrl: string }> { - const { applicationOAuthProvider, workspaceId, userId, userWorkspaceId } = - args; + const { connectionProvider, 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. + assertOAuthProvider(connectionProvider); + + // Reconnect target must live in the requesting workspace and belong to + // the same provider — without this guard a foreign id would silently + // leak through findOneByOrFail later in the flow. if (isDefined(args.reconnectingConnectedAccountId)) { const target = await this.connectedAccountRepository.findOne({ where: { id: args.reconnectingConnectedAccountId, workspaceId, - applicationConnectionProviderId: applicationOAuthProvider.id, + connectionProviderId: connectionProvider.id, }, }); if (!isDefined(target)) { - throw new ApplicationOAuthProviderException( + throw new ConnectionProviderException( `Cannot reconnect connectedAccount ${args.reconnectingConnectedAccountId}: not found in this workspace for the requested provider.`, - ApplicationOAuthProviderExceptionCode.FORBIDDEN, + ConnectionProviderExceptionCode.FORBIDDEN, ); } } - const { clientId } = await this.oauthProviderService.getClientCredentials( - applicationOAuthProvider, - ); + const { clientId } = + await this.oauthProviderService.getClientCredentials(connectionProvider); - const codeVerifier = applicationOAuthProvider.usePkce - ? generatePkceVerifier() - : null; + const { authorizationEndpoint, scopes, authorizationParams, usePkce } = + connectionProvider.oauthConfig; + + const codeVerifier = usePkce ? generatePkceVerifier() : null; const state = this.signState({ - sub: applicationOAuthProvider.id, + sub: connectionProvider.id, type: JwtTokenTypeEnum.APP_OAUTH_STATE, - applicationOAuthProviderId: applicationOAuthProvider.id, + connectionProviderId: connectionProvider.id, workspaceId, userId, userWorkspaceId, @@ -119,17 +115,12 @@ export class ApplicationOAuthProviderFlowService { const callbackUrl = buildAppOAuthCallbackUrl(this.getServerUrl()); - const authorizationUrl = new URL( - applicationOAuthProvider.authorizationEndpoint, - ); + const authorizationUrl = new URL(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('scope', scopes.join(' ')); authorizationUrl.searchParams.set('state', state); if (codeVerifier) { @@ -140,9 +131,7 @@ export class ApplicationOAuthProviderFlowService { authorizationUrl.searchParams.set('code_challenge_method', 'S256'); } - for (const [key, value] of Object.entries( - applicationOAuthProvider.authorizationParams ?? {}, - )) { + for (const [key, value] of Object.entries(authorizationParams ?? {})) { authorizationUrl.searchParams.set(key, value); } @@ -153,9 +142,11 @@ export class ApplicationOAuthProviderFlowService { const statePayload = this.verifyState(args.state); const provider = await this.oauthProviderService.findOneByIdOrThrow( - statePayload.applicationOAuthProviderId, + statePayload.connectionProviderId, ); + assertOAuthProvider(provider); + const { clientId, clientSecret } = await this.oauthProviderService.getClientCredentials(provider); @@ -166,22 +157,22 @@ export class ApplicationOAuthProviderFlowService { try { tokenResponse = await exchangeCodeForToken({ fetchFn: this.secureHttpClientService.createSsrfSafeFetch(), - tokenEndpoint: provider.tokenEndpoint, + tokenEndpoint: provider.oauthConfig.tokenEndpoint, clientId, clientSecret, code: args.code, redirectUri: callbackUrl, codeVerifier: statePayload.codeVerifier, - contentType: provider.tokenRequestContentType, + contentType: provider.oauthConfig.tokenRequestContentType, }); } catch (error) { this.logger.error( `OAuth token exchange failed for provider ${provider.id}: ${(error as Error).message}`, ); - throw new ApplicationOAuthProviderException( + throw new ConnectionProviderException( (error as Error).message, - ApplicationOAuthProviderExceptionCode.TOKEN_EXCHANGE_FAILED, + ConnectionProviderExceptionCode.TOKEN_EXCHANGE_FAILED, ); } @@ -231,9 +222,9 @@ export class ApplicationOAuthProviderFlowService { `Rejected OAuth state: ${(error as Error).message ?? 'unknown reason'}`, ); - throw new ApplicationOAuthProviderException( + throw new ConnectionProviderException( 'OAuth state signature invalid or expired', - ApplicationOAuthProviderExceptionCode.INVALID_STATE, + ConnectionProviderExceptionCode.INVALID_STATE, ); } } @@ -242,9 +233,6 @@ export class ApplicationOAuthProviderFlowService { 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, @@ -253,7 +241,7 @@ export class ApplicationOAuthProviderFlowService { visibility, reconnectingConnectedAccountId, }: { - provider: ApplicationOAuthProviderEntity; + provider: OAuthConnectionProvider; tokenResponse: TokenExchangeResponse; workspaceId: string; userWorkspaceId: string; @@ -263,17 +251,14 @@ export class ApplicationOAuthProviderFlowService { const sharedFields = { accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken, - scopes: tokenResponse.scopes ?? provider.scopes, + scopes: tokenResponse.scopes ?? provider.oauthConfig.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. + // Workspace-scope both the update and the read so a foreign id can't + // leak through findOneByOrFail. await this.connectedAccountRepository.update( { id: reconnectingConnectedAccountId, workspaceId }, sharedFields, @@ -286,10 +271,9 @@ export class ApplicationOAuthProviderFlowService { } const existingCount = await this.connectedAccountRepository.count({ - where: { applicationConnectionProviderId: provider.id, workspaceId }, + where: { connectionProviderId: 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({ @@ -300,7 +284,7 @@ export class ApplicationOAuthProviderFlowService { provider: ConnectedAccountProvider.APP, workspaceId, applicationId: provider.applicationId, - applicationConnectionProviderId: provider.id, + connectionProviderId: provider.id, userWorkspaceId, }); 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/connection-provider/connection-provider-oauth.controller.ts similarity index 83% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.controller.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider-oauth.controller.ts index fb7922e410..6d28871254 100644 --- 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/connection-provider/connection-provider-oauth.controller.ts @@ -6,10 +6,10 @@ 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 { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service'; +import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; +import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; +import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service'; import { AuthException, AuthExceptionCode, @@ -25,12 +25,12 @@ 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); +export class ConnectionProviderOAuthController { + private readonly logger = new Logger(ConnectionProviderOAuthController.name); constructor( - private readonly oauthProviderService: ApplicationOAuthProviderService, - private readonly oauthProviderFlowService: ApplicationOAuthProviderFlowService, + private readonly oauthProviderService: ConnectionProviderService, + private readonly oauthProviderFlowService: ConnectionProviderOAuthFlowService, private readonly transientTokenService: TransientTokenService, private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly guardRedirectService: GuardRedirectService, @@ -60,9 +60,9 @@ export class ApplicationOAuthProviderController { try { if (!applicationId || !providerName || !transientToken) { - throw new ApplicationOAuthProviderException( + throw new ConnectionProviderException( 'Missing required query parameters: applicationId, providerName, transientToken', - ApplicationOAuthProviderExceptionCode.INVALID_REQUEST, + ConnectionProviderExceptionCode.INVALID_REQUEST, ); } @@ -71,9 +71,9 @@ export class ApplicationOAuthProviderController { visibility !== 'user' && visibility !== 'workspace' ) { - throw new ApplicationOAuthProviderException( + throw new ConnectionProviderException( `Invalid visibility "${visibility}" — must be 'user' or 'workspace'`, - ApplicationOAuthProviderExceptionCode.INVALID_REQUEST, + ConnectionProviderExceptionCode.INVALID_REQUEST, ); } @@ -105,16 +105,16 @@ export class ApplicationOAuthProviderController { }); if (!provider) { - throw new ApplicationOAuthProviderException( + throw new ConnectionProviderException( `OAuth provider "${providerName}" not found for application ${applicationId}`, - ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND, + ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND, ); } if (provider.workspaceId !== workspaceId) { - throw new ApplicationOAuthProviderException( + throw new ConnectionProviderException( 'OAuth provider does not belong to the requesting workspace', - ApplicationOAuthProviderExceptionCode.FORBIDDEN, + ConnectionProviderExceptionCode.FORBIDDEN, ); } @@ -131,7 +131,7 @@ export class ApplicationOAuthProviderController { const { authorizationUrl } = await this.oauthProviderFlowService.startAuthorizationFlow({ - applicationOAuthProvider: provider, + connectionProvider: provider, workspaceId, userId, userWorkspaceId: userWorkspace.id, @@ -197,9 +197,9 @@ export class ApplicationOAuthProviderController { }); if (!workspace) { - throw new ApplicationOAuthProviderException( + throw new ConnectionProviderException( `Workspace ${workspaceId} not found after OAuth callback`, - ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND, + ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND, ); } diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.entity.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.entity.ts new file mode 100644 index 0000000000..a20c09e94e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.entity.ts @@ -0,0 +1,47 @@ +import { + type ConnectionProviderType, + type StoredOAuthConnectionProviderConfig, +} from 'twenty-shared/application'; +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; + +@Entity({ name: 'connectionProvider', schema: 'core' }) +@Unique('IDX_CONNECTION_PROVIDER_NAME_APPLICATION_UNIQUE', [ + 'name', + 'applicationId', +]) +@Index('IDX_CONNECTION_PROVIDER_APPLICATION_ID', ['applicationId']) +export class ConnectionProviderEntity + extends SyncableEntity + implements Required +{ + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: false, type: 'varchar' }) + name: string; + + @Column({ nullable: false, type: 'varchar' }) + displayName: string; + + @Column({ nullable: false, type: 'varchar' }) + type: ConnectionProviderType; + + @Column({ nullable: true, type: 'jsonb' }) + oauthConfig: StoredOAuthConnectionProviderConfig | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.exception.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.exception.ts new file mode 100644 index 0000000000..e71dec49c9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.exception.ts @@ -0,0 +1,49 @@ +import { type MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { assertUnreachable } from 'twenty-shared/utils'; + +import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; +import { CustomException } from 'src/utils/custom-exception'; + +const getConnectionProviderExceptionUserFriendlyMessage = ( + code: ConnectionProviderExceptionCode, +) => { + switch (code) { + case ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND: + return msg`OAuth provider not found.`; + case ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED: + return msg`Client credentials are not configured for this OAuth provider.`; + case ConnectionProviderExceptionCode.TOKEN_EXCHANGE_FAILED: + return msg`Failed to exchange the authorization code for an access token.`; + case ConnectionProviderExceptionCode.REFRESH_FAILED: + return msg`Failed to refresh the access token.`; + case ConnectionProviderExceptionCode.INVALID_STATE: + return msg`The OAuth state parameter is invalid or expired.`; + case ConnectionProviderExceptionCode.INVALID_REQUEST: + return msg`The OAuth request is missing required parameters.`; + case ConnectionProviderExceptionCode.FORBIDDEN: + return msg`Not authorized to access this OAuth provider.`; + case ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT: + return msg`The connection-provider manifest is missing required fields.`; + case ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NOT_FOUND: + return msg`Connection provider not found.`; + case ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NAME_ALREADY_EXISTS: + return msg`A connection provider with this name already exists for this application.`; + default: + assertUnreachable(code); + } +}; + +export class ConnectionProviderException extends CustomException { + constructor( + message: string, + code: ConnectionProviderExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getConnectionProviderExceptionUserFriendlyMessage(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/connection-provider/connection-provider.module.ts similarity index 58% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.module.ts index 6270a076d5..8d504040e1 100644 --- 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/connection-provider/connection-provider.module.ts @@ -1,10 +1,10 @@ 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 { ApplicationConnectionProviderResolver } from 'src/engine/core-modules/application/connection-provider/application-connection-provider.resolver'; +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service'; +import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-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'; @@ -12,11 +12,12 @@ import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryptio 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'; +import { FlatConnectionProviderModule } from 'src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module'; @Module({ imports: [ TypeOrmModule.forFeature([ - ApplicationOAuthProviderEntity, + ConnectionProviderEntity, ApplicationEntity, ApplicationRegistrationVariableEntity, ConnectedAccountEntity, @@ -25,15 +26,13 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac SecretEncryptionModule, SecureHttpClientModule, TwentyConfigModule, + FlatConnectionProviderModule, ], providers: [ - ApplicationOAuthProviderService, - ApplicationOAuthProviderFlowService, + ConnectionProviderService, + ConnectionProviderOAuthFlowService, ApplicationConnectionProviderResolver, ], - exports: [ - ApplicationOAuthProviderService, - ApplicationOAuthProviderFlowService, - ], + exports: [ConnectionProviderService, ConnectionProviderOAuthFlowService], }) -export class ApplicationOAuthProviderModule {} +export class ConnectionProviderModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.service.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.service.ts new file mode 100644 index 0000000000..bbd2f47b71 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/connection-provider.service.ts @@ -0,0 +1,208 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { In, Repository } from 'typeorm'; + +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; +import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; +import { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util'; +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 ConnectionProviderService { + constructor( + @InjectRepository(ConnectionProviderEntity) + private readonly connectionProviderRepository: Repository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + @InjectRepository(ApplicationRegistrationVariableEntity) + private readonly registrationVariableRepository: Repository, + private readonly secretEncryptionService: SecretEncryptionService, + ) {} + + async getClientCredentials( + provider: ConnectionProviderEntity, + ): Promise<{ clientId: string; clientSecret: string }> { + assertOAuthProvider(provider); + + const application = await this.applicationRepository.findOneBy({ + id: provider.applicationId, + }); + + if (!isDefined(application?.applicationRegistrationId)) { + throw new ConnectionProviderException( + `Application ${provider.applicationId} has no registration; OAuth client credentials cannot be resolved`, + ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED, + ); + } + + const { clientIdVariable, clientSecretVariable } = provider.oauthConfig; + + const variables = await this.registrationVariableRepository.find({ + where: { + applicationRegistrationId: application.applicationRegistrationId, + key: In([clientIdVariable, clientSecretVariable]), + }, + }); + + const valuesByKey = new Map( + variables.map((v) => [ + v.key, + v.encryptedValue + ? this.secretEncryptionService.decrypt(v.encryptedValue) + : '', + ]), + ); + + const clientId = valuesByKey.get(clientIdVariable) ?? ''; + const clientSecret = valuesByKey.get(clientSecretVariable) ?? ''; + + if (!clientId || !clientSecret) { + throw new ConnectionProviderException( + `OAuth client credentials are not configured for provider "${provider.name}". The server administrator needs to fill in "${clientIdVariable}" and "${clientSecretVariable}" on the application registration.`, + ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED, + ); + } + + return { clientId, clientSecret }; + } + + async areClientCredentialsConfigured( + provider: ConnectionProviderEntity, + ): Promise { + const result = await this.areClientCredentialsConfiguredBatch([provider]); + + return result.get(provider.id) ?? false; + } + + async areClientCredentialsConfiguredBatch( + providers: ConnectionProviderEntity[], + ): Promise> { + const result = new Map(); + + if (providers.length === 0) { + return result; + } + + const oauthProviders = providers.filter( + (p) => p.type === 'oauth' && isDefined(p.oauthConfig), + ); + + for (const provider of providers) { + result.set(provider.id, false); + } + + if (oauthProviders.length === 0) { + return result; + } + + const applicationIds = [ + ...new Set(oauthProviders.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) { + return result; + } + + const allKeys = oauthProviders.flatMap((p) => [ + p.oauthConfig!.clientIdVariable, + p.oauthConfig!.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 oauthProviders) { + const registrationId = registrationIdByApplicationId.get( + provider.applicationId, + ); + + if (!isDefined(registrationId)) { + continue; + } + + const filled = filledKeysByRegistrationId.get(registrationId); + const { clientIdVariable, clientSecretVariable } = provider.oauthConfig!; + + result.set( + provider.id, + filled?.has(clientIdVariable) === true && + filled?.has(clientSecretVariable) === true, + ); + } + + return result; + } + + async findOneByApplicationAndName({ + applicationId, + name, + }: { + applicationId: string; + name: string; + }): Promise { + return this.connectionProviderRepository.findOne({ + where: { applicationId, name }, + }); + } + + async findOneByIdOrThrow(id: string): Promise { + const provider = await this.connectionProviderRepository.findOne({ + where: { id }, + }); + + if (!isDefined(provider)) { + throw new ConnectionProviderException( + `Connection provider with id "${id}" not found`, + ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND, + ); + } + + return provider; + } + + async findManyByApplication({ + applicationId, + workspaceId, + }: { + applicationId: string; + workspaceId: string; + }): Promise { + return this.connectionProviderRepository.find({ + where: { applicationId, workspaceId }, + }); + } +} 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/connection-provider/connections/application-connections.controller.ts similarity index 88% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.controller.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connections/application-connections.controller.ts index 28b3e9b13e..d49100f761 100644 --- 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/connection-provider/connections/application-connections.controller.ts @@ -14,10 +14,10 @@ import { 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 { type AppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.dto'; +import { GetAppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/get-app-connection.dto'; +import { ListAppConnectionsDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/list-app-connections.dto'; +import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-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'; 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/connection-provider/connections/application-connections.module.ts similarity index 73% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/application-connections.module.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connections/application-connections.module.ts index 2b6f5752c9..3651a96eeb 100644 --- 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/connection-provider/connections/application-connections.module.ts @@ -1,9 +1,9 @@ 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 { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { ApplicationConnectionsController } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.controller'; +import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-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'; @@ -11,13 +11,13 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres // Top-level consumer: depends on RefreshTokensManagerModule (which itself // imports the engine-side AppOAuthRefreshModule). Kept separate from -// ApplicationOAuthProviderModule to avoid the import cycle. TokenModule + +// ConnectionProviderModule to avoid the import cycle. TokenModule + // WorkspaceCacheStorageModule are pulled in for the controller's JwtAuthGuard. @Module({ imports: [ TypeOrmModule.forFeature([ ConnectedAccountEntity, - ApplicationOAuthProviderEntity, + ConnectionProviderEntity, ]), TokenModule, WorkspaceCacheStorageModule, 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/connection-provider/connections/dtos/app-connection.dto.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.dto.ts 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/connection-provider/connections/dtos/get-app-connection.dto.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/get-app-connection.dto.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connections/dtos/get-app-connection.dto.ts 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/connection-provider/connections/dtos/list-app-connections.dto.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/dtos/list-app-connections.dto.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connections/dtos/list-app-connections.dto.ts 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/connection-provider/connections/services/application-connections-list.service.ts similarity index 77% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service.ts index 45c7e8030a..3fc43331ef 100644 --- 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/connection-provider/connections/services/application-connections-list.service.ts @@ -6,8 +6,8 @@ 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 { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { type AppConnectionDto } from 'src/engine/core-modules/application/connection-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'; @@ -40,8 +40,8 @@ export class ApplicationConnectionsListService { private readonly refreshTokensService: ConnectedAccountRefreshTokensService, @InjectRepository(ConnectedAccountEntity) private readonly connectedAccountRepository: Repository, - @InjectRepository(ApplicationOAuthProviderEntity) - private readonly oauthProviderRepository: Repository, + @InjectRepository(ConnectionProviderEntity) + private readonly oauthProviderRepository: Repository, ) {} async list({ @@ -72,7 +72,7 @@ export class ApplicationConnectionsListService { workspaceId, provider: ConnectedAccountProvider.APP, ...(isDefined(providerIds) - ? { applicationConnectionProviderId: In(providerIds) } + ? { connectionProviderId: In(providerIds) } : {}), ...(isDefined(filter.userWorkspaceId) ? { userWorkspaceId: filter.userWorkspaceId } @@ -126,12 +126,12 @@ export class ApplicationConnectionsListService { throw new NotFoundException(`Connection ${id} not found`); } - if (!isDefined(account.applicationConnectionProviderId)) { + if (!isDefined(account.connectionProviderId)) { throw new NotFoundException(`Connection ${id} has no provider`); } const provider = await this.oauthProviderRepository.findOneByOrFail({ - id: account.applicationConnectionProviderId, + id: account.connectionProviderId, workspaceId, }); @@ -150,14 +150,6 @@ export class ApplicationConnectionsListService { 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, @@ -165,15 +157,12 @@ export class ApplicationConnectionsListService { ): | 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, @@ -182,14 +171,10 @@ export class ApplicationConnectionsListService { }; } - // 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' }, { @@ -203,10 +188,10 @@ export class ApplicationConnectionsListService { private async refreshAndMap( account: ConnectedAccountEntity, workspaceId: string, - providerById: Map, + providerById: Map, ): Promise { - const provider = isDefined(account.applicationConnectionProviderId) - ? providerById.get(account.applicationConnectionProviderId) + const provider = isDefined(account.connectionProviderId) + ? providerById.get(account.connectionProviderId) : undefined; // Connections without a resolvable provider can't be refreshed and the @@ -215,7 +200,7 @@ export class ApplicationConnectionsListService { // ON DELETE CASCADE when the provider is removed. if (!isDefined(provider)) { this.logger.warn( - `Connection ${account.id} references missing provider ${account.applicationConnectionProviderId}`, + `Connection ${account.id} references missing provider ${account.connectionProviderId}`, ); return null; @@ -235,7 +220,7 @@ export class ApplicationConnectionsListService { visibility: account.visibility as 'user' | 'workspace', userWorkspaceId: account.userWorkspaceId, accessToken: tokens.accessToken, - scopes: account.scopes ?? provider.scopes, + scopes: account.scopes ?? provider.oauthConfig?.scopes ?? [], authFailedAt: account.authFailedAt?.toISOString() ?? null, }; } catch (error) { 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/connection-provider/dtos/application-connection-provider.dto.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/dtos/application-connection-provider.dto.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/dtos/application-connection-provider.dto.ts 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/connection-provider/refresh/app-oauth-refresh.module.ts similarity index 65% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module.ts index ef4851d2fb..94cffa7c1d 100644 --- 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/connection-provider/refresh/app-oauth-refresh.module.ts @@ -1,14 +1,14 @@ 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 { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module'; +import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service'; +import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-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, + ConnectionProviderModule, ApplicationVariableEntityModule, SecureHttpClientModule, ], 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/connection-provider/refresh/services/app-oauth-refresh-tokens.service.ts similarity index 58% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service.ts index d26bdc43e3..07b7999924 100644 --- 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/connection-provider/refresh/services/app-oauth-refresh-tokens.service.ts @@ -2,11 +2,12 @@ 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 { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; +import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service'; +import { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util'; 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 { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-refresh-token-for-token.util'; +import { OAuthTokenEndpointError } from 'src/engine/core-modules/application/connection-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 { @@ -19,7 +20,7 @@ export class AppOAuthRefreshAccessTokenService { private readonly logger = new Logger(AppOAuthRefreshAccessTokenService.name); constructor( - private readonly applicationOAuthProviderService: ApplicationOAuthProviderService, + private readonly connectionProviderService: ConnectionProviderService, private readonly secureHttpClientService: SecureHttpClientService, ) {} @@ -27,52 +28,31 @@ export class AppOAuthRefreshAccessTokenService { connectedAccount: ConnectedAccountEntity, refreshToken: string, ): Promise { - if (!isDefined(connectedAccount.applicationConnectionProviderId)) { + if (!isDefined(connectedAccount.connectionProviderId)) { throw new ConnectedAccountRefreshAccessTokenException( - `Connected account ${connectedAccount.id} has no applicationConnectionProviderId`, + `Connected account ${connectedAccount.id} has no connectionProviderId`, 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; - } + const { provider, clientId, clientSecret } = await this.resolveProvider( + connectedAccount.connectionProviderId, + ); try { const tokenResponse = await exchangeRefreshTokenForToken({ fetchFn: this.secureHttpClientService.createSsrfSafeFetch(), - tokenEndpoint: provider.tokenEndpoint, + tokenEndpoint: provider.oauthConfig.tokenEndpoint, clientId, clientSecret, refreshToken, - contentType: provider.tokenRequestContentType, + contentType: provider.oauthConfig.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. + // Fall back to the original when the response omits one — some + // providers don't rotate refresh tokens. refreshToken: tokenResponse.refreshToken ?? refreshToken, }; } catch (error) { @@ -80,9 +60,8 @@ export class AppOAuthRefreshAccessTokenService { `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. + // Only 4xx token-endpoint responses (esp. invalid_grant) imply the + // user must reconnect — 5xx and transport errors stay transient. const isTransient = !(error instanceof OAuthTokenEndpointError) || error.status >= 500; @@ -94,4 +73,29 @@ export class AppOAuthRefreshAccessTokenService { ); } } + + private async resolveProvider(connectionProviderId: string) { + try { + const provider = + await this.connectionProviderService.findOneByIdOrThrow( + connectionProviderId, + ); + + assertOAuthProvider(provider); + + const { clientId, clientSecret } = + await this.connectionProviderService.getClientCredentials(provider); + + return { provider, clientId, clientSecret }; + } catch (error) { + if (error instanceof ConnectionProviderException) { + throw new ConnectedAccountRefreshAccessTokenException( + error.message, + ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED, + ); + } + + throw error; + } + } } 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/connection-provider/refresh/services/app-oauth-revoke.service.ts similarity index 65% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service.ts index c81d83ea66..70583b958e 100644 --- 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/connection-provider/refresh/services/app-oauth-revoke.service.ts @@ -2,7 +2,7 @@ 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 { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-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'; @@ -11,18 +11,14 @@ export class AppOAuthRevokeService { private readonly logger = new Logger(AppOAuthRevokeService.name); constructor( - private readonly applicationOAuthProviderService: ApplicationOAuthProviderService, + private readonly connectionProviderService: ConnectionProviderService, 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. + // Best-effort: failures are logged but never block disconnect. async revokeIfApp(connectedAccount: ConnectedAccountEntity): Promise { if ( - !isDefined(connectedAccount.applicationConnectionProviderId) || + !isDefined(connectedAccount.connectionProviderId) || !isDefined(connectedAccount.accessToken) ) { return; @@ -31,20 +27,22 @@ export class AppOAuthRevokeService { let provider; try { - provider = await this.applicationOAuthProviderService.findOneByIdOrThrow( - connectedAccount.applicationConnectionProviderId, + provider = await this.connectionProviderService.findOneByIdOrThrow( + connectedAccount.connectionProviderId, ); } catch { return; } - if (!provider.revokeEndpoint) { + const revokeEndpoint = provider.oauthConfig?.revokeEndpoint; + + if (provider.type !== 'oauth' || !isDefined(revokeEndpoint)) { return; } try { const response = await this.secureHttpClientService.createSsrfSafeFetch()( - provider.revokeEndpoint, + revokeEndpoint, { method: 'POST', headers: { 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/connection-provider/types/token-exchange-response.type.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/types/token-exchange-response.type.ts 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/connection-provider/utils/__tests__/exchange-code-for-token.util.spec.ts similarity index 96% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/__tests__/exchange-code-for-token.util.spec.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/__tests__/exchange-code-for-token.util.spec.ts index 49c8441091..79d493bc0d 100644 --- 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/connection-provider/utils/__tests__/exchange-code-for-token.util.spec.ts @@ -1,5 +1,5 @@ -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'; +import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util'; +import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-refresh-token-for-token.util'; const buildResponse = ( json: unknown, diff --git a/packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util.ts b/packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util.ts new file mode 100644 index 0000000000..8d98c9a45f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util.ts @@ -0,0 +1,22 @@ +import { type StoredOAuthConnectionProviderConfig } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; + +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; +import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception'; + +export type OAuthConnectionProvider = ConnectionProviderEntity & { + type: 'oauth'; + oauthConfig: StoredOAuthConnectionProviderConfig; +}; + +export function assertOAuthProvider( + provider: ConnectionProviderEntity, +): asserts provider is OAuthConnectionProvider { + if (provider.type !== 'oauth' || !isDefined(provider.oauthConfig)) { + throw new ConnectionProviderException( + `Connection provider "${provider.name}" (id ${provider.id}) is not OAuth-typed or has no oauthConfig`, + ConnectionProviderExceptionCode.INVALID_REQUEST, + ); + } +} 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/connection-provider/utils/build-callback-url.util.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/build-callback-url.util.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/build-callback-url.util.ts 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/connection-provider/utils/compute-pkce-challenge.util.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/compute-pkce-challenge.util.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/compute-pkce-challenge.util.ts 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/connection-provider/utils/encode-oauth-body.util.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/encode-oauth-body.util.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/encode-oauth-body.util.ts 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/connection-provider/utils/exchange-code-for-token.util.ts similarity index 86% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util.ts index bba71e4039..e94b25fd5e 100644 --- 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/connection-provider/utils/exchange-code-for-token.util.ts @@ -1,7 +1,7 @@ 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'; +import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type'; +import { postOAuthTokenRequest } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util'; type FetchFn = typeof globalThis.fetch; 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/connection-provider/utils/exchange-refresh-token-for-token.util.ts similarity index 83% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/exchange-refresh-token-for-token.util.ts index ff7804408d..9daf8f9048 100644 --- 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/connection-provider/utils/exchange-refresh-token-for-token.util.ts @@ -1,7 +1,7 @@ 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'; +import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type'; +import { postOAuthTokenRequest } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util'; type FetchFn = typeof globalThis.fetch; 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/connection-provider/utils/generate-pkce-verifier.util.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/generate-pkce-verifier.util.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util.ts 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/connection-provider/utils/parse-token-response.util.ts similarity index 89% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/parse-token-response.util.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/parse-token-response.util.ts index 9b988de00e..e3249f7f69 100644 --- 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/connection-provider/utils/parse-token-response.util.ts @@ -1,4 +1,4 @@ -import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type'; +import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type'; export const parseTokenResponse = ( json: Record, 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/connection-provider/utils/post-oauth-token-request.util.ts similarity index 88% rename from packages/twenty-server/src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util.ts rename to packages/twenty-server/src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util.ts index 33a767afd8..8645411bcf 100644 --- 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/connection-provider/utils/post-oauth-token-request.util.ts @@ -1,8 +1,8 @@ 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'; +import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type'; +import { encodeOAuthBody } from 'src/engine/core-modules/application/connection-provider/utils/encode-oauth-body.util'; +import { parseTokenResponse } from 'src/engine/core-modules/application/connection-provider/utils/parse-token-response.util'; type FetchFn = typeof globalThis.fetch; 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 2f9159d04a..a40aa915b7 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,9 +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 { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module'; +import { ConnectionProviderOAuthController } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth.controller'; +import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-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'; @@ -118,7 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; DomainServerConfigModule, ApplicationRegistrationModule, ApplicationModule, - ApplicationOAuthProviderModule, + ConnectionProviderModule, ApplicationConnectionsModule, WorkspaceCacheModule, CoreEntityCacheModule, @@ -133,7 +133,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; MicrosoftAPIsAuthController, OAuthPropagatorController, SSOAuthController, - ApplicationOAuthProviderController, + ConnectionProviderOAuthController, ], providers: [ SignInUpService, 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 ab099f04fd..cd761a3474 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 @@ -145,7 +145,7 @@ export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & { export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & { type: JwtTokenTypeEnum.APP_OAUTH_STATE; workspaceId: string; - applicationOAuthProviderId: string; + connectionProviderId: string; userId: string; userWorkspaceId: string; // 'user' = the resulting credential is private to 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 c800662cb1..75cead6837 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,7 +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 { AppOAuthRefreshModule } from 'src/engine/core-modules/application/connection-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'; 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 6ca1fa8397..cb938fb647 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,7 +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 { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service'; import { ConnectedAccountException, ConnectedAccountExceptionCode, 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 460e15f768..2218229aef 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 @@ -75,7 +75,7 @@ export class ConnectedAccountDTO { @IsUUID() @IsOptional() @Field(() => UUIDScalarType, { nullable: true }) - applicationConnectionProviderId: string | null; + connectionProviderId: string | null; @IsUUID() @IsOptional() 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 20ce2b2c40..905da26aa9 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 @@ -14,21 +14,16 @@ 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 { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-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_CONNECTION_PROVIDER_ID', ['connectionProviderId']) @Index('IDX_CONNECTED_ACCOUNT_APPLICATION_ID', ['applicationId']) export class ConnectedAccountEntity extends WorkspaceRelatedEntity { @PrimaryGeneratedColumn('uuid') @@ -70,15 +65,15 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity { @Column({ type: 'uuid', nullable: false }) userWorkspaceId: string; - @Column({ type: 'uuid', nullable: true, name: 'applicationOAuthProviderId' }) - applicationConnectionProviderId: string | null; + @Column({ type: 'uuid', nullable: true }) + connectionProviderId: string | null; - @ManyToOne(() => ApplicationOAuthProviderEntity, { + @ManyToOne(() => ConnectionProviderEntity, { onDelete: 'CASCADE', nullable: true, }) - @JoinColumn({ name: 'applicationOAuthProviderId' }) - applicationConnectionProvider: Relation | null; + @JoinColumn({ name: 'connectionProviderId' }) + connectionProvider: Relation | null; @Column({ type: 'uuid', nullable: true }) applicationId: string | null; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/constants/flat-connection-provider-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/constants/flat-connection-provider-editable-properties.constant.ts new file mode 100644 index 0000000000..cf38ad1715 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/constants/flat-connection-provider-editable-properties.constant.ts @@ -0,0 +1,7 @@ +import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant'; + +export const FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES = [ + 'displayName', + 'type', + 'oauthConfig', +] as const satisfies MetadataEntityPropertyName<'connectionProvider'>[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module.ts new file mode 100644 index 0000000000..e13de0665a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { WorkspaceFlatConnectionProviderMapCacheService } from 'src/engine/metadata-modules/flat-connection-provider/services/workspace-flat-connection-provider-map-cache.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ApplicationEntity, ConnectionProviderEntity]), + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], + providers: [WorkspaceFlatConnectionProviderMapCacheService], + exports: [WorkspaceFlatConnectionProviderMapCacheService], +}) +export class FlatConnectionProviderModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/services/workspace-flat-connection-provider-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/services/workspace-flat-connection-provider-map-cache.service.ts new file mode 100644 index 0000000000..b36de14bb2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/services/workspace-flat-connection-provider-map-cache.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; + +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { type FlatConnectionProviderMaps } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider-maps.type'; +import { fromConnectionProviderEntityToFlatConnectionProvider } from 'src/engine/metadata-modules/flat-connection-provider/utils/from-connection-provider-entity-to-flat-connection-provider.util'; +import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; +import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util'; +import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util'; + +@Injectable() +@WorkspaceCache('flatConnectionProviderMaps') +export class WorkspaceFlatConnectionProviderMapCacheService extends WorkspaceCacheProvider { + constructor( + @InjectRepository(ConnectionProviderEntity) + private readonly connectionProviderRepository: Repository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + ) { + super(); + } + + async computeForCache( + workspaceId: string, + ): Promise { + const [connectionProviders, applications] = await Promise.all([ + this.connectionProviderRepository.find({ + where: { workspaceId }, + }), + this.applicationRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + }), + ]); + + const applicationIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(applications); + + const flatConnectionProviderMaps = createEmptyFlatEntityMaps(); + + for (const connectionProviderEntity of connectionProviders) { + const flatConnectionProvider = + fromConnectionProviderEntityToFlatConnectionProvider({ + entity: connectionProviderEntity, + applicationIdToUniversalIdentifierMap, + }); + + addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ + flatEntity: flatConnectionProvider, + flatEntityMapsToMutate: flatConnectionProviderMaps, + }); + } + + return flatConnectionProviderMaps; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider-maps.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider-maps.type.ts new file mode 100644 index 0000000000..15b290a2c5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider-maps.type.ts @@ -0,0 +1,4 @@ +import { type FlatConnectionProvider } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; + +export type FlatConnectionProviderMaps = FlatEntityMaps; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type.ts new file mode 100644 index 0000000000..3659525348 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type.ts @@ -0,0 +1,4 @@ +import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type'; + +export type FlatConnectionProvider = FlatEntityFrom; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/utils/from-connection-provider-entity-to-flat-connection-provider.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/utils/from-connection-provider-entity-to-flat-connection-provider.util.ts new file mode 100644 index 0000000000..d6c7adb56d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-connection-provider/utils/from-connection-provider-entity-to-flat-connection-provider.util.ts @@ -0,0 +1,39 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + FlatEntityMapsException, + FlatEntityMapsExceptionCode, +} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; +import { type FlatConnectionProvider } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type'; +import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type'; + +export const fromConnectionProviderEntityToFlatConnectionProvider = ({ + entity: connectionProviderEntity, + applicationIdToUniversalIdentifierMap, +}: FromEntityToFlatEntityArgs<'connectionProvider'>): FlatConnectionProvider => { + const applicationUniversalIdentifier = + applicationIdToUniversalIdentifierMap.get( + connectionProviderEntity.applicationId, + ); + + if (!isDefined(applicationUniversalIdentifier)) { + throw new FlatEntityMapsException( + `Application with id ${connectionProviderEntity.applicationId} not found for connection provider ${connectionProviderEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + return { + id: connectionProviderEntity.id, + universalIdentifier: connectionProviderEntity.universalIdentifier, + applicationId: connectionProviderEntity.applicationId, + workspaceId: connectionProviderEntity.workspaceId, + name: connectionProviderEntity.name, + displayName: connectionProviderEntity.displayName, + type: connectionProviderEntity.type, + oauthConfig: connectionProviderEntity.oauthConfig, + createdAt: connectionProviderEntity.createdAt.toISOString(), + updatedAt: connectionProviderEntity.updatedAt.toISOString(), + applicationUniversalIdentifier, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap index 1b3b1c980c..ba9fa953fb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap @@ -39,6 +39,17 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma "hotKeys", ], }, + "connectionProvider": { + "propertiesToCompare": [ + "name", + "displayName", + "type", + "oauthConfig", + ], + "propertiesToStringify": [ + "oauthConfig", + ], + }, "fieldMetadata": { "propertiesToCompare": [ "defaultValue", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts index 01f501d70e..8ba379b558 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts @@ -1542,6 +1542,30 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { universalProperty: undefined, }, }, + connectionProvider: { + name: { toCompare: true, toStringify: false, universalProperty: undefined }, + displayName: { + toCompare: true, + toStringify: false, + universalProperty: undefined, + }, + type: { toCompare: true, toStringify: false, universalProperty: undefined }, + oauthConfig: { + toCompare: true, + toStringify: true, + universalProperty: undefined, + }, + createdAt: { + toCompare: false, + toStringify: false, + universalProperty: undefined, + }, + updatedAt: { + toCompare: false, + toStringify: false, + universalProperty: undefined, + }, + }, } as const satisfies { [P in AllMetadataName]: MetadataEntityPropertyConfiguration

; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts index e0c999dfbb..3f721add17 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts @@ -285,6 +285,10 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = { foreignKey: 'viewId', }, }, + connectionProvider: { + workspace: null, + application: null, + }, } as const satisfies ManyToOneMetadataRelationsProperties; // satisfies with complex mapped types involving nested generics doesn't always catch missing required keys diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts index d5b7fd21d0..93ae146d70 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts @@ -505,6 +505,10 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = { metadataName: 'view', }, }, + connectionProvider: { + workspace: null, + application: null, + }, } as const satisfies ManyToOneMetadataRelationsProperties; // satisfies with complex mapped types involving nested generics doesn't always catch missing required keys diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts index ec0a5c9d40..f4fd588fa0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts @@ -1,6 +1,7 @@ import { type AllMetadataName } from 'twenty-shared/metadata'; import { type EntityTarget, type ObjectLiteral } from 'typeorm'; +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -57,4 +58,5 @@ export const ALL_METADATA_ENTITY_BY_METADATA_NAME = { permissionFlag: PermissionFlagEntity, webhook: WebhookEntity, viewSort: ViewSortEntity, + connectionProvider: ConnectionProviderEntity, } as const satisfies Record>; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts index b9f3999873..a39d51c91e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts @@ -112,4 +112,5 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { }, frontComponent: {}, webhook: {}, + connectionProvider: {}, } as const satisfies MetadataRequiredForValidation; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts index 31006fd298..2a2ab7c665 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts @@ -50,6 +50,7 @@ export const ALL_METADATA_SERIALIZED_RELATION = { viewSort: {}, frontComponent: {}, webhook: {}, + connectionProvider: {}, } as const satisfies MetadataSerializedRelationProperties; // satisfies with complex mapped types involving nested generics doesn't always catch missing required keys diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts index 6a3f9388c6..0d647b4942 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts @@ -244,6 +244,7 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = { frontComponent: {}, webhook: {}, viewSort: {}, + connectionProvider: {}, } as const satisfies OneToManyMetadataRelationsProperties; // satisfies with complex mapped types involving nested generics doesn't always catch missing required keys diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts index d3deb34eeb..9e359cac5f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts @@ -2,6 +2,8 @@ import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-meta import { type FlatAgentMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-agent-maps.type'; import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type'; +import { type FlatConnectionProviderMaps } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider-maps.type'; +import { type FlatConnectionProvider } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type'; import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type MetadataEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-entity.type'; @@ -53,6 +55,7 @@ import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-mo import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type'; import { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type'; import { type UniversalFlatCommandMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-command-menu-item.type'; +import { type UniversalFlatConnectionProvider } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type'; import { type UniversalFlatEntityFrom } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type'; import { type UniversalFlatFieldPermission } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type'; import { type UniversalFlatFrontComponent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-front-component.type'; @@ -94,6 +97,14 @@ import { type UniversalDeleteCommandMenuItemAction, type UniversalUpdateCommandMenuItemAction, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type'; +import { + type FlatCreateConnectionProviderAction, + type FlatDeleteConnectionProviderAction, + type FlatUpdateConnectionProviderAction, + type UniversalCreateConnectionProviderAction, + type UniversalDeleteConnectionProviderAction, + type UniversalUpdateConnectionProviderAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type'; import { type FlatCreateFieldPermissionAction, type FlatDeleteFieldPermissionAction, @@ -731,4 +742,20 @@ export type AllFlatEntityTypesByMetadataName = { universalFlatEntity: UniversalFlatWebhook; entity: MetadataEntity<'webhook'>; }; + connectionProvider: { + flatEntityMaps: FlatConnectionProviderMaps; + universalActions: { + create: UniversalCreateConnectionProviderAction; + update: UniversalUpdateConnectionProviderAction; + delete: UniversalDeleteConnectionProviderAction; + }; + flatActions: { + create: FlatCreateConnectionProviderAction; + update: FlatUpdateConnectionProviderAction; + delete: FlatDeleteConnectionProviderAction; + }; + flatEntity: FlatConnectionProvider; + universalFlatEntity: UniversalFlatConnectionProvider; + entity: MetadataEntity<'connectionProvider'>; + }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index 7731907c93..ecb07bfe6a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`getMetadataRelatedMetadataNames should return related metadata names for agent 1`] = `[]`; @@ -10,6 +10,8 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo ] `; +exports[`getMetadataRelatedMetadataNames should return related metadata names for connectionProvider 1`] = `[]`; + exports[`getMetadataRelatedMetadataNames should return related metadata names for fieldMetadata 1`] = ` [ "objectMetadata", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap index 460fe9de81..e370f70e03 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap @@ -18,6 +18,7 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with "rowLevelPermissionPredicateGroup", "viewGroup", "agent", + "connectionProvider", "frontComponent", "logicFunction", "pageLayoutTab", diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index 3d94e69bc4..d88af644a9 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -53,6 +53,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { 'flat-maps:row-level-permission-predicate-group', flatFrontComponentMaps: 'flat-maps:front-component', flatWebhookMaps: 'flat-maps:webhook', + flatConnectionProviderMaps: 'flat-maps:connection-provider', flatWorkspaceMemberMaps: 'flat-maps:workspace-member', apiKeyMap: 'cache:api-key-map', applicationVariableMaps: 'cache:application-variable', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts index 36ce23c76a..ce3f43b173 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts @@ -170,7 +170,8 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({ case 'permissionFlag': case 'objectPermission': case 'fieldPermission': - case 'webhook': { + case 'webhook': + case 'connectionProvider': { if (!isDefined(idByUniversalIdentifier)) { return action; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index 1f544d50eb..018e1d70db 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -16,6 +16,7 @@ import { crossEntityTransversalValidation } from 'src/engine/workspace-manager/w import { mergeOrchestratorFailureReports } from 'src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util'; import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/workspace-migration-agent-actions-builder.service'; import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service'; +import { WorkspaceMigrationConnectionProviderActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service'; import { WorkspaceMigrationFieldPermissionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service'; import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service'; import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service'; @@ -72,6 +73,7 @@ export class WorkspaceMigrationBuildOrchestratorService { private readonly workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService: WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, private readonly workspaceMigrationFrontComponentActionsBuilderService: WorkspaceMigrationFrontComponentActionsBuilderService, private readonly workspaceMigrationWebhookActionsBuilderService: WorkspaceMigrationWebhookActionsBuilderService, + private readonly workspaceMigrationConnectionProviderActionsBuilderService: WorkspaceMigrationConnectionProviderActionsBuilderService, ) {} private setupOptimisticCache({ @@ -164,6 +166,7 @@ export class WorkspaceMigrationBuildOrchestratorService { flatPageLayoutTabMaps, flatFrontComponentMaps, flatWebhookMaps, + flatConnectionProviderMaps, } = fromToAllFlatEntityMaps; if (isDefined(flatObjectMetadataMaps)) { @@ -823,6 +826,34 @@ export class WorkspaceMigrationBuildOrchestratorService { } } + if (isDefined(flatConnectionProviderMaps)) { + const { + from: fromFlatConnectionProviderMaps, + to: toFlatConnectionProviderMaps, + } = flatConnectionProviderMaps; + + const connectionProviderResult = + await this.workspaceMigrationConnectionProviderActionsBuilderService.validateAndBuild( + { + additionalCacheDataMaps, + from: fromFlatConnectionProviderMaps, + to: toFlatConnectionProviderMaps, + buildOptions, + dependencyOptimisticFlatEntityMaps: optimisticAllFlatEntityMaps, + workspaceId, + }, + ); + + if (connectionProviderResult.status === 'fail') { + orchestratorFailureReport.connectionProvider.push( + ...connectionProviderResult.errors, + ); + } else { + orchestratorActionsReport.connectionProvider = + connectionProviderResult.actions; + } + } + const crossEntityFailureReport = crossEntityTransversalValidation({ optimisticUniversalFlatMaps: optimisticAllFlatEntityMaps, orchestratorActionsReport, @@ -999,6 +1030,12 @@ export class WorkspaceMigrationBuildOrchestratorService { ...aggregatedOrchestratorActionsReport.webhook.create, ...aggregatedOrchestratorActionsReport.webhook.update, /// + + // Connection providers + ...aggregatedOrchestratorActionsReport.connectionProvider.delete, + ...aggregatedOrchestratorActionsReport.connectionProvider.create, + ...aggregatedOrchestratorActionsReport.connectionProvider.update, + /// ], }, }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap index fb96fe6916..fc0dff07d2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap @@ -1,9 +1,10 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should match snapshot 1`] = ` { "agent": [], "commandMenuItem": [], + "connectionProvider": [], "fieldMetadata": [ "viewSortUniversalIdentifiers", "fieldPermissionUniversalIdentifiers", diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts index 3107aa4362..c30adac710 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts @@ -42,6 +42,7 @@ export const ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION_BY_METADATA_NAME = { objectPermission: {}, fieldPermission: {}, frontComponent: {}, + connectionProvider: {}, } as const satisfies { [P in AllMetadataName]: Partial<{ // TODO prastoin: improve strict typing to recursively serach for nested SerializedRelation diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type.ts new file mode 100644 index 0000000000..e377d3bcad --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type.ts @@ -0,0 +1,7 @@ +import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { type UniversalFlatEntityFrom } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type'; + +export type UniversalFlatConnectionProvider = UniversalFlatEntityFrom< + ConnectionProviderEntity, + 'connectionProvider' +>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type.ts new file mode 100644 index 0000000000..f28a58104e --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type.ts @@ -0,0 +1,24 @@ +import { type BaseFlatCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-create-workspace-migration-action.type'; +import { type BaseFlatDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-delete-workspace-migration-action.type'; +import { type BaseFlatUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-update-workspace-migration-action.type'; +import { type BaseUniversalCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-create-workspace-migration-action.type'; +import { type BaseUniversalDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-delete-workspace-migration-action.type'; +import { type BaseUniversalUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-update-workspace-migration-action.type'; + +export type FlatCreateConnectionProviderAction = + BaseFlatCreateWorkspaceMigrationAction<'connectionProvider'>; + +export type UniversalCreateConnectionProviderAction = + BaseUniversalCreateWorkspaceMigrationAction<'connectionProvider'>; + +export type FlatUpdateConnectionProviderAction = + BaseFlatUpdateWorkspaceMigrationAction<'connectionProvider'>; + +export type UniversalUpdateConnectionProviderAction = + BaseUniversalUpdateWorkspaceMigrationAction<'connectionProvider'>; + +export type UniversalDeleteConnectionProviderAction = + BaseUniversalDeleteWorkspaceMigrationAction<'connectionProvider'>; + +export type FlatDeleteConnectionProviderAction = + BaseFlatDeleteWorkspaceMigrationAction<'connectionProvider'>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service.ts new file mode 100644 index 0000000000..02fb00d781 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; + +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; + +import { UniversalUpdateConnectionProviderAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type'; +import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service'; +import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type'; +import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type'; +import { UniversalFlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-result.type'; +import { FlatConnectionProviderValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service'; + +@Injectable() +export class WorkspaceMigrationConnectionProviderActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + typeof ALL_METADATA_NAME.connectionProvider +> { + constructor( + private readonly flatConnectionProviderValidatorService: FlatConnectionProviderValidatorService, + ) { + super(ALL_METADATA_NAME.connectionProvider); + } + + protected validateFlatEntityCreation( + args: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.connectionProvider + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.connectionProvider, + 'create' + > { + const validationResult = + this.flatConnectionProviderValidatorService.validateFlatConnectionProviderCreation( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatConnectionProviderToValidate } = args; + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'connectionProvider', + flatEntity: flatConnectionProviderToValidate, + }, + }; + } + + protected validateFlatEntityDeletion( + args: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.connectionProvider + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.connectionProvider, + 'delete' + > { + const validationResult = + this.flatConnectionProviderValidatorService.validateFlatConnectionProviderDeletion( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatConnectionProviderToValidate } = args; + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'connectionProvider', + universalIdentifier: + flatConnectionProviderToValidate.universalIdentifier, + }, + }; + } + + protected validateFlatEntityUpdate( + args: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.connectionProvider + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.connectionProvider, + 'update' + > { + const validationResult = + this.flatConnectionProviderValidatorService.validateFlatConnectionProviderUpdate( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { universalIdentifier, flatEntityUpdate } = args; + + const updateConnectionProviderAction: UniversalUpdateConnectionProviderAction = + { + type: 'update', + metadataName: 'connectionProvider', + universalIdentifier, + update: flatEntityUpdate, + }; + + return { + status: 'success', + action: updateConnectionProviderAction, + }; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service.ts new file mode 100644 index 0000000000..3c081bd5b3 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service.ts @@ -0,0 +1,173 @@ +import { Injectable } from '@nestjs/common'; + +import { msg, t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; +import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type'; +import { type UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type'; + +@Injectable() +export class FlatConnectionProviderValidatorService { + public validateFlatConnectionProviderCreation({ + flatEntityToValidate: flatConnectionProvider, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatConnectionProviderMaps: optimisticFlatConnectionProviderMaps, + }, + }: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.connectionProvider + >): FailedFlatEntityValidation<'connectionProvider', 'create'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier: flatConnectionProvider.universalIdentifier, + name: flatConnectionProvider.name, + }, + metadataName: 'connectionProvider', + type: 'create', + }); + + if (!isNonEmptyString(flatConnectionProvider.name)) { + validationResult.errors.push({ + code: ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT, + message: t`Connection provider name is required`, + userFriendlyMessage: msg`Connection provider name is required`, + }); + } + + if (!isNonEmptyString(flatConnectionProvider.displayName)) { + validationResult.errors.push({ + code: ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT, + message: t`Connection provider displayName is required`, + userFriendlyMessage: msg`Connection provider display name is required`, + }); + } + + if (flatConnectionProvider.type === 'oauth') { + const oauthConfig = flatConnectionProvider.oauthConfig; + + if (!isDefined(oauthConfig)) { + validationResult.errors.push({ + code: ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT, + message: t`Connection provider with type 'oauth' is missing oauthConfig`, + userFriendlyMessage: msg`OAuth connection provider is missing its oauth config block`, + }); + } else { + const requiredOAuthFields: Array<{ + key: keyof typeof oauthConfig; + label: string; + }> = [ + { key: 'authorizationEndpoint', label: 'authorizationEndpoint' }, + { key: 'tokenEndpoint', label: 'tokenEndpoint' }, + { key: 'clientIdVariable', label: 'clientIdVariable' }, + { key: 'clientSecretVariable', label: 'clientSecretVariable' }, + ]; + + for (const { key, label } of requiredOAuthFields) { + if (!isNonEmptyString(oauthConfig[key])) { + validationResult.errors.push({ + code: ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT, + message: t`Connection provider oauthConfig.${label} is required`, + userFriendlyMessage: msg`OAuth ${label} is required`, + }); + } + } + } + } + + const existingByName = Object.values( + optimisticFlatConnectionProviderMaps.byUniversalIdentifier, + ).find( + (existing) => + isDefined(existing) && + existing.name === flatConnectionProvider.name && + existing.applicationUniversalIdentifier === + flatConnectionProvider.applicationUniversalIdentifier && + existing.universalIdentifier !== + flatConnectionProvider.universalIdentifier, + ); + + if (isDefined(existingByName)) { + validationResult.errors.push({ + code: ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NAME_ALREADY_EXISTS, + message: t`Connection provider with name ${flatConnectionProvider.name} already exists for this application`, + userFriendlyMessage: msg`A connection provider with this name already exists for this application`, + }); + } + + return validationResult; + } + + public validateFlatConnectionProviderDeletion({ + flatEntityToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatConnectionProviderMaps: optimisticFlatConnectionProviderMaps, + }, + }: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.connectionProvider + >): FailedFlatEntityValidation<'connectionProvider', 'delete'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier: flatEntityToValidate.universalIdentifier, + name: flatEntityToValidate.name, + }, + metadataName: 'connectionProvider', + type: 'delete', + }); + + const existingConnectionProvider = findFlatEntityByUniversalIdentifier({ + universalIdentifier: flatEntityToValidate.universalIdentifier, + flatEntityMaps: optimisticFlatConnectionProviderMaps, + }); + + if (!isDefined(existingConnectionProvider)) { + validationResult.errors.push({ + code: ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NOT_FOUND, + message: t`Connection provider not found`, + userFriendlyMessage: msg`Connection provider not found`, + }); + + return validationResult; + } + + return validationResult; + } + + public validateFlatConnectionProviderUpdate({ + universalIdentifier, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatConnectionProviderMaps: optimisticFlatConnectionProviderMaps, + }, + }: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.connectionProvider + >): FailedFlatEntityValidation<'connectionProvider', 'update'> { + const fromFlatConnectionProvider = findFlatEntityByUniversalIdentifier({ + universalIdentifier, + flatEntityMaps: optimisticFlatConnectionProviderMaps, + }); + + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier, + }, + metadataName: 'connectionProvider', + type: 'update', + }); + + if (!isDefined(fromFlatConnectionProvider)) { + validationResult.errors.push({ + code: ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NOT_FOUND, + message: t`Connection provider not found`, + userFriendlyMessage: msg`Connection provider not found`, + }); + + return validationResult; + } + + return validationResult; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts index 9e0301f30f..f44b3e6995 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts @@ -30,6 +30,7 @@ import { FlatViewGroupValidatorService } from 'src/engine/workspace-manager/work import { FlatViewSortValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-sort-validator.service'; import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service'; import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service'; +import { FlatConnectionProviderValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service'; @Module({ imports: [FeatureFlagModule], @@ -63,6 +64,7 @@ import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/worksp FlatRowLevelPermissionPredicateGroupValidatorService, FlatFrontComponentValidatorService, FlatWebhookValidatorService, + FlatConnectionProviderValidatorService, ], exports: [ FlatViewValidatorService, @@ -93,6 +95,7 @@ import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/worksp FlatRowLevelPermissionPredicateGroupValidatorService, FlatFrontComponentValidatorService, FlatWebhookValidatorService, + FlatConnectionProviderValidatorService, ], }) export class WorkspaceMigrationBuilderValidatorsModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts index 493f5a2fea..e4e9159862 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts @@ -29,6 +29,7 @@ import { WorkspaceMigrationViewGroupActionsBuilderService } from 'src/engine/wor import { WorkspaceMigrationViewSortActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-sort/workspace-migration-view-sort-actions.builder.service'; import { WorkspaceMigrationViewActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/workspace-migration-view-actions-builder.service'; import { WorkspaceMigrationWebhookActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service'; +import { WorkspaceMigrationConnectionProviderActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service'; import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module'; @Module({ @@ -62,6 +63,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, WorkspaceMigrationFrontComponentActionsBuilderService, WorkspaceMigrationWebhookActionsBuilderService, + WorkspaceMigrationConnectionProviderActionsBuilderService, ], exports: [ WorkspaceMigrationViewActionsBuilderService, @@ -92,6 +94,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- FlatFieldMetadataTypeValidatorService, WorkspaceMigrationFrontComponentActionsBuilderService, WorkspaceMigrationWebhookActionsBuilderService, + WorkspaceMigrationConnectionProviderActionsBuilderService, ], }) export class WorkspaceMigrationBuilderModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/create-connection-provider-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/create-connection-provider-action-handler.service.ts new file mode 100644 index 0000000000..45b4de7054 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/create-connection-provider-action-handler.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; + +import { v4 } from 'uuid'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { getUniversalFlatEntityEmptyForeignKeyAggregators } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/reset-universal-flat-entity-foreign-key-aggregators.util'; +import { + FlatCreateConnectionProviderAction, + UniversalCreateConnectionProviderAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class CreateConnectionProviderActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'create', + 'connectionProvider', +) { + constructor() { + super(); + } + + override async transpileUniversalActionToFlatAction({ + action, + flatApplication, + workspaceId, + }: WorkspaceMigrationActionRunnerArgs): Promise { + const emptyUniversalForeignKeyAggregators = + getUniversalFlatEntityEmptyForeignKeyAggregators({ + metadataName: 'connectionProvider', + }); + + return { + ...action, + flatEntity: { + ...action.flatEntity, + applicationId: flatApplication.id, + id: action.id ?? v4(), + workspaceId, + ...emptyUniversalForeignKeyAggregators, + }, + }; + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner } = context; + const { flatEntity } = flatAction; + + await this.insertFlatEntitiesInRepository({ + queryRunner, + flatEntities: [flatEntity], + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/delete-connection-provider-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/delete-connection-provider-action-handler.service.ts new file mode 100644 index 0000000000..748ff7f618 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/delete-connection-provider-action-handler.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { + FlatDeleteConnectionProviderAction, + UniversalDeleteConnectionProviderAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class DeleteConnectionProviderActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'delete', + 'connectionProvider', +) { + constructor() { + super(); + } + + override async transpileUniversalActionToFlatAction( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return this.transpileUniversalDeleteActionToFlatDeleteAction(context); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner, workspaceId } = context; + + const connectionProviderRepository = + queryRunner.manager.getRepository( + ConnectionProviderEntity, + ); + + await connectionProviderRepository.delete({ + id: flatAction.entityId, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/update-connection-provider-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/update-connection-provider-action-handler.service.ts new file mode 100644 index 0000000000..1f1d42b699 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/update-connection-provider-action-handler.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity'; +import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; +import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-update-relation-identifiers-to-ids.util'; +import { + FlatUpdateConnectionProviderAction, + UniversalUpdateConnectionProviderAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class UpdateConnectionProviderActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'update', + 'connectionProvider', +) { + override async transpileUniversalActionToFlatAction( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, allFlatEntityMaps } = context; + + const flatConnectionProvider = findFlatEntityByUniversalIdentifierOrThrow({ + flatEntityMaps: allFlatEntityMaps.flatConnectionProviderMaps, + universalIdentifier: action.universalIdentifier, + }); + + const update = resolveUniversalUpdateRelationIdentifiersToIds({ + metadataName: 'connectionProvider', + universalUpdate: action.update, + allFlatEntityMaps, + }); + + return { + type: 'update', + metadataName: 'connectionProvider', + entityId: flatConnectionProvider.id, + update, + }; + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner, workspaceId } = context; + const { entityId, update } = flatAction; + + const connectionProviderRepository = + queryRunner.manager.getRepository( + ConnectionProviderEntity, + ); + + await connectionProviderRepository.update( + { id: entityId, workspaceId }, + update, + ); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts index 3ac0638dad..00d75dbd06 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts @@ -84,6 +84,9 @@ import { UpdateViewActionHandlerService } from 'src/engine/workspace-manager/wor import { CreateWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/create-webhook-action-handler.service'; import { DeleteWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/delete-webhook-action-handler.service'; import { UpdateWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/update-webhook-action-handler.service'; +import { CreateConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/create-connection-provider-action-handler.service'; +import { DeleteConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/delete-connection-provider-action-handler.service'; +import { UpdateConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/update-connection-provider-action-handler.service'; @Module({ imports: [ @@ -198,6 +201,9 @@ import { UpdateWebhookActionHandlerService } from 'src/engine/workspace-manager/ CreateWebhookActionHandlerService, UpdateWebhookActionHandlerService, DeleteWebhookActionHandlerService, + CreateConnectionProviderActionHandlerService, + UpdateConnectionProviderActionHandlerService, + DeleteConnectionProviderActionHandlerService, ], }) export class WorkspaceSchemaMigrationRunnerActionHandlersModule {} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts index 44780ea9c3..d97d711776 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts @@ -28,4 +28,5 @@ export const METADATA_EVENTS_TO_EMIT = { logicFunction: true, viewSort: true, webhook: false, + connectionProvider: true, } as const satisfies { [P in AllMetadataName]: boolean }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts index d522bae98f..0bedc7e398 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts @@ -93,7 +93,8 @@ const deriveAllMetadataEventsFromCreateAction = ( case 'objectPermission': case 'fieldPermission': case 'viewSort': - case 'webhook': { + case 'webhook': + case 'connectionProvider': { return [ { type: 'created', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts index 637977d591..d8df90b5f3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts @@ -57,7 +57,8 @@ const deriveAllMetadataEventsFromDeleteAction = ({ case 'objectPermission': case 'fieldPermission': case 'viewSort': - case 'webhook': { + case 'webhook': + case 'connectionProvider': { const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow< MetadataFlatEntity >({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts index c29eaf1ad6..cb89768e23 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts @@ -135,7 +135,8 @@ const deriveAllMetadataEventsFromUpdateAction = ({ case 'objectPermission': case 'fieldPermission': case 'viewSort': - case 'webhook': { + case 'webhook': + case 'connectionProvider': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( flatAction.metadataName, ); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts index 3c89e58b65..5d3f42b50e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts @@ -71,7 +71,8 @@ export const optimisticallyApplyCreateActionOnAllFlatEntityMaps = ({ case 'permissionFlag': case 'objectPermission': case 'fieldPermission': - case 'webhook': { + case 'webhook': + case 'connectionProvider': { addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow({ flatEntity: flatAction.flatEntity, flatEntityAndRelatedMapsToMutate: allFlatEntityMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts index 7eaa8f5bde..f0530d93ae 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts @@ -43,7 +43,8 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = ({ case 'permissionFlag': case 'objectPermission': case 'fieldPermission': - case 'webhook': { + case 'webhook': + case 'connectionProvider': { const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow< MetadataFlatEntity >({ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts index 27ca5d05ae..e48497a5c0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts @@ -64,7 +64,8 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = ({ case 'permissionFlag': case 'objectPermission': case 'fieldPermission': - case 'webhook': { + case 'webhook': + case 'connectionProvider': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( flatAction.metadataName, ); 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 a59cc6c3d6..9f64997870 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,7 +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 { AppOAuthRefreshModule } from 'src/engine/core-modules/application/connection-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'; 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 53541c5db8..db908fafc7 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,7 +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 { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-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'; 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 a2ae60840c..cab9e4f460 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,7 +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 { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-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'; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-connection-provider.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-connection-provider.integration-spec.ts new file mode 100644 index 0000000000..7a31deafea --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-connection-provider.integration-spec.ts @@ -0,0 +1,268 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { findConnectionProvidersByApplication } from 'test/integration/metadata/suites/connection-provider/utils/find-connection-providers-by-application.util'; +import { type Manifest } from 'twenty-shared/application'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_PROVIDER_ID = uuidv4(); +const TEST_SECOND_PROVIDER_ID = uuidv4(); + +const buildManifest = ( + overrides?: Partial>, +) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides }); + +describe('Manifest update - connection providers', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: 'App for testing connection-provider manifest updates', + sourcePath: 'test-manifest-update-connection-provider', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('should create a new connection provider when added to manifest on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ connectionProviders: [] }), + expectToFail: false, + }); + + const providersAfterFirstSync = + await findConnectionProvidersByApplication(TEST_APP_ID); + + expect(providersAfterFirstSync).toHaveLength(0); + + await syncApplication({ + manifest: buildManifest({ + connectionProviders: [ + { + universalIdentifier: TEST_PROVIDER_ID, + 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', + }, + }, + ], + }), + expectToFail: false, + }); + + const providersAfterSecondSync = + await findConnectionProvidersByApplication(TEST_APP_ID); + + expect(providersAfterSecondSync).toHaveLength(1); + expect(providersAfterSecondSync[0]).toMatchObject({ + universalIdentifier: TEST_PROVIDER_ID, + name: 'linear', + displayName: 'Linear', + type: 'oauth', + oauthConfig: { + 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: 'json', + usePkce: true, + }, + }); + }, 60000); + + it('should update oauthConfig fields when manifest changes on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ + connectionProviders: [ + { + universalIdentifier: TEST_PROVIDER_ID, + name: 'linear', + displayName: 'Linear', + type: 'oauth', + oauth: { + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + scopes: ['read'], + clientIdVariable: 'LINEAR_CLIENT_ID', + clientSecretVariable: 'LINEAR_CLIENT_SECRET', + }, + }, + ], + }), + expectToFail: false, + }); + + const providersAfterFirstSync = + await findConnectionProvidersByApplication(TEST_APP_ID); + + expect(providersAfterFirstSync).toHaveLength(1); + expect(providersAfterFirstSync[0].oauthConfig?.scopes).toEqual(['read']); + expect(providersAfterFirstSync[0].oauthConfig?.usePkce).toBe(true); + + await syncApplication({ + manifest: buildManifest({ + connectionProviders: [ + { + universalIdentifier: TEST_PROVIDER_ID, + name: 'linear', + displayName: 'Linear (renamed)', + 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', 'admin'], + clientIdVariable: 'LINEAR_CLIENT_ID', + clientSecretVariable: 'LINEAR_CLIENT_SECRET', + authorizationParams: { prompt: 'consent' }, + tokenRequestContentType: 'form-urlencoded', + usePkce: false, + }, + }, + ], + }), + expectToFail: false, + }); + + const providersAfterSecondSync = + await findConnectionProvidersByApplication(TEST_APP_ID); + + expect(providersAfterSecondSync).toHaveLength(1); + expect(providersAfterSecondSync[0]).toMatchObject({ + universalIdentifier: TEST_PROVIDER_ID, + displayName: 'Linear (renamed)', + oauthConfig: { + revokeEndpoint: 'https://api.linear.app/oauth/revoke', + scopes: ['read', 'write', 'admin'], + authorizationParams: { prompt: 'consent' }, + tokenRequestContentType: 'form-urlencoded', + usePkce: false, + }, + }); + }, 60000); + + it('should delete a connection provider when removed from manifest on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ + connectionProviders: [ + { + universalIdentifier: TEST_PROVIDER_ID, + name: 'linear', + displayName: 'Linear', + type: 'oauth', + oauth: { + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + scopes: ['read'], + clientIdVariable: 'LINEAR_CLIENT_ID', + clientSecretVariable: 'LINEAR_CLIENT_SECRET', + }, + }, + { + universalIdentifier: TEST_SECOND_PROVIDER_ID, + name: 'slack', + displayName: 'Slack', + type: 'oauth', + oauth: { + authorizationEndpoint: 'https://slack.com/oauth/v2/authorize', + tokenEndpoint: 'https://slack.com/api/oauth.v2.access', + scopes: ['chat:write'], + clientIdVariable: 'SLACK_CLIENT_ID', + clientSecretVariable: 'SLACK_CLIENT_SECRET', + }, + }, + ], + }), + expectToFail: false, + }); + + const providersAfterFirstSync = + await findConnectionProvidersByApplication(TEST_APP_ID); + + expect(providersAfterFirstSync).toHaveLength(2); + expect(providersAfterFirstSync.map((p) => p.name).sort()).toEqual([ + 'linear', + 'slack', + ]); + + await syncApplication({ + manifest: buildManifest({ + connectionProviders: [ + { + universalIdentifier: TEST_PROVIDER_ID, + name: 'linear', + displayName: 'Linear', + type: 'oauth', + oauth: { + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + scopes: ['read'], + clientIdVariable: 'LINEAR_CLIENT_ID', + clientSecretVariable: 'LINEAR_CLIENT_SECRET', + }, + }, + ], + }), + expectToFail: false, + }); + + const providersAfterSecondSync = + await findConnectionProvidersByApplication(TEST_APP_ID); + + expect(providersAfterSecondSync).toHaveLength(1); + expect(providersAfterSecondSync[0].name).toBe('linear'); + }, 60000); + + it('should hard-delete connection providers (no soft-delete behaviour)', async () => { + await syncApplication({ + manifest: buildManifest({ + connectionProviders: [ + { + universalIdentifier: TEST_PROVIDER_ID, + name: 'linear', + displayName: 'Linear', + type: 'oauth', + oauth: { + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + scopes: ['read'], + clientIdVariable: 'LINEAR_CLIENT_ID', + clientSecretVariable: 'LINEAR_CLIENT_SECRET', + }, + }, + ], + }), + expectToFail: false, + }); + + await syncApplication({ + manifest: buildManifest({ connectionProviders: [] }), + expectToFail: false, + }); + + // Bypass the helper's `JOIN application` so a soft-deleted row would + // still surface here if it existed. + const rawRows = await globalThis.testDataSource.query( + `SELECT id FROM core."connectionProvider" WHERE "universalIdentifier" = $1`, + [TEST_PROVIDER_ID], + ); + + expect(rawRows).toHaveLength(0); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/connection-provider/connection-provider-resolver.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/connection-provider/connection-provider-resolver.integration-spec.ts new file mode 100644 index 0000000000..e12d28860c --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/connection-provider/connection-provider-resolver.integration-spec.ts @@ -0,0 +1,132 @@ +import { gql } from 'graphql-tag'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type Manifest } from 'twenty-shared/application'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_PROVIDER_ID = uuidv4(); + +const buildManifest = ( + overrides?: Partial>, +) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides }); + +const findApplicationDbId = async ( + universalIdentifier: string, +): Promise => { + const rows = await globalThis.testDataSource.query( + `SELECT id FROM core."application" WHERE "universalIdentifier" = $1`, + [universalIdentifier], + ); + + return rows[0].id; +}; + +describe('applicationConnectionProviders resolver (e2e)', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: 'App for testing the connectionProviders resolver', + sourcePath: 'test-connection-provider-resolver', + }); + + await syncApplication({ + manifest: buildManifest({ + connectionProviders: [ + { + universalIdentifier: TEST_PROVIDER_ID, + 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', + }, + }, + ], + }), + expectToFail: false, + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('returns the provider with type read from the entity and oauth.scopes from oauthConfig', async () => { + const applicationId = await findApplicationDbId(TEST_APP_ID); + + const response = await makeMetadataAPIRequest({ + query: gql` + query ApplicationConnectionProviders($applicationId: UUID!) { + applicationConnectionProviders(applicationId: $applicationId) { + id + applicationId + type + name + displayName + oauth { + scopes + isClientCredentialsConfigured + } + } + } + `, + variables: { applicationId }, + }); + + expect(response.status).toBe(200); + expect(response.body.errors).toBeUndefined(); + + const providers = response.body.data.applicationConnectionProviders; + + expect(providers).toHaveLength(1); + expect(providers[0]).toMatchObject({ + applicationId, + type: 'oauth', + name: 'linear', + displayName: 'Linear', + oauth: { + scopes: ['read', 'write'], + // The fixture registration's OAuth client_id / client_secret server + // variables are not filled in by setupApplicationForSync, so the + // resolver should report credentials as not configured. + isClientCredentialsConfigured: false, + }, + }); + }, 60000); + + it('returns an empty array when the application has no connection providers', async () => { + await syncApplication({ + manifest: buildManifest({ connectionProviders: [] }), + expectToFail: false, + }); + + const applicationId = await findApplicationDbId(TEST_APP_ID); + + const response = await makeMetadataAPIRequest({ + query: gql` + query ApplicationConnectionProviders($applicationId: UUID!) { + applicationConnectionProviders(applicationId: $applicationId) { + id + } + } + `, + variables: { applicationId }, + }); + + expect(response.status).toBe(200); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.applicationConnectionProviders).toEqual([]); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/connection-provider/utils/find-connection-providers-by-application.util.ts b/packages/twenty-server/test/integration/metadata/suites/connection-provider/utils/find-connection-providers-by-application.util.ts new file mode 100644 index 0000000000..e5ddcb39ba --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/connection-provider/utils/find-connection-providers-by-application.util.ts @@ -0,0 +1,27 @@ +import { type StoredOAuthConnectionProviderConfig } from 'twenty-shared/application'; + +type ConnectionProviderRow = { + id: string; + universalIdentifier: string; + applicationId: string; + workspaceId: string; + name: string; + displayName: string; + type: string; + oauthConfig: StoredOAuthConnectionProviderConfig | null; +}; + +export const findConnectionProvidersByApplication = async ( + applicationUniversalIdentifier: string, +): Promise => { + return globalThis.testDataSource.query( + `SELECT cp.id, cp."universalIdentifier", cp."applicationId", + cp."workspaceId", cp.name, cp."displayName", cp.type, + cp."oauthConfig" + FROM core."connectionProvider" cp + JOIN core."application" app ON app.id = cp."applicationId" + WHERE app."universalIdentifier" = $1 + ORDER BY cp.name`, + [applicationUniversalIdentifier], + ); +}; diff --git a/packages/twenty-shared/src/application/connectionProviderType.ts b/packages/twenty-shared/src/application/connectionProviderType.ts index 3363cd85f5..2cffc4f146 100644 --- a/packages/twenty-shared/src/application/connectionProviderType.ts +++ b/packages/twenty-shared/src/application/connectionProviderType.ts @@ -1,5 +1,12 @@ -// 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. +// Discriminator on `connectionProvider.type`. Each value pairs with a +// typed sub-config column on the entity (today only `oauthConfig`). +// +// Anticipated future values: +// - 'apiKey' — single long-lived token in an applicationVariable +// - 'pat' — per-user personal access token entered at connect time +// - 'basicAuth' — username + password +// +// Adding a value is purely additive: define the new sub-config column on +// the entity, the manifest type, and the SDK; existing 'oauth' callers +// keep working. export type ConnectionProviderType = 'oauth'; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 0f3d4e3a66..d0cae7789f 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -61,6 +61,7 @@ export type { } from './roleManifestType'; export type { ServerVariables } from './server-variables.type'; export type { SkillManifest } from './skillManifestType'; +export type { StoredOAuthConnectionProviderConfig } from './storedOAuthConnectionProviderConfigType'; export type { SyncableEntityOptions } from './syncableEntityOptionsType'; export type { ViewManifestFilterValue, diff --git a/packages/twenty-shared/src/application/storedOAuthConnectionProviderConfigType.ts b/packages/twenty-shared/src/application/storedOAuthConnectionProviderConfigType.ts new file mode 100644 index 0000000000..1ad5d7f631 --- /dev/null +++ b/packages/twenty-shared/src/application/storedOAuthConnectionProviderConfigType.ts @@ -0,0 +1,16 @@ +import { type OAuthProviderTokenRequestContentType } from '@/application/oauthProviderTokenRequestContentType.type'; + +// Resolved form of `OAuthConnectionProviderConfig` as stored in the +// `connectionProvider.oauthConfig` JSONB column — manifest defaults are +// filled at write time. +export type StoredOAuthConnectionProviderConfig = { + authorizationEndpoint: string; + tokenEndpoint: string; + revokeEndpoint: string | null; + scopes: string[]; + clientIdVariable: string; + clientSecretVariable: string; + authorizationParams: Record | null; + tokenRequestContentType: OAuthProviderTokenRequestContentType; + usePkce: boolean; +}; diff --git a/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts b/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts index 54dacc8470..d7a34ddc51 100644 --- a/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts +++ b/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts @@ -26,4 +26,5 @@ export const ALL_METADATA_NAME = { fieldPermission: 'fieldPermission', frontComponent: 'frontComponent', webhook: 'webhook', + connectionProvider: 'connectionProvider', } as const;