diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a475046d51..9dd6338670 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2914,7 +2914,7 @@ export type MutationEditSsoIdentityProviderArgs = { export type MutationEmailPasswordResetLinkArgs = { email: Scalars['String']; - workspaceId: Scalars['UUID']; + workspaceId?: InputMaybe; }; @@ -5777,7 +5777,7 @@ export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __ export type EmailPasswordResetLinkMutationVariables = Exact<{ email: Scalars['String']; - workspaceId: Scalars['UUID']; + workspaceId?: InputMaybe; }>; @@ -9415,7 +9415,7 @@ export type AuthorizeAppMutationHookResult = ReturnType; export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions; export const EmailPasswordResetLinkDocument = gql` - mutation EmailPasswordResetLink($email: String!, $workspaceId: UUID!) { + mutation EmailPasswordResetLink($email: String!, $workspaceId: UUID) { emailPasswordResetLink(email: $email, workspaceId: $workspaceId) { success } diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts index d7f8cece2d..a6db1ef07a 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/emailPasswordResetLink.ts @@ -1,7 +1,7 @@ import { gql } from '@apollo/client'; export const EMAIL_PASSWORD_RESET_LINK = gql` - mutation EmailPasswordResetLink($email: String!, $workspaceId: UUID!) { + mutation EmailPasswordResetLink($email: String!, $workspaceId: UUID) { emailPasswordResetLink(email: $email, workspaceId: $workspaceId) { success } diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index 8bc0e30f76..2ddff5bd51 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -3,8 +3,6 @@ import { returnToPathState } from '@/auth/states/returnToPathState'; import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; import { styled } from '@linaria/react'; import { Trans, useLingui } from '@lingui/react/macro'; -import { motion } from 'framer-motion'; -import { useContext } from 'react'; import { FormProvider } from 'react-hook-form'; import { ClickToActionLink, UndecoratedLink } from 'twenty-ui/navigation'; @@ -12,6 +10,7 @@ import { useAuth } from '@/auth/hooks/useAuth'; import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/internal/SignInUpWithCredentials'; import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/internal/SignInUpWithGoogle'; import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft'; +import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace'; import { @@ -21,6 +20,10 @@ import { import { getAvailableWorkspacePathAndSearchParams } from '@/auth/utils/availableWorkspacesUtils'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNonEmptyString } from '@sniptt/guards'; +import { motion } from 'framer-motion'; +import { useContext } from 'react'; import { Avatar, HorizontalSeparator, @@ -31,8 +34,6 @@ import { ThemeContext } from 'twenty-ui/theme'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { type AvailableWorkspace } from '~/generated-metadata/graphql'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { isNonEmptyString } from '@sniptt/guards'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${themeCssVariables.spacing[8]}; @@ -127,18 +128,25 @@ const StyledActionLinkContainer = styled.div` justify-content: center; `; +const StyledForgotPasswordLinkContainer = styled.div` + display: flex; + justify-content: center; + padding-top: ${themeCssVariables.spacing[4]}; +`; + export const SignInUpGlobalScopeForm = () => { const authProviders = useAtomStateValue(authProvidersState); const signInUpStep = useAtomStateValue(signInUpStepState); const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); const { signOut } = useAuth(); + const { theme } = useContext(ThemeContext); const { createWorkspace } = useSignUpInNewWorkspace(); const availableWorkspaces = useAtomStateValue(availableWorkspacesState); - const { theme } = useContext(ThemeContext); const { t } = useLingui(); const { form } = useSignInUpForm(); + const { handleResetPassword } = useHandleResetPassword(); const returnToPath = useAtomStateValue(returnToPathState); const getAvailableWorkspaceUrl = (availableWorkspace: AvailableWorkspace) => { @@ -241,6 +249,15 @@ export const SignInUpGlobalScopeForm = () => { + {signInUpStep === SignInUpStep.Password && ( + + + Forgot your password? + + + )} )} diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/__tests__/SignInUpGlobalScopeForm.test.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/__tests__/SignInUpGlobalScopeForm.test.tsx new file mode 100644 index 0000000000..8bb2fbb74b --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/__tests__/SignInUpGlobalScopeForm.test.tsx @@ -0,0 +1,120 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { + THEME_LIGHT, + ThemeContextProvider, + ThemeProvider, +} from 'twenty-ui/theme'; + +import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; +import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; + +const buildWorkspaceUrlMock = jest.fn(); +const signOutMock = jest.fn(); +const createWorkspaceMock = jest.fn(); +const handleResetPasswordMock = jest.fn(); +const resetPasswordClickMock = jest.fn(); + +jest.mock('@/auth/hooks/useAuth', () => ({ + useAuth: () => ({ + signOut: signOutMock, + }), +})); + +jest.mock('@/domain-manager/hooks/useBuildWorkspaceUrl', () => ({ + useBuildWorkspaceUrl: () => ({ + buildWorkspaceUrl: buildWorkspaceUrlMock, + }), +})); + +jest.mock('@/auth/sign-in-up/hooks/useSignUpInNewWorkspace', () => ({ + useSignUpInNewWorkspace: () => ({ + createWorkspace: createWorkspaceMock, + }), +})); + +jest.mock('@/auth/sign-in-up/hooks/useSignInUpForm', () => ({ + useSignInUpForm: () => ({ + form: { + getValues: () => 'person@example.com', + }, + }), +})); + +jest.mock('@/auth/sign-in-up/hooks/useHandleResetPassword', () => ({ + useHandleResetPassword: () => ({ + handleResetPassword: handleResetPasswordMock, + }), +})); + +jest.mock( + '@/auth/sign-in-up/components/internal/SignInUpWithCredentials', + () => ({ + SignInUpWithCredentials: () =>
credentials-form
, + }), +); + +jest.mock('@/auth/sign-in-up/components/internal/SignInUpWithGoogle', () => ({ + SignInUpWithGoogle: () => null, +})); + +jest.mock( + '@/auth/sign-in-up/components/internal/SignInUpWithMicrosoft', + () => ({ + SignInUpWithMicrosoft: () => null, + }), +); + +dynamicActivate(SOURCE_LOCALE); + +describe('SignInUpGlobalScopeForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetJotaiStore(); + handleResetPasswordMock.mockReturnValue(resetPasswordClickMock); + }); + + it('renders forgot-password link on password step and triggers reset callback', () => { + jotaiStore.set(signInUpStepState.atom, SignInUpStep.Password); + jotaiStore.set(authProvidersState.atom, { + google: false, + magicLink: false, + microsoft: false, + password: true, + sso: [], + }); + + render( + + + + + + + + + , + ); + + const forgotPasswordLink = screen.getByText('Forgot your password?'); + + expect(forgotPasswordLink).toBeInTheDocument(); + expect(handleResetPasswordMock).toHaveBeenCalledWith('person@example.com'); + + fireEvent.click(forgotPasswordLink); + + expect(resetPasswordClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts index 492d00a78f..94a954f5fd 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/__tests__/useHandleResetPassword.test.ts @@ -37,6 +37,20 @@ const renderHooks = () => { return { result }; }; +const renderHooksWithoutWorkspace = () => { + jotaiStore.set(workspacePublicDataState.atom, null); + + const { result } = renderHook(() => useHandleResetPassword(), { + wrapper: ({ children }: { children: ReactNode }) => + createElement( + JotaiProvider, + { store: jotaiStore }, + createElement(I18nProvider, { i18n }, children), + ), + }); + return { result }; +}; + describe('useHandleResetPassword', () => { const enqueueErrorSnackBarMock = jest.fn(); const enqueueSuccessSnackBarMock = jest.fn(); @@ -71,6 +85,25 @@ describe('useHandleResetPassword', () => { const { result } = renderHooks(); await act(() => result.current.handleResetPassword('test@example.com')()); + expect(emailPasswordResetLinkMock).toHaveBeenCalledWith({ + variables: { email: 'test@example.com', workspaceId: 'workspace-id' }, + }); + expect(enqueueSuccessSnackBarMock).toHaveBeenCalledWith({ + message: 'Password reset link has been sent to the email', + }); + }); + + it('should send reset link without workspaceId if workspace context is missing', async () => { + emailPasswordResetLinkMock.mockResolvedValue({ + data: { emailPasswordResetLink: { success: true } }, + }); + + const { result } = renderHooksWithoutWorkspace(); + await act(() => result.current.handleResetPassword('test@example.com')()); + + expect(emailPasswordResetLinkMock).toHaveBeenCalledWith({ + variables: { email: 'test@example.com' }, + }); expect(enqueueSuccessSnackBarMock).toHaveBeenCalledWith({ message: 'Password reset link has been sent to the email', }); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts index 8b55af99a7..f44182bb93 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts @@ -26,16 +26,11 @@ export const useHandleResetPassword = () => { return; } - if (!workspacePublicData?.id) { - enqueueErrorSnackBar({ - message: t`Invalid workspace`, - }); - return; - } - try { const { data } = await emailPasswordResetLink({ - variables: { email, workspaceId: workspacePublicData.id }, + variables: workspacePublicData?.id + ? { email, workspaceId: workspacePublicData.id } + : { email }, }); if (data?.emailPasswordResetLink?.success === true) { diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx index 6ff660a23c..1291a749f5 100644 --- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx +++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx @@ -8,6 +8,7 @@ import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex'; import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; import { useCaptcha } from '@/client-config/hooks/useCaptcha'; +import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace'; import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { TextInput } from '@/ui/input/components/TextInput'; @@ -140,7 +141,8 @@ export const PasswordReset = () => { const [updatePasswordViaToken, { loading: isUpdatingPassword }] = useUpdatePasswordViaResetTokenMutation(); - const { signInWithCredentialsInWorkspace } = useAuth(); + const { signInWithCredentialsInWorkspace, signInWithCredentials } = useAuth(); + const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace(); const { readCaptchaToken } = useReadCaptchaToken(); const { isCaptchaReady } = useCaptcha(); @@ -186,11 +188,15 @@ export const PasswordReset = () => { const token = readCaptchaToken(); - await signInWithCredentialsInWorkspace( - email || '', - formData.newPassword, - token, - ); + if (isOnAWorkspace) { + await signInWithCredentialsInWorkspace( + email || '', + formData.newPassword, + token, + ); + } else { + await signInWithCredentials(email || '', formData.newPassword, token); + } redirect(AppPath.Index); } catch (err) { diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 855dda1cb7..fbc76d2963 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -9,18 +9,6 @@ import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; -import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input'; -import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input'; -import { AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto'; -import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; -import { EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto'; -import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input'; -import { InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto'; -import { TransientTokenDTO } from 'src/engine/core-modules/auth/dto/transient-token.dto'; -import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input'; -import { ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto'; -import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input'; -import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; @@ -30,12 +18,24 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input'; +import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input'; +import { AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto'; +import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; import { AvailableWorkspacesAndAccessTokensDTO } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.dto'; +import { EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto'; +import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input'; import { GetAuthTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-auth-token-from-email-verification-token.input'; -import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input'; import { GetAuthorizationUrlForSSODTO } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.dto'; +import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input'; +import { InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto'; import { SignUpDTO } from 'src/engine/core-modules/auth/dto/sign-up.dto'; +import { TransientTokenDTO } from 'src/engine/core-modules/auth/dto/transient-token.dto'; +import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input'; +import { ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto'; +import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input'; import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.dto'; +import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service'; @@ -828,11 +828,11 @@ export class AuthResolver { emailPasswordResetInput.workspaceId, ); - return await this.resetPasswordService.sendEmailPasswordResetLink( + return await this.resetPasswordService.sendEmailPasswordResetLink({ resetToken, - emailPasswordResetInput.email, - context.req.locale, - ); + email: emailPasswordResetInput.email, + locale: context.req.locale, + }); } @Mutation(() => InvalidatePasswordDTO) diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/email-password-reset-link.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/email-password-reset-link.input.ts index dcd1052a0e..a0afd5c4eb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/email-password-reset-link.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/email-password-reset-link.input.ts @@ -1,6 +1,6 @@ import { ArgsType, Field } from '@nestjs/graphql'; -import { IsEmail, IsNotEmpty, IsUUID } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -11,8 +11,8 @@ export class EmailPasswordResetLinkInput { @IsEmail() email: string; - @Field(() => UUIDScalarType) - @IsNotEmpty() + @Field(() => UUIDScalarType, { nullable: true }) + @IsOptional() @IsUUID() - workspaceId: string; + workspaceId?: string; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/password-reset-token.dto.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/password-reset-token.dto.ts deleted file mode 100644 index 95fb31738b..0000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/password-reset-token.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Field, ObjectType } from '@nestjs/graphql'; - -import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; - -@ObjectType() -export class PasswordResetToken { - @Field(() => String) - passwordResetToken: string; - - @Field(() => Date) - passwordResetTokenExpiresAt: Date; - - @Field(() => UUIDScalarType) - workspaceId: string; -} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts index dc57213921..25dbd7f160 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts @@ -12,7 +12,6 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; @@ -23,7 +22,6 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { ResetPasswordService } from './reset-password.service'; -// To avoid dynamic import issues in Jest jest.mock('@react-email/render', () => ({ render: jest.fn().mockImplementation(async (_, options) => { if (options?.plainText) { @@ -62,24 +60,12 @@ describe('ResetPasswordService', () => { provide: getRepositoryToken(AppTokenEntity), useClass: Repository, }, - { - provide: getRepositoryToken(WorkspaceEntity), - useClass: Repository, - }, { provide: EmailService, useValue: { send: jest.fn().mockResolvedValue({ success: true }), }, }, - { - provide: DomainServerConfigService, - useValue: { - getBaseUrl: jest - .fn() - .mockResolvedValue(new URL('http://localhost:3001')), - }, - }, { provide: WorkspaceDomainsService, useValue: { @@ -113,7 +99,6 @@ describe('ResetPasswordService', () => { ); emailService = module.get(EmailService); twentyConfigService = module.get(TwentyConfigService); - workspaceDomainsService = module.get( WorkspaceDomainsService, ); @@ -146,16 +131,58 @@ describe('ResetPasswordService', () => { expect(appTokenRepository.save).toHaveBeenCalledWith( expect.objectContaining({ userId: '1', + workspaceId: 'workspace-id', type: AppTokenType.PasswordResetToken, }), ); }); + it('should resolve workspace when workspaceId is missing', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + const mockWorkspace = { id: 'resolved-workspace-id' }; + + jest + .spyOn(userService, 'findUserByEmailOrThrow') + .mockResolvedValue(mockUser as UserEntity); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as WorkspaceEntity); + jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(appTokenRepository, 'save') + .mockResolvedValue({} as AppTokenEntity); + jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); + + const result = + await service.generatePasswordResetToken('test@example.com'); + + expect(result.workspaceId).toBe('resolved-workspace-id'); + expect(appTokenRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'resolved-workspace-id', + }), + ); + }); + + it('should throw an error if no password auth enabled workspace found', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + + jest + .spyOn(userService, 'findUserByEmailOrThrow') + .mockResolvedValue(mockUser as UserEntity); + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); + + await expect( + service.generatePasswordResetToken('test@example.com'), + ).rejects.toThrow(AuthException); + }); + it('should throw an error if user is not found', async () => { jest .spyOn(userService, 'findUserByEmailOrThrow') .mockRejectedValue( - new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), ); await expect( @@ -181,6 +208,7 @@ describe('ResetPasswordService', () => { jest .spyOn(appTokenRepository, 'findOne') .mockResolvedValue(mockExistingToken as AppTokenEntity); + jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h'); await expect( service.generatePasswordResetToken('test@example.com', 'workspace-id'), @@ -214,29 +242,35 @@ describe('ResetPasswordService', () => { ), ); - const result = await service.sendEmailPasswordResetLink( - mockToken, - 'test@example.com', - 'en', - ); + const result = await service.sendEmailPasswordResetLink({ + resetToken: mockToken, + email: 'test@example.com', + locale: 'en', + }); expect(result.success).toBe(true); expect(emailService.send).toHaveBeenCalled(); }); it('should throw an error if user is not found', async () => { + const mockToken = { + workspaceId: 'workspace-id', + passwordResetToken: 'token123', + passwordResetTokenExpiresAt: new Date(), + }; + jest .spyOn(userService, 'findUserByEmailOrThrow') .mockRejectedValue( - new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), ); await expect( - service.sendEmailPasswordResetLink( - {} as any, - 'nonexistent@example.com', - 'en', - ), + service.sendEmailPasswordResetLink({ + resetToken: mockToken, + email: 'nonexistent@example.com', + locale: 'en', + }), ).rejects.toThrow(AuthException); }); }); @@ -297,7 +331,7 @@ describe('ResetPasswordService', () => { jest .spyOn(userService, 'findUserByIdOrThrow') .mockRejectedValue( - new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), ); await expect( diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts index b96cfb2496..bb27dac35e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts @@ -27,8 +27,8 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { type EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto'; import { type InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto'; -import { type PasswordResetToken } from 'src/engine/core-modules/auth/dto/password-reset-token.dto'; import { type ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto'; +import { type PasswordResetToken } from 'src/engine/core-modules/auth/types/password-reset-token.type'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; @@ -53,13 +53,19 @@ export class ResetPasswordService { async generatePasswordResetToken( email: string, - workspaceId: string, + workspaceId?: string, ): Promise { const user = await this.userService.findUserByEmailOrThrow( email, - new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT, { + userFriendlyMessage: msg`User not found.`, + }), ); + const targetWorkspaceId = + workspaceId ?? + (await this.findFirstPasswordAuthEnabledWorkspaceIdOrThrow(user.id)); + const expiresIn = this.twentyConfigService.get( 'PASSWORD_RESET_TOKEN_EXPIRES_IN', ); @@ -71,6 +77,8 @@ export class ResetPasswordService { ); } + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + const existingToken = await this.appTokenRepository.findOne({ where: { userId: user.id, @@ -89,6 +97,9 @@ export class ResetPasswordService { throw new AuthException( `Token has already been generated. Please wait for ${timeToWait} to generate again.`, AuthExceptionCode.INVALID_INPUT, + { + userFriendlyMessage: msg`Password reset token has already been generated. Please wait for ${timeToWait} to generate again.`, + }, ); } @@ -98,34 +109,40 @@ export class ResetPasswordService { .update(plainResetToken) .digest('hex'); - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - await this.appTokenRepository.save({ userId: user.id, - workspaceId: workspaceId, + workspaceId: targetWorkspaceId, value: hashedResetToken, expiresAt, type: AppTokenType.PasswordResetToken, }); return { - workspaceId, + workspaceId: targetWorkspaceId, passwordResetToken: plainResetToken, passwordResetTokenExpiresAt: expiresAt, }; } - async sendEmailPasswordResetLink( - resetToken: PasswordResetToken, - email: string, - locale: keyof typeof APP_LOCALES, - ): Promise { + async sendEmailPasswordResetLink({ + resetToken, + email, + locale, + }: { + resetToken: PasswordResetToken; + email: string; + locale: keyof typeof APP_LOCALES; + }): Promise { const user = await this.userService.findUserByEmailOrThrow( email, new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), ); const hasPassword = isDefined(user.passwordHash); + const resetPasswordPath = getAppPath(AppPath.ResetPassword, { + passwordResetToken: resetToken.passwordResetToken, + }); + const workspace = await this.workspaceRepository.findOneBy({ id: resetToken.workspaceId, }); @@ -134,9 +151,7 @@ export class ResetPasswordService { const link = this.workspaceDomainsService.buildWorkspaceURL({ workspace, - pathname: getAppPath(AppPath.ResetPassword, { - passwordResetToken: resetToken.passwordResetToken, - }), + pathname: resetPasswordPath, }); const emailData = { @@ -234,4 +249,34 @@ export class ResetPasswordService { return { success: true }; } + + private async findFirstPasswordAuthEnabledWorkspaceIdOrThrow( + userId: string, + ): Promise { + const workspace = await this.workspaceRepository.findOne({ + where: { + workspaceUsers: { + user: { + id: userId, + }, + }, + isPasswordAuthEnabled: true, + }, + order: { + createdAt: 'ASC', + }, + }); + + if (!isDefined(workspace)) { + throw new AuthException( + 'No password auth enabled workspace found', + AuthExceptionCode.INVALID_INPUT, + { + userFriendlyMessage: msg`No workspace found with password auth enabled.`, + }, + ); + } + + return workspace.id; + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/password-reset-token.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/password-reset-token.type.ts new file mode 100644 index 0000000000..3f44407149 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/password-reset-token.type.ts @@ -0,0 +1,5 @@ +export type PasswordResetToken = { + passwordResetToken: string; + passwordResetTokenExpiresAt: Date; + workspaceId: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 4a275e35f2..e42ba54fa2 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -34,8 +34,8 @@ import { PermissionsExceptionCode, PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; -import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; +import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';