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
@@ -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: