diff --git a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts
index 03ce55c822..dc9cec40ac 100644
--- a/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts
+++ b/packages/twenty-front/src/modules/accounts/utils/hasMissingDraftEmailScopes.ts
@@ -25,6 +25,8 @@ export const getMissingDraftEmailScopes = (
return hasScope ? [] : [MICROSOFT_SEND_SCOPE];
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
+ case ConnectedAccountProvider.OIDC:
+ case ConnectedAccountProvider.SAML:
return [];
default:
assertUnreachable(
diff --git a/packages/twenty-front/src/modules/settings/accounts/hooks/useMyConnectedAccounts.ts b/packages/twenty-front/src/modules/settings/accounts/hooks/useMyConnectedAccounts.ts
index 481379b51e..1b0f1a9b66 100644
--- a/packages/twenty-front/src/modules/settings/accounts/hooks/useMyConnectedAccounts.ts
+++ b/packages/twenty-front/src/modules/settings/accounts/hooks/useMyConnectedAccounts.ts
@@ -72,33 +72,39 @@ export const useMyConnectedAccounts = () => {
return [];
}
- return metadataData.myConnectedAccounts.map(
- (account: MetadataConnectedAccount) =>
- ({
- id: account.id,
- handle: account.handle,
- provider: account.provider,
- accessToken: '',
- refreshToken: '',
- accountOwnerId: account.userWorkspaceId,
- lastSyncHistoryId: '',
- authFailedAt: account.authFailedAt
- ? new Date(account.authFailedAt)
- : null,
- messageChannels: messageChannels.filter(
- (channel) =>
- (channel as unknown as { connectedAccountId: string })
- .connectedAccountId === account.id,
- ),
- calendarChannels: calendarChannels.filter(
- (channel) =>
- (channel as unknown as { connectedAccountId: string })
- .connectedAccountId === account.id,
- ),
- scopes: account.scopes,
- __typename: 'ConnectedAccount',
- }) as ConnectedAccount,
- );
+ return metadataData.myConnectedAccounts
+ .map(
+ (account: MetadataConnectedAccount) =>
+ ({
+ id: account.id,
+ handle: account.handle,
+ provider: account.provider,
+ accessToken: '',
+ refreshToken: '',
+ accountOwnerId: account.userWorkspaceId,
+ lastSyncHistoryId: '',
+ authFailedAt: account.authFailedAt
+ ? new Date(account.authFailedAt)
+ : null,
+ messageChannels: messageChannels.filter(
+ (channel) =>
+ 'connectedAccountId' in channel &&
+ channel.connectedAccountId === account.id,
+ ),
+ calendarChannels: calendarChannels.filter(
+ (channel) =>
+ 'connectedAccountId' in channel &&
+ channel.connectedAccountId === account.id,
+ ),
+ scopes: account.scopes,
+ __typename: 'ConnectedAccount',
+ }) as ConnectedAccount,
+ )
+ .filter(
+ (account) =>
+ account.messageChannels.length > 0 ||
+ account.calendarChannels.length > 0,
+ );
}, [
isMigrated,
workspaceAccounts,
diff --git a/packages/twenty-front/src/modules/side-panel/pages/message-thread/components/SidePanelMessageThreadPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/message-thread/components/SidePanelMessageThreadPage.tsx
index b9a8708208..d927115f6e 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/message-thread/components/SidePanelMessageThreadPage.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/message-thread/components/SidePanelMessageThreadPage.tsx
@@ -86,8 +86,8 @@ export const SidePanelMessageThreadPage = () => {
const canReply = useMemo(() => {
return (
- connectedAccountHandle &&
- connectedAccountProvider &&
+ isDefined(connectedAccountHandle) &&
+ isDefined(connectedAccountProvider) &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
@@ -103,7 +103,7 @@ export const SidePanelMessageThreadPage = () => {
]);
const handleReplyClick = () => {
- if (!isDefined(canReply)) {
+ if (!canReply) {
return;
}
@@ -118,9 +118,10 @@ export const SidePanelMessageThreadPage = () => {
window.open(url, '_blank');
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
- throw new Error('Account provider not supported');
+ case ConnectedAccountProvider.OIDC:
+ case ConnectedAccountProvider.SAML:
case null:
- throw new Error('Account provider not provided');
+ return;
default:
assertUnreachable(connectedAccountProvider);
}
@@ -166,14 +167,14 @@ export const SidePanelMessageThreadPage = () => {
>
)}
- {isDefined(canReply) && !messageChannelLoading && (
+ {!messageChannelLoading && (
)}
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
index c1a45890af..4b14a0dd60 100644
--- a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
+++ b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
@@ -23,11 +23,13 @@ type ActorDisplayProps = Partial & {
avatarUrl?: string | null;
};
-const PROVIDORS_ICON_MAPPING = {
+const PROVIDERS_ICON_MAPPING = {
EMAIL: {
[ConnectedAccountProvider.MICROSOFT]: IconMicrosoftOutlook,
[ConnectedAccountProvider.GOOGLE]: IconGmail,
[ConnectedAccountProvider.IMAP_SMTP_CALDAV]: IconMail,
+ [ConnectedAccountProvider.OIDC]: IconMail,
+ [ConnectedAccountProvider.SAML]: IconMail,
default: IconMail,
},
CALENDAR: {
@@ -51,12 +53,12 @@ export const ActorDisplay = ({
case 'IMPORT':
return IconUpload;
case 'EMAIL':
- return PROVIDORS_ICON_MAPPING.EMAIL[context?.provider ?? 'default'];
+ return PROVIDERS_ICON_MAPPING.EMAIL[context?.provider ?? 'default'];
case 'CALENDAR':
return (
- PROVIDORS_ICON_MAPPING.CALENDAR[
- context?.provider as keyof typeof PROVIDORS_ICON_MAPPING.CALENDAR
- ] ?? PROVIDORS_ICON_MAPPING.CALENDAR.default
+ PROVIDERS_ICON_MAPPING.CALENDAR[
+ context?.provider as keyof typeof PROVIDERS_ICON_MAPPING.CALENDAR
+ ] ?? PROVIDERS_ICON_MAPPING.CALENDAR.default
);
case 'SYSTEM':
return IconRobot;
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
index 98f3885ec6..bd908e00d0 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts
@@ -20,6 +20,7 @@ import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.s
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
+import { CreateSSOConnectedAccountService } from 'src/engine/core-modules/auth/services/create-sso-connected-account.service';
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/auth/services/google-apis-service-availability.service';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
@@ -60,6 +61,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
+import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -99,6 +101,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
UserWorkspaceEntity,
TwoFactorAuthenticationMethodEntity,
ObjectMetadataEntity,
+ ConnectedAccountEntity,
]),
UserWorkspaceModule,
WorkspaceModule,
@@ -160,6 +163,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
CreateMessageChannelService,
CreateCalendarChannelService,
CreateConnectedAccountService,
+ CreateSSOConnectedAccountService,
UpdateConnectedAccountOnReconnectService,
TransientTokenService,
AuthSsoService,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts
index 3beee8b300..09ebedcaca 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts
@@ -13,7 +13,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { generateServiceProviderMetadata } from '@node-saml/node-saml';
import { Response } from 'express';
-import { AppPath } from 'twenty-shared/types';
+import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -167,9 +167,18 @@ export class SSOAuthController {
),
);
+ const oidcTokenClaims =
+ 'oidcTokenClaims' in req.user ? req.user.oidcTokenClaims : undefined;
+
+ const connectedAccountProvider =
+ workspaceIdentityProvider.type === IdentityProviderType.SAML
+ ? ConnectedAccountProvider.SAML
+ : ConnectedAccountProvider.OIDC;
+
const { loginToken } = await this.generateLoginToken(
req.user,
currentWorkspace,
+ { oidcTokenClaims, connectedAccountProvider },
);
return res.redirect(
@@ -195,6 +204,10 @@ export class SSOAuthController {
private async generateLoginToken(
payload: { email: string; workspaceInviteHash?: string },
currentWorkspace: WorkspaceEntity,
+ ssoContext?: {
+ oidcTokenClaims?: Record;
+ connectedAccountProvider: ConnectedAccountProvider;
+ },
) {
const invitation = payload.email
? await this.authService.findInvitationForSignInUp({
@@ -226,6 +239,17 @@ export class SSOAuthController {
},
});
+ if (ssoContext) {
+ await this.authService.createSSOConnectedAccountIfFeatureFlagIsOn({
+ workspaceId: workspace.id,
+ userId: user.id,
+ handle: payload.email.toLowerCase(),
+ authProvider: AuthProviderEnum.SSO,
+ oidcTokenClaims: ssoContext.oidcTokenClaims,
+ connectedAccountProvider: ssoContext.connectedAccountProvider,
+ });
+ }
+
return {
workspace,
loginToken: await this.loginTokenService.generateLoginToken(
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
index e9f5d871d0..f141dd14be 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts
@@ -30,6 +30,8 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
+import { CreateSSOConnectedAccountService } from 'src/engine/core-modules/auth/services/create-sso-connected-account.service';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { AuthService } from './auth.service';
@@ -174,6 +176,20 @@ describe('AuthService', () => {
provide: ApplicationRegistrationService,
useValue: {},
},
+ {
+ provide: FeatureFlagService,
+ useValue: {
+ isFeatureEnabled: jest.fn().mockResolvedValue(false),
+ },
+ },
+ {
+ provide: CreateSSOConnectedAccountService,
+ useValue: {
+ createOrUpdateSSOConnectedAccount: jest
+ .fn()
+ .mockResolvedValue(undefined),
+ },
+ },
],
}).compile();
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
index b70acebb0d..7d0dccb727 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
@@ -9,7 +9,11 @@ import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { PermissionFlagType } from 'twenty-shared/constants';
-import { AppPath } from 'twenty-shared/types';
+import {
+ AppPath,
+ ConnectedAccountProvider,
+ FeatureFlagKey,
+} from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
@@ -36,6 +40,7 @@ import { type UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user
import { type CheckUserExistDTO } from 'src/engine/core-modules/auth/dto/user-exists.dto';
import { type WorkspaceInviteHashValidDTO } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.dto';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
+import { CreateSSOConnectedAccountService } from 'src/engine/core-modules/auth/services/create-sso-connected-account.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { type GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { type MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
@@ -58,6 +63,7 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { WorkspaceDomainConfig } from 'src/engine/core-modules/domain/workspace-domains/types/workspace-domain-config.type';
import { EmailService } from 'src/engine/core-modules/email/email.service';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -69,6 +75,7 @@ import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/worksp
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
+// import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
@Injectable()
// oxlint-disable-next-line twenty/inject-workspace-repository
@@ -98,6 +105,8 @@ export class AuthService {
private readonly i18nService: I18nService,
private readonly auditService: AuditService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
+ private readonly featureFlagService: FeatureFlagService,
+ private readonly createSSOConnectedAccountService: CreateSSOConnectedAccountService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -1031,6 +1040,13 @@ export class AuthService {
billingCheckoutSessionState,
});
+ await this.createSSOConnectedAccountIfFeatureFlagIsOn({
+ workspaceId: workspace.id,
+ userId: user.id,
+ handle: email,
+ authProvider,
+ });
+
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
@@ -1053,4 +1069,85 @@ export class AuthService {
});
}
}
+
+ async createSSOConnectedAccountIfFeatureFlagIsOn(input: {
+ workspaceId: string;
+ userId: string;
+ handle: string;
+ authProvider:
+ | AuthProviderEnum.Google
+ | AuthProviderEnum.Microsoft
+ | AuthProviderEnum.SSO;
+ oidcTokenClaims?: Record;
+ connectedAccountProvider?: ConnectedAccountProvider;
+ }): Promise {
+ const isConnectedAccountMigrated =
+ await this.featureFlagService.isFeatureEnabled(
+ FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
+ input.workspaceId,
+ );
+
+ // const willBeEnabledByDefault = DEFAULT_FEATURE_FLAGS.includes(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED);
+ const willBeEnabledByDefault = false;
+
+ if (!isConnectedAccountMigrated && !willBeEnabledByDefault) {
+ return;
+ }
+
+ const provider =
+ input.connectedAccountProvider ??
+ this.mapAuthProviderToConnectedAccountProvider(input.authProvider);
+
+ const scopes = this.getSSOScopes(provider);
+
+ await this.createSSOConnectedAccountService.createOrUpdateSSOConnectedAccount(
+ {
+ workspaceId: input.workspaceId,
+ userId: input.userId,
+ handle: input.handle,
+ provider,
+ scopes,
+ oidcTokenClaims: input.oidcTokenClaims,
+ },
+ );
+ }
+
+ private mapAuthProviderToConnectedAccountProvider(
+ authProvider:
+ | AuthProviderEnum.Google
+ | AuthProviderEnum.Microsoft
+ | AuthProviderEnum.SSO,
+ ): ConnectedAccountProvider {
+ switch (authProvider) {
+ case AuthProviderEnum.Google:
+ return ConnectedAccountProvider.GOOGLE;
+ case AuthProviderEnum.Microsoft:
+ return ConnectedAccountProvider.MICROSOFT;
+ case AuthProviderEnum.SSO:
+ return ConnectedAccountProvider.OIDC;
+ default:
+ throw new Error(
+ `Unsupported auth provider: ${authProvider satisfies never}`,
+ );
+ }
+ }
+
+ private getSSOScopes(provider: ConnectedAccountProvider): string[] {
+ switch (provider) {
+ case ConnectedAccountProvider.GOOGLE:
+ return ['email', 'profile'];
+ case ConnectedAccountProvider.MICROSOFT:
+ return ['user.read'];
+ case ConnectedAccountProvider.OIDC:
+ return ['openid', 'email', 'profile'];
+ case ConnectedAccountProvider.SAML:
+ return [];
+ case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
+ return [];
+ default:
+ throw new Error(
+ `Unsupported connected account provider: ${provider satisfies never}`,
+ );
+ }
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/create-sso-connected-account.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/create-sso-connected-account.service.ts
new file mode 100644
index 0000000000..1195d147b4
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/create-sso-connected-account.service.ts
@@ -0,0 +1,81 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { type ConnectedAccountProvider } from 'twenty-shared/types';
+import { Repository } from 'typeorm';
+
+import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
+import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
+
+type CreateSSOConnectedAccountParams = {
+ workspaceId: string;
+ userId: string;
+ handle: string;
+ provider: ConnectedAccountProvider;
+ scopes: string[];
+ oidcTokenClaims?: Record;
+};
+
+@Injectable()
+export class CreateSSOConnectedAccountService {
+ private readonly logger = new Logger(CreateSSOConnectedAccountService.name);
+
+ constructor(
+ @InjectRepository(ConnectedAccountEntity)
+ private readonly connectedAccountRepository: Repository,
+ @InjectRepository(UserWorkspaceEntity)
+ private readonly userWorkspaceRepository: Repository,
+ ) {}
+
+ async createOrUpdateSSOConnectedAccount(
+ params: CreateSSOConnectedAccountParams,
+ ): Promise {
+ const { workspaceId, userId, handle, provider, scopes, oidcTokenClaims } =
+ params;
+
+ const userWorkspace = await this.userWorkspaceRepository.findOneBy({
+ userId,
+ workspaceId,
+ });
+
+ if (!userWorkspace) {
+ this.logger.warn(
+ `Could not find userWorkspace for userId=${userId} workspaceId=${workspaceId}, skipping SSO connected account creation`,
+ );
+
+ return;
+ }
+
+ const existing = await this.connectedAccountRepository.findOneBy({
+ handle,
+ provider,
+ userWorkspaceId: userWorkspace.id,
+ workspaceId,
+ });
+
+ if (existing) {
+ await this.connectedAccountRepository.update(existing.id, {
+ lastSignedInAt: new Date(),
+ provider,
+ scopes,
+ ...(oidcTokenClaims !== undefined
+ ? { oidcTokenClaims: oidcTokenClaims as object }
+ : {}),
+ });
+
+ return;
+ }
+
+ await this.connectedAccountRepository.save({
+ handle,
+ provider,
+ scopes,
+ accessToken: null,
+ refreshToken: null,
+ lastSignedInAt: new Date(),
+ userWorkspaceId: userWorkspace.id,
+ workspaceId,
+ ...(oidcTokenClaims ? { oidcTokenClaims } : {}),
+ });
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts
index 5ecb22f374..cdec314fd6 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts
@@ -266,7 +266,7 @@ describe('GoogleAPIsService', () => {
expect(
createMessageChannelService.createMessageChannel,
- ).not.toHaveBeenCalled();
+ ).toHaveBeenCalled();
});
});
});
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts
index 073de4ce7e..c71b6059f9 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts
@@ -145,41 +145,19 @@ export class GoogleAPIsService {
await workspaceDataSource.transaction(
async (manager: WorkspaceEntityManager) => {
- if (!existingAccountId) {
- await this.createConnectedAccountService.createConnectedAccount({
- workspaceId,
- connectedAccountId: newOrExistingConnectedAccountId,
- handle,
- provider: ConnectedAccountProvider.GOOGLE,
- accessToken: input.accessToken,
- refreshToken: input.refreshToken,
- accountOwnerId: workspaceMemberId,
- scopes,
- manager,
- });
+ await this.createConnectedAccountService.createConnectedAccount({
+ workspaceId,
+ connectedAccountId: newOrExistingConnectedAccountId,
+ handle,
+ provider: ConnectedAccountProvider.GOOGLE,
+ accessToken: input.accessToken,
+ refreshToken: input.refreshToken,
+ accountOwnerId: workspaceMemberId,
+ scopes,
+ manager,
+ });
- if (isMessagingEnabled && isMessagingAvailable) {
- await this.createMessageChannelService.createMessageChannel({
- workspaceId,
- connectedAccountId: newOrExistingConnectedAccountId,
- handle,
- messageVisibility,
- manager,
- skipMessageChannelConfiguration,
- });
- }
-
- if (isCalendarEnabled && isCalendarAvailable) {
- await this.createCalendarChannelService.createCalendarChannel({
- workspaceId,
- connectedAccountId: newOrExistingConnectedAccountId,
- handle,
- calendarVisibility,
- manager,
- skipMessageChannelConfiguration,
- });
- }
- } else {
+ if (existingAccountId) {
await this.updateConnectedAccountOnReconnectService.updateConnectedAccountOnReconnect(
{
workspaceId,
@@ -211,6 +189,48 @@ export class GoogleAPIsService {
newOrExistingConnectedAccountId,
);
}
+
+ const existingMessageChannels =
+ await this.messageChannelDataAccessService.find(workspaceId, {
+ connectedAccountId: newOrExistingConnectedAccountId,
+ });
+
+ if (
+ isMessagingEnabled &&
+ isMessagingAvailable &&
+ existingMessageChannels.length === 0
+ ) {
+ await this.createMessageChannelService.createMessageChannel({
+ workspaceId,
+ connectedAccountId: newOrExistingConnectedAccountId,
+ handle,
+ messageVisibility,
+ manager,
+ skipMessageChannelConfiguration,
+ });
+ }
+
+ const existingCalendarChannels =
+ await this.calendarChannelDataAccessService.find(workspaceId, {
+ where: {
+ connectedAccountId: newOrExistingConnectedAccountId,
+ },
+ });
+
+ if (
+ isCalendarEnabled &&
+ isCalendarAvailable &&
+ existingCalendarChannels.length === 0
+ ) {
+ await this.createCalendarChannelService.createCalendarChannel({
+ workspaceId,
+ connectedAccountId: newOrExistingConnectedAccountId,
+ handle,
+ calendarVisibility,
+ manager,
+ skipMessageChannelConfiguration,
+ });
+ }
},
);
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts
index 90772e97c2..9edfe43e74 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts
@@ -99,43 +99,19 @@ export class MicrosoftAPIsService {
await workspaceDataSource.transaction(
async (manager: WorkspaceEntityManager) => {
- if (!existingAccountId) {
- await this.createConnectedAccountService.createConnectedAccount({
- workspaceId,
- connectedAccountId: newOrExistingConnectedAccountId,
- handle,
- provider: ConnectedAccountProvider.MICROSOFT,
- accessToken: input.accessToken,
- refreshToken: input.refreshToken,
- accountOwnerId: workspaceMemberId,
- scopes,
- manager,
- });
+ await this.createConnectedAccountService.createConnectedAccount({
+ workspaceId,
+ connectedAccountId: newOrExistingConnectedAccountId,
+ handle,
+ provider: ConnectedAccountProvider.MICROSOFT,
+ accessToken: input.accessToken,
+ refreshToken: input.refreshToken,
+ accountOwnerId: workspaceMemberId,
+ scopes,
+ manager,
+ });
- await this.createMessageChannelService.createMessageChannel({
- workspaceId,
- connectedAccountId: newOrExistingConnectedAccountId,
- handle,
- messageVisibility,
- manager,
- skipMessageChannelConfiguration,
- });
-
- if (
- this.twentyConfigService.get(
- 'CALENDAR_PROVIDER_MICROSOFT_ENABLED',
- )
- ) {
- await this.createCalendarChannelService.createCalendarChannel({
- workspaceId,
- connectedAccountId: newOrExistingConnectedAccountId,
- handle,
- calendarVisibility,
- manager,
- skipMessageChannelConfiguration,
- });
- }
- } else {
+ if (existingAccountId) {
await this.updateConnectedAccountOnReconnectService.updateConnectedAccountOnReconnect(
{
workspaceId,
@@ -177,6 +153,50 @@ export class MicrosoftAPIsService {
workspaceId,
);
}
+
+ const existingMessageChannels =
+ await this.messageChannelDataAccessService.find(workspaceId, {
+ connectedAccountId: newOrExistingConnectedAccountId,
+ });
+
+ if (
+ this.twentyConfigService.get(
+ 'MESSAGING_PROVIDER_MICROSOFT_ENABLED',
+ ) &&
+ existingMessageChannels.length === 0
+ ) {
+ await this.createMessageChannelService.createMessageChannel({
+ workspaceId,
+ connectedAccountId: newOrExistingConnectedAccountId,
+ handle,
+ messageVisibility,
+ manager,
+ skipMessageChannelConfiguration,
+ });
+ }
+
+ const existingCalendarChannels =
+ await this.calendarChannelDataAccessService.find(workspaceId, {
+ where: {
+ connectedAccountId: newOrExistingConnectedAccountId,
+ },
+ });
+
+ if (
+ this.twentyConfigService.get(
+ 'CALENDAR_PROVIDER_MICROSOFT_ENABLED',
+ ) &&
+ existingCalendarChannels.length === 0
+ ) {
+ await this.createCalendarChannelService.createCalendarChannel({
+ workspaceId,
+ connectedAccountId: newOrExistingConnectedAccountId,
+ handle,
+ calendarVisibility,
+ manager,
+ skipMessageChannelConfiguration,
+ });
+ }
},
);
diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts
index 5e6baf48db..62c6e9c908 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/oidc.auth.strategy.ts
@@ -21,6 +21,7 @@ export type OIDCRequest = Omit<
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string;
+ oidcTokenClaims?: Record;
};
};
@@ -110,6 +111,7 @@ export class OIDCAuthStrategy extends PassportStrategy(
identityProviderId: state.identityProviderId,
...(userinfo.given_name ? { firstName: userinfo.given_name } : {}),
...(userinfo.family_name ? { lastName: userinfo.family_name } : {}),
+ oidcTokenClaims: tokenset.claims() as Record,
});
} catch (err) {
done(err);
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts
index ca83586018..68e03724e0 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts
@@ -4,4 +4,5 @@ export const DEFAULT_FEATURE_FLAGS = [
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
+ FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
] as const satisfies FeatureFlagKey[];
diff --git a/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts b/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts
index bcad7d39ad..ba6299b271 100644
--- a/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts
+++ b/packages/twenty-server/src/modules/connected-account/email-alias-manager/services/email-alias-manager.service.ts
@@ -39,7 +39,8 @@ export class EmailAliasManagerService {
);
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
- // IMAP Protocol does not support email aliases
+ case ConnectedAccountProvider.OIDC:
+ case ConnectedAccountProvider.SAML:
handleAliases = [];
break;
default:
diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts
index d819d77f86..04d94bf998 100644
--- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts
+++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.spec.ts
@@ -338,5 +338,85 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(result).toBe(true);
});
+
+ it('should return true for OIDC provider regardless of lastCredentialsRefreshedAt', async () => {
+ const connectedAccount = {
+ id: mockConnectedAccountId,
+ provider: ConnectedAccountProvider.OIDC,
+ lastCredentialsRefreshedAt: null,
+ } as ConnectedAccountWorkspaceEntity;
+
+ const result = await service.isAccessTokenStillValid(connectedAccount);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true for SAML provider regardless of lastCredentialsRefreshedAt', async () => {
+ const connectedAccount = {
+ id: mockConnectedAccountId,
+ provider: ConnectedAccountProvider.SAML,
+ lastCredentialsRefreshedAt: null,
+ } as ConnectedAccountWorkspaceEntity;
+
+ const result = await service.isAccessTokenStillValid(connectedAccount);
+
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('refreshAndSaveTokens - OIDC/SAML', () => {
+ it('should reuse existing tokens for OIDC without attempting a refresh', async () => {
+ const connectedAccount = {
+ id: mockConnectedAccountId,
+ provider: ConnectedAccountProvider.OIDC,
+ accessToken: mockAccessToken,
+ refreshToken: mockRefreshToken,
+ lastCredentialsRefreshedAt: null,
+ } as unknown as ConnectedAccountWorkspaceEntity;
+
+ const result = await service.refreshAndSaveTokens(
+ connectedAccount,
+ mockWorkspaceId,
+ );
+
+ expect(result).toEqual({
+ accessToken: mockAccessToken,
+ refreshToken: mockRefreshToken,
+ });
+ expect(
+ googleAPIRefreshAccessTokenService.refreshTokens,
+ ).not.toHaveBeenCalled();
+ expect(
+ microsoftAPIRefreshAccessTokenService.refreshTokens,
+ ).not.toHaveBeenCalled();
+ expect(connectedAccountDataAccessService.update).not.toHaveBeenCalled();
+ });
+
+ it('should reuse existing tokens for SAML without attempting a refresh', async () => {
+ const connectedAccount = {
+ id: mockConnectedAccountId,
+ provider: ConnectedAccountProvider.SAML,
+ accessToken: mockAccessToken,
+ refreshToken: mockRefreshToken,
+ lastCredentialsRefreshedAt: null,
+ } as unknown as ConnectedAccountWorkspaceEntity;
+
+ const result = await service.refreshAndSaveTokens(
+ connectedAccount,
+ mockWorkspaceId,
+ );
+
+ expect(result).toEqual({
+ accessToken: mockAccessToken,
+ refreshToken: mockRefreshToken,
+ });
+ expect(
+ googleAPIRefreshAccessTokenService.refreshTokens,
+ ).not.toHaveBeenCalled();
+ expect(
+ microsoftAPIRefreshAccessTokenService.refreshTokens,
+ ).not.toHaveBeenCalled();
+ expect(connectedAccountDataAccessService.update).not.toHaveBeenCalled();
+ });
});
});
diff --git a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts
index ef7931ae43..35849aa3a4 100644
--- a/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts
+++ b/packages/twenty-server/src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service.ts
@@ -114,6 +114,8 @@ export class ConnectedAccountRefreshTokensService {
);
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
+ case ConnectedAccountProvider.OIDC:
+ case ConnectedAccountProvider.SAML:
return true;
default:
return assertUnreachable(
@@ -139,8 +141,10 @@ export class ConnectedAccountRefreshTokensService {
refreshToken,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
+ case ConnectedAccountProvider.OIDC:
+ case ConnectedAccountProvider.SAML:
throw new ConnectedAccountRefreshAccessTokenException(
- `Token refresh is not supported for IMAP provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
+ `Token refresh is not supported for ${connectedAccount.provider} provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
default:
diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts
index 589e9ab9cd..5cba7dbc7b 100644
--- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service.ts
@@ -37,6 +37,11 @@ export class MessagingMessageOutboundService {
sendMessageInput,
connectedAccount,
);
+ case ConnectedAccountProvider.OIDC:
+ case ConnectedAccountProvider.SAML:
+ throw new Error(
+ `Provider ${connectedAccount.provider} does not support sending messages`,
+ );
default:
assertUnreachable(
connectedAccount.provider,
@@ -65,6 +70,11 @@ export class MessagingMessageOutboundService {
sendMessageInput,
connectedAccount,
);
+ case ConnectedAccountProvider.OIDC:
+ case ConnectedAccountProvider.SAML:
+ throw new Error(
+ `Provider ${connectedAccount.provider} does not support creating drafts`,
+ );
default:
assertUnreachable(
connectedAccount.provider,
diff --git a/packages/twenty-shared/src/types/ConnectedAccountProvider.ts b/packages/twenty-shared/src/types/ConnectedAccountProvider.ts
index 8841626a8a..68627ad2b3 100644
--- a/packages/twenty-shared/src/types/ConnectedAccountProvider.ts
+++ b/packages/twenty-shared/src/types/ConnectedAccountProvider.ts
@@ -2,4 +2,6 @@ export enum ConnectedAccountProvider {
GOOGLE = 'google',
MICROSOFT = 'microsoft',
IMAP_SMTP_CALDAV = 'imap_smtp_caldav',
+ OIDC = 'oidc',
+ SAML = 'saml',
}