From 5b2950c43a23c877b777a32e9a98e1ec96d0988b Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:40:09 +0500 Subject: [PATCH] Introduce SSO bypass permission. (#15417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes [Core Issue #1772](https://github.com/twentyhq/core-team-issues/issues/1772). --- > [!NOTE] > Introduces SSO bypass with a new permission flag and workspace-level provider toggles, enabling permitted users to log in via Google/Microsoft/Password when SSO-only, with backend enforcement and frontend UI/hooks/queries. > > - **Backend**: > - **Permission & Enforcement**: Add `PermissionFlagType.SSO_BYPASS`; update `AuthService` to allow login via non-SSO providers when workspace bypass is enabled and user has `SSO_BYPASS`. > - **Workspace Model**: Add `isGoogleAuthBypassEnabled`, `isMicrosoftAuthBypassEnabled`, `isPasswordAuthBypassEnabled` (migration, entity, update input, service validation). > - **Public API**: Extend `PublicWorkspaceDataOutput` with `authBypassProviders`; resolver computes it; permissions defaults include `SSO_BYPASS`. > - **Frontend**: > - **GraphQL/State**: Generate new types/fields; add `authBypassProviders` to `GetPublicWorkspaceDataByDomain`; new states `workspaceAuthBypassProvidersState`, `workspaceBypassModeState`. > - **Auth UI/Logic**: Add `useWorkspaceBypass`; update sign-in form and footer to offer "Bypass SSO" and use merged providers when enabled; remove auto-redirect when single SSO. > - **Settings**: Add Security section to toggle bypass methods per provider; conditionally show Change Password via `useCanChangePassword`. > - **Tests/Mocks**: Update mocks and tests to include bypass flags/providers. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8c393b2bad387fb6e8b8f40027f8637dd6e85723. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Félix Malfait --- .../src/generated-metadata/graphql.ts | 29 ++++- .../twenty-front/src/generated/graphql.ts | 15 +++ .../services/__tests__/apollo.factory.test.ts | 3 + .../queries/getPublicWorkspaceDataByDomain.ts | 5 + .../auth/sign-in-up/components/FooterNote.tsx | 116 +++++++++++++---- .../components/SignInUpWorkspaceScopeForm.tsx | 32 +++-- .../SignInUpWorkspaceScopeFormEffect.tsx | 8 +- .../sign-in-up/hooks/useWorkspaceBypass.ts | 55 ++++++++ .../auth/states/currentWorkspaceState.ts | 3 + .../useGetPublicWorkspaceDataByDomain.ts | 7 ++ ...ColumnDefinitionsFromFieldMetadata.test.ts | 3 + .../profile/hooks/useCanChangePassword.ts | 30 +++++ ...SettingsRolePermissionsSettingsSection.tsx | 7 ++ .../SettingsSecurityAuthBypassOptionsList.tsx | 119 ++++++++++++++++++ .../graphql/fragments/userQueryFragment.ts | 3 + .../modules/users/hooks/useLoadCurrentUser.ts | 20 ++- .../workspaceAuthBypassProvidersState.ts | 8 ++ .../states/workspaceBypassModeState.ts | 6 + .../src/pages/settings/SettingsProfile.tsx | 14 ++- .../settings/security/SettingsSecurity.tsx | 30 +++++ .../publicWorkspaceDataBySubdomain.ts | 6 + .../src/testing/mock-data/users.ts | 5 +- .../common/1761651107128-addSsoBypassFlag.ts | 29 +++++ .../auth/services/auth.service.spec.ts | 109 +++++++++++++++- .../auth/services/auth.service.ts | 92 ++++++++++++-- .../user/services/user.service.ts | 9 ++ .../dtos/public-workspace-data-output.ts | 15 +++ .../workspace/dtos/update-workspace-input.ts | 15 +++ .../workspace/services/workspace.service.ts | 24 ++++ ...auth-bypass-providers-by-workspace.util.ts | 25 ++++ .../workspace/workspace.entity.ts | 12 ++ .../workspace/workspace.resolver.ts | 5 + .../permission-flag-type.constants.ts | 1 + .../permissions/permissions.service.ts | 1 + 34 files changed, 803 insertions(+), 58 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceBypass.ts create mode 100644 packages/twenty-front/src/modules/settings/profile/hooks/useCanChangePassword.ts create mode 100644 packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthBypassOptionsList.tsx create mode 100644 packages/twenty-front/src/modules/workspace/states/workspaceAuthBypassProvidersState.ts create mode 100644 packages/twenty-front/src/modules/workspace/states/workspaceBypassModeState.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1761651107128-addSsoBypassFlag.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-bypass-providers-by-workspace.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 004ee0b185..f8f2ce81eb 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -240,6 +240,13 @@ export type ApprovedAccessDomain = { isValidated: Scalars['Boolean']; }; +export type AuthBypassProviders = { + __typename?: 'AuthBypassProviders'; + google: Scalars['Boolean']; + microsoft: Scalars['Boolean']; + password: Scalars['Boolean']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']; @@ -3033,6 +3040,7 @@ export enum PermissionFlagType { ROLES = 'ROLES', SECURITY = 'SECURITY', SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL', + SSO_BYPASS = 'SSO_BYPASS', WORKFLOWS = 'WORKFLOWS', WORKSPACE = 'WORKSPACE', WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS' @@ -3092,6 +3100,7 @@ export type PublicFeatureFlagMetadata = { export type PublicWorkspaceDataOutput = { __typename?: 'PublicWorkspaceDataOutput'; + authBypassProviders?: Maybe; authProviders: AuthProviders; displayName?: Maybe; id: Scalars['UUID']; @@ -4373,8 +4382,11 @@ export type UpdateWorkspaceInput = { defaultRoleId?: InputMaybe; displayName?: InputMaybe; inviteHash?: InputMaybe; + isGoogleAuthBypassEnabled?: InputMaybe; isGoogleAuthEnabled?: InputMaybe; + isMicrosoftAuthBypassEnabled?: InputMaybe; isMicrosoftAuthEnabled?: InputMaybe; + isPasswordAuthBypassEnabled?: InputMaybe; isPasswordAuthEnabled?: InputMaybe; isPublicInviteLinkEnabled?: InputMaybe; isTwoFactorAuthenticationEnforced?: InputMaybe; @@ -4707,8 +4719,11 @@ export type Workspace = { id: Scalars['UUID']; inviteHash?: Maybe; isCustomDomainEnabled: Scalars['Boolean']; + isGoogleAuthBypassEnabled: Scalars['Boolean']; isGoogleAuthEnabled: Scalars['Boolean']; + isMicrosoftAuthBypassEnabled: Scalars['Boolean']; isMicrosoftAuthEnabled: Scalars['Boolean']; + isPasswordAuthBypassEnabled: Scalars['Boolean']; isPasswordAuthEnabled: Scalars['Boolean']; isPublicInviteLinkEnabled: Scalars['Boolean']; isTwoFactorAuthenticationEnforced: Scalars['Boolean']; @@ -5169,7 +5184,7 @@ export type GetPublicWorkspaceDataByDomainQueryVariables = Exact<{ }>; -export type GetPublicWorkspaceDataByDomainQuery = { __typename?: 'Query', getPublicWorkspaceDataByDomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> } } }; +export type GetPublicWorkspaceDataByDomainQuery = { __typename?: 'Query', getPublicWorkspaceDataByDomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> }, authBypassProviders?: { __typename?: 'AuthBypassProviders', google: boolean, password: boolean, microsoft: boolean } | null } }; export type ValidatePasswordResetTokenQueryVariables = Exact<{ token: Scalars['String']; @@ -5854,7 +5869,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -5873,7 +5888,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -6832,6 +6847,9 @@ export const UserQueryFragmentFragmentDoc = gql` isGoogleAuthEnabled isMicrosoftAuthEnabled isPasswordAuthEnabled + isGoogleAuthBypassEnabled + isMicrosoftAuthBypassEnabled + isPasswordAuthBypassEnabled subdomain hasValidEnterpriseKey customDomain @@ -8600,6 +8618,11 @@ export const GetPublicWorkspaceDataByDomainDocument = gql` password microsoft } + authBypassProviders { + google + password + microsoft + } } } ${WorkspaceUrlsFragmentFragmentDoc}`; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 166dfe818d..020e91017e 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -240,6 +240,13 @@ export type ApprovedAccessDomain = { isValidated: Scalars['Boolean']; }; +export type AuthBypassProviders = { + __typename?: 'AuthBypassProviders'; + google: Scalars['Boolean']; + microsoft: Scalars['Boolean']; + password: Scalars['Boolean']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']; @@ -2944,6 +2951,7 @@ export enum PermissionFlagType { ROLES = 'ROLES', SECURITY = 'SECURITY', SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL', + SSO_BYPASS = 'SSO_BYPASS', WORKFLOWS = 'WORKFLOWS', WORKSPACE = 'WORKSPACE', WORKSPACE_MEMBERS = 'WORKSPACE_MEMBERS' @@ -3003,6 +3011,7 @@ export type PublicFeatureFlagMetadata = { export type PublicWorkspaceDataOutput = { __typename?: 'PublicWorkspaceDataOutput'; + authBypassProviders?: Maybe; authProviders: AuthProviders; displayName?: Maybe; id: Scalars['UUID']; @@ -4211,8 +4220,11 @@ export type UpdateWorkspaceInput = { defaultRoleId?: InputMaybe; displayName?: InputMaybe; inviteHash?: InputMaybe; + isGoogleAuthBypassEnabled?: InputMaybe; isGoogleAuthEnabled?: InputMaybe; + isMicrosoftAuthBypassEnabled?: InputMaybe; isMicrosoftAuthEnabled?: InputMaybe; + isPasswordAuthBypassEnabled?: InputMaybe; isPasswordAuthEnabled?: InputMaybe; isPublicInviteLinkEnabled?: InputMaybe; isTwoFactorAuthenticationEnforced?: InputMaybe; @@ -4535,8 +4547,11 @@ export type Workspace = { id: Scalars['UUID']; inviteHash?: Maybe; isCustomDomainEnabled: Scalars['Boolean']; + isGoogleAuthBypassEnabled: Scalars['Boolean']; isGoogleAuthEnabled: Scalars['Boolean']; + isMicrosoftAuthBypassEnabled: Scalars['Boolean']; isMicrosoftAuthEnabled: Scalars['Boolean']; + isPasswordAuthBypassEnabled: Scalars['Boolean']; isPasswordAuthEnabled: Scalars['Boolean']; isPublicInviteLinkEnabled: Scalars['Boolean']; isTwoFactorAuthenticationEnforced: Scalars['Boolean']; diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts index 7623e36bd0..2ff2143ee1 100644 --- a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts +++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts @@ -49,6 +49,9 @@ const mockWorkspace = { isMicrosoftAuthEnabled: false, isPasswordAuthEnabled: false, isCustomDomainEnabled: false, + isGoogleAuthBypassEnabled: false, + isPasswordAuthBypassEnabled: false, + isMicrosoftAuthBypassEnabled: false, hasValidEnterpriseKey: false, subdomain: 'test', customDomain: 'test.com', diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataByDomain.ts b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataByDomain.ts index 4dde5cd677..3eb9ef806c 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataByDomain.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataByDomain.ts @@ -23,6 +23,11 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql` password microsoft } + authBypassProviders { + google + password + microsoft + } } } ${WORKSPACE_URLS_FRAGMENT} diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx index cd3df73ea5..cf3a92dc0d 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx @@ -1,7 +1,10 @@ import styled from '@emotion/styled'; import { Trans } from '@lingui/react/macro'; -const StyledContainer = styled.div` +import { useWorkspaceBypass } from '@/auth/sign-in-up/hooks/useWorkspaceBypass'; +import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; + +const StyledCopyContainer = styled.div` align-items: center; color: ${({ theme }) => theme.font.color.tertiary}; font-size: ${({ theme }) => theme.font.size.sm}; @@ -18,24 +21,93 @@ const StyledContainer = styled.div` } `; -export const FooterNote = () => ( - - By using Twenty, you agree to the{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . - -); +const StyledLinksContainer = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + flex-wrap: nowrap; + font-size: ${({ theme }) => theme.font.size.sm}; + gap: ${({ theme }) => theme.spacing(2)}; + justify-content: center; + max-width: 100%; + text-align: center; + white-space: nowrap; + + & > a, + & > button { + background: none; + border: none; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +`; + +const StyledSeparator = styled.span` + color: ${({ theme }) => theme.font.color.tertiary}; +`; + +export const FooterNote = () => { + const isOnAWorkspace = useIsCurrentLocationOnAWorkspace(); + + const { shouldOfferBypass, shouldUseBypass, enableBypass } = + useWorkspaceBypass(); + + if (!isOnAWorkspace) { + return ( + + By using Twenty, you agree to the{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + + ); + } + + return ( + + {shouldOfferBypass && !shouldUseBypass && ( + <> + + + + )} + + Privacy Policy + + + + Terms of Service + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx index 5a812a19ef..7d0823a416 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx @@ -5,7 +5,9 @@ import { SignInUpWithSSO } from '@/auth/sign-in-up/components/internal/SignInUpW import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { useWorkspaceBypass } from '@/auth/sign-in-up/hooks/useWorkspaceBypass'; import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import styled from '@emotion/styled'; import { Trans } from '@lingui/react/macro'; @@ -22,6 +24,10 @@ const StyledContentContainer = styled.div` export const SignInUpWorkspaceScopeForm = () => { const workspaceAuthProviders = useRecoilValue(workspaceAuthProvidersState); + const workspaceAuthBypassProviders = useRecoilValue( + workspaceAuthBypassProvidersState, + ); + const { shouldOfferBypass, shouldUseBypass } = useWorkspaceBypass(); const { form } = useSignInUpForm(); @@ -33,26 +39,32 @@ export const SignInUpWorkspaceScopeForm = () => { return null; } + const providers = + shouldOfferBypass && shouldUseBypass + ? { + ...workspaceAuthBypassProviders, + sso: [], + } + : workspaceAuthProviders; + return ( <> - {workspaceAuthProviders.google && ( - - )} + {providers.google && } - {workspaceAuthProviders.microsoft && ( + {providers.microsoft && ( )} - {workspaceAuthProviders.sso.length > 0 && } + {providers.sso.length > 0 && } - {(workspaceAuthProviders.google || - workspaceAuthProviders.microsoft || - workspaceAuthProviders.sso.length > 0) && - workspaceAuthProviders.password ? ( + {(providers.google || + providers.microsoft || + providers.sso.length > 0) && + providers.password ? ( ) : null} - {workspaceAuthProviders.password && ( + {providers.password && ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect.tsx index 6d5e2f6466..59fb79db63 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect.tsx @@ -1,4 +1,3 @@ -import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { @@ -35,7 +34,6 @@ export const SignInUpWorkspaceScopeFormEffect = () => { ); const { form } = useSignInUpForm(); - const { redirectToSSOLoginPage } = useSSO(); const { signInUpStep, continueWithEmail, continueWithCredentials } = useSignInUp(form); @@ -55,11 +53,7 @@ export const SignInUpWorkspaceScopeFormEffect = () => { if (hasOnlySSOProvidersEnabled && workspaceAuthProviders.sso.length > 1) { return setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection); } - - if (hasOnlySSOProvidersEnabled && workspaceAuthProviders.sso.length === 1) { - redirectToSSOLoginPage(workspaceAuthProviders.sso[0].id); - } - }, [redirectToSSOLoginPage, setSignInUpStep, workspaceAuthProviders]); + }, [setSignInUpStep, workspaceAuthProviders]); useEffect(() => { if (loadingStatus === LoadingStatus.Done) { diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceBypass.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceBypass.ts new file mode 100644 index 0000000000..66fcc2ddfe --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceBypass.ts @@ -0,0 +1,55 @@ +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; +import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState'; +import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; +import { workspaceBypassModeState } from '@/workspace/states/workspaceBypassModeState'; + +export const useWorkspaceBypass = () => { + const workspaceAuthProviders = useRecoilValue(workspaceAuthProvidersState); + const workspaceAuthBypassProviders = useRecoilValue( + workspaceAuthBypassProvidersState, + ); + const [workspaceBypassMode, setWorkspaceBypassMode] = useRecoilState( + workspaceBypassModeState, + ); + + const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace(); + + const hasOnlySSOProvidersEnabled = (() => { + if (!workspaceAuthProviders) { + return false; + } + + const { sso, google, microsoft, password } = workspaceAuthProviders; + + return sso.length > 0 && !google && !microsoft && !password; + })(); + + const hasBypassProvidersAvailable = (() => { + if (!workspaceAuthBypassProviders) { + return false; + } + + const { google, microsoft, password } = workspaceAuthBypassProviders; + + return google || microsoft || password; + })(); + + const shouldOfferBypass = + isOnAWorkspace && hasOnlySSOProvidersEnabled && hasBypassProvidersAvailable; + + const shouldUseBypass = shouldOfferBypass ? workspaceBypassMode : false; + + const enableBypass = () => { + if (shouldOfferBypass) { + setWorkspaceBypassMode(true); + } + }; + + return { + shouldOfferBypass, + shouldUseBypass, + enableBypass, + }; +}; diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index a08d8daed9..69b3120fe4 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -15,8 +15,11 @@ export type CurrentWorkspace = Pick< | 'workspaceMembersCount' | 'isPublicInviteLinkEnabled' | 'isGoogleAuthEnabled' + | 'isGoogleAuthBypassEnabled' | 'isMicrosoftAuthEnabled' + | 'isMicrosoftAuthBypassEnabled' | 'isPasswordAuthEnabled' + | 'isPasswordAuthBypassEnabled' | 'isCustomDomainEnabled' | 'hasValidEnterpriseKey' | 'subdomain' diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataByDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataByDomain.ts index 69643e9524..2a23dd3dfa 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataByDomain.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useGetPublicWorkspaceDataByDomain.ts @@ -5,6 +5,7 @@ import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useI import { useOrigin } from '@/domain-manager/hooks/useOrigin'; import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; +import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { useGetPublicWorkspaceDataByDomainQuery } from '~/generated-metadata/graphql'; @@ -16,6 +17,9 @@ export const useGetPublicWorkspaceDataByDomain = () => { const setWorkspaceAuthProviders = useSetRecoilState( workspaceAuthProvidersState, ); + const setWorkspaceAuthBypassProviders = useSetRecoilState( + workspaceAuthBypassProvidersState, + ); const workspacePublicData = useRecoilValue(workspacePublicDataState); const { redirectToDefaultDomain } = useRedirectToDefaultDomain(); const setWorkspacePublicDataState = useSetRecoilState( @@ -35,6 +39,9 @@ export const useGetPublicWorkspaceDataByDomain = () => { setWorkspaceAuthProviders( data.getPublicWorkspaceDataByDomain.authProviders, ); + setWorkspaceAuthBypassProviders( + data.getPublicWorkspaceDataByDomain.authBypassProviders ?? null, + ); setWorkspacePublicDataState(data.getPublicWorkspaceDataByDomain); }, onError: (error) => { diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 41bb41cd81..0264aec7db 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -29,6 +29,9 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({ isMicrosoftAuthEnabled: false, isPasswordAuthEnabled: true, isCustomDomainEnabled: false, + isGoogleAuthBypassEnabled: false, + isMicrosoftAuthBypassEnabled: false, + isPasswordAuthBypassEnabled: false, customDomain: 'my-custom-domain.com', workspaceUrls: { subdomainUrl: 'https://twenty.twenty.com', diff --git a/packages/twenty-front/src/modules/settings/profile/hooks/useCanChangePassword.ts b/packages/twenty-front/src/modules/settings/profile/hooks/useCanChangePassword.ts new file mode 100644 index 0000000000..6f74d9f90c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/hooks/useCanChangePassword.ts @@ -0,0 +1,30 @@ +import { useRecoilValue } from 'recoil'; + +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { PermissionFlagType } from '~/generated-metadata/graphql'; + +export const useCanChangePassword = () => { + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState); + + const isPasswordAuthEnabled = + currentWorkspace?.isPasswordAuthEnabled === true; + + if (isPasswordAuthEnabled) { + return { canChangePassword: true }; + } + + const hasBypassPermission = currentUserWorkspace?.permissionFlags?.includes( + PermissionFlagType.SSO_BYPASS, + ); + + if (!hasBypassPermission) { + return { canChangePassword: false }; + } + + const canChangePassword = + currentWorkspace?.isPasswordAuthBypassEnabled === true; + + return { canChangePassword }; +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx index 40887dd23d..b796944c48 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsSettingsSection.tsx @@ -14,6 +14,7 @@ import { IconLockOpen, IconSettings, IconSettingsAutomation, + IconShield, IconSpy, IconUsers, } from 'twenty-ui/display'; @@ -90,6 +91,12 @@ export const SettingsRolePermissionsSettingsSection = ({ description: t`Manage workflows`, Icon: IconSettingsAutomation, }, + { + key: PermissionFlagType.SSO_BYPASS, + name: t`SSO Bypass`, + description: t`Enable bypass options`, + Icon: IconShield, + }, { key: PermissionFlagType.IMPERSONATE, name: t`Impersonate`, diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthBypassOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthBypassOptionsList.tsx new file mode 100644 index 0000000000..fd7adcc624 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthBypassOptionsList.tsx @@ -0,0 +1,119 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { ApolloError } from '@apollo/client'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { capitalize } from 'twenty-shared/utils'; +import { IconGoogle, IconMicrosoft, IconPassword } from 'twenty-ui/display'; +import { Card } from 'twenty-ui/layout'; +import { + type AuthProviders, + useUpdateWorkspaceMutation, +} from '~/generated-metadata/graphql'; + +const StyledSettingsSecurityOptionsList = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; + +export const SettingsSecurityAuthBypassOptionsList = () => { + const { t } = useLingui(); + + const { enqueueErrorSnackBar } = useSnackBar(); + const authProviders = useRecoilValue(authProvidersState); + + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const isValidAuthProvider = ( + key: string, + ): key is Exclude => { + if (!currentWorkspace) return false; + return Reflect.has(currentWorkspace, key); + }; + + const toggleAuthBypassMethod = async ( + authProvider: keyof Omit, + ) => { + if (!currentWorkspace?.id) { + throw new Error(t`User is not logged in`); + } + + const key = `is${capitalize(authProvider)}AuthBypassEnabled`; + + if (!isValidAuthProvider(key)) { + throw new Error(t`Invalid auth bypass provider`); + } + + setCurrentWorkspace({ + ...currentWorkspace, + [key]: !currentWorkspace[key], + }); + + updateWorkspace({ + variables: { + input: { + [key]: !currentWorkspace[key], + }, + }, + }).catch((err) => { + setCurrentWorkspace({ + ...currentWorkspace, + [key]: currentWorkspace[key], + }); + enqueueErrorSnackBar({ + apolloError: err instanceof ApolloError ? err : undefined, + }); + }); + }; + + if (!currentWorkspace) { + return null; + } + + return ( + + + {authProviders.google === true && ( + toggleAuthBypassMethod('google')} + /> + )} + {authProviders.microsoft === true && ( + toggleAuthBypassMethod('microsoft')} + /> + )} + {authProviders.password && ( + toggleAuthBypassMethod('password')} + /> + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 208c9971b9..8a9e74bdb4 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -53,6 +53,9 @@ export const USER_QUERY_FRAGMENT = gql` isGoogleAuthEnabled isMicrosoftAuthEnabled isPasswordAuthEnabled + isGoogleAuthBypassEnabled + isMicrosoftAuthBypassEnabled + isPasswordAuthBypassEnabled subdomain hasValidEnterpriseKey customDomain diff --git a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts index 78f06a9909..d4eb511db7 100644 --- a/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/hooks/useLoadCurrentUser.ts @@ -4,12 +4,14 @@ import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceSta import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain'; import { useInitializeFormatPreferences } from '@/localization/hooks/useInitializeFormatPreferences'; import { coreViewsState } from '@/views/states/coreViewState'; +import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState'; import { useCallback } from 'react'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { SOURCE_LOCALE, type APP_LOCALES } from 'twenty-shared/translations'; import { type ObjectPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -36,6 +38,10 @@ export const useLoadCurrentUser = () => { const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const { initializeFormatPreferences } = useInitializeFormatPreferences(); const setCoreViews = useSetRecoilState(coreViewsState); + const setWorkspaceAuthBypassProviders = useSetRecoilState( + workspaceAuthBypassProvidersState, + ); + const authProviders = useRecoilValue(authProvidersState); const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace(); @@ -103,6 +109,16 @@ export const useLoadCurrentUser = () => { setCurrentWorkspace(workspace); + if (isDefined(workspace)) { + setWorkspaceAuthBypassProviders({ + google: authProviders.google && workspace.isGoogleAuthBypassEnabled, + microsoft: + authProviders.microsoft && workspace.isMicrosoftAuthBypassEnabled, + password: + authProviders.password && workspace.isPasswordAuthBypassEnabled, + }); + } + if (isDefined(workspace) && isOnAWorkspace) { setLastAuthenticateWorkspaceDomain({ workspaceId: workspace.id, @@ -132,6 +148,8 @@ export const useLoadCurrentUser = () => { initializeFormatPreferences, setLastAuthenticateWorkspaceDomain, setCoreViews, + authProviders, + setWorkspaceAuthBypassProviders, ]); return { diff --git a/packages/twenty-front/src/modules/workspace/states/workspaceAuthBypassProvidersState.ts b/packages/twenty-front/src/modules/workspace/states/workspaceAuthBypassProvidersState.ts new file mode 100644 index 0000000000..acfa6a882f --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/states/workspaceAuthBypassProvidersState.ts @@ -0,0 +1,8 @@ +import { type AuthBypassProviders } from '~/generated/graphql'; +import { createState } from 'twenty-ui/utilities'; + +export const workspaceAuthBypassProvidersState = + createState({ + key: 'workspaceAuthBypassProvidersState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/workspace/states/workspaceBypassModeState.ts b/packages/twenty-front/src/modules/workspace/states/workspaceBypassModeState.ts new file mode 100644 index 0000000000..25a5989b16 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/states/workspaceBypassModeState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui/utilities'; + +export const workspaceBypassModeState = createState({ + key: 'workspaceBypassModeState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/pages/settings/SettingsProfile.tsx b/packages/twenty-front/src/pages/settings/SettingsProfile.tsx index ab9d70ff59..790ed2196c 100644 --- a/packages/twenty-front/src/pages/settings/SettingsProfile.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsProfile.tsx @@ -1,5 +1,3 @@ -import { Trans, useLingui } from '@lingui/react/macro'; - import { SettingsCard } from '@/settings/components/SettingsCard'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { ChangePassword } from '@/settings/profile/components/ChangePassword'; @@ -7,8 +5,10 @@ import { DeleteAccount } from '@/settings/profile/components/DeleteAccount'; import { EmailField } from '@/settings/profile/components/EmailField'; import { NameFields } from '@/settings/profile/components/NameFields'; import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader'; +import { useCanChangePassword } from '@/settings/profile/hooks/useCanChangePassword'; import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { Trans, useLingui } from '@lingui/react/macro'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { H2Title, IconShield, Status } from 'twenty-ui/display'; @@ -25,6 +25,8 @@ export const SettingsProfile = () => { currentUserWorkspaceTwoFactorAuthenticationMethods['TOTP']?.status === 'VERIFIED'; + const { canChangePassword } = useCanChangePassword(); + return ( { /> -
- -
+ {canChangePassword && ( +
+ +
+ )}
diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index b3069435b9..64452d24a0 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -3,11 +3,14 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { useDebouncedCallback } from 'use-debounce'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; +import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList'; import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; @@ -40,6 +43,8 @@ export const SettingsSecurity = () => { const { enqueueErrorSnackBar } = useSnackBar(); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const authProviders = useRecoilValue(authProvidersState); + const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, ); @@ -82,6 +87,20 @@ export const SettingsSecurity = () => { saveWorkspace(value); }; + const hasSsoIdentityProviders = SSOIdentitiesProviders.length > 0; + const hasDirectAuthEnabled = + currentWorkspace?.isGoogleAuthEnabled || + currentWorkspace?.isMicrosoftAuthEnabled || + currentWorkspace?.isPasswordAuthEnabled; + const hasBypassProviderAvailable = + authProviders?.google || + authProviders?.microsoft || + authProviders?.password; + const shouldShowBypassSection = + hasSsoIdentityProviders && + !hasDirectAuthEnabled && + hasBypassProviderAvailable; + return ( { + {shouldShowBypassSection && ( +
+ + + + +
+ )} {isMultiWorkspaceEnabled && (
{ + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isGoogleAuthBypassEnabled" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isPasswordAuthBypassEnabled" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isMicrosoftAuthBypassEnabled" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isMicrosoftAuthBypassEnabled"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isPasswordAuthBypassEnabled"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isGoogleAuthBypassEnabled"`, + ); + } +} 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 b41401b365..75427edb43 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 @@ -29,6 +29,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { AuthService } from './auth.service'; @@ -44,6 +45,10 @@ describe('AuthService', () => { let authSsoService: AuthSsoService; let userWorkspaceService: UserWorkspaceService; let workspaceInvitationService: WorkspaceInvitationService; + let permissionsService: PermissionsService; + let signInUpServiceMock: jest.Mocked< + Pick + >; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -94,7 +99,10 @@ describe('AuthService', () => { }, { provide: SignInUpService, - useValue: {}, + useValue: { + validatePassword: jest.fn().mockResolvedValue(undefined), + generateHash: jest.fn(), + }, }, { provide: TwentyConfigService, @@ -153,6 +161,14 @@ describe('AuthService', () => { provide: AuditService, useValue: {}, }, + { + provide: PermissionsService, + useValue: { + userHasWorkspaceSettingPermission: jest + .fn() + .mockResolvedValue(false), + }, + }, ], }).compile(); @@ -170,10 +186,15 @@ describe('AuthService', () => { userRepository = module.get>( getRepositoryToken(UserEntity), ); + permissionsService = module.get(PermissionsService); + signInUpServiceMock = module.get(SignInUpService) as jest.Mocked< + Pick + >; }); beforeEach(() => { twentyConfigServiceGetMock.mockReturnValue(false); + signInUpServiceMock.validatePassword.mockClear(); }); it('should be defined', async () => { @@ -216,6 +237,92 @@ describe('AuthService', () => { }); }); + it('allows password login through SSO bypass when user has permission', async () => { + const workspace = { + id: 'workspace-id', + isPasswordAuthEnabled: false, + isPasswordAuthBypassEnabled: true, + } as WorkspaceEntity; + + const userEntity = { + id: 'user-id', + email: 'email', + passwordHash: 'password-hash', + userWorkspaces: [ + { + id: 'user-workspace-id', + workspaceId: workspace.id, + } as any, + ], + } as unknown as UserEntity; + + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + jest.spyOn(userRepository, 'findOne').mockResolvedValueOnce(userEntity); + jest + .spyOn(userWorkspaceService, 'checkUserWorkspaceExists') + .mockResolvedValueOnce({ id: 'user-workspace-id' } as any); + jest + .spyOn(permissionsService, 'userHasWorkspaceSettingPermission') + .mockResolvedValueOnce(true); + + const response = await service.validateLoginWithPassword( + { + email: 'email', + password: 'password', + captchaToken: 'captcha-token', + }, + workspace, + ); + + expect(response).toBe(userEntity); + }); + + it('throws when bypass permission is missing for disabled password auth', async () => { + const workspace = { + id: 'workspace-id', + isPasswordAuthEnabled: false, + isPasswordAuthBypassEnabled: true, + } as WorkspaceEntity; + + const userEntity = { + id: 'user-id', + email: 'email', + passwordHash: 'password-hash', + userWorkspaces: [ + { + id: 'user-workspace-id', + workspaceId: workspace.id, + } as any, + ], + } as unknown as UserEntity; + + jest.spyOn(userRepository, 'findOne').mockResolvedValueOnce(userEntity); + jest + .spyOn(userWorkspaceService, 'checkUserWorkspaceExists') + .mockResolvedValueOnce(null); + jest + .spyOn(permissionsService, 'userHasWorkspaceSettingPermission') + .mockResolvedValueOnce(false); + + await expect( + service.validateLoginWithPassword( + { + email: 'email', + password: 'password', + captchaToken: 'captcha-token', + }, + workspace, + ), + ).rejects.toThrow( + new AuthException( + 'Email/Password auth is not enabled for this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ), + ); + expect(signInUpServiceMock.validatePassword).not.toHaveBeenCalled(); + }); + it('challenge - user who have an invitation', async () => { const user = { email: 'email', 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 29ac1b7787..f55483b123 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 @@ -64,6 +64,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 { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -81,6 +83,7 @@ export class AuthService { private readonly authSsoService: AuthSsoService, private readonly userService: UserService, private readonly signInUpService: SignInUpService, + private readonly permissionsService: PermissionsService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, @InjectRepository(UserEntity) @@ -135,13 +138,6 @@ export class AuthService { input: UserCredentialsInput, targetWorkspace?: WorkspaceEntity, ) { - if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) { - throw new AuthException( - 'Email/Password auth is not enabled for this workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - const user = await this.userRepository.findOne({ where: { email: input.email, @@ -156,6 +152,21 @@ export class AuthService { ); } + if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) { + const canBypass = await this.canUserBypassAuthProvider({ + user, + workspace: targetWorkspace, + provider: AuthProviderEnum.Password, + }); + + if (!canBypass) { + throw new AuthException( + 'Email/Password auth is not enabled for this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + } + if (targetWorkspace) { await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user); } @@ -230,10 +241,74 @@ export class AuthService { } if (isDefined(workspace)) { + const isProviderEnabled = workspaceValidator.isAuthEnabled( + authParams.provider, + workspace, + ); + + if (isProviderEnabled) { + return; + } + + const existingUser = + userData.type === 'existingUser' ? userData.existingUser : undefined; + + if ( + existingUser && + (await this.canUserBypassAuthProvider({ + user: existingUser, + workspace, + provider: authParams.provider, + })) + ) { + return; + } + workspaceValidator.isAuthEnabledOrThrow(authParams.provider, workspace); } } + private async canUserBypassAuthProvider({ + user, + workspace, + provider, + }: { + user: UserEntity; + workspace: WorkspaceEntity; + provider: AuthProviderEnum; + }): Promise { + const bypassEnabled = (() => { + switch (provider) { + case AuthProviderEnum.Password: + return workspace.isPasswordAuthBypassEnabled; + case AuthProviderEnum.Google: + return workspace.isGoogleAuthBypassEnabled; + case AuthProviderEnum.Microsoft: + return workspace.isMicrosoftAuthBypassEnabled; + default: + return false; + } + })(); + + if (!bypassEnabled) { + return false; + } + + const userWorkspace = user.userWorkspaces?.find( + (userWorkspace) => userWorkspace.workspaceId === workspace.id, + ); + + if (!userWorkspace) { + return false; + } + + return await this.permissionsService.userHasWorkspaceSettingPermission({ + userWorkspaceId: userWorkspace.id, + workspaceId: workspace.id, + setting: PermissionFlagType.SSO_BYPASS, + }); + } + async signInUp( params: SignInUpBaseParams & ExistingUserOrNewUser & @@ -775,7 +850,8 @@ export class AuthService { ? await this.countAvailableWorkspacesByEmail(email) : 0; - const existingUser = await this.userService.findUserByEmail(email); + const existingUser = + await this.userService.findUserByEmailWithWorkspaces(email); if ( !workspaceId && diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index d3d8894264..a6231fb817 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -205,6 +205,15 @@ export class UserService extends TypeOrmQueryService { }); } + async findUserByEmailWithWorkspaces(email: string) { + return await this.userRepository.findOne({ + where: { + email, + }, + relations: { userWorkspaces: true }, + }); + } + async findUserById(id: string) { return await this.userRepository.findOne({ where: { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data-output.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data-output.ts index bb4efeea1d..bd8c7e1d35 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data-output.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data-output.ts @@ -44,6 +44,18 @@ export class AuthProvidersDTO { microsoft: boolean; } +@ObjectType('AuthBypassProviders') +export class AuthBypassProvidersDTO { + @Field(() => Boolean) + google: boolean; + + @Field(() => Boolean) + password: boolean; + + @Field(() => Boolean) + microsoft: boolean; +} + @ObjectType('PublicWorkspaceDataOutput') export class PublicWorkspaceDataOutput { @Field(() => UUIDScalarType) @@ -52,6 +64,9 @@ export class PublicWorkspaceDataOutput { @Field(() => AuthProvidersDTO) authProviders: AuthProvidersDTO; + @Field(() => AuthBypassProvidersDTO, { nullable: true }) + authBypassProviders?: AuthBypassProvidersDTO; + @Field(() => String, { nullable: true }) logo: WorkspaceEntity['logo']; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index e6c5d859e4..eef8295e9d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -67,6 +67,21 @@ export class UpdateWorkspaceInput { @IsOptional() isPasswordAuthEnabled?: boolean; + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isGoogleAuthBypassEnabled?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isMicrosoftAuthBypassEnabled?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isPasswordAuthBypassEnabled?: boolean; + @Field(() => UUIDScalarType, { nullable: true }) @IsUUID() @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 6ef245727e..823becf36e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -160,6 +160,30 @@ export class WorkspaceService extends TypeOrmQueryService { WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED, ); } + if (payload.isGoogleAuthBypassEnabled && !authProvidersBySystem.google) { + throw new WorkspaceException( + 'Google auth is not enabled in the system.', + WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED, + ); + } + if ( + payload.isMicrosoftAuthBypassEnabled && + !authProvidersBySystem.microsoft + ) { + throw new WorkspaceException( + 'Microsoft auth is not enabled in the system.', + WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED, + ); + } + if ( + payload.isPasswordAuthBypassEnabled && + !authProvidersBySystem.password + ) { + throw new WorkspaceException( + 'Password auth is not enabled in the system.', + WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED, + ); + } try { return await this.workspaceRepository.save({ diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-bypass-providers-by-workspace.util.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-bypass-providers-by-workspace.util.ts new file mode 100644 index 0000000000..3f127f603c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/utils/get-auth-bypass-providers-by-workspace.util.ts @@ -0,0 +1,25 @@ +import { type AuthBypassProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +export const getAuthBypassProvidersByWorkspace = ({ + workspace, + systemEnabledProviders, +}: { + workspace: Pick< + WorkspaceEntity, + | 'isGoogleAuthBypassEnabled' + | 'isPasswordAuthBypassEnabled' + | 'isMicrosoftAuthBypassEnabled' + >; + systemEnabledProviders: AuthBypassProvidersDTO; +}) => { + return { + google: + workspace.isGoogleAuthBypassEnabled && systemEnabledProviders.google, + password: + workspace.isPasswordAuthBypassEnabled && systemEnabledProviders.password, + microsoft: + workspace.isMicrosoftAuthBypassEnabled && + systemEnabledProviders.microsoft, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 007848ff73..3e2632ed3a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -230,6 +230,10 @@ export class WorkspaceEntity { @Column({ default: true }) isGoogleAuthEnabled: boolean; + @Field() + @Column({ default: false }) + isGoogleAuthBypassEnabled: boolean; + @Field() @Column({ default: false }) isTwoFactorAuthenticationEnforced: boolean; @@ -238,10 +242,18 @@ export class WorkspaceEntity { @Column({ default: true }) isPasswordAuthEnabled: boolean; + @Field() + @Column({ default: false }) + isPasswordAuthBypassEnabled: boolean; + @Field() @Column({ default: true }) isMicrosoftAuthEnabled: boolean; + @Field() + @Column({ default: false }) + isMicrosoftAuthBypassEnabled: boolean; + @Field() @Column({ default: false }) isCustomDomainEnabled: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index cd2ed9df06..a7c746ef5a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -46,6 +46,7 @@ import { import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input'; import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; +import { getAuthBypassProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-bypass-providers-by-workspace.util'; import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util'; import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -367,6 +368,10 @@ export class WorkspaceResolver { workspace, systemEnabledProviders, }), + authBypassProviders: getAuthBypassProvidersByWorkspace({ + workspace, + systemEnabledProviders, + }), }; } catch (err) { workspaceGraphqlApiExceptionHandler(err); diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts index 27773a085d..d9d1dbfeb4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts @@ -9,6 +9,7 @@ export enum PermissionFlagType { SECURITY = 'SECURITY', WORKFLOWS = 'WORKFLOWS', IMPERSONATE = 'IMPERSONATE', + SSO_BYPASS = 'SSO_BYPASS', // Tool permissions SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL', diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts index 902ab6df24..868d777f35 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts @@ -105,6 +105,7 @@ export class PermissionsService { [PermissionFlagType.IMPORT_CSV]: false, [PermissionFlagType.EXPORT_CSV]: false, [PermissionFlagType.IMPERSONATE]: false, + [PermissionFlagType.SSO_BYPASS]: false, }, objectsPermissions: {}, }) as const satisfies UserWorkspacePermissions;