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>
This commit is contained in:
Antoine Moreaux
2025-10-08 15:43:43 +02:00
committed by GitHub
parent 2e9779b511
commit 8e83be43fe
19 changed files with 555 additions and 144 deletions
@@ -173,7 +173,7 @@ export const SignInUpGlobalScopeForm = () => {
return;
}
continueWithCredentials();
await continueWithCredentials();
};
const onEmailChange = (email: string) => {
@@ -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<Form>) => {
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<Form>) => {
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<Form>) => {
enqueueErrorSnackBar,
setSignInUpStep,
setSignInUpMode,
errorMsgUserAlreadyExist,
]);
const submitCredentials: SubmitHandler<Form> = useCallback(
@@ -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 {
@@ -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 }) => (
<MockedProvider>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter>
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'test-scope-id' }}
>
{children}
</SnackBarComponentInstanceContext.Provider>
<I18nProvider i18n={i18n}>
<SnackBarComponentInstanceContext.Provider
value={{ instanceId: 'test-scope-id' }}
>
{children}
</SnackBarComponentInstanceContext.Provider>
</I18nProvider>
</MemoryRouter>
</RecoilRoot>
</MockedProvider>
@@ -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);
@@ -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<User>,
@InjectRepository(UserWorkspace)
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@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);
@@ -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<User>,
@InjectRepository(WorkspaceSSOIdentityProvider)
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
) {}
@@ -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,
@@ -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<CheckUserExistOutput> {
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 &&
@@ -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<User>;
let userService: UserService;
let workspaceRepository: Repository<Workspace>;
let appTokenRepository: Repository<AppToken>;
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>(ResetPasswordService);
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
userService = module.get<UserService>(UserService);
workspaceRepository = module.get<Repository<Workspace>>(
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'),
@@ -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<User>,
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(AppToken)
private readonly appTokenRepository: Repository<AppToken>,
private readonly emailService: EmailService,
private readonly i18nService: I18nService,
private readonly userService: UserService,
) {}
async generatePasswordResetToken(
email: string,
workspaceId: string,
): Promise<PasswordResetToken> {
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<EmailPasswordResetLink> {
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<InvalidatePassword> {
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,
},
{
@@ -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(),
@@ -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:
@@ -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:
@@ -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(
@@ -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(
@@ -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(
@@ -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<User>;
let workspaceService: WorkspaceService;
let twentyORMGlobalManager: TwentyORMGlobalManager;
let userRoleService: UserRoleService;
const mockWorkspaceMemberRepo = {
findOne: jest.fn(),
find: jest.fn(),
delete: jest.fn(),
} as unknown as WorkspaceRepository<WorkspaceMemberWorkspaceEntity>;
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>(UserService);
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
userRoleService = module.get<UserRoleService>(UserRoleService);
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
workspaceService = module.get<WorkspaceService>(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<User> = { 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<WorkspaceMemberWorkspaceEntity>,
);
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,
);
});
});
});
@@ -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<User> {
);
}
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;
@@ -49,7 +49,6 @@ import { UserService } from './services/user.service';
UserRoleModule,
FeatureFlagModule,
PermissionsModule,
UserWorkspaceModule,
],
exports: [UserService, WorkspaceMemberTranspiler],
providers: [UserService, UserResolver, WorkspaceMemberTranspiler],