Implement Two-Factor Authentication (2FA) (#13141)
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 <charlesBochet@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Jean-Baptiste Ronssin <65334819+jbronssin@users.noreply.github.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -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<SettingPermission>;
|
||||
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<Scalars['String']>;
|
||||
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<Scalars['UUID']>;
|
||||
gt?: InputMaybe<Scalars['UUID']>;
|
||||
@ -2687,6 +2736,7 @@ export type UpdateWorkspaceInput = {
|
||||
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isPasswordAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isTwoFactorAuthenticationEnforced?: InputMaybe<Scalars['Boolean']>;
|
||||
logo?: InputMaybe<Scalars['String']>;
|
||||
subdomain?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
@ -2782,6 +2832,7 @@ export type UserWorkspace = {
|
||||
/** @deprecated Use objectPermissions instead */
|
||||
objectRecordsPermissions?: Maybe<Array<PermissionsOnAllObjectRecords>>;
|
||||
settingsPermissions?: Maybe<Array<SettingPermissionType>>;
|
||||
twoFactorAuthenticationMethodSummary?: Maybe<Array<TwoFactorAuthenticationMethodDto>>;
|
||||
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<Scalars['String']>;
|
||||
@ -2875,6 +2931,7 @@ export type Workspace = {
|
||||
isMicrosoftAuthEnabled: Scalars['Boolean'];
|
||||
isPasswordAuthEnabled: Scalars['Boolean'];
|
||||
isPublicInviteLinkEnabled: Scalars['Boolean'];
|
||||
isTwoFactorAuthenticationEnforced: Scalars['Boolean'];
|
||||
logo?: Maybe<Scalars['String']>;
|
||||
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<Scalars['String']>;
|
||||
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<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | 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<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | 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<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | 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<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | 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<typeof useGenerateTransientTokenMutation>;
|
||||
export type GenerateTransientTokenMutationResult = Apollo.MutationResult<GenerateTransientTokenMutation>;
|
||||
export type GenerateTransientTokenMutationOptions = Apollo.BaseMutationOptions<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>;
|
||||
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<GetAuthTokensFromOtpMutation, GetAuthTokensFromOtpMutationVariables>;
|
||||
|
||||
/**
|
||||
* __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<GetAuthTokensFromOtpMutation, GetAuthTokensFromOtpMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<GetAuthTokensFromOtpMutation, GetAuthTokensFromOtpMutationVariables>(GetAuthTokensFromOtpDocument, options);
|
||||
}
|
||||
export type GetAuthTokensFromOtpMutationHookResult = ReturnType<typeof useGetAuthTokensFromOtpMutation>;
|
||||
export type GetAuthTokensFromOtpMutationResult = Apollo.MutationResult<GetAuthTokensFromOtpMutation>;
|
||||
export type GetAuthTokensFromOtpMutationOptions = Apollo.BaseMutationOptions<GetAuthTokensFromOtpMutation, GetAuthTokensFromOtpMutationVariables>;
|
||||
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<typeof useImpersonateMutation>;
|
||||
export type ImpersonateMutationResult = Apollo.MutationResult<ImpersonateMutation>;
|
||||
export type ImpersonateMutationOptions = Apollo.BaseMutationOptions<ImpersonateMutation, ImpersonateMutationVariables>;
|
||||
export const InitiateOtpProvisioningDocument = gql`
|
||||
mutation initiateOTPProvisioning($loginToken: String!, $origin: String!) {
|
||||
initiateOTPProvisioning(loginToken: $loginToken, origin: $origin) {
|
||||
uri
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type InitiateOtpProvisioningMutationFn = Apollo.MutationFunction<InitiateOtpProvisioningMutation, InitiateOtpProvisioningMutationVariables>;
|
||||
|
||||
/**
|
||||
* __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<InitiateOtpProvisioningMutation, InitiateOtpProvisioningMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<InitiateOtpProvisioningMutation, InitiateOtpProvisioningMutationVariables>(InitiateOtpProvisioningDocument, options);
|
||||
}
|
||||
export type InitiateOtpProvisioningMutationHookResult = ReturnType<typeof useInitiateOtpProvisioningMutation>;
|
||||
export type InitiateOtpProvisioningMutationResult = Apollo.MutationResult<InitiateOtpProvisioningMutation>;
|
||||
export type InitiateOtpProvisioningMutationOptions = Apollo.BaseMutationOptions<InitiateOtpProvisioningMutation, InitiateOtpProvisioningMutationVariables>;
|
||||
export const InitiateOtpProvisioningForAuthenticatedUserDocument = gql`
|
||||
mutation initiateOTPProvisioningForAuthenticatedUser {
|
||||
initiateOTPProvisioningForAuthenticatedUser {
|
||||
uri
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type InitiateOtpProvisioningForAuthenticatedUserMutationFn = Apollo.MutationFunction<InitiateOtpProvisioningForAuthenticatedUserMutation, InitiateOtpProvisioningForAuthenticatedUserMutationVariables>;
|
||||
|
||||
/**
|
||||
* __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<InitiateOtpProvisioningForAuthenticatedUserMutation, InitiateOtpProvisioningForAuthenticatedUserMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<InitiateOtpProvisioningForAuthenticatedUserMutation, InitiateOtpProvisioningForAuthenticatedUserMutationVariables>(InitiateOtpProvisioningForAuthenticatedUserDocument, options);
|
||||
}
|
||||
export type InitiateOtpProvisioningForAuthenticatedUserMutationHookResult = ReturnType<typeof useInitiateOtpProvisioningForAuthenticatedUserMutation>;
|
||||
export type InitiateOtpProvisioningForAuthenticatedUserMutationResult = Apollo.MutationResult<InitiateOtpProvisioningForAuthenticatedUserMutation>;
|
||||
export type InitiateOtpProvisioningForAuthenticatedUserMutationOptions = Apollo.BaseMutationOptions<InitiateOtpProvisioningForAuthenticatedUserMutation, InitiateOtpProvisioningForAuthenticatedUserMutationVariables>;
|
||||
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<typeof useResendEmailVerificationTokenMutation>;
|
||||
export type ResendEmailVerificationTokenMutationResult = Apollo.MutationResult<ResendEmailVerificationTokenMutation>;
|
||||
export type ResendEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<ResendEmailVerificationTokenMutation, ResendEmailVerificationTokenMutationVariables>;
|
||||
export const DeleteTwoFactorAuthenticationMethodDocument = gql`
|
||||
mutation deleteTwoFactorAuthenticationMethod($twoFactorAuthenticationMethodId: UUID!) {
|
||||
deleteTwoFactorAuthenticationMethod(
|
||||
twoFactorAuthenticationMethodId: $twoFactorAuthenticationMethodId
|
||||
) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type DeleteTwoFactorAuthenticationMethodMutationFn = Apollo.MutationFunction<DeleteTwoFactorAuthenticationMethodMutation, DeleteTwoFactorAuthenticationMethodMutationVariables>;
|
||||
|
||||
/**
|
||||
* __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<DeleteTwoFactorAuthenticationMethodMutation, DeleteTwoFactorAuthenticationMethodMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<DeleteTwoFactorAuthenticationMethodMutation, DeleteTwoFactorAuthenticationMethodMutationVariables>(DeleteTwoFactorAuthenticationMethodDocument, options);
|
||||
}
|
||||
export type DeleteTwoFactorAuthenticationMethodMutationHookResult = ReturnType<typeof useDeleteTwoFactorAuthenticationMethodMutation>;
|
||||
export type DeleteTwoFactorAuthenticationMethodMutationResult = Apollo.MutationResult<DeleteTwoFactorAuthenticationMethodMutation>;
|
||||
export type DeleteTwoFactorAuthenticationMethodMutationOptions = Apollo.BaseMutationOptions<DeleteTwoFactorAuthenticationMethodMutation, DeleteTwoFactorAuthenticationMethodMutationVariables>;
|
||||
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<typeof useFindOneServerlessFunctionSourceCodeQuery>;
|
||||
export type FindOneServerlessFunctionSourceCodeLazyQueryHookResult = ReturnType<typeof useFindOneServerlessFunctionSourceCodeLazyQuery>;
|
||||
export type FindOneServerlessFunctionSourceCodeQueryResult = Apollo.QueryResult<FindOneServerlessFunctionSourceCodeQuery, FindOneServerlessFunctionSourceCodeQueryVariables>;
|
||||
export const VerifyTwoFactorAuthenticationMethodForAuthenticatedUserDocument = gql`
|
||||
mutation verifyTwoFactorAuthenticationMethodForAuthenticatedUser($otp: String!) {
|
||||
verifyTwoFactorAuthenticationMethodForAuthenticatedUser(otp: $otp) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationFn = Apollo.MutationFunction<VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation, VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationVariables>;
|
||||
|
||||
/**
|
||||
* __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<VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation, VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation, VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationVariables>(VerifyTwoFactorAuthenticationMethodForAuthenticatedUserDocument, options);
|
||||
}
|
||||
export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationHookResult = ReturnType<typeof useVerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation>;
|
||||
export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationResult = Apollo.MutationResult<VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation>;
|
||||
export type VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationOptions = Apollo.BaseMutationOptions<VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutation, VerifyTwoFactorAuthenticationMethodForAuthenticatedUserMutationVariables>;
|
||||
export const DeleteUserAccountDocument = gql`
|
||||
mutation DeleteUserAccount {
|
||||
deleteUser {
|
||||
@ -8863,6 +9140,7 @@ export const UpdateWorkspaceDocument = gql`
|
||||
isGoogleAuthEnabled
|
||||
isMicrosoftAuthEnabled
|
||||
isPasswordAuthEnabled
|
||||
isTwoFactorAuthenticationEnforced
|
||||
defaultRole {
|
||||
...RoleFragment
|
||||
}
|
||||
|
||||
@ -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<SettingPermission>;
|
||||
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<Scalars['String']>;
|
||||
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<Scalars['UUID']>;
|
||||
gt?: InputMaybe<Scalars['UUID']>;
|
||||
@ -2525,6 +2574,7 @@ export type UpdateWorkspaceInput = {
|
||||
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isPasswordAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||
isTwoFactorAuthenticationEnforced?: InputMaybe<Scalars['Boolean']>;
|
||||
logo?: InputMaybe<Scalars['String']>;
|
||||
subdomain?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
@ -2610,6 +2660,7 @@ export type UserWorkspace = {
|
||||
/** @deprecated Use objectPermissions instead */
|
||||
objectRecordsPermissions?: Maybe<Array<PermissionsOnAllObjectRecords>>;
|
||||
settingsPermissions?: Maybe<Array<SettingPermissionType>>;
|
||||
twoFactorAuthenticationMethodSummary?: Maybe<Array<TwoFactorAuthenticationMethodDto>>;
|
||||
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<Scalars['String']>;
|
||||
@ -2703,6 +2759,7 @@ export type Workspace = {
|
||||
isMicrosoftAuthEnabled: Scalars['Boolean'];
|
||||
isPasswordAuthEnabled: Scalars['Boolean'];
|
||||
isPublicInviteLinkEnabled: Scalars['Boolean'];
|
||||
isTwoFactorAuthenticationEnforced: Scalars['Boolean'];
|
||||
logo?: Maybe<Scalars['String']>;
|
||||
metadataVersion: Scalars['Float'];
|
||||
subdomain: Scalars['String'];
|
||||
|
||||
@ -53,6 +53,7 @@ const mockWorkspace = {
|
||||
subdomainUrl: 'test.com',
|
||||
customUrl: 'test.com',
|
||||
},
|
||||
isTwoFactorAuthenticationEnforced: false,
|
||||
};
|
||||
|
||||
const createMockOptions = (): Options<any> => ({
|
||||
|
||||
@ -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 = ({
|
||||
<Suspense fallback={<SettingsSkeletonLoader />}>
|
||||
<Routes>
|
||||
<Route path={SettingsPath.ProfilePage} element={<SettingsProfile />} />
|
||||
<Route
|
||||
path={SettingsPath.TwoFactorAuthenticationStrategyConfig}
|
||||
element={<SettingsTwoFactorAuthenticationMethod />}
|
||||
/>
|
||||
<Route path={SettingsPath.Experience} element={<SettingsExperience />} />
|
||||
<Route path={SettingsPath.Accounts} element={<SettingsAccounts />} />
|
||||
<Route path={SettingsPath.NewAccount} element={<SettingsNewAccount />} />
|
||||
|
||||
@ -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 <></>;
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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 }) => (
|
||||
<MockedProvider mocks={Object.values(mocks)} addTypename={false}>
|
||||
<RecoilRoot>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TwoFactorAuthenticationSetupEffect />
|
||||
<StyledForm>
|
||||
<StyledTextContainer>
|
||||
<Trans>
|
||||
Use authenticator apps and browser extensions like 1Password, Authy,
|
||||
Microsoft Authenticator to generate one-time passwords
|
||||
</Trans>
|
||||
</StyledTextContainer>
|
||||
<StyledMainContentContainer>
|
||||
{!qrCode ? <Loader /> : <QRCode value={qrCode} />}
|
||||
{qrCode && (
|
||||
<StyledCopySetupKeyLink onClick={handleCopySetupKey}>
|
||||
<IconCopy size={theme.icon.size.sm} />
|
||||
<Trans>Copy Setup Key</Trans>
|
||||
</StyledCopySetupKeyLink>
|
||||
)}
|
||||
</StyledMainContentContainer>
|
||||
<MainButton
|
||||
title={'Next'}
|
||||
onClick={handleClick}
|
||||
variant={'primary'}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 (
|
||||
<StyledSlot isActive={props.isActive}>
|
||||
<StyledPlaceholderChar>
|
||||
{props.char ?? props.placeholderChar}
|
||||
</StyledPlaceholderChar>
|
||||
{props.hasFakeCaret && <FakeCaret />}
|
||||
</StyledSlot>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<StyledCaretContainer>
|
||||
<StyledCaret />
|
||||
</StyledCaretContainer>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<StyledDashContainer>
|
||||
<StyledDash />
|
||||
</StyledDashContainer>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<StyledForm onSubmit={form.handleSubmit(submitOTP)}>
|
||||
<StyledTextContainer>
|
||||
<Trans>Paste the code below</Trans>
|
||||
</StyledTextContainer>
|
||||
<StyledMainContentContainer>
|
||||
{/* // eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<Controller
|
||||
name="otp"
|
||||
control={form.control}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<OTPInput
|
||||
maxLength={6}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
render={({ slots }) => (
|
||||
<StyledOTPContainer>
|
||||
<StyledSlotGroup>
|
||||
{slots.slice(0, 3).map((slot, idx) => (
|
||||
<Slot
|
||||
key={idx}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...slot}
|
||||
/>
|
||||
))}
|
||||
</StyledSlotGroup>
|
||||
|
||||
<FakeDash />
|
||||
|
||||
<StyledSlotGroup>
|
||||
{slots.slice(3).map((slot, idx) => (
|
||||
<Slot
|
||||
key={idx}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...slot}
|
||||
/>
|
||||
))}
|
||||
</StyledSlotGroup>
|
||||
</StyledOTPContainer>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledMainContentContainer>
|
||||
<MainButton
|
||||
title={'Submit'}
|
||||
type="submit"
|
||||
variant={'primary'}
|
||||
fullWidth
|
||||
/>
|
||||
<StyledActionBackLinkContainer>
|
||||
<ClickToActionLink onClick={handleBack}>
|
||||
<Trans>Back</Trans>
|
||||
</ClickToActionLink>
|
||||
</StyledActionBackLinkContainer>
|
||||
</StyledForm>
|
||||
);
|
||||
};
|
||||
@ -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<typeof otpValidationSchema>;
|
||||
export const useTwoFactorAuthenticationForm = () => {
|
||||
const form = useForm<OTPFormValues>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: {
|
||||
otp: '',
|
||||
},
|
||||
resolver: zodResolver(otpValidationSchema),
|
||||
});
|
||||
|
||||
return { form };
|
||||
};
|
||||
@ -3,7 +3,10 @@ import { UserWorkspace } from '~/generated/graphql';
|
||||
|
||||
export type CurrentUserWorkspace = Pick<
|
||||
UserWorkspace,
|
||||
'settingsPermissions' | 'objectRecordsPermissions' | 'objectPermissions'
|
||||
| 'settingsPermissions'
|
||||
| 'objectRecordsPermissions'
|
||||
| 'objectPermissions'
|
||||
| 'twoFactorAuthenticationMethodSummary'
|
||||
>;
|
||||
|
||||
export const currentUserWorkspaceState =
|
||||
|
||||
@ -23,6 +23,7 @@ export type CurrentWorkspace = Pick<
|
||||
| 'customDomain'
|
||||
| 'workspaceUrls'
|
||||
| 'metadataVersion'
|
||||
| 'isTwoFactorAuthenticationEnforced'
|
||||
> & {
|
||||
defaultRole?: Omit<Role, 'workspaceMembers'> | null;
|
||||
defaultAgent?: { id: string } | null;
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
import { AuthToken } from '~/generated/graphql';
|
||||
|
||||
export const loginTokenState = createState<AuthToken['token'] | null>({
|
||||
key: 'loginTokenState',
|
||||
defaultValue: null,
|
||||
});
|
||||
6
packages/twenty-front/src/modules/auth/states/qrCode.ts
Normal file
6
packages/twenty-front/src/modules/auth/states/qrCode.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const qrCodeState = createState<string | null>({
|
||||
key: 'qrCodeState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@ -6,6 +6,8 @@ export enum SignInUpStep {
|
||||
EmailVerification = 'emailVerification',
|
||||
WorkspaceSelection = 'workspaceSelection',
|
||||
SSOIdentityProviderSelection = 'SSOIdentityProviderSelection',
|
||||
TwoFactorAuthenticationVerification = 'TwoFactorAuthenticationVerification',
|
||||
TwoFactorAuthenticationProvision = 'TwoFactorAuthenticationProvision',
|
||||
}
|
||||
|
||||
export const signInUpStepState = createState<SignInUpStep>({
|
||||
|
||||
@ -35,4 +35,5 @@ export type ClientConfig = {
|
||||
sentry: Sentry;
|
||||
signInPrefilled: boolean;
|
||||
support: Support;
|
||||
isTwoFactorAuthenticationEnabled: boolean;
|
||||
};
|
||||
|
||||
@ -47,6 +47,7 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
isTwoFactorAuthenticationEnforced: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 = () => {
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
{isTwoFactorAuthenticationEnabled && (
|
||||
<Card rounded>
|
||||
<Toggle2FA />
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</StyledSettingsSecurityOptionsList>
|
||||
|
||||
@ -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 && (
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconLifebuoy}
|
||||
title={t`Two Factor Authentication`}
|
||||
description={t`Enforce two-step verification for every user login.`}
|
||||
checked={currentWorkspace.isTwoFactorAuthenticationEnforced}
|
||||
onChange={handleChange}
|
||||
advancedMode
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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 (
|
||||
<>
|
||||
<H2Title
|
||||
title={t`Delete Two-Factor Authentication Method`}
|
||||
description={t`Deleting this method will remove it permanently from your account.`}
|
||||
/>
|
||||
|
||||
<Button
|
||||
accent="danger"
|
||||
onClick={() => openModal(DELETE_TWO_FACTOR_AUTHENTICATION_MODAL_ID)}
|
||||
variant="secondary"
|
||||
title={t`Reset 2FA`}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
confirmationValue={userEmail}
|
||||
confirmationPlaceholder={userEmail ?? ''}
|
||||
modalId={DELETE_TWO_FACTOR_AUTHENTICATION_MODAL_ID}
|
||||
title={t`2FA Method Reset`}
|
||||
subtitle={
|
||||
isTwoFactorAuthenticationEnforced ? (
|
||||
<>
|
||||
This will permanently delete your two factor authentication
|
||||
method.
|
||||
<br />
|
||||
Since 2FA is mandatory in your workspace, you will be logged out
|
||||
after deletion and will be asked to configure it again upon login.{' '}
|
||||
<br />
|
||||
Please type in your email to confirm.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This action cannot be undone. This will permanently reset your two
|
||||
factor authentication method. <br /> Please type in your email to
|
||||
confirm.
|
||||
</>
|
||||
)
|
||||
}
|
||||
onConfirmClick={reset2FA}
|
||||
confirmButtonText={t`Reset 2FA`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
import { qrCodeState } from '@/auth/states/qrCode';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { gql, useMutation } from '@apollo/client';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const INITIATE_OTP_PROVISIONING_FOR_AUTHENTICATED_USER = gql`
|
||||
mutation initiateOTPProvisioningForAuthenticatedUser {
|
||||
initiateOTPProvisioningForAuthenticatedUser {
|
||||
uri
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const TwoFactorAuthenticationSetupForSettingsEffect = () => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const qrCode = useRecoilValue(qrCodeState);
|
||||
const setQrCodeState = useSetRecoilState(qrCodeState);
|
||||
const { t } = useLingui();
|
||||
|
||||
const [initiateOTPProvisioningForAuthenticatedUser] = useMutation(
|
||||
INITIATE_OTP_PROVISIONING_FOR_AUTHENTICATED_USER,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(qrCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleTwoFactorAuthenticationProvisioningInitiation = async () => {
|
||||
try {
|
||||
const initiateOTPProvisioningResult =
|
||||
await initiateOTPProvisioningForAuthenticatedUser();
|
||||
|
||||
if (
|
||||
!initiateOTPProvisioningResult.data
|
||||
?.initiateOTPProvisioningForAuthenticatedUser.uri
|
||||
) {
|
||||
throw new Error('No URI returned from OTP provisioning');
|
||||
}
|
||||
|
||||
setQrCodeState(
|
||||
initiateOTPProvisioningResult.data
|
||||
.initiateOTPProvisioningForAuthenticatedUser.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 <></>;
|
||||
};
|
||||
@ -0,0 +1,263 @@
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { OTPInput, SlotProps } from 'input-otp';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER } from '@/settings/two-factor-authentication/graphql/mutations/verifyTwoFactorAuthenticationMethod';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
// OTP Form Types
|
||||
type OTPFormValues = {
|
||||
otp: string;
|
||||
};
|
||||
|
||||
const StyledOTPContainer = styled.div`
|
||||
display: flex;
|
||||
|
||||
margin-bottom: ${({ theme }) => theme.spacing(8)};
|
||||
|
||||
&:has(:disabled) {
|
||||
opacity: 0.3;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSlotGroup = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
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: ${({ theme }) => theme.font.color.primary};
|
||||
`;
|
||||
|
||||
const StyledDashContainer = styled.div`
|
||||
display: flex;
|
||||
width: 2.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledDash = styled.div`
|
||||
background-color: ${({ theme }) => theme.font.color.tertiary};
|
||||
border-radius: 9999px;
|
||||
height: 0.25rem;
|
||||
width: 0.75rem;
|
||||
`;
|
||||
|
||||
const FakeCaret = () => {
|
||||
return (
|
||||
<StyledCaretContainer>
|
||||
<StyledCaret />
|
||||
</StyledCaretContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const FakeDash = () => {
|
||||
return (
|
||||
<StyledDashContainer>
|
||||
<StyledDash />
|
||||
</StyledDashContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const Slot = (props: SlotProps) => {
|
||||
return (
|
||||
<StyledSlot isActive={props.isActive}>
|
||||
<StyledPlaceholderChar>
|
||||
{props.char ?? props.placeholderChar}
|
||||
</StyledPlaceholderChar>
|
||||
{props.hasFakeCaret && <FakeCaret />}
|
||||
</StyledSlot>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTwoFactorVerificationForSettings = () => {
|
||||
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
|
||||
const navigate = useNavigateSettings();
|
||||
const { t } = useLingui();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { loadCurrentUser } = useAuth();
|
||||
|
||||
const [verifyTwoFactorAuthenticationMethod] = useMutation(
|
||||
VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER,
|
||||
);
|
||||
|
||||
const formConfig = useForm<OTPFormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { isSubmitting } = formConfig.formState;
|
||||
const otpValue = formConfig.watch('otp');
|
||||
const canSave = !isSubmitting && otpValue?.length === 6;
|
||||
|
||||
const handleVerificationSuccess = async () => {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Two-factor authentication setup completed successfully!`,
|
||||
});
|
||||
|
||||
// Reload current user to refresh 2FA status
|
||||
await loadCurrentUser();
|
||||
|
||||
// Navigate back to profile page
|
||||
navigate(SettingsPath.ProfilePage);
|
||||
};
|
||||
|
||||
const handleSave = async (values: OTPFormValues) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await verifyTwoFactorAuthenticationMethod({
|
||||
variables: {
|
||||
otp: values.otp,
|
||||
},
|
||||
});
|
||||
|
||||
await handleVerificationSuccess();
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Invalid verification code. Please try again.`,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form and navigate back to profile page
|
||||
formConfig.reset();
|
||||
navigate(SettingsPath.ProfilePage);
|
||||
};
|
||||
|
||||
return {
|
||||
formConfig,
|
||||
isLoading,
|
||||
canSave,
|
||||
isSubmitting,
|
||||
handleSave,
|
||||
handleCancel,
|
||||
};
|
||||
};
|
||||
|
||||
export const TwoFactorAuthenticationVerificationForSettings = () => {
|
||||
// Use the form context from the parent instead of creating a new form instance
|
||||
const formContext = useFormContext<OTPFormValues>();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="otp"
|
||||
control={formContext.control}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<OTPInput
|
||||
maxLength={6}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
render={({ slots }) => (
|
||||
<StyledOTPContainer>
|
||||
<StyledSlotGroup>
|
||||
{slots.slice(0, 3).map((slot, idx) => (
|
||||
<Slot
|
||||
key={idx}
|
||||
char={slot.char}
|
||||
placeholderChar={slot.placeholderChar}
|
||||
isActive={slot.isActive}
|
||||
hasFakeCaret={slot.hasFakeCaret}
|
||||
/>
|
||||
))}
|
||||
</StyledSlotGroup>
|
||||
<FakeDash />
|
||||
<StyledSlotGroup>
|
||||
{slots.slice(3).map((slot, idx) => (
|
||||
<Slot
|
||||
key={idx}
|
||||
char={slot.char}
|
||||
placeholderChar={slot.placeholderChar}
|
||||
isActive={slot.isActive}
|
||||
hasFakeCaret={slot.hasFakeCaret}
|
||||
/>
|
||||
))}
|
||||
</StyledSlotGroup>
|
||||
</StyledOTPContainer>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const VERIFY_TWO_FACTOR_AUTHENTICATION_METHOD_FOR_AUTHENTICATED_USER = gql`
|
||||
mutation verifyTwoFactorAuthenticationMethodForAuthenticatedUser(
|
||||
$otp: String!
|
||||
) {
|
||||
verifyTwoFactorAuthenticationMethodForAuthenticatedUser(otp: $otp) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,28 @@
|
||||
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
TwoFactorAuthenticationMethodDto,
|
||||
useInitiateOtpProvisioningMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCurrentUserWorkspaceTwoFactorAuthentication = () => {
|
||||
const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState);
|
||||
const [initiateCurrentUserWorkspaceOtpProvisioning] =
|
||||
useInitiateOtpProvisioningMutation();
|
||||
|
||||
const currentUserWorkspaceTwoFactorAuthenticationMethods = useMemo(() => {
|
||||
const methods: Record<string, TwoFactorAuthenticationMethodDto> = {};
|
||||
|
||||
(currentUserWorkspace?.twoFactorAuthenticationMethodSummary ?? []).forEach(
|
||||
(method) => (methods[method.strategy] = method),
|
||||
);
|
||||
|
||||
return methods;
|
||||
}, [currentUserWorkspace]);
|
||||
|
||||
return {
|
||||
currentUserWorkspaceTwoFactorAuthenticationMethods,
|
||||
initiateCurrentUserWorkspaceOtpProvisioning,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export const useCurrentWorkspaceTwoFactorAuthenticationPolicy = () => {
|
||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||
|
||||
return {
|
||||
isEnforced: currentWorkspace?.isTwoFactorAuthenticationEnforced ?? false,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Extracts the secret from an OTP URI (otpauth://totp/...)
|
||||
* @param otpUri - The OTP URI containing the secret
|
||||
* @returns The secret string or null if not found
|
||||
*/
|
||||
export const extractSecretFromOtpUri = (otpUri: string): string | null => {
|
||||
try {
|
||||
const url = new URL(otpUri);
|
||||
return url.searchParams.get('secret');
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
export enum SettingsPath {
|
||||
ProfilePage = 'profile',
|
||||
TwoFactorAuthenticationStrategyConfig = 'profile/two-factor-authentication/:twoFactorAuthenticationStrategy',
|
||||
Experience = 'experience',
|
||||
Accounts = 'accounts',
|
||||
NewAccount = 'accounts/new',
|
||||
|
||||
@ -39,6 +39,11 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
objectPermissions {
|
||||
...ObjectPermissionFragment
|
||||
}
|
||||
twoFactorAuthenticationMethodSummary {
|
||||
twoFactorAuthenticationMethodId
|
||||
status
|
||||
strategy
|
||||
}
|
||||
}
|
||||
currentWorkspace {
|
||||
id
|
||||
@ -95,6 +100,7 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
defaultAgent {
|
||||
id
|
||||
}
|
||||
isTwoFactorAuthenticationEnforced
|
||||
}
|
||||
availableWorkspaces {
|
||||
...AvailableWorkspacesFragment
|
||||
|
||||
@ -15,6 +15,7 @@ export const UPDATE_WORKSPACE = gql`
|
||||
isGoogleAuthEnabled
|
||||
isMicrosoftAuthEnabled
|
||||
isPasswordAuthEnabled
|
||||
isTwoFactorAuthenticationEnforced
|
||||
defaultRole {
|
||||
...RoleFragment
|
||||
}
|
||||
|
||||
@ -23,6 +23,8 @@ import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/consta
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { SignInUpGlobalScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect';
|
||||
import { SignInUpTwoFactorAuthenticationProvision } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationProvision';
|
||||
import { SignInUpTOTPVerification } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification';
|
||||
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
|
||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@ -55,8 +57,12 @@ const StandardContent = ({
|
||||
</AnimatedEaseIn>
|
||||
<Title animate>{title}</Title>
|
||||
{signInUpForm}
|
||||
{signInUpStep !== SignInUpStep.Password &&
|
||||
signInUpStep !== SignInUpStep.WorkspaceSelection && <FooterNote />}
|
||||
{![
|
||||
SignInUpStep.Password,
|
||||
SignInUpStep.TwoFactorAuthenticationProvision,
|
||||
SignInUpStep.TwoFactorAuthenticationVerification,
|
||||
SignInUpStep.WorkspaceSelection,
|
||||
].includes(signInUpStep) && <FooterNote />}
|
||||
</Modal.Content>
|
||||
);
|
||||
};
|
||||
@ -91,6 +97,14 @@ export const SignInUp = () => {
|
||||
return t`Choose a Workspace`;
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.TwoFactorAuthenticationProvision) {
|
||||
return t`Setup your 2FA`;
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.TwoFactorAuthenticationVerification) {
|
||||
return t`Verify code from the app`;
|
||||
}
|
||||
|
||||
const workspaceName = !isDefined(workspacePublicData?.displayName)
|
||||
? DEFAULT_WORKSPACE_NAME
|
||||
: workspacePublicData?.displayName === ''
|
||||
@ -124,6 +138,15 @@ export const SignInUp = () => {
|
||||
) {
|
||||
return <SignInUpSSOIdentityProviderSelection />;
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.TwoFactorAuthenticationProvision) {
|
||||
return <SignInUpTwoFactorAuthenticationProvision />;
|
||||
}
|
||||
|
||||
if (signInUpStep === SignInUpStep.TwoFactorAuthenticationVerification) {
|
||||
return <SignInUpTOTPVerification />;
|
||||
}
|
||||
|
||||
if (isDefined(workspacePublicData) && isOnAWorkspace) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -1,20 +1,36 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { ChangePassword } from '@/settings/profile/components/ChangePassword';
|
||||
import { DeleteAccount } from '@/settings/profile/components/DeleteAccount';
|
||||
import { EmailField } from '@/settings/profile/components/EmailField';
|
||||
import { NameFields } from '@/settings/profile/components/NameFields';
|
||||
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
|
||||
import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { H2Title, IconShield, Status } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
export const SettingsProfile = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { currentUserWorkspaceTwoFactorAuthenticationMethods } =
|
||||
useCurrentUserWorkspaceTwoFactorAuthentication();
|
||||
|
||||
const isTwoFactorAuthenticationEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_TWO_FACTOR_AUTHENTICATION_ENABLED,
|
||||
);
|
||||
|
||||
const has2FAMethod =
|
||||
currentUserWorkspaceTwoFactorAuthenticationMethods['TOTP']?.status ===
|
||||
'VERIFIED';
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Profile`}
|
||||
@ -45,6 +61,32 @@ export const SettingsProfile = () => {
|
||||
/>
|
||||
<EmailField />
|
||||
</Section>
|
||||
{isTwoFactorAuthenticationEnabled && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Two Factor Authentication`}
|
||||
description={t`Enhances security by requiring a code along with your password`}
|
||||
/>
|
||||
<UndecoratedLink
|
||||
to={getSettingsPath(
|
||||
SettingsPath.TwoFactorAuthenticationStrategyConfig,
|
||||
{ twoFactorAuthenticationStrategy: 'TOTP' },
|
||||
)}
|
||||
>
|
||||
<SettingsCard
|
||||
title={t`Authenticator App`}
|
||||
Icon={<IconShield />}
|
||||
Status={
|
||||
has2FAMethod ? (
|
||||
<Status text={'Active'} color={'turquoise'} />
|
||||
) : (
|
||||
<Status text={'Setup'} color={'blue'} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
<ChangePassword />
|
||||
</Section>
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { qrCodeState } from '@/auth/states/qrCode';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { DeleteTwoFactorAuthentication } from '@/settings/two-factor-authentication/components/DeleteTwoFactorAuthenticationMethod';
|
||||
import { TwoFactorAuthenticationSetupForSettingsEffect } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationSetupForSettingsEffect';
|
||||
import {
|
||||
TwoFactorAuthenticationVerificationForSettings,
|
||||
useTwoFactorVerificationForSettings,
|
||||
} from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationForSettings';
|
||||
import { useCurrentUserWorkspaceTwoFactorAuthentication } from '@/settings/two-factor-authentication/hooks/useCurrentUserWorkspaceTwoFactorAuthentication';
|
||||
import { extractSecretFromOtpUri } from '@/settings/two-factor-authentication/utils/extractSecretFromOtpUri';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { H2Title, IconCopy } from 'twenty-ui/display';
|
||||
import { Loader } from 'twenty-ui/feedback';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
const StyledQRCodeContainer = styled.div`
|
||||
margin: ${({ theme }) => theme.spacing(4)} 0;
|
||||
`;
|
||||
|
||||
const StyledInstructions = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const StyledDivider = styled.div`
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: ${({ theme }) => theme.border.color.light};
|
||||
margin: ${({ theme }) => theme.spacing(6)} 0;
|
||||
`;
|
||||
|
||||
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 SettingsTwoFactorAuthenticationMethod = () => {
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const { enqueueSuccessSnackBar } = useSnackBar();
|
||||
const qrCode = useRecoilValue(qrCodeState);
|
||||
|
||||
const { currentUserWorkspaceTwoFactorAuthenticationMethods } =
|
||||
useCurrentUserWorkspaceTwoFactorAuthentication();
|
||||
|
||||
const has2FAMethod =
|
||||
currentUserWorkspaceTwoFactorAuthenticationMethods['TOTP']?.status ===
|
||||
'VERIFIED';
|
||||
|
||||
const verificationForm = useTwoFactorVerificationForSettings();
|
||||
|
||||
const shouldShowActionButtons = !has2FAMethod;
|
||||
|
||||
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: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<FormProvider {...verificationForm.formConfig}>
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Two Factor Authentication`}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>User</Trans>,
|
||||
href: getSettingsPath(SettingsPath.ProfilePage),
|
||||
},
|
||||
{
|
||||
children: <Trans>Profile</Trans>,
|
||||
href: getSettingsPath(SettingsPath.ProfilePage),
|
||||
},
|
||||
{
|
||||
children: <Trans>Two-Factor Authentication</Trans>,
|
||||
},
|
||||
]}
|
||||
actionButton={
|
||||
shouldShowActionButtons ? (
|
||||
<SaveAndCancelButtons
|
||||
isSaveDisabled={!verificationForm.canSave}
|
||||
isCancelDisabled={verificationForm.isSubmitting}
|
||||
isLoading={verificationForm.isLoading}
|
||||
onCancel={verificationForm.handleCancel}
|
||||
onSave={verificationForm.formConfig.handleSubmit(
|
||||
verificationForm.handleSave,
|
||||
)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
{has2FAMethod ? (
|
||||
<Section>
|
||||
<DeleteTwoFactorAuthentication />
|
||||
</Section>
|
||||
) : (
|
||||
<Section>
|
||||
<TwoFactorAuthenticationSetupForSettingsEffect />
|
||||
|
||||
<H2Title title={t`1. Scan the QR code`} />
|
||||
<StyledInstructions>
|
||||
<Trans>
|
||||
Use an authenticator app like Google Authenticator, Authy, or
|
||||
Microsoft Authenticator to scan this QR code.
|
||||
</Trans>
|
||||
</StyledInstructions>
|
||||
<StyledQRCodeContainer>
|
||||
{!qrCode ? <Loader /> : <QRCode value={qrCode} />}
|
||||
{qrCode && (
|
||||
<StyledCopySetupKeyLink onClick={handleCopySetupKey}>
|
||||
<IconCopy size={theme.icon.size.sm} />
|
||||
<Trans>Copy Setup Key</Trans>
|
||||
</StyledCopySetupKeyLink>
|
||||
)}
|
||||
</StyledQRCodeContainer>
|
||||
|
||||
<StyledDivider />
|
||||
|
||||
<H2Title title={t`2. Enter the code`} />
|
||||
|
||||
<StyledInstructions>
|
||||
<Trans>
|
||||
Enter the 6-digit verification code from your authenticator
|
||||
app to complete the setup.
|
||||
</Trans>
|
||||
</StyledInstructions>
|
||||
|
||||
<TwoFactorAuthenticationVerificationForSettings />
|
||||
</Section>
|
||||
)}
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@ -21,11 +21,11 @@ const StyledTableCell = styled(TableCell)`
|
||||
display: block;
|
||||
padding: 0 ${({ theme }) => theme.spacing(3)} 0 0;
|
||||
|
||||
&:first-child {
|
||||
&:first-of-type {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
&:last-of-type {
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@ -55,4 +55,5 @@ export const mockedClientConfig: ClientConfig = {
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
isTwoFactorAuthenticationEnabled: false,
|
||||
};
|
||||
|
||||
@ -90,6 +90,7 @@ export const mockCurrentWorkspace: Workspace = {
|
||||
workspaceMembersCount: 1,
|
||||
databaseSchema: '',
|
||||
databaseUrl: '',
|
||||
isTwoFactorAuthenticationEnforced: false,
|
||||
};
|
||||
|
||||
export const mockedWorkspaceMemberData: WorkspaceMember = {
|
||||
|
||||
Reference in New Issue
Block a user