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:
committed by
GitHub
parent
eda905f271
commit
aeedcf3353
@@ -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
|
||||
}
|
||||
|
||||
+22
-5
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
+120
@@ -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);
|
||||
});
|
||||
});
|
||||
+33
@@ -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)
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
+62
-28
@@ -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(
|
||||
|
||||
+60
-15
@@ -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;
|
||||
};
|
||||
+1
-1
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user