From a108d36040e52f52204d2aa48778025d68c20fc1 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sun, 25 Feb 2024 11:51:17 +0100 Subject: [PATCH] Refactor sign-up into its own service (#4173) * Refactor sign-up into its own service * Fix tests --- .../src/core/auth/auth.module.ts | 2 + .../core/auth/services/auth.service.spec.ts | 14 +- .../src/core/auth/services/auth.service.ts | 132 +--------- .../auth/services/sign-up.service.spec.ts | 52 ++++ .../src/core/auth/services/sign-up.service.ts | 241 ++++++++++++++++++ 5 files changed, 306 insertions(+), 135 deletions(-) create mode 100644 packages/twenty-server/src/core/auth/services/sign-up.service.spec.ts create mode 100644 packages/twenty-server/src/core/auth/services/sign-up.service.ts diff --git a/packages/twenty-server/src/core/auth/auth.module.ts b/packages/twenty-server/src/core/auth/auth.module.ts index fe40a5c395..c13769645c 100644 --- a/packages/twenty-server/src/core/auth/auth.module.ts +++ b/packages/twenty-server/src/core/auth/auth.module.ts @@ -19,6 +19,7 @@ import { VerifyAuthController } from 'src/core/auth/controllers/verify-auth.cont import { TokenService } from 'src/core/auth/services/token.service'; import { GoogleGmailService } from 'src/core/auth/services/google-gmail.service'; import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module'; +import { SignUpService } from 'src/core/auth/services/sign-up.service'; import { AuthResolver } from './auth.resolver'; @@ -54,6 +55,7 @@ const jwtModule = JwtModule.registerAsync({ VerifyAuthController, ], providers: [ + SignUpService, AuthService, TokenService, JwtAuthStrategy, diff --git a/packages/twenty-server/src/core/auth/services/auth.service.spec.ts b/packages/twenty-server/src/core/auth/services/auth.service.spec.ts index 21e4986574..696dae6901 100644 --- a/packages/twenty-server/src/core/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/core/auth/services/auth.service.spec.ts @@ -1,15 +1,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { HttpService } from '@nestjs/axios'; import { UserService } from 'src/core/user/services/user.service'; import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service'; -import { FileUploadService } from 'src/core/file/services/file-upload.service'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { User } from 'src/core/user/user.entity'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { EmailService } from 'src/integrations/email/email.service'; -import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; +import { SignUpService } from 'src/core/auth/services/sign-up.service'; import { AuthService } from './auth.service'; import { TokenService } from './token.service'; @@ -30,21 +28,13 @@ describe('AuthService', () => { useValue: {}, }, { - provide: UserWorkspaceService, + provide: SignUpService, useValue: {}, }, { provide: WorkspaceManagerService, useValue: {}, }, - { - provide: FileUploadService, - useValue: {}, - }, - { - provide: HttpService, - useValue: {}, - }, { provide: getRepositoryToken(Workspace, 'core'), useValue: {}, diff --git a/packages/twenty-server/src/core/auth/services/auth.service.ts b/packages/twenty-server/src/core/auth/services/auth.service.ts index 9902007117..18ac914fb5 100644 --- a/packages/twenty-server/src/core/auth/services/auth.service.ts +++ b/packages/twenty-server/src/core/auth/services/auth.service.ts @@ -5,15 +5,10 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { HttpService } from '@nestjs/axios'; import { Repository } from 'typeorm'; -import { v4 } from 'uuid'; import { render } from '@react-email/components'; import { PasswordUpdateNotifyEmail } from 'twenty-emails'; -import FileType from 'file-type'; - -import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; import { ChallengeInput } from 'src/core/auth/dto/challenge.input'; import { assert } from 'src/utils/assert'; @@ -28,13 +23,10 @@ import { WorkspaceInviteHashValid } from 'src/core/auth/dto/workspace-invite-has import { User } from 'src/core/user/user.entity'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { UserService } from 'src/core/user/services/user.service'; -import { WorkspaceManagerService } from 'src/workspace/workspace-manager/workspace-manager.service'; -import { FileUploadService } from 'src/core/file/services/file-upload.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { EmailService } from 'src/integrations/email/email.service'; import { UpdatePassword } from 'src/core/auth/dto/update-password.entity'; -import { getImageBufferFromUrl } from 'src/utils/image'; -import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; +import { SignUpService } from 'src/core/auth/services/sign-up.service'; import { TokenService } from './token.service'; @@ -49,14 +41,11 @@ export class AuthService { constructor( private readonly tokenService: TokenService, private readonly userService: UserService, - private readonly workspaceManagerService: WorkspaceManagerService, - private readonly fileUploadService: FileUploadService, + private readonly signUpService: SignUpService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, - private readonly userWorkspaceService: UserWorkspaceService, - private readonly httpService: HttpService, private readonly environmentService: EnvironmentService, private readonly emailService: EmailService, ) {} @@ -94,117 +83,14 @@ export class AuthService { workspaceInviteHash?: string | null; picture?: string | null; }) { - if (!firstName) firstName = ''; - if (!lastName) lastName = ''; - - const existingUser = await this.userRepository.findOne({ - where: { - email: email, - }, - relations: ['defaultWorkspace'], + return await this.signUpService.signUp({ + email, + password, + firstName, + lastName, + workspaceInviteHash, + picture, }); - - if (existingUser && !workspaceInviteHash) { - assert(!existingUser, 'This user already exists', ForbiddenException); - } - - if (password) { - const isPasswordValid = PASSWORD_REGEX.test(password); - - assert(isPasswordValid, 'Password too weak', BadRequestException); - } - - const passwordHash = password ? await hashPassword(password) : undefined; - let workspace: Workspace | null; - - if (workspaceInviteHash) { - workspace = await this.workspaceRepository.findOneBy({ - inviteHash: workspaceInviteHash, - }); - - assert( - workspace, - 'This workspace inviteHash is invalid', - ForbiddenException, - ); - } else { - assert( - !this.environmentService.isSignUpDisabled(), - 'Sign up is disabled', - ForbiddenException, - ); - - const workspaceToCreate = this.workspaceRepository.create({ - displayName: '', - domainName: '', - inviteHash: v4(), - subscriptionStatus: 'incomplete', - }); - - workspace = await this.workspaceRepository.save(workspaceToCreate); - } - - let imagePath: string | undefined = undefined; - - if (picture) { - const buffer = await getImageBufferFromUrl( - picture, - this.httpService.axiosRef, - ); - - const type = await FileType.fromBuffer(buffer); - - const { paths } = await this.fileUploadService.uploadImage({ - file: buffer, - filename: `${v4()}.${type?.ext}`, - mimeType: type?.mime, - fileFolder: FileFolder.ProfilePicture, - }); - - imagePath = paths[0]; - } - - if (existingUser && workspaceInviteHash) { - const userWorkspaceExists = - await this.userWorkspaceService.checkUserWorkspaceExists( - existingUser.id, - workspace.id, - ); - - if (!userWorkspaceExists) { - await this.userWorkspaceService.create(existingUser.id, workspace.id); - - await this.userWorkspaceService.createWorkspaceMember( - workspace.id, - existingUser, - ); - } - - const updatedUser = await this.userRepository.save({ - id: existingUser.id, - defaultWorkspace: workspace, - updatedAt: new Date().toISOString(), - }); - - return Object.assign(existingUser, updatedUser); - } - - const userToCreate = this.userRepository.create({ - email: email, - firstName: firstName, - lastName: lastName, - defaultAvatarUrl: imagePath, - canImpersonate: false, - passwordHash, - defaultWorkspace: workspace, - }); - - const user = await this.userRepository.save(userToCreate); - - await this.userWorkspaceService.create(user.id, workspace.id); - await this.userWorkspaceService.createWorkspaceMember(workspace.id, user); - - return user; } async verify(email: string): Promise { diff --git a/packages/twenty-server/src/core/auth/services/sign-up.service.spec.ts b/packages/twenty-server/src/core/auth/services/sign-up.service.spec.ts new file mode 100644 index 0000000000..a4423a7fb9 --- /dev/null +++ b/packages/twenty-server/src/core/auth/services/sign-up.service.spec.ts @@ -0,0 +1,52 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { HttpService } from '@nestjs/axios'; + +import { Workspace } from 'src/core/workspace/workspace.entity'; +import { User } from 'src/core/user/user.entity'; +import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { SignUpService } from 'src/core/auth/services/sign-up.service'; +import { FileUploadService } from 'src/core/file/services/file-upload.service'; +import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; + +describe('SignUpService', () => { + let service: SignUpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SignUpService, + { + provide: FileUploadService, + useValue: {}, + }, + { + provide: UserWorkspaceService, + useValue: {}, + }, + { + provide: getRepositoryToken(Workspace, 'core'), + useValue: {}, + }, + { + provide: getRepositoryToken(User, 'core'), + useValue: {}, + }, + { + provide: EnvironmentService, + useValue: {}, + }, + { + provide: HttpService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get(SignUpService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/twenty-server/src/core/auth/services/sign-up.service.ts b/packages/twenty-server/src/core/auth/services/sign-up.service.ts new file mode 100644 index 0000000000..21e8fbbe58 --- /dev/null +++ b/packages/twenty-server/src/core/auth/services/sign-up.service.ts @@ -0,0 +1,241 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { HttpService } from '@nestjs/axios'; + +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; +import FileType from 'file-type'; + +import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; + +import { assert } from 'src/utils/assert'; +import { PASSWORD_REGEX, hashPassword } from 'src/core/auth/auth.util'; +import { User } from 'src/core/user/user.entity'; +import { Workspace } from 'src/core/workspace/workspace.entity'; +import { FileUploadService } from 'src/core/file/services/file-upload.service'; +import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { getImageBufferFromUrl } from 'src/utils/image'; +import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service'; + +export type SignUpServiceInput = { + email: string; + password?: string; + firstName?: string | null; + lastName?: string | null; + workspaceInviteHash?: string | null; + picture?: string | null; +}; + +@Injectable() +export class SignUpService { + constructor( + private readonly fileUploadService: FileUploadService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + private readonly userWorkspaceService: UserWorkspaceService, + private readonly httpService: HttpService, + private readonly environmentService: EnvironmentService, + ) {} + + async signUp({ + email, + workspaceInviteHash, + password, + firstName, + lastName, + picture, + }: SignUpServiceInput) { + if (!firstName) firstName = ''; + if (!lastName) lastName = ''; + + assert(email, 'Email is required', BadRequestException); + + if (password) { + const isPasswordValid = PASSWORD_REGEX.test(password); + + assert(isPasswordValid, 'Password too weak', BadRequestException); + } + + const passwordHash = password ? await hashPassword(password) : undefined; + + let imagePath: string | undefined; + + if (picture) { + imagePath = await this.uploadPicture(picture); + } + + if (workspaceInviteHash) { + return await this.signUpOnExistingWorkspace({ + email, + passwordHash, + workspaceInviteHash, + firstName, + lastName, + imagePath, + }); + } else { + return await this.signUpOnNewWorkspace({ + email, + passwordHash, + firstName, + lastName, + imagePath, + }); + } + } + + private async signUpOnExistingWorkspace({ + email, + passwordHash, + workspaceInviteHash, + firstName, + lastName, + imagePath, + }: { + email: string; + passwordHash: string | undefined; + workspaceInviteHash: string; + firstName: string; + lastName: string; + imagePath: string | undefined; + }) { + const existingUser = await this.userRepository.findOne({ + where: { + email: email, + }, + relations: ['defaultWorkspace'], + }); + + const workspace = await this.workspaceRepository.findOneBy({ + inviteHash: workspaceInviteHash, + }); + + assert( + workspace, + 'This workspace inviteHash is invalid', + ForbiddenException, + ); + + if (existingUser) { + const userWorkspaceExists = + await this.userWorkspaceService.checkUserWorkspaceExists( + existingUser.id, + workspace.id, + ); + + if (!userWorkspaceExists) { + await this.userWorkspaceService.create(existingUser.id, workspace.id); + + await this.userWorkspaceService.createWorkspaceMember( + workspace.id, + existingUser, + ); + } + + const updatedUser = await this.userRepository.save({ + id: existingUser.id, + defaultWorkspace: workspace, + updatedAt: new Date().toISOString(), + }); + + return Object.assign(existingUser, updatedUser); + } + + const userToCreate = this.userRepository.create({ + email: email, + firstName: firstName, + lastName: lastName, + defaultAvatarUrl: imagePath, + canImpersonate: false, + passwordHash, + defaultWorkspace: workspace, + }); + + const user = await this.userRepository.save(userToCreate); + + await this.userWorkspaceService.create(user.id, workspace.id); + await this.userWorkspaceService.createWorkspaceMember(workspace.id, user); + + return user; + } + + private async signUpOnNewWorkspace({ + email, + passwordHash, + firstName, + lastName, + imagePath, + }: { + email: string; + passwordHash: string | undefined; + firstName: string; + lastName: string; + imagePath: string | undefined; + }) { + const existingUser = await this.userRepository.findOne({ + where: { + email: email, + }, + relations: ['defaultWorkspace'], + }); + + if (existingUser) { + assert(!existingUser, 'This user already exists', ForbiddenException); + } + + assert( + !this.environmentService.isSignUpDisabled(), + 'Sign up is disabled', + ForbiddenException, + ); + + const workspaceToCreate = this.workspaceRepository.create({ + displayName: '', + domainName: '', + inviteHash: v4(), + subscriptionStatus: 'incomplete', + }); + + const workspace = await this.workspaceRepository.save(workspaceToCreate); + + const userToCreate = this.userRepository.create({ + email: email, + firstName: firstName, + lastName: lastName, + defaultAvatarUrl: imagePath, + canImpersonate: false, + passwordHash, + defaultWorkspace: workspace, + }); + + const user = await this.userRepository.save(userToCreate); + + await this.userWorkspaceService.create(user.id, workspace.id); + + return user; + } + + async uploadPicture(picture: string): Promise { + const buffer = await getImageBufferFromUrl( + picture, + this.httpService.axiosRef, + ); + + const type = await FileType.fromBuffer(buffer); + + const { paths } = await this.fileUploadService.uploadImage({ + file: buffer, + filename: `${v4()}.${type?.ext}`, + mimeType: type?.mime, + fileFolder: FileFolder.ProfilePicture, + }); + + return paths[0]; + } +}