From 8e83be43feb2384bb225bad0ee4c91c746a7b4e4 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 8 Oct 2025 15:43:43 +0200 Subject: [PATCH] feat(auth): enhance error handling for sign-up and existing user checks (#14953) Refactored error handling in `useSignUpInNewWorkspace` and `useSignInUp` hooks to provide user-friendly messages. Added validation for existing users during sign-up, updating server-side logic to throw specific exceptions (`USER_ALREADY_EXIST`). Fix https://twenty-v7.sentry.io/issues/6686753138/events/latest/?environment=prod&project=4507072499810304&query=is%3Aunresolved%20issue.priority%3A%5Bhigh%2C%20medium%5D%20QueryFailedError&referrer=latest-event&sort=date --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../components/SignInUpGlobalScopeForm.tsx | 2 +- .../auth/sign-in-up/hooks/useSignInUp.ts | 42 +- .../hooks/useSignUpInNewWorkspace.ts | 38 +- .../useSettingsNavigationItems.test.tsx | 21 +- .../core-modules/auth/auth.exception.ts | 1 + .../engine/core-modules/auth/auth.resolver.ts | 16 +- .../auth/controllers/sso-auth.controller.ts | 12 +- .../auth/services/auth.service.ts | 14 +- .../services/reset-password.service.spec.ts | 49 ++- .../auth/services/reset-password.service.ts | 63 +-- .../auth/services/sign-in-up.service.ts | 11 +- ...auth-graphql-api-exception-handler.util.ts | 1 + .../get-auth-exception-rest-status.util.ts | 1 + .../services/email-verification.service.ts | 2 +- ...two-factor-authentication.resolver.spec.ts | 8 +- .../two-factor-authentication.resolver.ts | 2 +- .../user/services/user.service.spec.ts | 380 ++++++++++++++++++ .../user/services/user.service.ts | 35 +- .../engine/core-modules/user/user.module.ts | 1 - 19 files changed, 555 insertions(+), 144 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts 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 035cc613575..8cc83d00b70 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 @@ -173,7 +173,7 @@ export const SignInUpGlobalScopeForm = () => { return; } - continueWithCredentials(); + await continueWithCredentials(); }; const onEmailChange = (email: string) => { diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts index 9fc736c487a..3fe4ee8fc36 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.ts @@ -19,9 +19,11 @@ import { AppPath } from 'twenty-shared/types'; import { buildAppPathWithQueryParams } from '~/utils/buildAppPathWithQueryParams'; import { isMatchingLocation } from '~/utils/isMatchingLocation'; import { useAuth } from '../../hooks/useAuth'; +import { useLingui } from '@lingui/react/macro'; export const useSignInUp = (form: UseFormReturn
) => { const { enqueueErrorSnackBar } = useSnackBar(); + const { t } = useLingui(); const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState); const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); @@ -55,28 +57,33 @@ export const useSignInUp = (form: UseFormReturn) => { setSignInUpStep(SignInUpStep.Email); }, [setSignInUpStep]); + const errorMsgUserAlreadyExist = t`An error occurred while checking user existence`; const continueWithCredentials = useCallback(async () => { const token = await readCaptchaToken(); if (!form.getValues('email')) { return; } - checkUserExistsQuery({ - variables: { - email: form.getValues('email').toLowerCase().trim(), - captchaToken: token, - }, - onError: (error) => { - enqueueErrorSnackBar({ apolloError: error }); - }, - onCompleted: (data) => { - setSignInUpMode( - data?.checkUserExists.exists - ? SignInUpMode.SignIn - : SignInUpMode.SignUp, - ); - setSignInUpStep(SignInUpStep.Password); - }, - }); + try { + const { data } = await checkUserExistsQuery({ + variables: { + email: form.getValues('email').toLowerCase().trim(), + captchaToken: token, + }, + }); + + setSignInUpMode( + data?.checkUserExists.exists + ? SignInUpMode.SignIn + : SignInUpMode.SignUp, + ); + setSignInUpStep(SignInUpStep.Password); + } catch (error) { + enqueueErrorSnackBar({ + ...(error instanceof ApolloError + ? { apolloError: error } + : { message: errorMsgUserAlreadyExist }), + }); + } }, [ readCaptchaToken, form, @@ -84,6 +91,7 @@ export const useSignInUp = (form: UseFormReturn) => { enqueueErrorSnackBar, setSignInUpStep, setSignInUpMode, + errorMsgUserAlreadyExist, ]); const submitCredentials: SubmitHandler = useCallback( diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts index 9182a9dcf2d..e7bc22a3efb 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignUpInNewWorkspace.ts @@ -1,32 +1,38 @@ import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { type ApolloError } from '@apollo/client'; +import { ApolloError } from '@apollo/client'; import { AppPath } from 'twenty-shared/types'; import { useSignUpInNewWorkspaceMutation } from '~/generated-metadata/graphql'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; +import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; +import { useLingui } from '@lingui/react/macro'; export const useSignUpInNewWorkspace = () => { const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain(); const { enqueueErrorSnackBar } = useSnackBar(); + const { t } = useLingui(); const [signUpInNewWorkspaceMutation] = useSignUpInNewWorkspaceMutation(); const createWorkspace = async ({ newTab } = { newTab: true }) => { - await signUpInNewWorkspaceMutation({ - onCompleted: async (data) => { - return await redirectToWorkspaceDomain( - getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls), - AppPath.Verify, - { - loginToken: data.signUpInNewWorkspace.loginToken.token, - }, - newTab ? '_blank' : '_self', - ); - }, - onError: (error: ApolloError) => { - enqueueErrorSnackBar({ apolloError: error }); - }, - }); + try { + const { data } = await signUpInNewWorkspaceMutation(); + assertIsDefinedOrThrow(data?.signUpInNewWorkspace); + return await redirectToWorkspaceDomain( + getWorkspaceUrl(data.signUpInNewWorkspace.workspace.workspaceUrls), + AppPath.Verify, + { + loginToken: data.signUpInNewWorkspace.loginToken.token, + }, + newTab ? '_blank' : '_self', + ); + } catch (error) { + enqueueErrorSnackBar({ + ...(error instanceof ApolloError + ? { apolloError: error } + : { message: t`Workspace creation failed` }), + }); + } }; return { diff --git a/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx b/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx index bb11b17cb5f..7b3c5e1f747 100644 --- a/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/__tests__/useSettingsNavigationItems.test.tsx @@ -15,6 +15,15 @@ import { billingState } from '@/client-config/states/billingState'; import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState'; import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap'; import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext'; +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { messages } from '../../../../locales/generated/en'; + +i18n.load({ + [SOURCE_LOCALE]: messages, +}); +i18n.activate(SOURCE_LOCALE); const mockCurrentUser = { id: 'fake-user-id', @@ -45,11 +54,13 @@ const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - + + + {children} + + diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts index 8b61edac75c..afe4567eebc 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts @@ -30,4 +30,5 @@ export const AuthExceptionCode = appendCommonExceptionCode({ 'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED', TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED: 'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED', + USER_ALREADY_EXIST: 'USER_ALREADY_EXIST', } as const); 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 23989431fc5..50a5b84d9b7 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 @@ -61,7 +61,6 @@ import { TwoFactorAuthenticationExceptionFilter } from 'src/engine/core-modules/ import { TwoFactorAuthenticationService } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -75,6 +74,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input'; import { LoginToken } from './dto/login-token.entity'; @@ -99,8 +99,6 @@ import { AuthService } from './services/auth.service'; ) export class AuthResolver { constructor( - @InjectRepository(User) - private readonly userRepository: Repository, @InjectRepository(UserWorkspace) private readonly userWorkspaceRepository: Repository, @InjectRepository(AppToken) @@ -345,7 +343,7 @@ export class AuthResolver { ), ); - const user = await this.userService.getUserByEmail(email); + const user = await this.userService.findUserByEmailOrThrow(email); await this.twoFactorAuthenticationService.validateStrategy( user.id, @@ -430,11 +428,9 @@ export class AuthResolver { }) : undefined; - const existingUser = await this.userRepository.findOne({ - where: { - email: signUpInput.email, - }, - }); + const existingUser = await this.userService.findUserByEmail( + signUpInput.email, + ); const { userData } = this.authService.formatUserDataPayload( { @@ -636,7 +632,7 @@ export class AuthResolver { email: string, workspaceId: string, ): Promise<{ user: User; userWorkspace: UserWorkspace }> { - const user = await this.userService.getUserByEmail(email); + const user = await this.userService.findUserByEmailOrThrow(email); await this.authService.checkIsEmailVerified(user.isEmailVerified); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 74c12d648bf..d8d81741a66 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -36,10 +36,10 @@ import { IdentityProviderType, WorkspaceSSOIdentityProvider, } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; -import { User } from 'src/engine/core-modules/user/user.entity'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; @Controller('auth') @UseFilters(AuthRestApiExceptionFilter) @@ -49,10 +49,8 @@ export class SSOAuthController { private readonly authService: AuthService, private readonly guardRedirectService: GuardRedirectService, private readonly domainManagerService: DomainManagerService, - + private readonly userService: UserService, private readonly sSOService: SSOService, - @InjectRepository(User) - private readonly userRepository: Repository, @InjectRepository(WorkspaceSSOIdentityProvider) private readonly workspaceSSOIdentityProviderRepository: Repository, ) {} @@ -180,11 +178,7 @@ export class SSOAuthController { }) : undefined; - const existingUser = await this.userRepository.findOne({ - where: { - email: payload.email, - }, - }); + const existingUser = await this.userService.findUserByEmail(payload.email); const { userData } = this.authService.formatUserDataPayload( payload, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 2a14f0ba809..9eef2e75751 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -57,12 +57,12 @@ import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/ser import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -280,9 +280,7 @@ export class AuthService { ); } - const user = await this.userRepository.findOne({ - where: { email }, - }); + const user = await this.userService.findUserByEmail(email); assertIsDefinedOrThrow( user, @@ -378,9 +376,7 @@ export class AuthService { } async checkUserExists(email: string): Promise { - const user = await this.userRepository.findOneBy({ - email, - }); + const user = await this.userService.findUserByEmail(email); const isUserExist = isDefined(user); @@ -777,9 +773,7 @@ export class AuthService { ? await this.countAvailableWorkspacesByEmail(email) : 0; - const existingUser = await this.userRepository.findOne({ - where: { email }, - }); + const existingUser = await this.userService.findUserByEmail(email); if ( !workspaceId && 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 fdf963572ad..7561766c5be 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 @@ -8,19 +8,23 @@ import { AppToken, AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; -import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; +import { type User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { ResetPasswordService } from './reset-password.service'; // To avoid dynamic import issues in Jest jest.mock('@react-email/render', () => ({ - render: jest.fn().mockImplementation(async (template, options) => { + render: jest.fn().mockImplementation(async (_, options) => { if (options?.plainText) { return 'Plain Text Email'; } @@ -31,7 +35,7 @@ jest.mock('@react-email/render', () => ({ describe('ResetPasswordService', () => { let service: ResetPasswordService; - let userRepository: Repository; + let userService: UserService; let workspaceRepository: Repository; let appTokenRepository: Repository; let emailService: EmailService; @@ -43,8 +47,11 @@ describe('ResetPasswordService', () => { providers: [ ResetPasswordService, { - provide: getRepositoryToken(User), - useClass: Repository, + provide: UserService, + useValue: { + findUserByEmailOrThrow: jest.fn(), + findUserByIdOrThrow: jest.fn(), + }, }, { provide: getRepositoryToken(Workspace), @@ -91,7 +98,7 @@ describe('ResetPasswordService', () => { }).compile(); service = module.get(ResetPasswordService); - userRepository = module.get>(getRepositoryToken(User)); + userService = module.get(UserService); workspaceRepository = module.get>( getRepositoryToken(Workspace), ); @@ -113,7 +120,7 @@ describe('ResetPasswordService', () => { const mockUser = { id: '1', email: 'test@example.com' }; jest - .spyOn(userRepository, 'findOneBy') + .spyOn(userService, 'findUserByEmailOrThrow') .mockResolvedValue(mockUser as User); jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken); @@ -135,7 +142,11 @@ describe('ResetPasswordService', () => { }); it('should throw an error if user is not found', async () => { - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + jest + .spyOn(userService, 'findUserByEmailOrThrow') + .mockRejectedValue( + new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + ); await expect( service.generatePasswordResetToken( @@ -155,7 +166,7 @@ describe('ResetPasswordService', () => { }; jest - .spyOn(userRepository, 'findOneBy') + .spyOn(userService, 'findUserByEmailOrThrow') .mockResolvedValue(mockUser as User); jest .spyOn(appTokenRepository, 'findOne') @@ -177,7 +188,7 @@ describe('ResetPasswordService', () => { }; jest - .spyOn(userRepository, 'findOneBy') + .spyOn(userService, 'findUserByEmailOrThrow') .mockResolvedValue(mockUser as User); jest .spyOn(workspaceRepository, 'findOneBy') @@ -204,7 +215,11 @@ describe('ResetPasswordService', () => { }); it('should throw an error if user is not found', async () => { - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + jest + .spyOn(userService, 'findUserByEmailOrThrow') + .mockRejectedValue( + new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + ); await expect( service.sendEmailPasswordResetLink( @@ -229,7 +244,7 @@ describe('ResetPasswordService', () => { .spyOn(appTokenRepository, 'findOne') .mockResolvedValue(mockToken as AppToken); jest - .spyOn(userRepository, 'findOneBy') + .spyOn(userService, 'findUserByIdOrThrow') .mockResolvedValue(mockUser as User); const result = await service.validatePasswordResetToken('validToken'); @@ -251,7 +266,7 @@ describe('ResetPasswordService', () => { const mockUser = { id: '1', email: 'test@example.com' }; jest - .spyOn(userRepository, 'findOneBy') + .spyOn(userService, 'findUserByIdOrThrow') .mockResolvedValue(mockUser as User); jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any); @@ -265,7 +280,11 @@ describe('ResetPasswordService', () => { }); it('should throw an error if user is not found', async () => { - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + jest + .spyOn(userService, 'findUserByIdOrThrow') + .mockRejectedValue( + new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + ); await expect( service.invalidatePasswordResetToken('nonexistent'), 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 583ff93d7a5..0c0d8deb84a 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 @@ -29,39 +29,32 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser import { EmailService } from 'src/engine/core-modules/email/email.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; @Injectable() export class ResetPasswordService { constructor( private readonly twentyConfigService: TwentyConfigService, private readonly domainManagerService: DomainManagerService, - @InjectRepository(User) - private readonly userRepository: Repository, @InjectRepository(Workspace) private readonly workspaceRepository: Repository, @InjectRepository(AppToken) private readonly appTokenRepository: Repository, private readonly emailService: EmailService, private readonly i18nService: I18nService, + private readonly userService: UserService, ) {} async generatePasswordResetToken( email: string, workspaceId: string, ): Promise { - const user = await this.userRepository.findOneBy({ + const user = await this.userService.findUserByEmailOrThrow( email, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), + ); const expiresIn = this.twentyConfigService.get( 'PASSWORD_RESET_TOKEN_EXPIRES_IN', @@ -123,16 +116,10 @@ export class ResetPasswordService { email: string, locale: keyof typeof APP_LOCALES, ): Promise { - const user = await this.userRepository.findOneBy({ + const user = await this.userService.findUserByEmailOrThrow( email, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), + ); const workspace = await this.workspaceRepository.findOneBy({ id: resetToken.workspaceId, @@ -170,11 +157,11 @@ export class ResetPasswordService { const i18n = this.i18nService.getI18nInstance(locale); const subject = i18n._(resetPasswordMsg); - this.emailService.send({ + await this.emailService.send({ from: `${this.twentyConfigService.get( 'EMAIL_FROM_NAME', )} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`, - to: email, + to: user.email, subject, text, html, @@ -207,16 +194,10 @@ export class ResetPasswordService { ); } - const user = await this.userRepository.findOneBy({ - id: token.userId, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } + const user = await this.userService.findUserByIdOrThrow( + token.userId, + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), + ); return { id: user.id, @@ -227,20 +208,14 @@ export class ResetPasswordService { async invalidatePasswordResetToken( userId: string, ): Promise { - const user = await this.userRepository.findOneBy({ - id: userId, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } + const user = await this.userService.findUserByIdOrThrow( + userId, + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), + ); await this.appTokenRepository.update( { - userId, + userId: user.id, type: AppTokenType.PasswordResetToken, }, { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 8443501f9e5..95d9c50a65c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -30,7 +30,6 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; @@ -38,6 +37,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; import { isWorkEmail } from 'src/utils/is-work-email'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -465,6 +465,15 @@ export class SignInUpService { newUserParams: SignInUpNewUserPayload, authParams: AuthProviderWithPasswordType['authParams'], ) { + await this.userService.findUserByEmailOrThrow( + newUserParams.email, + new AuthException( + 'User already exist', + AuthExceptionCode.USER_ALREADY_EXIST, + { userFriendlyMessage: t`User already exists` }, + ), + ); + return this.saveNewUser( await this.computePartialUserFromUserPayload(newUserParams, authParams), await this.setDefaultImpersonateAndAccessFullAdminPanel(), diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts index 553184252f6..a7ce319ec1d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/auth-graphql-api-exception-handler.util.ts @@ -26,6 +26,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => { case AuthExceptionCode.SIGNUP_DISABLED: case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE: case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE: + case AuthExceptionCode.USER_ALREADY_EXIST: throw new ForbiddenError(exception); case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED: case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED: diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts index 24c6a76794f..4fdc35502d4 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util.ts @@ -22,6 +22,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => { case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE: case AuthExceptionCode.EMAIL_NOT_VERIFIED: case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE: + case AuthExceptionCode.USER_ALREADY_EXIST: return 403; case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED: case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED: diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts index f3c3ef2e7df..11dfff13068 100644 --- a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts +++ b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts @@ -116,7 +116,7 @@ export class EmailVerificationService { ); } - const user = await this.userService.getUserByEmail(email); + const user = await this.userService.findUserByEmailOrThrow(email); if (user.isEmailVerified) { throw new EmailVerificationException( diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts index 0900b1942d5..8a18a097438 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts @@ -34,7 +34,7 @@ const createMockLoginTokenService = () => ({ }); const createMockUserService = () => ({ - getUserByEmail: jest.fn(), + findUserByEmailOrThrow: jest.fn(), }); const createMockDomainManagerService = () => ({ @@ -128,7 +128,7 @@ describe('TwoFactorAuthenticationResolver', () => { domainManagerService.getWorkspaceByOriginOrDefaultWorkspace.mockResolvedValue( mockWorkspace, ); - userService.getUserByEmail.mockResolvedValue(mockUser); + userService.findUserByEmailOrThrow.mockResolvedValue(mockUser); twoFactorAuthenticationService.initiateStrategyConfiguration.mockResolvedValue( 'otpauth://totp/Twenty:test@example.com?secret=SECRETKEY&issuer=Twenty', ); @@ -146,7 +146,9 @@ describe('TwoFactorAuthenticationResolver', () => { expect( domainManagerService.getWorkspaceByOriginOrDefaultWorkspace, ).toHaveBeenCalledWith(origin); - expect(userService.getUserByEmail).toHaveBeenCalledWith(mockUser.email); + expect(userService.findUserByEmailOrThrow).toHaveBeenCalledWith( + mockUser.email, + ); expect( twoFactorAuthenticationService.initiateStrategyConfiguration, ).toHaveBeenCalledWith( diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts index 9427aa9c0e4..ab8f41b0cdc 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts @@ -76,7 +76,7 @@ export class TwoFactorAuthenticationResolver { ); } - const user = await this.userService.getUserByEmail(userEmail); + const user = await this.userService.findUserByEmailOrThrow(userEmail); const uri = await this.twoFactorAuthenticationService.initiateStrategyConfiguration( diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts new file mode 100644 index 00000000000..1fb12bed04d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts @@ -0,0 +1,380 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { type Repository } from 'typeorm'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; + +import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import type { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import type { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { + PermissionsException, + PermissionsExceptionCode, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +describe('UserService', () => { + let service: UserService; + let userRepository: Repository; + let workspaceService: WorkspaceService; + let twentyORMGlobalManager: TwentyORMGlobalManager; + let userRoleService: UserRoleService; + + const mockWorkspaceMemberRepo = { + findOne: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + } as unknown as WorkspaceRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { + provide: getRepositoryToken(User), + useValue: { + findOne: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: WorkspaceService, + useValue: { deleteWorkspace: jest.fn() }, + }, + { + provide: TwentyORMGlobalManager, + useValue: { + getRepositoryForWorkspace: jest.fn(), + }, + }, + { + provide: UserRoleService, + useValue: { + validateUserWorkspaceIsNotUniqueAdminOrThrow: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UserService); + userRepository = module.get>(getRepositoryToken(User)); + userRoleService = module.get(UserRoleService); + twentyORMGlobalManager = module.get( + TwentyORMGlobalManager, + ); + workspaceService = module.get(WorkspaceService); + }); + + describe('loadWorkspaceMember', () => { + it('returns null when workspace is not active/suspended', async () => { + // isWorkspaceActiveOrSuspendedSpy.mockReturnValue(false); + + const res = await service.loadWorkspaceMember( + { id: 'u1' } as User, + { id: 'w1' } as Workspace, + ); + + expect(res).toBeNull(); + expect( + twentyORMGlobalManager.getRepositoryForWorkspace, + ).not.toHaveBeenCalled(); + }); + + it('fetches from workspace member repo when workspace active', async () => { + jest.spyOn(mockWorkspaceMemberRepo, 'findOne').mockResolvedValue({ + id: 'wm1', + userId: 'u1', + } as WorkspaceMemberWorkspaceEntity); + + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue(mockWorkspaceMemberRepo); + + const res = await service.loadWorkspaceMember( + { id: 'u1' } as User, + { + id: 'w1', + activationStatus: WorkspaceActivationStatus.ACTIVE, + } as Workspace, + ); + + expect( + twentyORMGlobalManager.getRepositoryForWorkspace, + ).toHaveBeenCalledWith('w1', 'workspaceMember'); + expect(mockWorkspaceMemberRepo.findOne).toHaveBeenCalledWith({ + where: { userId: 'u1' }, + }); + expect(res).toEqual({ id: 'wm1', userId: 'u1' }); + }); + }); + + describe('loadWorkspaceMembers', () => { + it('returns [] when workspace is not active/suspended', async () => { + const res = await service.loadWorkspaceMembers({ + id: 'w1', + activationStatus: WorkspaceActivationStatus.INACTIVE, + } as Workspace); + + expect(res).toEqual([]); + expect( + twentyORMGlobalManager.getRepositoryForWorkspace, + ).not.toHaveBeenCalled(); + }); + + it('fetches members withDeleted flag', async () => { + jest + .spyOn(mockWorkspaceMemberRepo, 'find') + .mockResolvedValue([{ id: 'wm1' } as WorkspaceMemberWorkspaceEntity]); + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue(mockWorkspaceMemberRepo); + + const res = await service.loadWorkspaceMembers( + { + id: 'w2', + activationStatus: WorkspaceActivationStatus.ACTIVE, + } as Workspace, + true, + ); + + expect(mockWorkspaceMemberRepo.find).toHaveBeenCalledWith({ + withDeleted: true, + }); + expect(res).toEqual([{ id: 'wm1' }]); + }); + }); + + describe('loadDeletedWorkspaceMembersOnly', () => { + it('returns [] when workspace is not active/suspended', async () => { + const res = await service.loadDeletedWorkspaceMembersOnly({ + id: 'w1', + activationStatus: WorkspaceActivationStatus.INACTIVE, + } as Workspace); + + expect(res).toEqual([]); + }); + + it('fetches only deleted members with withDeleted:true', async () => { + jest + .spyOn(mockWorkspaceMemberRepo, 'find') + .mockResolvedValue([ + { id: 'wm-del' } as WorkspaceMemberWorkspaceEntity, + ]); + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue(mockWorkspaceMemberRepo); + + await service.loadDeletedWorkspaceMembersOnly({ + id: 'w3', + activationStatus: WorkspaceActivationStatus.ACTIVE, + } as Workspace); + + expect(mockWorkspaceMemberRepo.find).toHaveBeenCalledWith({ + where: { deletedAt: expect.any(Object) }, + withDeleted: true, + }); + }); + }); + + describe('findUserByEmailOrThrow', () => { + it('returns user when found', async () => { + const user = { id: 'u1', email: 'a@b.com' } as User; + + (userRepository.findOne as jest.Mock).mockResolvedValue(user); + + await expect(service.findUserByEmailOrThrow('a@b.com')).resolves.toEqual( + user, + ); + }); + + it('throws when not found', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue(null); + await expect( + service.findUserByEmailOrThrow('none@b.com'), + ).rejects.toBeTruthy(); + }); + }); + + describe('findUserByEmail', () => { + it('returns the user when found', async () => { + const user: Partial = { id: 'u1', email: 'john@doe.com' }; + + (userRepository.findOne as jest.Mock).mockResolvedValue(user); + + const result = await service.findUserByEmail('john@doe.com'); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'john@doe.com' }, + }); + expect(result).toEqual(user); + }); + + it('returns null when not found', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue(null); + const result = await service.findUserByEmail('missing@doe.com'); + + expect(result).toBeNull(); + }); + }); + + describe('hasUserAccessToWorkspaceOrThrow', () => { + it('resolves when user has access', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue({ id: 'u1' }); + await expect( + service.hasUserAccessToWorkspaceOrThrow('u1', 'w1'), + ).resolves.toBeUndefined(); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'u1', userWorkspaces: { workspaceId: 'w1' } }, + relations: { userWorkspaces: true }, + }); + }); + + it('throws AuthException when user has no access', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue(null); + await expect( + service.hasUserAccessToWorkspaceOrThrow('u2', 'w2'), + ).rejects.toBeInstanceOf(AuthException); + }); + }); + + describe('markEmailAsVerified', () => { + it('sets isEmailVerified and saves', async () => { + const user = { id: 'u1', isEmailVerified: false } as User; + + (userRepository.findOne as jest.Mock).mockResolvedValue(user); + (userRepository.save as jest.Mock).mockImplementation( + async (u: User) => u, + ); + + const res = await service.markEmailAsVerified('u1'); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'u1' }, + }); + expect(res.isEmailVerified).toBe(true); + expect(userRepository.save).toHaveBeenCalledWith({ + id: 'u1', + isEmailVerified: true, + }); + }); + + it('throws when user not found', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue(null); + await expect(service.markEmailAsVerified('nope')).rejects.toBeTruthy(); + }); + }); + + describe('deleteUser', () => { + const wmForUser = (userId: string) => + ({ id: 'wm-1', userId }) as WorkspaceMemberWorkspaceEntity; + + it('throws mapped PermissionsException when cannot unassign last admin', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue({ + id: 'u1', + userWorkspaces: [{ id: 'uw1', workspaceId: 'w1' }], + }); + + jest + .spyOn(mockWorkspaceMemberRepo, 'find') + .mockResolvedValue([ + wmForUser('u1'), + { id: 'wm-2', userId: 'uX' } as WorkspaceMemberWorkspaceEntity, + ]); + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue( + mockWorkspaceMemberRepo as unknown as WorkspaceRepository, + ); + + jest + .spyOn(userRoleService, 'validateUserWorkspaceIsNotUniqueAdminOrThrow') + .mockRejectedValue( + new PermissionsException( + 'x', + PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN, + ), + ); + + await expect(service.deleteUser('u1')).rejects.toBeInstanceOf( + PermissionsException, + ); + await expect(service.deleteUser('u1')).rejects.toMatchObject({ + code: PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER, + }); + }); + + it('deletes workspace member and workspace when user is sole member', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue({ + id: 'u2', + userWorkspaces: [{ id: 'uw2', workspaceId: 'w2' }], + }); + + jest + .spyOn(mockWorkspaceMemberRepo, 'find') + .mockResolvedValue([wmForUser('u2')]); + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue(mockWorkspaceMemberRepo); + + const res = await service.deleteUser('u2'); + + expect(mockWorkspaceMemberRepo.delete).toHaveBeenCalledWith({ + userId: 'u2', + }); + expect(workspaceService.deleteWorkspace).toHaveBeenCalledWith('w2'); + expect(res).toMatchObject({ id: 'u2' }); + }); + + it('throws when user not found', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue(null); + await expect(service.deleteUser('missing')).rejects.toBeTruthy(); + }); + }); + + describe('findUserById', () => { + it('returns the user when found', async () => { + const user = { id: 'u42' } as User; + + (userRepository.findOne as jest.Mock).mockResolvedValue(user); + + const result = await service.findUserById('u42'); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'u42' }, + }); + expect(result).toEqual(user); + }); + + it('returns null when not found', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue(null); + + const result = await service.findUserById('missing'); + + expect(result).toBeNull(); + }); + }); + + describe('findUserByIdOrThrow', () => { + it('returns user when found', async () => { + const user = { id: 'u99' } as User; + + (userRepository.findOne as jest.Mock).mockResolvedValue(user); + + await expect(service.findUserByIdOrThrow('u99')).resolves.toEqual(user); + }); + + it('throws provided error when not found', async () => { + (userRepository.findOne as jest.Mock).mockResolvedValue(null); + const error = new Error('not found'); + + await expect(service.findUserByIdOrThrow('nope', error)).rejects.toThrow( + error, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index 3ecd7ba956e..9b60bb847ed 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace'; import { IsNull, Not, Repository } from 'typeorm'; +import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; import { AuthException, @@ -188,26 +189,40 @@ export class UserService extends TypeOrmQueryService { ); } - async getUserByEmail(email: string) { - const user = await this.userRepository.findOne({ + async findUserByEmailOrThrow(email: string, error?: Error) { + const user = await this.findUserByEmail(email); + + assertIsDefinedOrThrow(user, error); + + return user; + } + + async findUserByEmail(email: string) { + return await this.userRepository.findOne({ where: { email, }, }); + } - userValidator.assertIsDefinedOrThrow(user); + async findUserById(id: string) { + return await this.userRepository.findOne({ + where: { + id, + }, + }); + } + + async findUserByIdOrThrow(id: string, error?: Error) { + const user = await this.findUserById(id); + + assertIsDefinedOrThrow(user, error); return user; } async markEmailAsVerified(userId: string) { - const user = await this.userRepository.findOne({ - where: { - id: userId, - }, - }); - - userValidator.assertIsDefinedOrThrow(user); + const user = await this.findUserByIdOrThrow(userId); user.isEmailVerified = true; diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index 5942b997a52..f044a576f81 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -49,7 +49,6 @@ import { UserService } from './services/user.service'; UserRoleModule, FeatureFlagModule, PermissionsModule, - UserWorkspaceModule, ], exports: [UserService, WorkspaceMemberTranspiler], providers: [UserService, UserResolver, WorkspaceMemberTranspiler],