From b44bc5d5cc015d2b402fadede3b34e528e3c4496 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 26 May 2025 15:24:20 +0300 Subject: [PATCH 1/2] fix: localize customer already exist message --- src/i18n/ar/app.json | 3 ++- src/i18n/en/app.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 085b78d..c4a650f 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -41,7 +41,8 @@ "ALREADY_REJECTED": "تم رفض طلب تغيير المصروف بالفعل." }, "CUSTOMER": { - "NOT_FOUND": "لم يتم العثور على العميل." + "NOT_FOUND": "لم يتم العثور على العميل.", + "ALREADY_EXISTS": "العميل موجود بالفعل." }, "GIFT": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index da4c402..d7bc7b7 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -40,7 +40,8 @@ "ALREADY_REJECTED": "The allowance change request has already been rejected." }, "CUSTOMER": { - "NOT_FOUND": "The customer was not found." + "NOT_FOUND": "The customer was not found.", + "ALREADY_EXISTS": "The customer already exists." }, "GIFT": { From 6c859a25d2a6fea266526823c20b2001779ad6ae Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 4 Jun 2025 14:52:09 +0300 Subject: [PATCH 2/2] feat: handle oauth2 login --- .gitignore | 1 + src/auth/controllers/auth.controller.ts | 25 +++-- .../apple-additional-data.request.dto.ts | 14 +++ .../dtos/request/apple-login.request.dto.ts | 21 ++++ .../dtos/request/google-login.request.dto.ts | 10 ++ src/auth/dtos/request/index.ts | 2 + src/auth/services/auth.service.ts | 96 +++++++++++-------- src/i18n/en/general.json | 5 +- src/user/services/user.service.ts | 25 ++++- 9 files changed, 147 insertions(+), 52 deletions(-) create mode 100644 src/auth/dtos/request/apple-additional-data.request.dto.ts create mode 100644 src/auth/dtos/request/apple-login.request.dto.ts create mode 100644 src/auth/dtos/request/google-login.request.dto.ts diff --git a/.gitignore b/.gitignore index 6732258..ff85744 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +zod-certs \ No newline at end of file diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 3e3909a..d402ad6 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -6,11 +6,12 @@ import { AccessTokenGuard } from '~/common/guards'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { + AppleLoginRequestDto, CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, - LoginRequestDto, + GoogleLoginRequestDto, RefreshTokenRequestDto, SendForgetPasswordOtpRequestDto, SendLoginOtpRequestDto, @@ -57,6 +58,22 @@ export class AuthController { return ResponseFactory.data(new LoginResponseDto(token, user)); } + @Post('login/google') + @HttpCode(HttpStatus.OK) + @ApiDataResponse(LoginResponseDto) + async loginWithGoogle(@Body() data: GoogleLoginRequestDto) { + const [token, user] = await this.authService.loginWithGoogle(data); + return ResponseFactory.data(new LoginResponseDto(token, user)); + } + + @Post('login/apple') + @HttpCode(HttpStatus.OK) + @ApiDataResponse(LoginResponseDto) + async loginWithApple(@Body() data: AppleLoginRequestDto) { + const [token, user] = await this.authService.loginWithApple(data); + return ResponseFactory.data(new LoginResponseDto(token, user)); + } + @Post('register/set-email') @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(AccessTokenGuard) @@ -128,12 +145,6 @@ export class AuthController { return ResponseFactory.data(new LoginResponseDto(res, user)); } - @Post('login') - async login(@Body() loginDto: LoginRequestDto) { - const [res, user] = await this.authService.login(loginDto); - return ResponseFactory.data(new LoginResponseDto(res, user)); - } - @Post('logout') @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(AccessTokenGuard) diff --git a/src/auth/dtos/request/apple-additional-data.request.dto.ts b/src/auth/dtos/request/apple-additional-data.request.dto.ts new file mode 100644 index 0000000..fae4a53 --- /dev/null +++ b/src/auth/dtos/request/apple-additional-data.request.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +export class AppleAdditionalData { + @ApiProperty({ example: 'Ahmad' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) + firstName!: string; + + @ApiProperty({ example: 'Khan' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) + lastName!: string; +} diff --git a/src/auth/dtos/request/apple-login.request.dto.ts b/src/auth/dtos/request/apple-login.request.dto.ts new file mode 100644 index 0000000..34e89da --- /dev/null +++ b/src/auth/dtos/request/apple-login.request.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { AppleAdditionalData } from './apple-additional-data.request.dto'; + +export class AppleLoginRequestDto { + @ApiProperty({ example: 'apple_token' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) }) + appleToken!: string; + + @ApiProperty({ type: AppleAdditionalData }) + @ValidateNested({ + each: true, + message: i18n('validation.ValidateNested', { path: 'general', property: 'auth.apple.additionalData' }), + }) + @IsOptional() + @Type(() => AppleAdditionalData) + additionalData?: AppleAdditionalData; +} diff --git a/src/auth/dtos/request/google-login.request.dto.ts b/src/auth/dtos/request/google-login.request.dto.ts new file mode 100644 index 0000000..0ca1ca4 --- /dev/null +++ b/src/auth/dtos/request/google-login.request.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; + +export class GoogleLoginRequestDto { + @ApiProperty({ example: 'google_token' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) }) + googleToken!: string; +} diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index bc3643b..d5c81cf 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,7 +1,9 @@ +export * from './apple-login.request.dto'; export * from './create-unverified-user.request.dto'; export * from './disable-biometric.request.dto'; export * from './enable-biometric.request.dto'; export * from './forget-password.request.dto'; +export * from './google-login.request.dto'; export * from './login.request.dto'; export * from './refresh-token.request.dto'; export * from './send-forget-password-otp.request.dto'; diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index d35bfea..0d192b2 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -12,10 +12,12 @@ import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; import { PASSCODE_REGEX } from '../constants'; import { + AppleLoginRequestDto, CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, + GoogleLoginRequestDto, LoginRequestDto, SendForgetPasswordOtpRequestDto, SendLoginOtpRequestDto, @@ -24,7 +26,7 @@ import { VerifyLoginOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; -import { GrantType, Roles } from '../enums'; +import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; import { removePadding, verifySignature } from '../utils'; import { Oauth2Service } from './oauth2.service'; @@ -243,29 +245,6 @@ export class AuthService { this.logger.log(`Passcode updated successfully for user with email ${email}`); } - async login(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { - let user: User; - let tokens: ILoginResponse; - - if (loginDto.grantType === GrantType.PASSWORD || loginDto.grantType === GrantType.BIOMETRIC) { - throw new BadRequestException('AUTH.GRANT_TYPE_NOT_SUPPORTED_YET'); - } - - if (loginDto.grantType === GrantType.GOOGLE) { - this.logger.log(`Logging in user with email ${loginDto.email} using google`); - [tokens, user] = await this.loginWithGoogle(loginDto); - } - - if (loginDto.grantType === GrantType.APPLE) { - this.logger.log(`Logging in user with email ${loginDto.email} using apple`); - [tokens, user] = await this.loginWithApple(loginDto); - } - - this.logger.log(`User with email ${loginDto.email} logged in successfully`); - - return [tokens!, user!]; - } - async setJuniorPasscode(body: setJuniorPasswordRequestDto) { this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`); const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR); @@ -403,65 +382,96 @@ export class AuthService { return [tokens, user]; } - private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { - const { email, sub } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken); - const [existingUser, isJunior] = await Promise.all([ + async loginWithGoogle(loginDto: GoogleLoginRequestDto): Promise<[ILoginResponse, User]> { + const { + email, + sub, + given_name: firstName, + family_name: lastName, + } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken); + const [existingUser, isJunior, existingUserWithEmail] = await Promise.all([ this.userService.findUser({ googleId: sub }), this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), + this.userService.findUser({ email }), ]); - if (isJunior && email) { + if (isJunior) { this.logger.error(`User with email ${email} is an already registered junior`); throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); } - if (!existingUser) { - this.logger.debug(`User with google id ${sub} not found, creating new user`); - const user = await this.userService.createGoogleUser(sub, email); + if (!existingUser && existingUserWithEmail) { + this.logger.error(`User with email ${email} already exists adding google id to existing user`); + await this.userService.updateUser(existingUserWithEmail.id, { googleId: sub }); + const tokens = await this.generateAuthToken(existingUserWithEmail); + return [tokens, existingUserWithEmail]; + } + + if (!existingUser && !existingUserWithEmail) { + this.logger.debug(`User with google id ${sub} or email ${email} not found, creating new user`); + const user = await this.userService.createGoogleUser(sub, email, firstName, lastName); const tokens = await this.generateAuthToken(user); return [tokens, user]; } - const tokens = await this.generateAuthToken(existingUser); + const tokens = await this.generateAuthToken(existingUser!); - return [tokens, existingUser]; + return [tokens, existingUser!]; } - private async loginWithApple(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { + async loginWithApple(loginDto: AppleLoginRequestDto): Promise<[ILoginResponse, User]> { const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken); - const [existingUser, isJunior] = await Promise.all([ + const [existingUserWithSub, isJunior] = await Promise.all([ this.userService.findUser({ appleId: sub }), this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), ]); - if (isJunior && email) { - this.logger.error(`User with email ${email} is an already registered junior`); + if (isJunior) { + this.logger.error(`User with apple id ${sub} is an already registered junior`); throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); } - if (!existingUser) { + if (email) { + const existingUserWithEmail = await this.userService.findUser({ email }); + + if (existingUserWithEmail && !existingUserWithSub) { + { + this.logger.error(`User with email ${email} already exists adding apple id to existing user`); + await this.userService.updateUser(existingUserWithEmail.id, { appleId: sub }); + const tokens = await this.generateAuthToken(existingUserWithEmail); + return [tokens, existingUserWithEmail]; + } + } + } + + if (!existingUserWithSub) { // Apple only provides email if user authorized zod for the first time - if (!email) { + if (!email || !loginDto.additionalData) { this.logger.error(`User authorized zod before but his email is not stored in the database`); throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED'); } this.logger.debug(`User with apple id ${sub} not found, creating new user`); - const user = await this.userService.createAppleUser(sub, email); + const user = await this.userService.createAppleUser( + sub, + email, + loginDto.additionalData.firstName, + loginDto.additionalData.lastName, + ); const tokens = await this.generateAuthToken(user); return [tokens, user]; } - const tokens = await this.generateAuthToken(existingUser); + const tokens = await this.generateAuthToken(existingUserWithSub); this.logger.log(`User with apple id ${sub} logged in successfully`); - return [tokens, existingUser]; + return [tokens, existingUserWithSub]; } private async generateAuthToken(user: User) { @@ -499,4 +509,6 @@ export class AuthService { throw new BadRequestException('AUTH.INVALID_PASSCODE'); } } + + private validateGoogleToken(googleToken: string) {} } diff --git a/src/i18n/en/general.json b/src/i18n/en/general.json index 2defaf3..4a9c259 100644 --- a/src/i18n/en/general.json +++ b/src/i18n/en/general.json @@ -22,7 +22,10 @@ "fcmToken": "FCM token", "refreshToken": "Refresh token", "qrToken": "QR token", - "passcode": "Passcode" + "passcode": "Passcode", + "apple": { + "additionalData": "Additional data" + } }, "customer": { diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index 25aba53..ec32953 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt'; import moment from 'moment'; import { FindOptionsWhere } from 'typeorm'; import { Transactional } from 'typeorm-transactional'; +import { CountryIso } from '~/common/enums'; import { NotificationsService } from '~/common/modules/notification/services'; import { CustomerService } from '~/customer/services'; import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request'; @@ -180,7 +181,7 @@ export class UserService { }); } - async createGoogleUser(googleId: string, email: string) { + async createGoogleUser(googleId: string, email: string, firstName: string, lastName: string) { this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`); const user = await this.userRepository.createUser({ googleId, @@ -189,10 +190,16 @@ export class UserService { isEmailVerified: true, }); + await this.customerService.createGuardianCustomer(user.id, { + firstName, + lastName, + countryOfResidence: CountryIso.SAUDI_ARABIA, + }); + return this.findUserOrThrow({ id: user.id }); } - async createAppleUser(appleId: string, email: string) { + async createAppleUser(appleId: string, email: string, firstName: string, lastName: string) { this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`); const user = await this.userRepository.createUser({ appleId, @@ -201,6 +208,11 @@ export class UserService { isEmailVerified: true, }); + await this.customerService.createGuardianCustomer(user.id, { + firstName, + lastName, + countryOfResidence: CountryIso.SAUDI_ARABIA, + }); return this.findUserOrThrow({ id: user.id }); } @@ -213,6 +225,15 @@ export class UserService { return this.userRepository.update(userId, { password: hashedPasscode, salt, isProfileCompleted: true }); } + async updateUser(userId: string, data: Partial) { + this.logger.log(`Updating user ${userId} with data ${JSON.stringify(data)}`); + const { affected } = await this.userRepository.update(userId, data); + if (affected === 0) { + this.logger.error(`User with id ${userId} not found`); + throw new BadRequestException('USER.NOT_FOUND'); + } + } + private sendCheckerAccountCreatedEmail(email: string, token: string) { return this.notificationsService.sendEmailAsync({ to: email,