From 9a306ddb9ab9ee6ecb0ad17171f317a711540b26 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sun, 22 Mar 2026 12:29:58 +0100 Subject: [PATCH] feat: store SSO connections as connected accounts during sign-in (#18825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Store SSO connections (Google, Microsoft, OIDC, SAML) as connected accounts in the core schema during sign-in/sign-up, gated behind the `IS_CONNECTED_ACCOUNT_MIGRATED` feature flag - Add `OIDC` and `SAML` to `ConnectedAccountProvider` enum with exhaustive switch handling across frontend and backend - Add `IS_CONNECTED_ACCOUNT_MIGRATED` to `DEFAULT_FEATURE_FLAGS` for new workspaces, with a fallback check so SSO accounts are created even before workspace activation - Always upsert connected accounts to both workspace and core schemas during messaging OAuth flow, fixing FK constraint violations when SSO-only accounts exist only in core - Create message/calendar channels when they don't exist regardless of new vs reconnect flow - Filter settings accounts list to only show accounts that have message or calendar channels ## Test plan - [ ] Sign up with Google SSO → verify connected account is created in core schema - [ ] Connect messaging (Google APIs) after SSO sign-up → verify no FK errors, channels created, configuration page renders correctly - [ ] Reconnect an existing messaging account → verify tokens updated, sync resets triggered - [ ] Sign in with OIDC/SAML SSO → verify connected account created with oidcTokenClaims - [ ] Verify settings accounts page only shows accounts with channels (SSO-only accounts hidden) - [ ] Verify typecheck, lint, and unit tests pass --- .../utils/hasMissingDraftEmailScopes.ts | 2 + .../accounts/hooks/useMyConnectedAccounts.ts | 60 ++++++----- .../components/SidePanelMessageThreadPage.tsx | 15 +-- .../field/display/components/ActorDisplay.tsx | 12 ++- .../engine/core-modules/auth/auth.module.ts | 4 + .../auth/controllers/sso-auth.controller.ts | 26 ++++- .../auth/services/auth.service.spec.ts | 16 +++ .../auth/services/auth.service.ts | 99 ++++++++++++++++++- .../create-sso-connected-account.service.ts | 81 +++++++++++++++ .../auth/services/google-apis.service.spec.ts | 2 +- .../auth/services/google-apis.service.ts | 88 ++++++++++------- .../auth/services/microsoft-apis.service.ts | 92 ++++++++++------- .../auth/strategies/oidc.auth.strategy.ts | 2 + .../constant/default-feature-flags.ts | 1 + .../services/email-alias-manager.service.ts | 3 +- ...ted-account-refresh-tokens.service.spec.ts | 80 +++++++++++++++ ...onnected-account-refresh-tokens.service.ts | 6 +- .../messaging-message-outbound.service.ts | 10 ++ .../src/types/ConnectedAccountProvider.ts | 2 + 19 files changed, 487 insertions(+), 114 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/create-sso-connected-account.service.ts 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 && (