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;