Enable password reset from app.twenty.com with workspace fallback (#18271)

## Summary
- add a working `Forgot your password?` flow on `app.twenty.com` sign-in
- keep existing workspace-domain reset behavior
- when triggered without workspace context, resolve a workspace from the
user when possible, otherwise fallback to `app.twenty.com` reset URL

## Backend
- make `workspaceId` optional in `emailPasswordResetLink` input
- allow nullable `workspaceId` in password reset token DTO
- update reset token generation to accept optional `workspaceId`
- when missing, resolve first workspace by user membership
- if no workspace is found, persist token with `workspaceId = null`
- send reset links via:
  - workspace URL when `workspaceId` exists
  - app front URL + reset path when `workspaceId` is null

## Frontend
- make reset-link mutation `workspaceId` variable optional
- regenerate/patched generated metadata types accordingly
- add `Forgot your password?` in global password step
- allow reset request without workspace context in
`useHandleResetPassword`
- make reset page auto sign-in domain-aware (`workspace` vs `app`)
- apply design-system spacing above the global forgot-password link
(`theme.spacing(4)`)

## Tests
- extend reset-password service tests for:
  - explicit workspace id
  - inferred workspace when workspace id is missing
  - app-domain fallback when no workspace is found
- extend reset-password hook tests for with/without workspace context
- add focused global form test for forgot-password link rendering/click
behavior

## Product behavior for users with multiple workspaces
- no workspace chooser is shown in this flow
- backend uses the first resolvable workspace membership for the
reset-link domain
- password change remains account-level and works across all workspaces


Feature has been tested and is working 

<img width="3268" height="2106" alt="CleanShot 2026-02-26 at 14 09
14@2x"
src="https://github.com/user-attachments/assets/b5db3bed-f3aa-4d35-b54e-66e4d99141f9"
/>

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Thomas des Francs
2026-03-04 16:14:38 +01:00
committed by GitHub
parent eda905f271
commit aeedcf3353
14 changed files with 343 additions and 103 deletions
@@ -2914,7 +2914,7 @@ export type MutationEditSsoIdentityProviderArgs = {
export type MutationEmailPasswordResetLinkArgs = {
email: Scalars['String'];
workspaceId: Scalars['UUID'];
workspaceId?: InputMaybe<Scalars['UUID']>;
};
@@ -5777,7 +5777,7 @@ export type AuthorizeAppMutation = { __typename?: 'Mutation', authorizeApp: { __
export type EmailPasswordResetLinkMutationVariables = Exact<{
email: Scalars['String'];
workspaceId: Scalars['UUID'];
workspaceId?: InputMaybe<Scalars['UUID']>;
}>;
@@ -9415,7 +9415,7 @@ export type AuthorizeAppMutationHookResult = ReturnType<typeof useAuthorizeAppMu
export type AuthorizeAppMutationResult = Apollo.MutationResult<AuthorizeAppMutation>;
export type AuthorizeAppMutationOptions = Apollo.BaseMutationOptions<AuthorizeAppMutation, AuthorizeAppMutationVariables>;
export const EmailPasswordResetLinkDocument = gql`
mutation EmailPasswordResetLink($email: String!, $workspaceId: UUID!) {
mutation EmailPasswordResetLink($email: String!, $workspaceId: UUID) {
emailPasswordResetLink(email: $email, workspaceId: $workspaceId) {
success
}
@@ -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
}
@@ -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 = () => {
<FormProvider {...form}>
<SignInUpWithCredentials isGlobalScope />
</FormProvider>
{signInUpStep === SignInUpStep.Password && (
<StyledForgotPasswordLinkContainer>
<ClickToActionLink
onClick={handleResetPassword(form.getValues('email'))}
>
<Trans>Forgot your password?</Trans>
</ClickToActionLink>
</StyledForgotPasswordLinkContainer>
)}
</StyledContentContainer>
)}
</>
@@ -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: () => <div>credentials-form</div>,
}),
);
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(
<JotaiProvider store={jotaiStore}>
<ThemeProvider theme={THEME_LIGHT}>
<ThemeContextProvider theme={THEME_LIGHT}>
<I18nProvider i18n={i18n}>
<SignInUpGlobalScopeForm />
</I18nProvider>
</ThemeContextProvider>
</ThemeProvider>
</JotaiProvider>,
);
const forgotPasswordLink = screen.getByText('Forgot your password?');
expect(forgotPasswordLink).toBeInTheDocument();
expect(handleResetPasswordMock).toHaveBeenCalledWith('person@example.com');
fireEvent.click(forgotPasswordLink);
expect(resetPasswordClickMock).toHaveBeenCalledTimes(1);
});
});
@@ -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',
});
@@ -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) {
@@ -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) {
@@ -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)
@@ -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;
}
@@ -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;
}
@@ -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>(EmailService);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
workspaceDomainsService = module.get<WorkspaceDomainsService>(
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(
@@ -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<PasswordResetToken> {
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<EmailPasswordResetLinkDTO> {
async sendEmailPasswordResetLink({
resetToken,
email,
locale,
}: {
resetToken: PasswordResetToken;
email: string;
locale: keyof typeof APP_LOCALES;
}): Promise<EmailPasswordResetLinkDTO> {
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<string> {
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;
}
}
@@ -0,0 +1,5 @@
export type PasswordResetToken = {
passwordResetToken: string;
passwordResetTokenExpiresAt: Date;
workspaceId: string;
};
@@ -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';