mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-08-25 13:49:40 +00:00
feat: add apple login
This commit is contained in:
92
package-lock.json
generated
92
package-lock.json
generated
@ -37,6 +37,7 @@
|
||||
"google-libphonenumber": "^3.2.39",
|
||||
"handlebars": "^4.7.8",
|
||||
"ioredis": "^5.4.1",
|
||||
"jwk-to-pem": "^2.0.7",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"nestjs-i18n": "^10.4.9",
|
||||
@ -65,6 +66,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/google-libphonenumber": "^7.4.30",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/jwk-to-pem": "^2.0.3",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.3.1",
|
||||
@ -2858,6 +2860,13 @@
|
||||
"version": "2.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jwk-to-pem": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/jwk-to-pem/-/jwk-to-pem-2.0.3.tgz",
|
||||
"integrity": "sha512-I/WFyFgk5GrNbkpmt14auGO3yFK1Wt4jXzkLuI+fDBNtO5ZI2rbymyGd6bKzfSBEuyRdM64ZUwxU1+eDcPSOEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.13",
|
||||
"dev": true,
|
||||
@ -3788,6 +3797,18 @@
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assert-never": {
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
@ -4055,6 +4076,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
|
||||
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"license": "MIT",
|
||||
@ -4155,6 +4182,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brorand": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
|
||||
"integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.24.2",
|
||||
"dev": true,
|
||||
@ -5514,6 +5547,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/elliptic": {
|
||||
"version": "6.6.1",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz",
|
||||
"integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.11.9",
|
||||
"brorand": "^1.1.0",
|
||||
"hash.js": "^1.0.0",
|
||||
"hmac-drbg": "^1.0.1",
|
||||
"inherits": "^2.0.4",
|
||||
"minimalistic-assert": "^1.0.1",
|
||||
"minimalistic-crypto-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/emitter-listener": {
|
||||
"version": "1.1.2",
|
||||
"license": "BSD-2-Clause",
|
||||
@ -6994,6 +7042,16 @@
|
||||
"version": "2.0.1",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"license": "MIT",
|
||||
@ -7031,6 +7089,17 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/hmac-drbg": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
|
||||
"integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hash.js": "^1.0.3",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"minimalistic-crypto-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hookified": {
|
||||
"version": "1.5.1",
|
||||
"license": "MIT"
|
||||
@ -8503,6 +8572,17 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jwk-to-pem": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.7.tgz",
|
||||
"integrity": "sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"elliptic": "^6.6.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jwks-rsa": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.1.0.tgz",
|
||||
@ -9532,6 +9612,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimalistic-crypto-utils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
|
||||
"integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"license": "ISC",
|
||||
|
@ -54,6 +54,7 @@
|
||||
"google-libphonenumber": "^3.2.39",
|
||||
"handlebars": "^4.7.8",
|
||||
"ioredis": "^5.4.1",
|
||||
"jwk-to-pem": "^2.0.7",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"nestjs-i18n": "^10.4.9",
|
||||
@ -82,6 +83,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/google-libphonenumber": "^7.4.30",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/jwk-to-pem": "^2.0.3",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.3.1",
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { JuniorModule } from '~/junior/junior.module';
|
||||
import { UserModule } from '~/user/user.module';
|
||||
import { AuthController } from './controllers';
|
||||
import { AuthService } from './services';
|
||||
import { AuthService, Oauth2Service } from './services';
|
||||
import { AccessTokenStrategy } from './strategies';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), JuniorModule, UserModule],
|
||||
providers: [AuthService, AccessTokenStrategy],
|
||||
imports: [JwtModule.register({}), JuniorModule, UserModule, HttpModule],
|
||||
providers: [AuthService, AccessTokenStrategy, Oauth2Service],
|
||||
controllers: [AuthController],
|
||||
exports: [],
|
||||
})
|
||||
|
@ -29,6 +29,12 @@ export class LoginRequestDto {
|
||||
@ValidateIf((o) => o.grantType === GrantType.GOOGLE)
|
||||
googleToken!: string;
|
||||
|
||||
@ApiProperty({ example: 'apple_token' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) })
|
||||
@ValidateIf((o) => o.grantType === GrantType.APPLE)
|
||||
appleToken!: string;
|
||||
|
||||
@ApiProperty({ example: 'fcm-device-token' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) })
|
||||
|
11
src/auth/interfaces/apple-payload.interface.ts
Normal file
11
src/auth/interfaces/apple-payload.interface.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface ApplePayload {
|
||||
iss: string;
|
||||
aud: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
sub: string;
|
||||
c_hash: string;
|
||||
auth_time: number;
|
||||
nonce_supported: boolean;
|
||||
email?: string;
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
export * from './apple-payload.interface';
|
||||
export * from './jwt-payload.interface';
|
||||
export * from './login-response.interface';
|
||||
|
@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { Request } from 'express';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { ArrayContains } from 'typeorm';
|
||||
import { CacheService } from '~/common/modules/cache/services';
|
||||
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
||||
import { OtpService } from '~/common/modules/otp/services';
|
||||
@ -22,19 +22,16 @@ import {
|
||||
setJuniorPasswordRequestDto,
|
||||
VerifyUserRequestDto,
|
||||
} from '../dtos/request';
|
||||
import { GrantType } from '../enums';
|
||||
import { GrantType, Roles } from '../enums';
|
||||
import { IJwtPayload, ILoginResponse } from '../interfaces';
|
||||
import { removePadding, verifySignature } from '../utils';
|
||||
import { Oauth2Service } from './oauth2.service';
|
||||
|
||||
const ONE_THOUSAND = 1000;
|
||||
const SALT_ROUNDS = 10;
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly googleWebClientId = this.configService.getOrThrow('GOOGLE_WEB_CLIENT_ID');
|
||||
private readonly googleAndroidClientId = this.configService.getOrThrow('GOOGLE_ANDROID_CLIENT_ID');
|
||||
private readonly googleIosClientId = this.configService.getOrThrow('GOOGLE_IOS_CLIENT_ID');
|
||||
private readonly client = new OAuth2Client();
|
||||
|
||||
constructor(
|
||||
private readonly otpService: OtpService,
|
||||
@ -44,6 +41,7 @@ export class AuthService {
|
||||
private readonly deviceService: DeviceService,
|
||||
private readonly juniorTokenService: JuniorTokenService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly oauth2Service: Oauth2Service,
|
||||
) {}
|
||||
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
|
||||
this.logger.log(`Sending OTP to ${countryCode + phoneNumber}`);
|
||||
@ -269,7 +267,7 @@ export class AuthService {
|
||||
|
||||
if (loginDto.grantType === GrantType.APPLE) {
|
||||
this.logger.log(`Logging in user with email ${loginDto.email} using apple`);
|
||||
throw new BadRequestException('AUTH.APPLE_LOGIN_NOT_IMPLEMENTED');
|
||||
[tokens, user] = await this.loginWithApple(loginDto);
|
||||
}
|
||||
|
||||
if (loginDto.grantType === GrantType.PASSWORD) {
|
||||
@ -383,32 +381,64 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
|
||||
try {
|
||||
const ticket = await this.client.verifyIdToken({
|
||||
idToken: loginDto.googleToken,
|
||||
audience: [this.googleWebClientId, this.googleAndroidClientId, this.googleIosClientId],
|
||||
});
|
||||
const { email, sub } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
|
||||
const [existingUser, isJunior] = await Promise.all([
|
||||
this.userService.findUser({ googleId: sub }),
|
||||
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
|
||||
]);
|
||||
|
||||
const payload = ticket.getPayload();
|
||||
if (isJunior && email) {
|
||||
this.logger.error(`User with email ${email} is an already registered junior`);
|
||||
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
|
||||
}
|
||||
|
||||
const existingUser = await this.userService.findUser({ googleId: payload?.sub });
|
||||
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) {
|
||||
this.logger.debug(`User with google id ${payload?.sub} not found, creating new user`);
|
||||
const user = await this.userService.createGoogleUser(payload!.sub, payload!.email!);
|
||||
const tokens = await this.generateAuthToken(user);
|
||||
|
||||
const tokens = await this.generateAuthToken(user);
|
||||
return [tokens, user];
|
||||
}
|
||||
|
||||
return [tokens, user];
|
||||
const tokens = await this.generateAuthToken(existingUser);
|
||||
|
||||
return [tokens, existingUser];
|
||||
}
|
||||
|
||||
private async loginWithApple(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
|
||||
const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken);
|
||||
|
||||
const [existingUser, 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`);
|
||||
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
|
||||
}
|
||||
|
||||
if (!existingUser) {
|
||||
// Apple only provides email if user authorized zod for the first time
|
||||
if (!email) {
|
||||
this.logger.error(`User authorized zod before but his email is not stored in the database`);
|
||||
throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED');
|
||||
}
|
||||
|
||||
const tokens = await this.generateAuthToken(existingUser);
|
||||
this.logger.debug(`User with apple id ${sub} not found, creating new user`);
|
||||
const user = await this.userService.createAppleUser(sub, email);
|
||||
|
||||
return [tokens, existingUser];
|
||||
} catch (error) {
|
||||
this.logger.error(`Invalid google token`, error);
|
||||
throw new UnauthorizedException();
|
||||
const tokens = await this.generateAuthToken(user);
|
||||
|
||||
return [tokens, user];
|
||||
}
|
||||
|
||||
const tokens = await this.generateAuthToken(existingUser);
|
||||
|
||||
this.logger.log(`User with apple id ${sub} logged in successfully`);
|
||||
|
||||
return [tokens, existingUser];
|
||||
}
|
||||
|
||||
private async generateAuthToken(user: User) {
|
||||
|
@ -1 +1,2 @@
|
||||
export * from './auth.service';
|
||||
export * from './oauth2.service';
|
||||
|
83
src/auth/services/oauth2.service.ts
Normal file
83
src/auth/services/oauth2.service.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import jwkToPem from 'jwk-to-pem';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { ApplePayload } from '../interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class Oauth2Service {
|
||||
private readonly logger = new Logger(Oauth2Service.name);
|
||||
private appleKeysEndpoint = 'https://appleid.apple.com/auth/keys';
|
||||
private appleIssuer = 'https://appleid.apple.com';
|
||||
private readonly googleWebClientId = this.configService.getOrThrow('GOOGLE_WEB_CLIENT_ID');
|
||||
private readonly googleAndroidClientId = this.configService.getOrThrow('GOOGLE_ANDROID_CLIENT_ID');
|
||||
private readonly googleIosClientId = this.configService.getOrThrow('GOOGLE_IOS_CLIENT_ID');
|
||||
private readonly client = new OAuth2Client();
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async verifyAppleToken(appleToken: string): Promise<ApplePayload> {
|
||||
try {
|
||||
const response = await lastValueFrom(this.httpService.get(this.appleKeysEndpoint));
|
||||
|
||||
const keys = response.data.keys;
|
||||
|
||||
const decodedHeader = this.jwtService.decode(appleToken, { complete: true })?.header;
|
||||
|
||||
if (!decodedHeader) {
|
||||
this.logger.error(`Invalid apple token`);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const keyId = decodedHeader.kid;
|
||||
|
||||
const appleKey = keys.find((key: any) => key.kid === keyId);
|
||||
|
||||
if (!appleKey) {
|
||||
this.logger.error(`Invalid apple token`);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const publicKey = jwkToPem(appleKey);
|
||||
|
||||
const payload = this.jwtService.verify(appleToken, {
|
||||
publicKey,
|
||||
algorithms: ['RS256'],
|
||||
audience: this.configService.getOrThrow('APPLE_CLIENT_ID'),
|
||||
issuer: this.appleIssuer,
|
||||
});
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error verifying apple token: ${error} `);
|
||||
throw new UnauthorizedException(error);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyGoogleToken(googleToken: string): Promise<any> {
|
||||
try {
|
||||
const ticket = await this.client.verifyIdToken({
|
||||
idToken: googleToken,
|
||||
audience: [this.googleWebClientId, this.googleAndroidClientId, this.googleIosClientId],
|
||||
});
|
||||
|
||||
const payload = ticket.getPayload();
|
||||
|
||||
if (!payload) {
|
||||
this.logger.error(`payload not found in google token`);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
this.logger.error(`Invalid google token`, error);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
}
|
@ -87,7 +87,27 @@ export class UserService {
|
||||
@Transactional()
|
||||
async createGoogleUser(googleId: string, email: string) {
|
||||
this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`);
|
||||
const user = await this.userRepository.createUser({ googleId, email, roles: [Roles.GUARDIAN] });
|
||||
const user = await this.userRepository.createUser({
|
||||
googleId,
|
||||
email,
|
||||
roles: [Roles.GUARDIAN],
|
||||
isEmailVerified: true,
|
||||
});
|
||||
|
||||
await this.customerService.createGuardianCustomer(user.id);
|
||||
|
||||
return this.findUserOrThrow({ id: user.id });
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async createAppleUser(appleId: string, email: string) {
|
||||
this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`);
|
||||
const user = await this.userRepository.createUser({
|
||||
appleId,
|
||||
email,
|
||||
roles: [Roles.GUARDIAN],
|
||||
isEmailVerified: true,
|
||||
});
|
||||
|
||||
await this.customerService.createGuardianCustomer(user.id);
|
||||
|
||||
|
Reference in New Issue
Block a user