Make ConnectionProvider a true SyncableEntity (#20232)
## 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -5088,7 +5088,7 @@ export default {
|
||||
"userWorkspaceId": [
|
||||
3
|
||||
],
|
||||
"applicationConnectionProviderId": [
|
||||
"connectionProviderId": [
|
||||
3
|
||||
],
|
||||
"applicationId": [
|
||||
@@ -5169,7 +5169,7 @@ export default {
|
||||
"userWorkspaceId": [
|
||||
3
|
||||
],
|
||||
"applicationConnectionProviderId": [
|
||||
"connectionProviderId": [
|
||||
3
|
||||
],
|
||||
"applicationId": [
|
||||
|
||||
@@ -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<Scalars['UUID']>;
|
||||
applicationId?: Maybe<Scalars['UUID']>;
|
||||
authFailedAt?: Maybe<Scalars['DateTime']>;
|
||||
connectionParameters?: Maybe<ImapSmtpCaldavConnectionParameters>;
|
||||
connectionProviderId?: Maybe<Scalars['UUID']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
handle: Scalars['String'];
|
||||
handleAliases?: Maybe<Array<Scalars['String']>>;
|
||||
@@ -916,10 +917,10 @@ export type ConnectedAccountDto = {
|
||||
|
||||
export type ConnectedAccountPublicDto = {
|
||||
__typename?: 'ConnectedAccountPublicDTO';
|
||||
applicationConnectionProviderId?: Maybe<Scalars['UUID']>;
|
||||
applicationId?: Maybe<Scalars['UUID']>;
|
||||
authFailedAt?: Maybe<Scalars['DateTime']>;
|
||||
connectionParameters?: Maybe<PublicImapSmtpCaldavConnectionParameters>;
|
||||
connectionProviderId?: Maybe<Scalars['UUID']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
handle: Scalars['String'];
|
||||
handleAliases?: Maybe<Array<Scalars['String']>>;
|
||||
@@ -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<string> | null, handleAliases?: Array<string> | null, lastSignedInAt?: string | null, userWorkspaceId: string, applicationConnectionProviderId?: string | null, name?: string | null, visibility: string, lastCredentialsRefreshedAt?: string | null, createdAt: string, updatedAt: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, password: string } | null } | null }> };
|
||||
export type MyConnectedAccountsQuery = { __typename?: 'Query', myConnectedAccounts: Array<{ __typename?: 'ConnectedAccountDTO', id: string, handle: string, provider: string, authFailedAt?: string | null, scopes?: Array<string> | null, handleAliases?: Array<string> | null, lastSignedInAt?: string | null, userWorkspaceId: string, 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<Scalars['UUID']>;
|
||||
@@ -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<ConnectedAccountByIdQuery, ConnectedAccountByIdQueryVariables>;
|
||||
export const GetConnectedImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectedImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConnectedImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
|
||||
export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCalendarChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCalendarChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyCalendarChannelsQuery, MyCalendarChannelsQueryVariables>;
|
||||
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"applicationConnectionProviderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"lastCredentialsRefreshedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
|
||||
export const 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<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
|
||||
export const MyMessageChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageChannelsQuery, MyMessageChannelsQueryVariables>;
|
||||
export const MyMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"messageChannelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}},{"kind":"Field","name":{"kind":"Name","value":"isSentFolder"}},{"kind":"Field","name":{"kind":"Name","value":"parentFolderId"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"messageChannelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageFoldersQuery, MyMessageFoldersQueryVariables>;
|
||||
export const DeleteApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode<DeleteApplicationRegistrationMutation, DeleteApplicationRegistrationMutationVariables>;
|
||||
|
||||
@@ -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).
|
||||
|
||||
+1
@@ -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<AllMetadataName, string>;
|
||||
|
||||
const handleMetadataError = (
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql`
|
||||
handleAliases
|
||||
lastSignedInAt
|
||||
userWorkspaceId
|
||||
applicationConnectionProviderId
|
||||
connectionProviderId
|
||||
name
|
||||
visibility
|
||||
lastCredentialsRefreshedAt
|
||||
|
||||
+1
-1
@@ -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 (
|
||||
|
||||
+117
@@ -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<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddApplicationOAuthProviderAndConnectedAccountColumn1777558657640
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddApplicationOAuthProviderAndConnectedAccountColumn1777558657640';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."applicationOAuthProvider" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"workspaceId" uuid NOT NULL,
|
||||
"applicationId" uuid NOT NULL,
|
||||
"universalIdentifier" uuid NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"displayName" varchar NOT NULL,
|
||||
"authorizationEndpoint" varchar NOT NULL,
|
||||
"tokenEndpoint" varchar NOT NULL,
|
||||
"revokeEndpoint" varchar,
|
||||
"scopes" varchar array NOT NULL DEFAULT '{}',
|
||||
"clientIdVariable" varchar NOT NULL,
|
||||
"clientSecretVariable" varchar NOT NULL,
|
||||
"authorizationParams" jsonb,
|
||||
"tokenRequestContentType" varchar NOT NULL DEFAULT 'json',
|
||||
"usePkce" boolean NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "IDX_APP_OAUTH_PROVIDER_NAME_APPLICATION_UNIQUE" UNIQUE ("name", "applicationId"),
|
||||
CONSTRAINT "IDX_APP_OAUTH_PROVIDER_UNIVERSAL_ID_APPLICATION_UNIQUE" UNIQUE ("universalIdentifier", "applicationId"),
|
||||
CONSTRAINT "PK_applicationOAuthProvider_id" PRIMARY KEY ("id")
|
||||
)`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_APP_OAUTH_PROVIDER_APPLICATION_ID" ON "core"."applicationOAuthProvider" ("applicationId")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID" ON "core"."applicationOAuthProvider" ("workspaceId")`,
|
||||
);
|
||||
|
||||
// FK constraint names match the hashes that TypeORM auto-generates from
|
||||
// the @ManyToOne decorators in the entities. Keeping them in sync here
|
||||
// avoids a "pending migration" diff on every CI run.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationOAuthProvider"
|
||||
ADD CONSTRAINT "FK_c63de8b90514de1798876c30f2e"
|
||||
FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationOAuthProvider"
|
||||
ADD CONSTRAINT "FK_2d01320998547c2f5059d8b09d6"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount"
|
||||
ADD COLUMN "applicationOAuthProviderId" uuid,
|
||||
ADD COLUMN "applicationId" uuid,
|
||||
ADD COLUMN "name" varchar,
|
||||
ADD COLUMN "visibility" varchar NOT NULL DEFAULT 'user'`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID" ON "core"."connectedAccount" ("applicationOAuthProviderId")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_APPLICATION_ID" ON "core"."connectedAccount" ("applicationId")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount"
|
||||
ADD CONSTRAINT "FK_344a905bc2041c998d5b57f9bde"
|
||||
FOREIGN KEY ("applicationOAuthProviderId") REFERENCES "core"."applicationOAuthProvider"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount"
|
||||
ADD CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"
|
||||
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_344a905bc2041c998d5b57f9bde"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APPLICATION_ID"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount"
|
||||
DROP COLUMN "visibility",
|
||||
DROP COLUMN "name",
|
||||
DROP COLUMN "applicationId",
|
||||
DROP COLUMN "applicationOAuthProviderId"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationOAuthProvider" DROP CONSTRAINT "FK_2d01320998547c2f5059d8b09d6"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationOAuthProvider" DROP CONSTRAINT "FK_c63de8b90514de1798876c30f2e"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_APP_OAUTH_PROVIDER_APPLICATION_ID"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`DROP TABLE "core"."applicationOAuthProvider"`);
|
||||
}
|
||||
}
|
||||
-2
@@ -4,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,
|
||||
|
||||
-8
@@ -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;
|
||||
|
||||
|
||||
+97
@@ -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> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
+49
@@ -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,
|
||||
};
|
||||
};
|
||||
+14
@@ -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({
|
||||
|
||||
-384
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
-142
@@ -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> = {},
|
||||
): 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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
-81
@@ -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<ApplicationEntity>;
|
||||
|
||||
@Column({ nullable: false, type: 'varchar' })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: false, type: 'varchar' })
|
||||
displayName: string;
|
||||
|
||||
@Column({ nullable: false, type: 'varchar' })
|
||||
authorizationEndpoint: string;
|
||||
|
||||
@Column({ nullable: false, type: 'varchar' })
|
||||
tokenEndpoint: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
revokeEndpoint: string | null;
|
||||
|
||||
@Column({ type: 'varchar', array: true, nullable: false, default: '{}' })
|
||||
scopes: string[];
|
||||
|
||||
@Column({ nullable: false, type: 'varchar' })
|
||||
clientIdVariable: string;
|
||||
|
||||
@Column({ nullable: false, type: 'varchar' })
|
||||
clientSecretVariable: string;
|
||||
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
authorizationParams: Record<string, string> | null;
|
||||
|
||||
@Column({ nullable: false, type: 'varchar', default: 'json' })
|
||||
tokenRequestContentType: OAuthProviderTokenRequestContentType;
|
||||
|
||||
@Column({ nullable: false, type: 'boolean', default: true })
|
||||
usePkce: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
-43
@@ -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<ApplicationOAuthProviderExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApplicationOAuthProviderExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getApplicationOAuthProviderExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
-285
@@ -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<ApplicationOAuthProviderEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
@InjectRepository(ApplicationRegistrationVariableEntity)
|
||||
private readonly registrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
|
||||
private readonly secretEncryptionService: SecretEncryptionService,
|
||||
) {}
|
||||
|
||||
// Stored on the registration (one OAuth app per Twenty server, set by
|
||||
// the server admin) — not per-workspace.
|
||||
async getClientCredentials(
|
||||
provider: ApplicationOAuthProviderEntity,
|
||||
): Promise<{ clientId: string; clientSecret: string }> {
|
||||
const application = await this.applicationRepository.findOneBy({
|
||||
id: provider.applicationId,
|
||||
});
|
||||
|
||||
if (!isDefined(application?.applicationRegistrationId)) {
|
||||
throw new ApplicationOAuthProviderException(
|
||||
`Application ${provider.applicationId} has no registration; OAuth client credentials cannot be resolved`,
|
||||
ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
const variables = await this.registrationVariableRepository.find({
|
||||
where: {
|
||||
applicationRegistrationId: application.applicationRegistrationId,
|
||||
key: In([provider.clientIdVariable, provider.clientSecretVariable]),
|
||||
},
|
||||
});
|
||||
|
||||
const valuesByKey = new Map(
|
||||
variables.map((v) => [
|
||||
v.key,
|
||||
v.encryptedValue
|
||||
? this.secretEncryptionService.decrypt(v.encryptedValue)
|
||||
: '',
|
||||
]),
|
||||
);
|
||||
|
||||
const clientId = valuesByKey.get(provider.clientIdVariable) ?? '';
|
||||
const clientSecret = valuesByKey.get(provider.clientSecretVariable) ?? '';
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new ApplicationOAuthProviderException(
|
||||
`OAuth client credentials are not configured for provider "${provider.name}". The server administrator needs to fill in "${provider.clientIdVariable}" and "${provider.clientSecretVariable}" on the application registration.`,
|
||||
ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
|
||||
);
|
||||
}
|
||||
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
// For batched calls (e.g. the resolver listing path) prefer
|
||||
// `areClientCredentialsConfiguredBatch` to avoid N+1.
|
||||
async areClientCredentialsConfigured(
|
||||
provider: ApplicationOAuthProviderEntity,
|
||||
): Promise<boolean> {
|
||||
const result = await this.areClientCredentialsConfiguredBatch([provider]);
|
||||
|
||||
return result.get(provider.id) ?? false;
|
||||
}
|
||||
|
||||
async areClientCredentialsConfiguredBatch(
|
||||
providers: ApplicationOAuthProviderEntity[],
|
||||
): Promise<Map<string, boolean>> {
|
||||
const result = new Map<string, boolean>();
|
||||
|
||||
if (providers.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const applicationIds = [...new Set(providers.map((p) => p.applicationId))];
|
||||
const applications = await this.applicationRepository.find({
|
||||
where: { id: In(applicationIds) },
|
||||
});
|
||||
const registrationIdByApplicationId = new Map(
|
||||
applications.map((app) => [app.id, app.applicationRegistrationId]),
|
||||
);
|
||||
|
||||
const registrationIds = [
|
||||
...new Set(
|
||||
applications
|
||||
.map((app) => app.applicationRegistrationId)
|
||||
.filter(isDefined),
|
||||
),
|
||||
];
|
||||
|
||||
if (registrationIds.length === 0) {
|
||||
providers.forEach((p) => result.set(p.id, false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const allKeys = providers.flatMap((p) => [
|
||||
p.clientIdVariable,
|
||||
p.clientSecretVariable,
|
||||
]);
|
||||
const variables = await this.registrationVariableRepository.find({
|
||||
where: {
|
||||
applicationRegistrationId: In(registrationIds),
|
||||
key: In(allKeys),
|
||||
},
|
||||
});
|
||||
|
||||
const filledKeysByRegistrationId = new Map<string, Set<string>>();
|
||||
|
||||
for (const variable of variables) {
|
||||
if (variable.encryptedValue === '') continue;
|
||||
const set =
|
||||
filledKeysByRegistrationId.get(variable.applicationRegistrationId) ??
|
||||
new Set<string>();
|
||||
|
||||
set.add(variable.key);
|
||||
filledKeysByRegistrationId.set(variable.applicationRegistrationId, set);
|
||||
}
|
||||
|
||||
for (const provider of providers) {
|
||||
const registrationId = registrationIdByApplicationId.get(
|
||||
provider.applicationId,
|
||||
);
|
||||
|
||||
if (!isDefined(registrationId)) {
|
||||
result.set(provider.id, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filled = filledKeysByRegistrationId.get(registrationId);
|
||||
|
||||
result.set(
|
||||
provider.id,
|
||||
filled?.has(provider.clientIdVariable) === true &&
|
||||
filled?.has(provider.clientSecretVariable) === true,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async findOneByApplicationAndName({
|
||||
applicationId,
|
||||
name,
|
||||
}: {
|
||||
applicationId: string;
|
||||
name: string;
|
||||
}): Promise<ApplicationOAuthProviderEntity | null> {
|
||||
return this.oauthProviderRepository.findOne({
|
||||
where: { applicationId, name },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneByIdOrThrow(
|
||||
id: string,
|
||||
): Promise<ApplicationOAuthProviderEntity> {
|
||||
const provider = await this.oauthProviderRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!isDefined(provider)) {
|
||||
throw new ApplicationOAuthProviderException(
|
||||
`OAuth provider with id "${id}" not found`,
|
||||
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
async findManyByApplication({
|
||||
applicationId,
|
||||
workspaceId,
|
||||
}: {
|
||||
applicationId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<ApplicationOAuthProviderEntity[]> {
|
||||
return this.oauthProviderRepository.find({
|
||||
where: { applicationId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
// Persists OAuth-typed entries only. Other connection-provider types get
|
||||
// their own sibling persistence helpers when added.
|
||||
async upsertManyFromManifest({
|
||||
connectionProviders,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
}: {
|
||||
connectionProviders?: ConnectionProviderManifest[];
|
||||
applicationId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
const oauthProviders = (connectionProviders ?? []).filter(
|
||||
(provider) => provider.type === 'oauth',
|
||||
);
|
||||
|
||||
// The DB column is `uuid NOT NULL`. The manifest type is just `string`
|
||||
// because manifests are dev-supplied and TS can't enforce UUID at the
|
||||
// type level. Validate up-front so we throw a typed exception instead
|
||||
// of letting Postgres reject the insert with an opaque type error.
|
||||
for (const provider of oauthProviders) {
|
||||
if (!isUUID(provider.universalIdentifier)) {
|
||||
throw new ApplicationOAuthProviderException(
|
||||
`Connection provider "${provider.name}" has an invalid universalIdentifier "${provider.universalIdentifier}" — must be a UUID.`,
|
||||
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.oauthProviderRepository.find({
|
||||
where: { applicationId, workspaceId },
|
||||
});
|
||||
|
||||
if (oauthProviders.length === 0 && existing.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingByUniversalIdentifier = new Map(
|
||||
existing.map((p) => [p.universalIdentifier, p]),
|
||||
);
|
||||
|
||||
const toSave: Partial<ApplicationOAuthProviderEntity>[] =
|
||||
oauthProviders.map((manifest) => {
|
||||
const fields = {
|
||||
applicationId,
|
||||
workspaceId,
|
||||
universalIdentifier: manifest.universalIdentifier,
|
||||
name: manifest.name,
|
||||
displayName: manifest.displayName,
|
||||
authorizationEndpoint: manifest.oauth.authorizationEndpoint,
|
||||
tokenEndpoint: manifest.oauth.tokenEndpoint,
|
||||
revokeEndpoint: manifest.oauth.revokeEndpoint ?? null,
|
||||
scopes: manifest.oauth.scopes,
|
||||
clientIdVariable: manifest.oauth.clientIdVariable,
|
||||
clientSecretVariable: manifest.oauth.clientSecretVariable,
|
||||
authorizationParams: manifest.oauth.authorizationParams ?? null,
|
||||
tokenRequestContentType:
|
||||
manifest.oauth.tokenRequestContentType ?? 'json',
|
||||
usePkce: manifest.oauth.usePkce ?? true,
|
||||
};
|
||||
|
||||
const existingEntity = existingByUniversalIdentifier.get(
|
||||
manifest.universalIdentifier,
|
||||
);
|
||||
|
||||
return isDefined(existingEntity)
|
||||
? { id: existingEntity.id, ...fields }
|
||||
: fields;
|
||||
});
|
||||
|
||||
if (toSave.length > 0) {
|
||||
await this.oauthProviderRepository.save(toSave);
|
||||
}
|
||||
|
||||
await this.oauthProviderRepository.delete(
|
||||
oauthProviders.length > 0
|
||||
? {
|
||||
applicationId,
|
||||
workspaceId,
|
||||
universalIdentifier: Not(
|
||||
In(oauthProviders.map((p) => p.universalIdentifier)),
|
||||
),
|
||||
}
|
||||
: { applicationId, workspaceId },
|
||||
);
|
||||
}
|
||||
}
|
||||
-405
@@ -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> = {},
|
||||
): ApplicationOAuthProviderEntity =>
|
||||
({
|
||||
id: PROVIDER_ID,
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
name: 'linear',
|
||||
displayName: 'Linear',
|
||||
scopes: ['read', 'write'],
|
||||
...overrides,
|
||||
}) as unknown as ApplicationOAuthProviderEntity;
|
||||
|
||||
const buildAccount = (
|
||||
overrides: Partial<ConnectedAccountEntity> = {},
|
||||
): ConnectedAccountEntity =>
|
||||
({
|
||||
id: 'conn-1',
|
||||
name: 'Linear #1',
|
||||
handle: 'octocat@example.com',
|
||||
visibility: 'user',
|
||||
applicationId: APP_ID,
|
||||
applicationConnectionProviderId: PROVIDER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
provider: ConnectedAccountProvider.APP,
|
||||
accessToken: 'enc',
|
||||
refreshToken: 'enc',
|
||||
// OAuth scopes granted by the upstream provider — distinct from the
|
||||
// row-level `visibility` field above.
|
||||
scopes: ['read', 'write'],
|
||||
lastCredentialsRefreshedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
authFailedAt: null,
|
||||
...overrides,
|
||||
}) as unknown as ConnectedAccountEntity;
|
||||
|
||||
describe('ApplicationConnectionsListService', () => {
|
||||
let service: ApplicationConnectionsListService;
|
||||
let connectedAccountRepository: { find: jest.Mock; findOne: jest.Mock };
|
||||
let oauthProviderRepository: {
|
||||
find: jest.Mock;
|
||||
findOneByOrFail: jest.Mock;
|
||||
};
|
||||
let refreshTokensService: { refreshAndSaveTokens: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
connectedAccountRepository = { find: jest.fn(), findOne: jest.fn() };
|
||||
oauthProviderRepository = {
|
||||
find: jest.fn().mockResolvedValue([buildProvider()]),
|
||||
findOneByOrFail: jest.fn().mockResolvedValue(buildProvider()),
|
||||
};
|
||||
refreshTokensService = {
|
||||
refreshAndSaveTokens: jest.fn(async () => ({
|
||||
accessToken: 'fresh-access',
|
||||
refreshToken: 'fresh-refresh',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApplicationConnectionsListService,
|
||||
{
|
||||
provide: ConnectedAccountRefreshTokensService,
|
||||
useValue: refreshTokensService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ConnectedAccountEntity),
|
||||
useValue: connectedAccountRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ApplicationOAuthProviderEntity),
|
||||
useValue: oauthProviderRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(ApplicationConnectionsListService);
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('list', () => {
|
||||
it('asks SQL to OR (visibility = workspace) with (visibility = user AND userWorkspaceId = me) when there is a request user', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([
|
||||
buildAccount({ id: 'mine' }),
|
||||
buildAccount({
|
||||
id: 'shared',
|
||||
visibility: 'workspace',
|
||||
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: {},
|
||||
});
|
||||
|
||||
expect(result.map((c) => c.id).sort()).toEqual(['mine', 'shared']);
|
||||
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
|
||||
where: [
|
||||
expect.objectContaining({ visibility: 'workspace' }),
|
||||
expect.objectContaining({
|
||||
visibility: 'user',
|
||||
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the privacy OR clause when no request user is provided (cron)', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([
|
||||
buildAccount({ id: 'mine' }),
|
||||
buildAccount({
|
||||
id: 'theirs',
|
||||
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: null,
|
||||
filter: {},
|
||||
});
|
||||
|
||||
expect(result.map((c) => c.id).sort()).toEqual(['mine', 'theirs']);
|
||||
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
|
||||
where: expect.not.objectContaining({ visibility: expect.anything() }),
|
||||
});
|
||||
});
|
||||
|
||||
it('respects filter.visibility=user under request-user privacy (regression)', async () => {
|
||||
// Bug guard: an earlier version OR'd { visibility: 'workspace' } into
|
||||
// the privacy where regardless of the caller's filter, so requesting
|
||||
// user-visibility only would silently leak workspace-shared rows back.
|
||||
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
|
||||
|
||||
await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: { visibility: 'user' },
|
||||
});
|
||||
|
||||
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
visibility: 'user',
|
||||
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
}),
|
||||
});
|
||||
// Specifically not the OR shape — single AND object.
|
||||
const passed = connectedAccountRepository.find.mock.calls[0][0];
|
||||
|
||||
expect(Array.isArray(passed.where)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects filter.visibility=workspace under request-user privacy', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
|
||||
|
||||
await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: { visibility: 'workspace' },
|
||||
});
|
||||
|
||||
const passed = connectedAccountRepository.find.mock.calls[0][0];
|
||||
|
||||
expect(passed.where).toEqual(
|
||||
expect.objectContaining({ visibility: 'workspace' }),
|
||||
);
|
||||
expect(passed.where).not.toHaveProperty('userWorkspaceId');
|
||||
expect(Array.isArray(passed.where)).toBe(false);
|
||||
});
|
||||
|
||||
it('passes filter.visibility through unchanged in cron context', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
|
||||
|
||||
await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: null,
|
||||
filter: { visibility: 'user' },
|
||||
});
|
||||
|
||||
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({ visibility: 'user' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty list when filter.providerName matches no provider for this app', async () => {
|
||||
oauthProviderRepository.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: { providerName: 'unknown-provider' },
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(connectedAccountRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the access token before returning', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
|
||||
|
||||
const result = await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: {},
|
||||
});
|
||||
|
||||
expect(refreshTokensService.refreshAndSaveTokens).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(result[0].accessToken).toBe('fresh-access');
|
||||
});
|
||||
|
||||
it('exposes provider name and other public fields in the DTO', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
|
||||
|
||||
const [connection] = await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: {},
|
||||
});
|
||||
|
||||
expect(connection).toEqual({
|
||||
id: 'conn-1',
|
||||
providerName: 'linear',
|
||||
name: 'Linear #1',
|
||||
handle: 'octocat@example.com',
|
||||
visibility: 'user',
|
||||
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
accessToken: 'fresh-access',
|
||||
scopes: ['read', 'write'],
|
||||
authFailedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to handle when name is null', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([
|
||||
buildAccount({ name: null }),
|
||||
]);
|
||||
|
||||
const [connection] = await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: {},
|
||||
});
|
||||
|
||||
expect(connection.name).toBe('octocat@example.com');
|
||||
});
|
||||
|
||||
it('skips a connection when the refresh fails', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([
|
||||
buildAccount({ id: 'good' }),
|
||||
buildAccount({ id: 'broken' }),
|
||||
]);
|
||||
refreshTokensService.refreshAndSaveTokens
|
||||
.mockResolvedValueOnce({ accessToken: 'fresh', refreshToken: 'r' })
|
||||
.mockRejectedValueOnce(new Error('refresh failed'));
|
||||
|
||||
const result = await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: {},
|
||||
});
|
||||
|
||||
expect(result.map((c) => c.id)).toEqual(['good']);
|
||||
});
|
||||
|
||||
it('skips a connection whose provider was deleted (orphan)', async () => {
|
||||
connectedAccountRepository.find.mockResolvedValue([
|
||||
buildAccount({
|
||||
id: 'orphan',
|
||||
applicationConnectionProviderId: 'gone-provider',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await service.list({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
filter: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOne', () => {
|
||||
it('returns the connection when the request user owns it', async () => {
|
||||
connectedAccountRepository.findOne.mockResolvedValue(buildAccount());
|
||||
|
||||
const result = await service.getOne({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
id: 'conn-1',
|
||||
});
|
||||
|
||||
expect(result.id).toBe('conn-1');
|
||||
expect(result.providerName).toBe('linear');
|
||||
expect(result.accessToken).toBe('fresh-access');
|
||||
});
|
||||
|
||||
it('returns the connection when visibility is workspace, regardless of owner', async () => {
|
||||
connectedAccountRepository.findOne.mockResolvedValue(
|
||||
buildAccount({
|
||||
visibility: 'workspace',
|
||||
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getOne({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
id: 'conn-1',
|
||||
});
|
||||
|
||||
expect(result.id).toBe('conn-1');
|
||||
});
|
||||
|
||||
it('throws NotFound when the connection does not exist', async () => {
|
||||
connectedAccountRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getOne({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
id: 'missing',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws NotFound when a request user asks for another user-visibility connection', async () => {
|
||||
connectedAccountRepository.findOne.mockResolvedValue(
|
||||
buildAccount({ userWorkspaceId: OTHER_USER_WORKSPACE_ID }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getOne({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
|
||||
id: 'conn-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns another user-visibility connection in cron context (no request user)', async () => {
|
||||
connectedAccountRepository.findOne.mockResolvedValue(
|
||||
buildAccount({ userWorkspaceId: OTHER_USER_WORKSPACE_ID }),
|
||||
);
|
||||
|
||||
const result = await service.getOne({
|
||||
applicationId: APP_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
requestUserWorkspaceId: null,
|
||||
id: 'conn-1',
|
||||
});
|
||||
|
||||
expect(result.userWorkspaceId).toBe(OTHER_USER_WORKSPACE_ID);
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
-9
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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',
|
||||
}
|
||||
+52
-68
@@ -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,
|
||||
});
|
||||
|
||||
+19
-19
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+47
@@ -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<ConnectionProviderEntity>
|
||||
{
|
||||
@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;
|
||||
}
|
||||
+49
@@ -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<ConnectionProviderExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: ConnectionProviderExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getConnectionProviderExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -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 {}
|
||||
+208
@@ -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<ConnectionProviderEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
@InjectRepository(ApplicationRegistrationVariableEntity)
|
||||
private readonly registrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
|
||||
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<boolean> {
|
||||
const result = await this.areClientCredentialsConfiguredBatch([provider]);
|
||||
|
||||
return result.get(provider.id) ?? false;
|
||||
}
|
||||
|
||||
async areClientCredentialsConfiguredBatch(
|
||||
providers: ConnectionProviderEntity[],
|
||||
): Promise<Map<string, boolean>> {
|
||||
const result = new Map<string, boolean>();
|
||||
|
||||
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<string, Set<string>>();
|
||||
|
||||
for (const variable of variables) {
|
||||
if (variable.encryptedValue === '') continue;
|
||||
const set =
|
||||
filledKeysByRegistrationId.get(variable.applicationRegistrationId) ??
|
||||
new Set<string>();
|
||||
|
||||
set.add(variable.key);
|
||||
filledKeysByRegistrationId.set(variable.applicationRegistrationId, set);
|
||||
}
|
||||
|
||||
for (const provider of 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<ConnectionProviderEntity | null> {
|
||||
return this.connectionProviderRepository.findOne({
|
||||
where: { applicationId, name },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneByIdOrThrow(id: string): Promise<ConnectionProviderEntity> {
|
||||
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<ConnectionProviderEntity[]> {
|
||||
return this.connectionProviderRepository.find({
|
||||
where: { applicationId, workspaceId },
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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';
|
||||
+5
-5
@@ -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,
|
||||
+12
-27
@@ -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<ConnectedAccountEntity>,
|
||||
@InjectRepository(ApplicationOAuthProviderEntity)
|
||||
private readonly oauthProviderRepository: Repository<ApplicationOAuthProviderEntity>,
|
||||
@InjectRepository(ConnectionProviderEntity)
|
||||
private readonly oauthProviderRepository: Repository<ConnectionProviderEntity>,
|
||||
) {}
|
||||
|
||||
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<ConnectedAccountEntity>,
|
||||
requestUserWorkspaceId: string | null,
|
||||
@@ -165,15 +157,12 @@ export class ApplicationConnectionsListService {
|
||||
):
|
||||
| FindOptionsWhere<ConnectedAccountEntity>
|
||||
| FindOptionsWhere<ConnectedAccountEntity>[] {
|
||||
// Cron / DB-event triggers carry no user — the app is trusted to use
|
||||
// its own criteria, so honour the visibility filter as-is.
|
||||
if (!isDefined(requestUserWorkspaceId)) {
|
||||
return isDefined(visibilityFilter)
|
||||
? { ...baseWhere, visibility: visibilityFilter }
|
||||
: baseWhere;
|
||||
}
|
||||
|
||||
// Caller asked for user-visibility only → must be theirs.
|
||||
if (visibilityFilter === 'user') {
|
||||
return {
|
||||
...baseWhere,
|
||||
@@ -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<string, ApplicationOAuthProviderEntity>,
|
||||
providerById: Map<string, ConnectionProviderEntity>,
|
||||
): Promise<AppConnectionDto | null> {
|
||||
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) {
|
||||
+4
-4
@@ -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,
|
||||
],
|
||||
+42
-38
@@ -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<ConnectedAccountTokens> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-12
@@ -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<void> {
|
||||
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: {
|
||||
+2
-2
@@ -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,
|
||||
+22
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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<string, unknown>,
|
||||
+3
-3
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ export class ConnectedAccountDTO {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
applicationConnectionProviderId: string | null;
|
||||
connectionProviderId: string | null;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
|
||||
+7
-12
@@ -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<ApplicationOAuthProviderEntity> | null;
|
||||
@JoinColumn({ name: 'connectionProviderId' })
|
||||
connectionProvider: Relation<ConnectionProviderEntity> | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
applicationId: string | null;
|
||||
|
||||
+7
@@ -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'>[];
|
||||
+17
@@ -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 {}
|
||||
+62
@@ -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<FlatConnectionProviderMaps> {
|
||||
constructor(
|
||||
@InjectRepository(ConnectionProviderEntity)
|
||||
private readonly connectionProviderRepository: Repository<ConnectionProviderEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(
|
||||
workspaceId: string,
|
||||
): Promise<FlatConnectionProviderMaps> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+4
@@ -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<FlatConnectionProvider>;
|
||||
+4
@@ -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<ConnectionProviderEntity>;
|
||||
+39
@@ -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,
|
||||
};
|
||||
};
|
||||
+11
@@ -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",
|
||||
|
||||
+24
@@ -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<P>;
|
||||
};
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+2
@@ -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<AllMetadataName, EntityTarget<ObjectLiteral>>;
|
||||
|
||||
+1
@@ -112,4 +112,5 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
|
||||
},
|
||||
frontComponent: {},
|
||||
webhook: {},
|
||||
connectionProvider: {},
|
||||
} as const satisfies MetadataRequiredForValidation;
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+27
@@ -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'>;
|
||||
};
|
||||
};
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
|
||||
"rowLevelPermissionPredicateGroup",
|
||||
"viewGroup",
|
||||
"agent",
|
||||
"connectionProvider",
|
||||
"frontComponent",
|
||||
"logicFunction",
|
||||
"pageLayoutTab",
|
||||
|
||||
@@ -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',
|
||||
|
||||
+2
-1
@@ -170,7 +170,8 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({
|
||||
case 'permissionFlag':
|
||||
case 'objectPermission':
|
||||
case 'fieldPermission':
|
||||
case 'webhook': {
|
||||
case 'webhook':
|
||||
case 'connectionProvider': {
|
||||
if (!isDefined(idByUniversalIdentifier)) {
|
||||
return action;
|
||||
}
|
||||
|
||||
+37
@@ -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,
|
||||
///
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+7
@@ -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'
|
||||
>;
|
||||
+24
@@ -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'>;
|
||||
+122
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+173
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
@@ -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 {}
|
||||
|
||||
+3
@@ -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 {}
|
||||
|
||||
+65
@@ -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<UniversalCreateConnectionProviderAction>): Promise<FlatCreateConnectionProviderAction> {
|
||||
const emptyUniversalForeignKeyAggregators =
|
||||
getUniversalFlatEntityEmptyForeignKeyAggregators({
|
||||
metadataName: 'connectionProvider',
|
||||
});
|
||||
|
||||
return {
|
||||
...action,
|
||||
flatEntity: {
|
||||
...action.flatEntity,
|
||||
applicationId: flatApplication.id,
|
||||
id: action.id ?? v4(),
|
||||
workspaceId,
|
||||
...emptyUniversalForeignKeyAggregators,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async executeForMetadata(
|
||||
context: WorkspaceMigrationActionRunnerContext<FlatCreateConnectionProviderAction>,
|
||||
): Promise<void> {
|
||||
const { flatAction, queryRunner } = context;
|
||||
const { flatEntity } = flatAction;
|
||||
|
||||
await this.insertFlatEntitiesInRepository({
|
||||
queryRunner,
|
||||
flatEntities: [flatEntity],
|
||||
});
|
||||
}
|
||||
|
||||
async executeForWorkspaceSchema(
|
||||
_context: WorkspaceMigrationActionRunnerContext<FlatCreateConnectionProviderAction>,
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
+51
@@ -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<UniversalDeleteConnectionProviderAction>,
|
||||
): Promise<FlatDeleteConnectionProviderAction> {
|
||||
return this.transpileUniversalDeleteActionToFlatDeleteAction(context);
|
||||
}
|
||||
|
||||
async executeForMetadata(
|
||||
context: WorkspaceMigrationActionRunnerContext<FlatDeleteConnectionProviderAction>,
|
||||
): Promise<void> {
|
||||
const { flatAction, queryRunner, workspaceId } = context;
|
||||
|
||||
const connectionProviderRepository =
|
||||
queryRunner.manager.getRepository<ConnectionProviderEntity>(
|
||||
ConnectionProviderEntity,
|
||||
);
|
||||
|
||||
await connectionProviderRepository.delete({
|
||||
id: flatAction.entityId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async executeForWorkspaceSchema(
|
||||
_context: WorkspaceMigrationActionRunnerContext<FlatDeleteConnectionProviderAction>,
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
+68
@@ -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<UniversalUpdateConnectionProviderAction>,
|
||||
): Promise<FlatUpdateConnectionProviderAction> {
|
||||
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<FlatUpdateConnectionProviderAction>,
|
||||
): Promise<void> {
|
||||
const { flatAction, queryRunner, workspaceId } = context;
|
||||
const { entityId, update } = flatAction;
|
||||
|
||||
const connectionProviderRepository =
|
||||
queryRunner.manager.getRepository<ConnectionProviderEntity>(
|
||||
ConnectionProviderEntity,
|
||||
);
|
||||
|
||||
await connectionProviderRepository.update(
|
||||
{ id: entityId, workspaceId },
|
||||
update,
|
||||
);
|
||||
}
|
||||
|
||||
async executeForWorkspaceSchema(
|
||||
_context: WorkspaceMigrationActionRunnerContext<FlatUpdateConnectionProviderAction>,
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
+6
@@ -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 {}
|
||||
|
||||
+1
@@ -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 };
|
||||
|
||||
+2
-1
@@ -93,7 +93,8 @@ const deriveAllMetadataEventsFromCreateAction = (
|
||||
case 'objectPermission':
|
||||
case 'fieldPermission':
|
||||
case 'viewSort':
|
||||
case 'webhook': {
|
||||
case 'webhook':
|
||||
case 'connectionProvider': {
|
||||
return [
|
||||
{
|
||||
type: 'created',
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ const deriveAllMetadataEventsFromDeleteAction = ({
|
||||
case 'objectPermission':
|
||||
case 'fieldPermission':
|
||||
case 'viewSort':
|
||||
case 'webhook': {
|
||||
case 'webhook':
|
||||
case 'connectionProvider': {
|
||||
const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow<
|
||||
MetadataFlatEntity<typeof flatAction.metadataName>
|
||||
>({
|
||||
|
||||
+2
-1
@@ -135,7 +135,8 @@ const deriveAllMetadataEventsFromUpdateAction = ({
|
||||
case 'objectPermission':
|
||||
case 'fieldPermission':
|
||||
case 'viewSort':
|
||||
case 'webhook': {
|
||||
case 'webhook':
|
||||
case 'connectionProvider': {
|
||||
const flatEntityMapsKey = getMetadataFlatEntityMapsKey(
|
||||
flatAction.metadataName,
|
||||
);
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -43,7 +43,8 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = ({
|
||||
case 'permissionFlag':
|
||||
case 'objectPermission':
|
||||
case 'fieldPermission':
|
||||
case 'webhook': {
|
||||
case 'webhook':
|
||||
case 'connectionProvider': {
|
||||
const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow<
|
||||
MetadataFlatEntity<typeof flatAction.metadataName>
|
||||
>({
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = ({
|
||||
case 'permissionFlag':
|
||||
case 'objectPermission':
|
||||
case 'fieldPermission':
|
||||
case 'webhook': {
|
||||
case 'webhook':
|
||||
case 'connectionProvider': {
|
||||
const flatEntityMapsKey = getMetadataFlatEntityMapsKey(
|
||||
flatAction.metadataName,
|
||||
);
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+268
@@ -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<Pick<Manifest, 'connectionProviders'>>,
|
||||
) => 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);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user