From 4d3124f840ff3aa218f15eee1c03ed0edfbf023f Mon Sep 17 00:00:00 2001 From: oliver <8559757+oliverqx@users.noreply.github.com> Date: Wed, 23 Jul 2025 06:42:01 -0600 Subject: [PATCH] Implement Two-Factor Authentication (2FA) (#13141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementation is very simple Established authentication dynamic is intercepted at getAuthTokensFromLoginToken. If 2FA is required, a pattern similar to EmailVerification is executed. That is, getAuthTokensFromLoginToken mutation fails with either of the following errors: 1. TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED 2. TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED UI knows how to respond accordingly. 2FA provisioning occurs at the 2FA resolver. 2FA verification, currently only OTP, is handled by auth.resolver's getAuthTokensFromOTP --------- Co-authored-by: Charles Bochet Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com> Co-authored-by: Lucas Bordeau Co-authored-by: Félix Malfait Co-authored-by: Félix Malfait --- packages/twenty-front/jest.config.ts | 2 +- packages/twenty-front/package.json | 2 + .../src/generated-metadata/graphql.ts | 286 +++++++++++- .../twenty-front/src/generated/graphql.ts | 59 ++- .../services/__tests__/apollo.factory.test.ts | 1 + .../modules/app/components/SettingsRoutes.tsx | 12 + ...TwoFactorAuthenticationProvisionEffect.tsx | 75 +++ .../graphql/mutations/getAuthTokenFromOTP.ts | 21 + .../mutations/initiateOTPProvisioning.ts | 17 + .../mutations/resetTwoFactorAuthentication.ts | 13 + .../auth/hooks/__tests__/useAuth.test.tsx | 36 ++ .../src/modules/auth/hooks/useAuth.ts | 113 ++++- ...gnInUpTwoFactorAuthenticationProvision.tsx | 123 +++++ ...nUpTwoFactorAuthenticationVerification.tsx | 273 +++++++++++ .../hooks/useTwoFactorAuthenticationForm.ts | 20 + .../auth/states/currentUserWorkspaceState.ts | 5 +- .../auth/states/currentWorkspaceState.ts | 1 + .../modules/auth/states/loginTokenState.ts | 7 + .../src/modules/auth/states/qrCode.ts | 6 + .../modules/auth/states/signInUpStepState.ts | 2 + .../client-config/types/ClientConfig.ts | 1 + ...ColumnDefinitionsFromFieldMetadata.test.ts | 1 + ...ttingsSecurityAuthProvidersOptionsList.tsx | 13 + .../security/components/Toggle2FA.tsx | 67 +++ .../DeleteTwoFactorAuthenticationMethod.tsx | 126 +++++ ...orAuthenticationSetupForSettingsEffect.tsx | 66 +++ ...rAuthenticationVerificationForSettings.tsx | 263 +++++++++++ .../verifyTwoFactorAuthenticationMethod.ts | 11 + ...entUserWorkspaceTwoFactorAuthentication.ts | 28 ++ .../hooks/useTwoFactorAuthentication.ts | 0 ...eWorkspaceTwoFactorAuthenticationPolicy.ts | 10 + .../utils/extractSecretFromOtpUri.ts | 13 + .../src/modules/types/SettingsPath.ts | 1 + .../graphql/fragments/userQueryFragment.ts | 6 + .../graphql/mutations/updateWorkspace.ts | 1 + .../twenty-front/src/pages/auth/SignInUp.tsx | 27 +- .../src/pages/settings/SettingsProfile.tsx | 46 +- .../SettingsTwoFactorAuthenticationMethod.tsx | 172 +++++++ .../workspace/SettingsCustomDomainRecords.tsx | 4 +- .../src/testing/mock-data/config.ts | 1 + .../src/testing/mock-data/users.ts | 1 + .../1752170600000-drop-two-factor-method.ts | 23 + ...1752839063082-two-factor-authentication.ts | 49 ++ .../core-modules/auth/auth.exception.ts | 2 + .../engine/core-modules/auth/auth.module.ts | 5 + .../core-modules/auth/auth.resolver.spec.ts | 10 + .../engine/core-modules/auth/auth.resolver.ts | 56 +++ .../auth/services/auth.service.ts | 2 +- .../token/services/login-token.service.ts | 6 +- .../auth/types/auth-context.type.ts | 1 + ...auth-graphql-api-exception-handler.util.ts | 5 + .../get-auth-exception-rest-status.util.ts | 2 + .../client-config.controller.spec.ts | 1 + .../constants/public-feature-flag.const.ts | 8 + .../enums/feature-flag-key.enum.ts | 1 + .../twenty-config/config-variables.ts | 11 + .../config-variables-group-metadata.ts | 6 + .../enums/config-variables-group.enum.ts | 1 + ...factor-authentication-method.input.spec.ts | 112 +++++ ...-two-factor-authentication-method.input.ts | 13 + ...two-factor-authentication-method.output.ts | 9 + ...actor-authentication-provisioning.input.ts | 11 + ...ctor-authentication-provisioning.output.ts | 7 + .../two-factor-authentication-method.dto.ts | 15 + ...-authentication-verification.input.spec.ts | 222 +++++++++ ...actor-authentication-verification.input.ts | 21 + ...factor-authentication-method.input.spec.ts | 114 +++++ ...-two-factor-authentication-method.input.ts | 13 + ...two-factor-authentication-method.output.ts | 7 + ...wo-factor-authentication-method.entity.ts} | 27 +- .../otp/interfaces/otp.strategy.interface.ts | 21 + .../strategies/otp/otp.constants.ts | 8 + .../totp/constants/totp.strategy.constants.ts | 65 +++ .../strategies/otp/totp/totp.strategy.spec.ts | 219 +++++++++ .../strategies/otp/totp/totp.strategy.ts | 85 ++++ ...or-authentication-exception.filter.spec.ts | 116 +++++ ...-factor-authentication-exception.filter.ts | 35 ++ .../two-factor-authentication.exception.ts | 16 + .../two-factor-authentication.module.ts | 39 ++ ...two-factor-authentication.resolver.spec.ts | 382 +++++++++++++++ .../two-factor-authentication.resolver.ts | 176 +++++++ .../two-factor-authentication.service.spec.ts | 437 ++++++++++++++++++ .../two-factor-authentication.service.ts | 191 ++++++++ ...o-factor-authentication.validation.spec.ts | 180 ++++++++ .../two-factor-authentication.validation.ts | 58 +++ .../simple-secret-encryption.util.spec.ts | 106 +++++ .../utils/simple-secret-encryption.util.ts | 75 +++ ...or-authentication-method.presenter.spec.ts | 148 ++++++ ...-factor-authentication-method.presenter.ts | 16 + .../two-factor-method.module.ts | 14 - .../two-factor-method.service.ts | 36 -- .../user-workspace/user-workspace.entity.ts | 14 +- .../user-workspace/user-workspace.module.ts | 3 +- .../user-workspace.service.spec.ts | 1 + .../user-workspace/user-workspace.service.ts | 1 + .../engine/core-modules/user/user.resolver.ts | 13 +- .../workspace/dtos/update-workspace-input.ts | 5 + .../workspace/workspace.entity.ts | 4 + .../workspace-entity-manager.spec.ts | 1 + .../core/utils/seed-feature-flags.util.ts | 5 + .../core/utils/seed-workspaces.util.ts | 3 + .../types/TwoFactorAuthenticationStrategy.ts | 3 + packages/twenty-shared/src/types/index.ts | 1 + .../display/icon/components/TablerIcons.ts | 3 +- packages/twenty-ui/src/display/index.ts | 3 +- yarn.lock | 31 ++ 106 files changed, 5103 insertions(+), 103 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/components/TwoFactorAuthenticationProvisionEffect.tsx create mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/getAuthTokenFromOTP.ts create mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/initiateOTPProvisioning.ts create mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/resetTwoFactorAuthentication.ts create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationProvision.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useTwoFactorAuthenticationForm.ts create mode 100644 packages/twenty-front/src/modules/auth/states/loginTokenState.ts create mode 100644 packages/twenty-front/src/modules/auth/states/qrCode.ts create mode 100644 packages/twenty-front/src/modules/settings/security/components/Toggle2FA.tsx create mode 100644 packages/twenty-front/src/modules/settings/two-factor-authentication/components/DeleteTwoFactorAuthenticationMethod.tsx create mode 100644 packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationSetupForSettingsEffect.tsx create mode 100644 packages/twenty-front/src/modules/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings.tsx create mode 100644 packages/twenty-front/src/modules/settings/two-factor-authentication/graphql/mutations/verifyTwoFactorAuthenticationMethod.ts create mode 100644 packages/twenty-front/src/modules/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication.ts create mode 100644 packages/twenty-front/src/modules/settings/two-factor-authentication/hooks/useTwoFactorAuthentication.ts create mode 100644 packages/twenty-front/src/modules/settings/two-factor-authentication/hooks/useWorkspaceTwoFactorAuthenticationPolicy.ts create mode 100644 packages/twenty-front/src/modules/settings/two-factor-authentication/utils/extractSecretFromOtpUri.ts create mode 100644 packages/twenty-front/src/pages/settings/SettingsTwoFactorAuthenticationMethod.tsx create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1752170600000-drop-two-factor-method.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1752839063082-two-factor-authentication.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/delete-two-factor-authentication-method.input.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/delete-two-factor-authentication-method.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/delete-two-factor-authentication-method.output.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/initiate-two-factor-authentication-provisioning.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/initiate-two-factor-authentication-provisioning.output.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-method.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-verification.input.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/two-factor-authentication-verification.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/verify-two-factor-authentication-method.input.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/verify-two-factor-authentication-method.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/dto/verify-two-factor-authentication-method.output.ts rename packages/twenty-server/src/engine/core-modules/{two-factor-method/two-factor-method.entity.ts => two-factor-authentication/entities/two-factor-authentication-method.entity.ts} (54%) create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/interfaces/otp.strategy.interface.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/constants/totp.strategy.constants.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.validation.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.validation.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/two-factor-authentication-method.presenter.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-authentication/utils/two-factor-authentication-method.presenter.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-method/two-factor-method.module.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/two-factor-method/two-factor-method.service.ts create mode 100644 packages/twenty-shared/src/types/TwoFactorAuthenticationStrategy.ts diff --git a/packages/twenty-front/jest.config.ts b/packages/twenty-front/jest.config.ts index dd5c04409..c924d7571 100644 --- a/packages/twenty-front/jest.config.ts +++ b/packages/twenty-front/jest.config.ts @@ -52,7 +52,7 @@ const jestConfig: JestConfigWithTsJest = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 56.9, + statements: 56.8, lines: 55, functions: 46, }, diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index e9e83bb00..d77cf8e03 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -55,6 +55,8 @@ "buffer": "^6.0.3", "docx": "^9.1.0", "file-saver": "^2.0.5", + "input-otp": "^1.4.2", + "react-qr-code": "^2.0.18", "transliteration": "^2.3.5", "twenty-shared": "workspace:*", "twenty-ui": "workspace:*" diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 836979e30..09acbc456 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -420,7 +420,8 @@ export enum ConfigVariablesGroup { ServerlessConfig = 'ServerlessConfig', StorageConfig = 'StorageConfig', SupportChatConfig = 'SupportChatConfig', - TokensDuration = 'TokensDuration' + TokensDuration = 'TokensDuration', + TwoFactorAuthentication = 'TwoFactorAuthentication' } export type ConfigVariablesGroupData = { @@ -652,6 +653,12 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']; }; +export type DeleteTwoFactorAuthenticationMethodOutput = { + __typename?: 'DeleteTwoFactorAuthenticationMethodOutput'; + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']; +}; + export type DeleteWebhookDto = { id: Scalars['String']; }; @@ -740,6 +747,7 @@ export enum FeatureFlagKey { IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', + IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED' @@ -1049,6 +1057,11 @@ export enum IndexType { GIN = 'GIN' } +export type InitiateTwoFactorAuthenticationProvisioningOutput = { + __typename?: 'InitiateTwoFactorAuthenticationProvisioningOutput'; + uri: Scalars['String']; +}; + export type InvalidatePassword = { __typename?: 'InvalidatePassword'; /** Boolean that confirms query was dispatched */ @@ -1123,6 +1136,7 @@ export type Mutation = { deleteOneRole: Scalars['String']; deleteOneServerlessFunction: ServerlessFunction; deleteSSOIdentityProvider: DeleteSsoOutput; + deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; deleteWebhook: Scalars['Boolean']; deleteWorkflowVersionStep: WorkflowAction; @@ -1136,10 +1150,13 @@ export type Mutation = { generateApiKeyToken: ApiKeyToken; generateTransientToken: TransientToken; getAuthTokensFromLoginToken: AuthTokens; + getAuthTokensFromOTP: AuthTokens; getAuthorizationUrlForSSO: GetAuthorizationUrlForSsoOutput; getLoginTokenFromCredentials: LoginToken; getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput; impersonate: ImpersonateOutput; + initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput; + initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput; publishServerlessFunction: ServerlessFunction; removeRoleFromAgent: Scalars['Boolean']; renewToken: AuthTokens; @@ -1187,6 +1204,7 @@ export type Mutation = { upsertSettingPermissions: Array; userLookupAdminPanel: UserLookup; validateApprovedAccessDomain: ApprovedAccessDomain; + verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodOutput; }; @@ -1365,6 +1383,11 @@ export type MutationDeleteSsoIdentityProviderArgs = { }; +export type MutationDeleteTwoFactorAuthenticationMethodArgs = { + twoFactorAuthenticationMethodId: Scalars['UUID']; +}; + + export type MutationDeleteWebhookArgs = { input: DeleteWebhookDto; }; @@ -1408,6 +1431,14 @@ export type MutationGetAuthTokensFromLoginTokenArgs = { }; +export type MutationGetAuthTokensFromOtpArgs = { + captchaToken?: InputMaybe; + loginToken: Scalars['String']; + origin: Scalars['String']; + otp: Scalars['String']; +}; + + export type MutationGetAuthorizationUrlForSsoArgs = { input: GetAuthorizationUrlForSsoInput; }; @@ -1435,6 +1466,12 @@ export type MutationImpersonateArgs = { }; +export type MutationInitiateOtpProvisioningArgs = { + loginToken: Scalars['String']; + origin: Scalars['String']; +}; + + export type MutationPublishServerlessFunctionArgs = { input: PublishServerlessFunctionInput; }; @@ -1669,6 +1706,11 @@ export type MutationValidateApprovedAccessDomainArgs = { input: ValidateApprovedAccessDomainInput; }; + +export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs = { + otp: Scalars['String']; +}; + export type Object = { __typename?: 'Object'; createdAt: Scalars['DateTime']; @@ -2530,6 +2572,13 @@ export type TransientToken = { transientToken: AuthToken; }; +export type TwoFactorAuthenticationMethodDto = { + __typename?: 'TwoFactorAuthenticationMethodDTO'; + status: Scalars['String']; + strategy: Scalars['String']; + twoFactorAuthenticationMethodId: Scalars['UUID']; +}; + export type UuidFilter = { eq?: InputMaybe; gt?: InputMaybe; @@ -2687,6 +2736,7 @@ export type UpdateWorkspaceInput = { isMicrosoftAuthEnabled?: InputMaybe; isPasswordAuthEnabled?: InputMaybe; isPublicInviteLinkEnabled?: InputMaybe; + isTwoFactorAuthenticationEnforced?: InputMaybe; logo?: InputMaybe; subdomain?: InputMaybe; }; @@ -2782,6 +2832,7 @@ export type UserWorkspace = { /** @deprecated Use objectPermissions instead */ objectRecordsPermissions?: Maybe>; settingsPermissions?: Maybe>; + twoFactorAuthenticationMethodSummary?: Maybe>; updatedAt: Scalars['DateTime']; user: User; userId: Scalars['String']; @@ -2800,6 +2851,11 @@ export type ValidatePasswordResetToken = { id: Scalars['String']; }; +export type VerifyTwoFactorAuthenticationMethodOutput = { + __typename?: 'VerifyTwoFactorAuthenticationMethodOutput'; + success: Scalars['Boolean']; +}; + export type VersionInfo = { __typename?: 'VersionInfo'; currentVersion?: Maybe; @@ -2875,6 +2931,7 @@ export type Workspace = { isMicrosoftAuthEnabled: Scalars['Boolean']; isPasswordAuthEnabled: Scalars['Boolean']; isPublicInviteLinkEnabled: Scalars['Boolean']; + isTwoFactorAuthenticationEnforced: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; subdomain: Scalars['String']; @@ -3087,6 +3144,16 @@ export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: nev export type GenerateTransientTokenMutation = { __typename?: 'Mutation', generateTransientToken: { __typename?: 'TransientToken', transientToken: { __typename?: 'AuthToken', token: string } } }; +export type GetAuthTokensFromOtpMutationVariables = Exact<{ + loginToken: Scalars['String']; + otp: Scalars['String']; + captchaToken?: InputMaybe; + origin: Scalars['String']; +}>; + + +export type GetAuthTokensFromOtpMutation = { __typename?: 'Mutation', getAuthTokensFromOTP: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; + export type GetAuthTokensFromLoginTokenMutationVariables = Exact<{ loginToken: Scalars['String']; origin: Scalars['String']; @@ -3130,6 +3197,19 @@ export type ImpersonateMutationVariables = Exact<{ export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'ImpersonateOutput', workspace: { __typename?: 'WorkspaceUrlsAndId', id: string, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } }, loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; +export type InitiateOtpProvisioningMutationVariables = Exact<{ + loginToken: Scalars['String']; + origin: Scalars['String']; +}>; + + +export type InitiateOtpProvisioningMutation = { __typename?: 'Mutation', initiateOTPProvisioning: { __typename?: 'InitiateTwoFactorAuthenticationProvisioningOutput', uri: string } }; + +export type InitiateOtpProvisioningForAuthenticatedUserMutationVariables = Exact<{ [key: string]: never; }>; + + +export type InitiateOtpProvisioningForAuthenticatedUserMutation = { __typename?: 'Mutation', initiateOTPProvisioningForAuthenticatedUser: { __typename?: 'InitiateTwoFactorAuthenticationProvisioningOutput', uri: string } }; + export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; }>; @@ -3145,6 +3225,13 @@ export type ResendEmailVerificationTokenMutationVariables = Exact<{ export type ResendEmailVerificationTokenMutation = { __typename?: 'Mutation', resendEmailVerificationToken: { __typename?: 'ResendEmailVerificationTokenOutput', success: boolean } }; +export type DeleteTwoFactorAuthenticationMethodMutationVariables = Exact<{ + twoFactorAuthenticationMethodId: Scalars['UUID']; +}>; + + +export type DeleteTwoFactorAuthenticationMethodMutation = { __typename?: 'Mutation', deleteTwoFactorAuthenticationMethod: { __typename?: 'DeleteTwoFactorAuthenticationMethodOutput', success: boolean } }; + export type SignInMutationVariables = Exact<{ email: Scalars['String']; password: Scalars['String']; @@ -3740,7 +3827,14 @@ export type FindOneServerlessFunctionSourceCodeQueryVariables = Exact<{ export type FindOneServerlessFunctionSourceCodeQuery = { __typename?: 'Query', getServerlessFunctionSourceCode?: any | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, 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, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: any } | 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 VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationVariables = Exact<{ + otp: Scalars['String']; +}>; + + +export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation = { __typename?: 'Mutation', verifyTwoFactorAuthenticationMethodForAuthenticatedUser: { __typename?: 'VerifyTwoFactorAuthenticationMethodOutput', success: boolean } }; + +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: any, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, 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, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: any } | 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 }; @@ -3759,7 +3853,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, 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, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: any } | 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: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: any, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array | null, objectRecordsPermissions?: Array | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: any, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, 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, isTwoFactorAuthenticationEnforced: boolean, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, metadata: any }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null, defaultAgent?: { __typename?: 'Agent', id: any } | 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 ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -3878,7 +3972,7 @@ export type UpdateWorkspaceMutationVariables = Exact<{ }>; -export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, customDomain?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } }; +export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, customDomain?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isTwoFactorAuthenticationEnforced: boolean, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } }; export type UploadWorkspaceLogoMutationVariables = Exact<{ file: Scalars['Upload']; @@ -4101,6 +4195,11 @@ export const UserQueryFragmentFragmentDoc = gql` objectPermissions { ...ObjectPermissionFragment } + twoFactorAuthenticationMethodSummary { + twoFactorAuthenticationMethodId + status + strategy + } } currentWorkspace { id @@ -4157,6 +4256,7 @@ export const UserQueryFragmentFragmentDoc = gql` defaultAgent { id } + isTwoFactorAuthenticationEnforced } availableWorkspaces { ...AvailableWorkspacesFragment @@ -4681,6 +4781,49 @@ export function useGenerateTransientTokenMutation(baseOptions?: Apollo.MutationH export type GenerateTransientTokenMutationHookResult = ReturnType; export type GenerateTransientTokenMutationResult = Apollo.MutationResult; export type GenerateTransientTokenMutationOptions = Apollo.BaseMutationOptions; +export const GetAuthTokensFromOtpDocument = gql` + mutation getAuthTokensFromOTP($loginToken: String!, $otp: String!, $captchaToken: String, $origin: String!) { + getAuthTokensFromOTP( + loginToken: $loginToken + otp: $otp + captchaToken: $captchaToken + origin: $origin + ) { + tokens { + ...AuthTokensFragment + } + } +} + ${AuthTokensFragmentFragmentDoc}`; +export type GetAuthTokensFromOtpMutationFn = Apollo.MutationFunction; + +/** + * __useGetAuthTokensFromOtpMutation__ + * + * To run a mutation, you first call `useGetAuthTokensFromOtpMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGetAuthTokensFromOtpMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [getAuthTokensFromOtpMutation, { data, loading, error }] = useGetAuthTokensFromOtpMutation({ + * variables: { + * loginToken: // value for 'loginToken' + * otp: // value for 'otp' + * captchaToken: // value for 'captchaToken' + * origin: // value for 'origin' + * }, + * }); + */ +export function useGetAuthTokensFromOtpMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GetAuthTokensFromOtpDocument, options); + } +export type GetAuthTokensFromOtpMutationHookResult = ReturnType; +export type GetAuthTokensFromOtpMutationResult = Apollo.MutationResult; +export type GetAuthTokensFromOtpMutationOptions = Apollo.BaseMutationOptions; export const GetAuthTokensFromLoginTokenDocument = gql` mutation GetAuthTokensFromLoginToken($loginToken: String!, $origin: String!) { getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) { @@ -4885,6 +5028,72 @@ export function useImpersonateMutation(baseOptions?: Apollo.MutationHookOptions< export type ImpersonateMutationHookResult = ReturnType; export type ImpersonateMutationResult = Apollo.MutationResult; export type ImpersonateMutationOptions = Apollo.BaseMutationOptions; +export const InitiateOtpProvisioningDocument = gql` + mutation initiateOTPProvisioning($loginToken: String!, $origin: String!) { + initiateOTPProvisioning(loginToken: $loginToken, origin: $origin) { + uri + } +} + `; +export type InitiateOtpProvisioningMutationFn = Apollo.MutationFunction; + +/** + * __useInitiateOtpProvisioningMutation__ + * + * To run a mutation, you first call `useInitiateOtpProvisioningMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useInitiateOtpProvisioningMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [initiateOtpProvisioningMutation, { data, loading, error }] = useInitiateOtpProvisioningMutation({ + * variables: { + * loginToken: // value for 'loginToken' + * origin: // value for 'origin' + * }, + * }); + */ +export function useInitiateOtpProvisioningMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(InitiateOtpProvisioningDocument, options); + } +export type InitiateOtpProvisioningMutationHookResult = ReturnType; +export type InitiateOtpProvisioningMutationResult = Apollo.MutationResult; +export type InitiateOtpProvisioningMutationOptions = Apollo.BaseMutationOptions; +export const InitiateOtpProvisioningForAuthenticatedUserDocument = gql` + mutation initiateOTPProvisioningForAuthenticatedUser { + initiateOTPProvisioningForAuthenticatedUser { + uri + } +} + `; +export type InitiateOtpProvisioningForAuthenticatedUserMutationFn = Apollo.MutationFunction; + +/** + * __useInitiateOtpProvisioningForAuthenticatedUserMutation__ + * + * To run a mutation, you first call `useInitiateOtpProvisioningForAuthenticatedUserMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useInitiateOtpProvisioningForAuthenticatedUserMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [initiateOtpProvisioningForAuthenticatedUserMutation, { data, loading, error }] = useInitiateOtpProvisioningForAuthenticatedUserMutation({ + * variables: { + * }, + * }); + */ +export function useInitiateOtpProvisioningForAuthenticatedUserMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(InitiateOtpProvisioningForAuthenticatedUserDocument, options); + } +export type InitiateOtpProvisioningForAuthenticatedUserMutationHookResult = ReturnType; +export type InitiateOtpProvisioningForAuthenticatedUserMutationResult = Apollo.MutationResult; +export type InitiateOtpProvisioningForAuthenticatedUserMutationOptions = Apollo.BaseMutationOptions; export const RenewTokenDocument = gql` mutation RenewToken($appToken: String!) { renewToken(appToken: $appToken) { @@ -4954,6 +5163,41 @@ export function useResendEmailVerificationTokenMutation(baseOptions?: Apollo.Mut export type ResendEmailVerificationTokenMutationHookResult = ReturnType; export type ResendEmailVerificationTokenMutationResult = Apollo.MutationResult; export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions; +export const DeleteTwoFactorAuthenticationMethodDocument = gql` + mutation deleteTwoFactorAuthenticationMethod($twoFactorAuthenticationMethodId: UUID!) { + deleteTwoFactorAuthenticationMethod( + twoFactorAuthenticationMethodId: $twoFactorAuthenticationMethodId + ) { + success + } +} + `; +export type DeleteTwoFactorAuthenticationMethodMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteTwoFactorAuthenticationMethodMutation__ + * + * To run a mutation, you first call `useDeleteTwoFactorAuthenticationMethodMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteTwoFactorAuthenticationMethodMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteTwoFactorAuthenticationMethodMutation, { data, loading, error }] = useDeleteTwoFactorAuthenticationMethodMutation({ + * variables: { + * twoFactorAuthenticationMethodId: // value for 'twoFactorAuthenticationMethodId' + * }, + * }); + */ +export function useDeleteTwoFactorAuthenticationMethodMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteTwoFactorAuthenticationMethodDocument, options); + } +export type DeleteTwoFactorAuthenticationMethodMutationHookResult = ReturnType; +export type DeleteTwoFactorAuthenticationMethodMutationResult = Apollo.MutationResult; +export type DeleteTwoFactorAuthenticationMethodMutationOptions = Apollo.BaseMutationOptions; export const SignInDocument = gql` mutation SignIn($email: String!, $password: String!, $captchaToken: String) { signIn(email: $email, password: $password, captchaToken: $captchaToken) { @@ -8194,6 +8438,39 @@ export function useFindOneServerlessFunctionSourceCodeLazyQuery(baseOptions?: Ap export type FindOneServerlessFunctionSourceCodeQueryHookResult = ReturnType; export type FindOneServerlessFunctionSourceCodeLazyQueryHookResult = ReturnType; export type FindOneServerlessFunctionSourceCodeQueryResult = Apollo.QueryResult; +export const VerifyTwoFactorAuthenticationMethodForAuthenticatedUserDocument = gql` + mutation verifyTwoFactorAuthenticationMethodForAuthenticatedUser($otp: String!) { + verifyTwoFactorAuthenticationMethodForAuthenticatedUser(otp: $otp) { + success + } +} + `; +export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationFn = Apollo.MutationFunction; + +/** + * __useVerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation__ + * + * To run a mutation, you first call `useVerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useVerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [verifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation, { data, loading, error }] = useVerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation({ + * variables: { + * otp: // value for 'otp' + * }, + * }); + */ +export function useVerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(VerifyTwoFactorAuthenticationMethodForAuthenticatedUserDocument, options); + } +export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationHookResult = ReturnType; +export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationResult = Apollo.MutationResult; +export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationOptions = Apollo.BaseMutationOptions; export const DeleteUserAccountDocument = gql` mutation DeleteUserAccount { deleteUser { @@ -8863,6 +9140,7 @@ export const UpdateWorkspaceDocument = gql` isGoogleAuthEnabled isMicrosoftAuthEnabled isPasswordAuthEnabled + isTwoFactorAuthenticationEnforced defaultRole { ...RoleFragment } diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 6d8bc0356..4799279eb 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -420,7 +420,8 @@ export enum ConfigVariablesGroup { ServerlessConfig = 'ServerlessConfig', StorageConfig = 'StorageConfig', SupportChatConfig = 'SupportChatConfig', - TokensDuration = 'TokensDuration' + TokensDuration = 'TokensDuration', + TwoFactorAuthentication = 'TwoFactorAuthentication' } export type ConfigVariablesGroupData = { @@ -616,6 +617,12 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']; }; +export type DeleteTwoFactorAuthenticationMethodOutput = { + __typename?: 'DeleteTwoFactorAuthenticationMethodOutput'; + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']; +}; + export type DeleteWebhookDto = { id: Scalars['String']; }; @@ -704,6 +711,7 @@ export enum FeatureFlagKey { IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', + IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED' @@ -1006,6 +1014,11 @@ export enum IndexType { GIN = 'GIN' } +export type InitiateTwoFactorAuthenticationProvisioningOutput = { + __typename?: 'InitiateTwoFactorAuthenticationProvisioningOutput'; + uri: Scalars['String']; +}; + export type InvalidatePassword = { __typename?: 'InvalidatePassword'; /** Boolean that confirms query was dispatched */ @@ -1078,6 +1091,7 @@ export type Mutation = { deleteOneRole: Scalars['String']; deleteOneServerlessFunction: ServerlessFunction; deleteSSOIdentityProvider: DeleteSsoOutput; + deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; deleteWebhook: Scalars['Boolean']; deleteWorkflowVersionStep: WorkflowAction; @@ -1091,10 +1105,13 @@ export type Mutation = { generateApiKeyToken: ApiKeyToken; generateTransientToken: TransientToken; getAuthTokensFromLoginToken: AuthTokens; + getAuthTokensFromOTP: AuthTokens; getAuthorizationUrlForSSO: GetAuthorizationUrlForSsoOutput; getLoginTokenFromCredentials: LoginToken; getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput; impersonate: ImpersonateOutput; + initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput; + initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput; publishServerlessFunction: ServerlessFunction; removeRoleFromAgent: Scalars['Boolean']; renewToken: AuthTokens; @@ -1138,6 +1155,7 @@ export type Mutation = { upsertSettingPermissions: Array; userLookupAdminPanel: UserLookup; validateApprovedAccessDomain: ApprovedAccessDomain; + verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodOutput; }; @@ -1296,6 +1314,11 @@ export type MutationDeleteSsoIdentityProviderArgs = { }; +export type MutationDeleteTwoFactorAuthenticationMethodArgs = { + twoFactorAuthenticationMethodId: Scalars['UUID']; +}; + + export type MutationDeleteWebhookArgs = { input: DeleteWebhookDto; }; @@ -1339,6 +1362,14 @@ export type MutationGetAuthTokensFromLoginTokenArgs = { }; +export type MutationGetAuthTokensFromOtpArgs = { + captchaToken?: InputMaybe; + loginToken: Scalars['String']; + origin: Scalars['String']; + otp: Scalars['String']; +}; + + export type MutationGetAuthorizationUrlForSsoArgs = { input: GetAuthorizationUrlForSsoInput; }; @@ -1366,6 +1397,12 @@ export type MutationImpersonateArgs = { }; +export type MutationInitiateOtpProvisioningArgs = { + loginToken: Scalars['String']; + origin: Scalars['String']; +}; + + export type MutationPublishServerlessFunctionArgs = { input: PublishServerlessFunctionInput; }; @@ -1580,6 +1617,11 @@ export type MutationValidateApprovedAccessDomainArgs = { input: ValidateApprovedAccessDomainInput; }; + +export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs = { + otp: Scalars['String']; +}; + export type Object = { __typename?: 'Object'; createdAt: Scalars['DateTime']; @@ -2376,6 +2418,13 @@ export type TransientToken = { transientToken: AuthToken; }; +export type TwoFactorAuthenticationMethodDto = { + __typename?: 'TwoFactorAuthenticationMethodDTO'; + status: Scalars['String']; + strategy: Scalars['String']; + twoFactorAuthenticationMethodId: Scalars['UUID']; +}; + export type UuidFilter = { eq?: InputMaybe; gt?: InputMaybe; @@ -2525,6 +2574,7 @@ export type UpdateWorkspaceInput = { isMicrosoftAuthEnabled?: InputMaybe; isPasswordAuthEnabled?: InputMaybe; isPublicInviteLinkEnabled?: InputMaybe; + isTwoFactorAuthenticationEnforced?: InputMaybe; logo?: InputMaybe; subdomain?: InputMaybe; }; @@ -2610,6 +2660,7 @@ export type UserWorkspace = { /** @deprecated Use objectPermissions instead */ objectRecordsPermissions?: Maybe>; settingsPermissions?: Maybe>; + twoFactorAuthenticationMethodSummary?: Maybe>; updatedAt: Scalars['DateTime']; user: User; userId: Scalars['String']; @@ -2628,6 +2679,11 @@ export type ValidatePasswordResetToken = { id: Scalars['String']; }; +export type VerifyTwoFactorAuthenticationMethodOutput = { + __typename?: 'VerifyTwoFactorAuthenticationMethodOutput'; + success: Scalars['Boolean']; +}; + export type VersionInfo = { __typename?: 'VersionInfo'; currentVersion?: Maybe; @@ -2703,6 +2759,7 @@ export type Workspace = { isMicrosoftAuthEnabled: Scalars['Boolean']; isPasswordAuthEnabled: Scalars['Boolean']; isPublicInviteLinkEnabled: Scalars['Boolean']; + isTwoFactorAuthenticationEnforced: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; subdomain: Scalars['String']; 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 cc31cbbdb..9ea6e72a8 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 @@ -53,6 +53,7 @@ const mockWorkspace = { subdomainUrl: 'test.com', customUrl: 'test.com', }, + isTwoFactorAuthenticationEnforced: false, }; const createMockOptions = (): Options => ({ diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index b69b827b8..5b5126f47 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -164,6 +164,14 @@ const SettingsProfile = lazy(() => })), ); +const SettingsTwoFactorAuthenticationMethod = lazy(() => + import('~/pages/settings/SettingsTwoFactorAuthenticationMethod').then( + (module) => ({ + default: module.SettingsTwoFactorAuthenticationMethod, + }), + ), +); + const SettingsExperience = lazy(() => import( '~/pages/settings/profile/appearance/components/SettingsExperience' @@ -371,6 +379,10 @@ export const SettingsRoutes = ({ }> } /> + } + /> } /> } /> } /> diff --git a/packages/twenty-front/src/modules/auth/components/TwoFactorAuthenticationProvisionEffect.tsx b/packages/twenty-front/src/modules/auth/components/TwoFactorAuthenticationProvisionEffect.tsx new file mode 100644 index 000000000..126339de6 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/components/TwoFactorAuthenticationProvisionEffect.tsx @@ -0,0 +1,75 @@ +import { loginTokenState } from '@/auth/states/loginTokenState'; +import { qrCodeState } from '@/auth/states/qrCode'; +import { useOrigin } from '@/domain-manager/hooks/useOrigin'; +import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication'; +import { AppPath } from '@/types/AppPath'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useLingui } from '@lingui/react/macro'; +import { useEffect } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; + +export const TwoFactorAuthenticationSetupEffect = () => { + const { initiateCurrentUserWorkspaceOtpProvisioning } = + useCurrentUserWorkspaceTwoFactorAuthentication(); + const { enqueueErrorSnackBar } = useSnackBar(); + + const navigate = useNavigateApp(); + const { origin } = useOrigin(); + const loginToken = useRecoilValue(loginTokenState); + const qrCode = useRecoilValue(qrCodeState); + const setQrCodeState = useSetRecoilState(qrCodeState); + + const { t } = useLingui(); + + useEffect(() => { + if (isDefined(qrCode)) { + return; + } + + const handleTwoFactorAuthenticationProvisioningInitiation = async () => { + try { + if (!loginToken) { + enqueueErrorSnackBar({ + message: t`Login token missing. Two Factor Authentication setup can not be initiated.`, + options: { + dedupeKey: 'invalid-session-dedupe-key', + }, + }); + return navigate(AppPath.SignInUp); + } + + const initiateOTPProvisioningResult = + await initiateCurrentUserWorkspaceOtpProvisioning({ + variables: { + loginToken: loginToken, + origin, + }, + }); + + if (!initiateOTPProvisioningResult.data?.initiateOTPProvisioning.uri) + return; + + setQrCodeState( + initiateOTPProvisioningResult.data?.initiateOTPProvisioning.uri, + ); + } catch (error) { + enqueueErrorSnackBar({ + message: t`Two factor authentication provisioning failed.`, + options: { + dedupeKey: + 'two-factor-authentication-provisioning-initiation-failed', + }, + }); + } + }; + + handleTwoFactorAuthenticationProvisioningInitiation(); + + // Two factor authentication provisioning only needs to run once at mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthTokenFromOTP.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthTokenFromOTP.ts new file mode 100644 index 000000000..2a3a586d3 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthTokenFromOTP.ts @@ -0,0 +1,21 @@ +import { gql } from '@apollo/client'; + +export const GET_AUTH_TOKENS_FROM_OTP = gql` + mutation getAuthTokensFromOTP( + $loginToken: String! + $otp: String! + $captchaToken: String + $origin: String! + ) { + getAuthTokensFromOTP( + loginToken: $loginToken + otp: $otp + captchaToken: $captchaToken + origin: $origin + ) { + tokens { + ...AuthTokensFragment + } + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/initiateOTPProvisioning.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/initiateOTPProvisioning.ts new file mode 100644 index 000000000..ae8df9ff3 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/initiateOTPProvisioning.ts @@ -0,0 +1,17 @@ +import { gql } from '@apollo/client'; + +export const INITIATE_OTP_PROVISIONING = gql` + mutation initiateOTPProvisioning($loginToken: String!, $origin: String!) { + initiateOTPProvisioning(loginToken: $loginToken, origin: $origin) { + uri + } + } +`; + +export const INITIATE_OTP_PROVISIONING_FOR_AUTHENTICATED_USER = gql` + mutation initiateOTPProvisioningForAuthenticatedUser { + initiateOTPProvisioningForAuthenticatedUser { + uri + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/resetTwoFactorAuthentication.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/resetTwoFactorAuthentication.ts new file mode 100644 index 000000000..d2ed052a1 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/resetTwoFactorAuthentication.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const DELETE_TWO_FACTOR_AUTHENTICATION_METHOD = gql` + mutation deleteTwoFactorAuthenticationMethod( + $twoFactorAuthenticationMethodId: UUID! + ) { + deleteTwoFactorAuthenticationMethod( + twoFactorAuthenticationMethodId: $twoFactorAuthenticationMethodId + ) { + success + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx index 1184a5de2..e50db75b1 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx +++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useAuth.test.tsx @@ -31,6 +31,42 @@ jest.mock('@/object-metadata/hooks/useRefreshObjectMetadataItem', () => ({ })), })); +jest.mock('@/domain-manager/hooks/useOrigin', () => ({ + useOrigin: jest.fn().mockImplementation(() => ({ + origin: 'http://localhost', + })), +})); + +jest.mock('@/captcha/hooks/useRequestFreshCaptchaToken', () => ({ + useRequestFreshCaptchaToken: jest.fn().mockImplementation(() => ({ + requestFreshCaptchaToken: jest.fn(), + })), +})); + +jest.mock('@/auth/sign-in-up/hooks/useSignUpInNewWorkspace', () => ({ + useSignUpInNewWorkspace: jest.fn().mockImplementation(() => ({ + createWorkspace: jest.fn(), + })), +})); + +jest.mock('@/domain-manager/hooks/useRedirectToWorkspaceDomain', () => ({ + useRedirectToWorkspaceDomain: jest.fn().mockImplementation(() => ({ + redirectToWorkspaceDomain: jest.fn(), + })), +})); + +jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace', () => ({ + useIsCurrentLocationOnAWorkspace: jest.fn().mockImplementation(() => ({ + isOnAWorkspace: true, + })), +})); + +jest.mock('@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain', () => ({ + useLastAuthenticatedWorkspaceDomain: jest.fn().mockImplementation(() => ({ + setLastAuthenticateWorkspaceDomain: jest.fn(), + })), +})); + const Wrapper = ({ children }: { children: ReactNode }) => ( diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 2bf9f12f8..030b8f89e 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -20,6 +20,7 @@ import { AuthTokenPair, useCheckUserExistsLazyQuery, useGetAuthTokensFromLoginTokenMutation, + useGetAuthTokensFromOtpMutation, useGetCurrentUserLazyQuery, useGetLoginTokenFromCredentialsMutation, useGetLoginTokenFromEmailVerificationTokenMutation, @@ -74,12 +75,15 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { APP_LOCALES } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; import { iconsState } from 'twenty-ui/display'; +import { AuthToken } from '~/generated/graphql'; import { cookieStorage } from '~/utils/cookie-storage'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; +import { loginTokenState } from '../states/loginTokenState'; export const useAuth = () => { const setTokenPair = useSetRecoilState(tokenPairState); + const setLoginToken = useSetRecoilState(loginTokenState); const setCurrentUser = useSetRecoilState(currentUserState); const setAvailableWorkspaces = useSetRecoilState(availableWorkspacesState); const setCurrentWorkspaceMember = useSetRecoilState( @@ -114,6 +118,7 @@ export const useAuth = () => { const [getLoginTokenFromEmailVerificationToken] = useGetLoginTokenFromEmailVerificationTokenMutation(); const [getCurrentUser] = useGetCurrentUserLazyQuery(); + const [getAuthTokensFromOtp] = useGetAuthTokensFromOtpMutation(); const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace(); @@ -368,26 +373,16 @@ export const useAuth = () => { [setTokenPair], ); - const handleGetAuthTokensFromLoginToken = useCallback( - async (loginToken: string) => { - const getAuthTokensResult = await getAuthTokensFromLoginToken({ - variables: { - loginToken, - origin, - }, - }); + const handleSetLoginToken = useCallback( + (token: AuthToken['token']) => { + setLoginToken(token); + }, + [setLoginToken], + ); - if (isDefined(getAuthTokensResult.errors)) { - throw getAuthTokensResult.errors; - } - - if (!getAuthTokensResult.data?.getAuthTokensFromLoginToken) { - throw new Error('No getAuthTokensFromLoginToken result'); - } - - handleSetAuthTokens( - getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens, - ); + const handleLoadWorkspaceAfterAuthentication = useCallback( + async (authTokens: AuthTokenPair) => { + handleSetAuthTokens(authTokens); // TODO: We can't parallelize this yet because when loadCurrentUSer is loaded // then UserProvider updates its children and PrefetchDataProvider is triggered @@ -395,12 +390,59 @@ export const useAuth = () => { await refreshObjectMetadataItems(); await loadCurrentUser(); }, + [loadCurrentUser, handleSetAuthTokens, refreshObjectMetadataItems], + ); + + const handleGetAuthTokensFromLoginToken = useCallback( + async (loginToken: string) => { + try { + const getAuthTokensResult = await getAuthTokensFromLoginToken({ + variables: { + loginToken: loginToken, + origin, + }, + }); + + if (isDefined(getAuthTokensResult.errors)) { + throw getAuthTokensResult.errors; + } + + if (!getAuthTokensResult.data?.getAuthTokensFromLoginToken) { + throw new Error('No getAuthTokensFromLoginToken result'); + } + + await handleLoadWorkspaceAfterAuthentication( + getAuthTokensResult.data.getAuthTokensFromLoginToken.tokens, + ); + } catch (error) { + if ( + error instanceof ApolloError && + error.graphQLErrors[0]?.extensions?.subCode === + 'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED' + ) { + handleSetLoginToken(loginToken); + navigate(AppPath.SignInUp); + setSignInUpStep(SignInUpStep.TwoFactorAuthenticationProvision); + } + + if ( + error instanceof ApolloError && + error.graphQLErrors[0]?.extensions?.subCode === + 'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED' + ) { + handleSetLoginToken(loginToken); + navigate(AppPath.SignInUp); + setSignInUpStep(SignInUpStep.TwoFactorAuthenticationVerification); + } + } + }, [ + handleSetLoginToken, getAuthTokensFromLoginToken, - loadCurrentUser, origin, - handleSetAuthTokens, - refreshObjectMetadataItems, + handleLoadWorkspaceAfterAuthentication, + setSignInUpStep, + navigate, ], ); @@ -654,6 +696,32 @@ export const useAuth = () => { [buildRedirectUrl, redirect], ); + const handleGetAuthTokensFromOTP = useCallback( + async (otp: string, loginToken: string, captchaToken?: string) => { + const getAuthTokensFromOtpResult = await getAuthTokensFromOtp({ + variables: { + captchaToken, + origin, + otp, + loginToken, + }, + }); + + if (isDefined(getAuthTokensFromOtpResult.errors)) { + throw getAuthTokensFromOtpResult.errors; + } + + if (!getAuthTokensFromOtpResult.data?.getAuthTokensFromOTP) { + throw new Error('No getAuthTokensFromLoginToken result'); + } + + await handleLoadWorkspaceAfterAuthentication( + getAuthTokensFromOtpResult.data.getAuthTokensFromOTP.tokens, + ); + }, + [getAuthTokensFromOtp, origin, handleLoadWorkspaceAfterAuthentication], + ); + return { getLoginTokenFromCredentials: handleGetLoginTokenFromCredentials, getLoginTokenFromEmailVerificationToken: @@ -672,5 +740,6 @@ export const useAuth = () => { signInWithGoogle: handleGoogleLogin, signInWithMicrosoft: handleMicrosoftLogin, setAuthTokens: handleSetAuthTokens, + getAuthTokensFromOTP: handleGetAuthTokensFromOTP, }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationProvision.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationProvision.tsx new file mode 100644 index 000000000..e6169f2fe --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationProvision.tsx @@ -0,0 +1,123 @@ +import { TwoFactorAuthenticationSetupEffect } from '@/auth/components/TwoFactorAuthenticationProvisionEffect'; +import { qrCodeState } from '@/auth/states/qrCode'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { extractSecretFromOtpUri } from '@/settings/two-factor-authentication/utils/extractSecretFromOtpUri'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { Trans, useLingui } from '@lingui/react/macro'; +import QRCode from 'react-qr-code'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { IconCopy } from 'twenty-ui/display'; +import { Loader } from 'twenty-ui/feedback'; +import { MainButton } from 'twenty-ui/input'; + +const StyledMainContentContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; + text-align: center; +`; + +const StyledTextContainer = styled.div` + align-items: center; + margin-bottom: ${({ theme }) => theme.spacing(4)}; + color: ${({ theme }) => theme.font.color.tertiary}; + + max-width: 280px; + text-align: center; + font-size: ${({ theme }) => theme.font.size.sm}; + + & > a { + color: ${({ theme }) => theme.font.color.tertiary}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +`; + +const StyledForm = styled.div` + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +`; + +const StyledCopySetupKeyLink = styled.button` + background: none; + border: none; + color: ${({ theme }) => theme.font.color.secondary}; + cursor: pointer; + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; + font-size: ${({ theme }) => theme.font.size.sm}; + margin-top: ${({ theme }) => theme.spacing(2)}; + padding: 0; + text-decoration: underline; + + &:hover { + color: ${({ theme }) => theme.font.color.primary}; + } +`; + +export const SignInUpTwoFactorAuthenticationProvision = () => { + const { t } = useLingui(); + const theme = useTheme(); + const { enqueueSuccessSnackBar } = useSnackBar(); + const qrCode = useRecoilValue(qrCodeState); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + + const handleClick = () => { + setSignInUpStep(SignInUpStep.TwoFactorAuthenticationVerification); + }; + + const handleCopySetupKey = async () => { + if (!qrCode) return; + + const secret = extractSecretFromOtpUri(qrCode); + if (secret !== null) { + await navigator.clipboard.writeText(secret); + enqueueSuccessSnackBar({ + message: t`Setup key copied to clipboard`, + options: { + icon: , + duration: 2000, + }, + }); + } + }; + + return ( + <> + + + + + Use authenticator apps and browser extensions like 1Password, Authy, + Microsoft Authenticator to generate one-time passwords + + + + {!qrCode ? : } + {qrCode && ( + + + Copy Setup Key + + )} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification.tsx new file mode 100644 index 000000000..3ade007d6 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification.tsx @@ -0,0 +1,273 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { useAuth } from '@/auth/hooks/useAuth'; +import { + OTPFormValues, + useTwoFactorAuthenticationForm, +} from '@/auth/sign-in-up/hooks/useTwoFactorAuthenticationForm'; +import { loginTokenState } from '@/auth/states/loginTokenState'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; +import { AppPath } from '@/types/AppPath'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { OTPInput, SlotProps } from 'input-otp'; +import { Controller } from 'react-hook-form'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { MainButton } from 'twenty-ui/input'; +import { ClickToActionLink } from 'twenty-ui/navigation'; +import { useNavigateApp } from '~/hooks/useNavigateApp'; + +const StyledMainContentContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; + text-align: center; +`; + +const StyledForm = styled.form` + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +`; + +const StyledSlot = styled.div<{ isActive: boolean }>` + position: relative; + width: 2.5rem; + height: 3.5rem; + font-size: 2rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + border-top: 1px solid ${({ theme }) => theme.border.color.medium}; + border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; + border-right: 1px solid ${({ theme }) => theme.border.color.medium}; + + &:first-of-type { + border-left: 1px solid ${({ theme }) => theme.border.color.medium}; + border-top-left-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; + } + + &:last-of-type { + border-top-right-radius: 0.375rem; + border-bottom-right-radius: 0.375rem; + } + + .group:hover &, + .group:focus-within & { + border-color: ${({ theme }) => theme.border.color.medium}; + } + + outline: 0; + outline-color: ${({ theme }) => theme.border.color.medium}; + + ${({ isActive, theme }) => + isActive && + css` + outline-width: 1px; + outline-style: solid; + outline-color: ${theme.border.color.strong}; + `} +`; + +const StyledPlaceholderChar = styled.div` + .group:has(input[data-input-otp-placeholder-shown]) & { + opacity: 0.2; + } +`; + +export const Slot = (props: SlotProps) => { + return ( + + + {props.char ?? props.placeholderChar} + + {props.hasFakeCaret && } + + ); +}; + +const StyledCaretContainer = styled.div` + align-items: center; + animation: caret-blink 1s steps(2, start) infinite; + display: flex; + inset: 0; + justify-content: center; + pointer-events: none; + position: absolute; + + @keyframes caret-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } + } +`; + +const StyledCaret = styled.div` + width: 1px; + height: 2rem; + background-color: white; +`; + +const FakeCaret = () => { + return ( + + + + ); +}; + +const StyledDashContainer = styled.div` + display: flex; + width: 2.5rem; + justify-content: center; + align-items: center; +`; + +const StyledDash = styled.div` + background-color: black; + border-radius: 9999px; + height: 0.25rem; + width: 0.75rem; +`; + +const FakeDash = () => { + return ( + + + + ); +}; + +const StyledOTPContainer = styled.div` + display: flex; + align-items: center; + + &:has(:disabled) { + opacity: 0.3; + } +`; + +const StyledSlotGroup = styled.div` + display: flex; +`; +const StyledTextContainer = styled.div` + align-items: center; + margin-bottom: ${({ theme }) => theme.spacing(4)}; + color: ${({ theme }) => theme.font.color.tertiary}; + + max-width: 280px; + text-align: center; + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +const StyledActionBackLinkContainer = styled.div` + margin: ${({ theme }) => theme.spacing(3)} 0 0; +`; + +export const SignInUpTOTPVerification = () => { + const { getAuthTokensFromOTP } = useAuth(); + const { enqueueErrorSnackBar } = useSnackBar(); + + const navigate = useNavigateApp(); + const { readCaptchaToken } = useReadCaptchaToken(); + const loginToken = useRecoilValue(loginTokenState); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const { t } = useLingui(); + + const { form } = useTwoFactorAuthenticationForm(); + + const submitOTP = async (values: OTPFormValues) => { + try { + const captchaToken = await readCaptchaToken(); + + if (!loginToken) { + return navigate(AppPath.SignInUp); + } + + await getAuthTokensFromOTP(values.otp, loginToken, captchaToken); + } catch (error) { + form.setValue('otp', ''); + + enqueueErrorSnackBar({ + message: t`Invalid verification code. Please try again.`, + options: { + dedupeKey: 'invalid-otp-dedupe-key', + }, + }); + } + }; + + const handleBack = () => { + setSignInUpStep(SignInUpStep.TwoFactorAuthenticationProvision); + }; + + return ( + + + Paste the code below + + + {/* // eslint-disable-next-line react/jsx-props-no-spreading */} + ( + ( + + + {slots.slice(0, 3).map((slot, idx) => ( + + ))} + + + + + + {slots.slice(3).map((slot, idx) => ( + + ))} + + + )} + /> + )} + /> + + + + + Back + + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useTwoFactorAuthenticationForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useTwoFactorAuthenticationForm.ts new file mode 100644 index 000000000..3ef8c14b3 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useTwoFactorAuthenticationForm.ts @@ -0,0 +1,20 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const otpValidationSchema = z.object({ + otp: z.string().trim().length(6, 'OTP must be exactly 6 digits'), +}); + +export type OTPFormValues = z.infer; +export const useTwoFactorAuthenticationForm = () => { + const form = useForm({ + mode: 'onSubmit', + defaultValues: { + otp: '', + }, + resolver: zodResolver(otpValidationSchema), + }); + + return { form }; +}; diff --git a/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts index 790b3a573..a285406cf 100644 --- a/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentUserWorkspaceState.ts @@ -3,7 +3,10 @@ import { UserWorkspace } from '~/generated/graphql'; export type CurrentUserWorkspace = Pick< UserWorkspace, - 'settingsPermissions' | 'objectRecordsPermissions' | 'objectPermissions' + | 'settingsPermissions' + | 'objectRecordsPermissions' + | 'objectPermissions' + | 'twoFactorAuthenticationMethodSummary' >; export const currentUserWorkspaceState = diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 69d1a6541..3aba5a211 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -23,6 +23,7 @@ export type CurrentWorkspace = Pick< | 'customDomain' | 'workspaceUrls' | 'metadataVersion' + | 'isTwoFactorAuthenticationEnforced' > & { defaultRole?: Omit | null; defaultAgent?: { id: string } | null; diff --git a/packages/twenty-front/src/modules/auth/states/loginTokenState.ts b/packages/twenty-front/src/modules/auth/states/loginTokenState.ts new file mode 100644 index 000000000..78a03c22f --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/loginTokenState.ts @@ -0,0 +1,7 @@ +import { createState } from 'twenty-ui/utilities'; +import { AuthToken } from '~/generated/graphql'; + +export const loginTokenState = createState({ + key: 'loginTokenState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/auth/states/qrCode.ts b/packages/twenty-front/src/modules/auth/states/qrCode.ts new file mode 100644 index 000000000..3e917880c --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/qrCode.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui/utilities'; + +export const qrCodeState = createState({ + key: 'qrCodeState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts index 9522195a7..c49e9b00c 100644 --- a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts +++ b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts @@ -6,6 +6,8 @@ export enum SignInUpStep { EmailVerification = 'emailVerification', WorkspaceSelection = 'workspaceSelection', SSOIdentityProviderSelection = 'SSOIdentityProviderSelection', + TwoFactorAuthenticationVerification = 'TwoFactorAuthenticationVerification', + TwoFactorAuthenticationProvision = 'TwoFactorAuthenticationProvision', } export const signInUpStepState = createState({ diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index fcef4b3e3..77c01f8ed 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -35,4 +35,5 @@ export type ClientConfig = { sentry: Sentry; signInPrefilled: boolean; support: Support; + isTwoFactorAuthenticationEnabled: boolean; }; 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 7ab4e3e31..2abcd9194 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 @@ -47,6 +47,7 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({ metadata: {}, }, ], + isTwoFactorAuthenticationEnforced: false, }); }, }); diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx index a78ae8cb0..6bf7367e5 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx @@ -18,9 +18,13 @@ import { import { Card } from 'twenty-ui/layout'; import { AuthProviders, + FeatureFlagKey, useUpdateWorkspaceMutation, } from '~/generated-metadata/graphql'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { Toggle2FA } from './Toggle2FA'; + const StyledSettingsSecurityOptionsList = styled.div` display: flex; flex-direction: column; @@ -38,6 +42,10 @@ export const SettingsSecurityAuthProvidersOptionsList = () => { currentWorkspaceState, ); + const isTwoFactorAuthenticationEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_TWO_FACTOR_AUTHENTICATION_ENABLED, + ); + const [updateWorkspace] = useUpdateWorkspaceMutation(); const isValidAuthProvider = ( @@ -177,6 +185,11 @@ export const SettingsSecurityAuthProvidersOptionsList = () => { } /> + {isTwoFactorAuthenticationEnabled && ( + + + + )} )} diff --git a/packages/twenty-front/src/modules/settings/security/components/Toggle2FA.tsx b/packages/twenty-front/src/modules/settings/security/components/Toggle2FA.tsx new file mode 100644 index 000000000..d633cb4d4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/Toggle2FA.tsx @@ -0,0 +1,67 @@ +import { useRecoilState } from 'recoil'; + +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { ApolloError } from '@apollo/client'; +import { t } from '@lingui/core/macro'; +import { IconLifebuoy } from 'twenty-ui/display'; +import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql'; + +export const Toggle2FA = () => { + const { enqueueErrorSnackBar } = useSnackBar(); + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const handleChange = async () => { + if (!currentWorkspace?.id) { + throw new Error('User is not logged in'); + } + + const newEnforceValue = !currentWorkspace.isTwoFactorAuthenticationEnforced; + + try { + // Optimistic update + setCurrentWorkspace({ + ...currentWorkspace, + isTwoFactorAuthenticationEnforced: newEnforceValue, + }); + + await updateWorkspace({ + variables: { + input: { + isTwoFactorAuthenticationEnforced: newEnforceValue, + }, + }, + }); + } catch (err: any) { + // Rollback optimistic update if error + setCurrentWorkspace({ + ...currentWorkspace, + isTwoFactorAuthenticationEnforced: !newEnforceValue, + }); + enqueueErrorSnackBar({ + apolloError: err instanceof ApolloError ? err : undefined, + message: err?.message, + }); + } + }; + + return ( + <> + {currentWorkspace && ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/two-factor-authentication/components/DeleteTwoFactorAuthenticationMethod.tsx b/packages/twenty-front/src/modules/settings/two-factor-authentication/components/DeleteTwoFactorAuthenticationMethod.tsx new file mode 100644 index 000000000..8646ad1b3 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/two-factor-authentication/components/DeleteTwoFactorAuthenticationMethod.tsx @@ -0,0 +1,126 @@ +import { useRecoilValue } from 'recoil'; + +import { useAuth } from '@/auth/hooks/useAuth'; +import { currentUserState } from '@/auth/states/currentUserState'; +import { SettingsPath } from '@/types/SettingsPath'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { useLingui } from '@lingui/react/macro'; +import { useParams } from 'react-router-dom'; +import { isDefined } from 'twenty-shared/utils'; +import { H2Title } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { useDeleteTwoFactorAuthenticationMethodMutation } from '~/generated-metadata/graphql'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { useCurrentUserWorkspaceTwoFactorAuthentication } from '../hooks/useCurrentUserWorkspaceTwoFactorAuthentication'; +import { useCurrentWorkspaceTwoFactorAuthenticationPolicy } from '../hooks/useWorkspaceTwoFactorAuthenticationPolicy'; + +const DELETE_TWO_FACTOR_AUTHENTICATION_MODAL_ID = + 'delete-two-factor-authentication-modal'; +export const DeleteTwoFactorAuthentication = () => { + const { t } = useLingui(); + const { openModal } = useModal(); + + const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); + const { signOut, loadCurrentUser } = useAuth(); + const [deleteTwoFactorAuthenticationMethod] = + useDeleteTwoFactorAuthenticationMethodMutation(); + const currentUser = useRecoilValue(currentUserState); + const userEmail = currentUser?.email; + const navigate = useNavigateSettings(); + const twoFactorAuthenticationStrategy = + useParams().twoFactorAuthenticationStrategy; + + const { currentUserWorkspaceTwoFactorAuthenticationMethods } = + useCurrentUserWorkspaceTwoFactorAuthentication(); + + const { isEnforced: isTwoFactorAuthenticationEnforced } = + useCurrentWorkspaceTwoFactorAuthenticationPolicy(); + + const reset2FA = async () => { + if ( + !isDefined(twoFactorAuthenticationStrategy) || + !isDefined( + currentUserWorkspaceTwoFactorAuthenticationMethods[ + twoFactorAuthenticationStrategy + ]?.twoFactorAuthenticationMethodId, + ) + ) { + enqueueErrorSnackBar({ + message: t`Invalid 2FA information.`, + options: { + dedupeKey: '2fa-dedupe-key', + }, + }); + return navigate(SettingsPath.ProfilePage); + } + + await deleteTwoFactorAuthenticationMethod({ + variables: { + twoFactorAuthenticationMethodId: + currentUserWorkspaceTwoFactorAuthenticationMethods[ + twoFactorAuthenticationStrategy + ].twoFactorAuthenticationMethodId, + }, + }); + + enqueueSuccessSnackBar({ + message: t`2FA Method has been deleted successfully.`, + options: { + dedupeKey: '2fa-dedupe-key', + }, + }); + + if (isTwoFactorAuthenticationEnforced === true) { + await signOut(); + } else { + navigate(SettingsPath.ProfilePage); + await loadCurrentUser(); + } + }; + + return ( + <> + + +