diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 17a7a5f29..247fa0b48 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1,5 +1,5 @@ -import * as Apollo from '@apollo/client'; import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -715,6 +715,7 @@ export type MutationSignUpArgs = { captchaToken?: InputMaybe; email: Scalars['String']; password: Scalars['String']; + workspaceId?: InputMaybe; workspaceInviteHash?: InputMaybe; workspacePersonalInviteToken?: InputMaybe; }; @@ -1977,6 +1978,7 @@ export type SignUpMutationVariables = Exact<{ workspaceInviteHash?: InputMaybe; workspacePersonalInviteToken?: InputMaybe; captchaToken?: InputMaybe; + workspaceId?: InputMaybe; }>; @@ -2989,13 +2991,14 @@ export type RenewTokenMutationHookResult = ReturnType; export type RenewTokenMutationOptions = Apollo.BaseMutationOptions; export const SignUpDocument = gql` - mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String) { + mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String) { signUp( email: $email password: $password workspaceInviteHash: $workspaceInviteHash workspacePersonalInviteToken: $workspacePersonalInviteToken captchaToken: $captchaToken + workspaceId: $workspaceId ) { loginToken { ...AuthTokenFragment @@ -3027,6 +3030,7 @@ export type SignUpMutationFn = Apollo.MutationFunction { const setTokenPair = useSetRecoilState(tokenPairState); @@ -82,7 +82,8 @@ export const useAuth = () => { const { isOnAWorkspaceSubdomain } = useIsCurrentLocationOnAWorkspaceSubdomain(); - const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation(); + + const workspacePublicData = useRecoilValue(workspacePublicDataState); const { setLastAuthenticateWorkspaceDomain } = useLastAuthenticatedWorkspaceDomain(); @@ -328,6 +329,9 @@ export const useAuth = () => { workspaceInviteHash, workspacePersonalInviteToken, captchaToken, + ...(workspacePublicData?.id + ? { workspaceId: workspacePublicData.id } + : {}), }, }); @@ -354,6 +358,7 @@ export const useAuth = () => { [ setIsVerifyPendingState, signUp, + workspacePublicData, isMultiWorkspaceEnabled, handleVerify, redirectToWorkspaceDomain, @@ -386,13 +391,13 @@ export const useAuth = () => { ); } - if (isDefined(workspaceSubdomain)) { - url.searchParams.set('workspaceSubdomain', workspaceSubdomain); + if (isDefined(workspacePublicData)) { + url.searchParams.set('workspaceId', workspacePublicData.id); } return url.toString(); }, - [workspaceSubdomain], + [workspacePublicData], ); const handleGoogleLogin = useCallback( diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 8346f4265..8a01a8782 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -43,6 +43,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; +import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; import { AuthResolver } from './auth.resolver'; @@ -103,6 +104,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; SwitchWorkspaceService, TransientTokenService, ApiKeyService, + SocialSsoService, // reenable when working on: https://github.com/twentyhq/twenty/issues/9143 // OAuthService, ], diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index ed69a8b7a..091a9fa1d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -1,5 +1,8 @@ import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input'; import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input'; @@ -55,6 +58,8 @@ import { AuthService } from './services/auth.service'; @UseFilters(AuthGraphqlApiExceptionFilter) export class AuthResolver { constructor( + @InjectRepository(User, 'core') + private readonly userRepository: Repository, private authService: AuthService, private renewTokenService: RenewTokenService, private userService: UserService, @@ -104,12 +109,14 @@ export class AuthResolver { origin, ); - if (!workspace) { - throw new AuthException( + workspaceValidator.assertIsDefinedOrThrow( + workspace, + new AuthException( 'Workspace not found', AuthExceptionCode.WORKSPACE_NOT_FOUND, - ); - } + ), + ); + const user = await this.authService.challenge(challengeInput, workspace); const loginToken = await this.loginTokenService.generateLoginToken( user.email, @@ -121,16 +128,46 @@ export class AuthResolver { @UseGuards(CaptchaGuard) @Mutation(() => SignUpOutput) - async signUp( - @Args() signUpInput: SignUpInput, - @OriginHeader() origin: string, - ): Promise { - const { user, workspace } = await this.authService.signInUp({ - ...signUpInput, - targetWorkspaceSubdomain: - this.domainManagerService.getWorkspaceSubdomainByOrigin(origin), - fromSSO: false, + async signUp(@Args() signUpInput: SignUpInput): Promise { + const currentWorkspace = await this.authService.findWorkspaceForSignInUp({ + workspaceInviteHash: signUpInput.workspaceInviteHash, authProvider: 'password', + workspaceId: signUpInput.workspaceId, + }); + + const invitation = await this.authService.findInvitationForSignInUp({ + currentWorkspace, + workspacePersonalInviteToken: signUpInput.workspacePersonalInviteToken, + }); + + const existingUser = await this.userRepository.findOne({ + where: { + email: signUpInput.email, + }, + }); + + const { userData } = this.authService.formatUserDataPayload( + { + email: signUpInput.email, + }, + existingUser, + ); + + await this.authService.checkAccessForSignIn({ + userData, + invitation, + workspaceInviteHash: signUpInput.workspaceInviteHash, + workspace: currentWorkspace, + }); + + const { user, workspace } = await this.authService.signInUp({ + userData, + workspace: currentWorkspace, + invitation, + authParams: { + provider: 'password', + password: signUpInput.password, + }, }); const loginToken = await this.loginTokenService.generateLoginToken( diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index d4a1cd485..fe89ad551 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -23,8 +23,7 @@ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service' import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) @@ -33,9 +32,8 @@ export class GoogleAuthController { private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, private readonly domainManagerService: DomainManagerService, - private readonly environmentService: EnvironmentService, - @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, ) {} @Get() @@ -49,57 +47,61 @@ export class GoogleAuthController { @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard) @UseFilters(AuthOAuthExceptionFilter) async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) { + const { + firstName, + lastName, + email, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + workspaceId, + billingCheckoutSessionState, + } = req.user; + + const currentWorkspace = await this.authService.findWorkspaceForSignInUp({ + workspaceId, + workspaceInviteHash, + email, + authProvider: 'google', + }); + try { - const { - firstName, - lastName, - email, - picture, - workspaceInviteHash, + const invitation = await this.authService.findInvitationForSignInUp({ + currentWorkspace, workspacePersonalInviteToken, - targetWorkspaceSubdomain, + email, + }); + + const existingUser = await this.userRepository.findOne({ + where: { email }, + }); + + const { userData } = this.authService.formatUserDataPayload( + { + firstName, + lastName, + email, + picture, + }, + existingUser, + ); + + await this.authService.checkAccessForSignIn({ + userData, + invitation, + workspaceInviteHash, + workspace: currentWorkspace, + }); + + const { user, workspace } = await this.authService.signInUp({ + invitation, + workspace: currentWorkspace, + userData, + authParams: { + provider: 'google', + }, billingCheckoutSessionState, - } = req.user; - - const signInUpParams = { - email, - firstName, - lastName, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - targetWorkspaceSubdomain, - fromSSO: true, - isAuthEnabled: 'google', - }; - - if ( - this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && - (targetWorkspaceSubdomain === - this.environmentService.get('DEFAULT_SUBDOMAIN') || - !targetWorkspaceSubdomain) - ) { - const workspaceWithGoogleAuthActive = - await this.workspaceRepository.findOne({ - where: { - isGoogleAuthEnabled: true, - workspaceUsers: { - user: { - email, - }, - }, - }, - relations: ['workspaceUsers', 'workspaceUsers.user'], - }); - - if (workspaceWithGoogleAuthActive) { - signInUpParams.targetWorkspaceSubdomain = - workspaceWithGoogleAuthActive.subdomain; - } - } - - const { user, workspace } = - await this.authService.signInUp(signInUpParams); + }); const loginToken = await this.loginTokenService.generateLoginToken( user.email, @@ -107,20 +109,17 @@ export class GoogleAuthController { ); return res.redirect( - this.authService.computeRedirectURI( - loginToken.token, - workspace.subdomain, + this.authService.computeRedirectURI({ + loginToken: loginToken.token, + subdomain: workspace.subdomain, billingCheckoutSessionState, - ), + }), ); } catch (err) { if (err instanceof AuthException) { return res.redirect( - this.domainManagerService.computeRedirectErrorUrl({ - subdomain: - req.user.targetWorkspaceSubdomain ?? - this.environmentService.get('DEFAULT_SUBDOMAIN'), - errorMessage: err.message, + this.domainManagerService.computeRedirectErrorUrl(err.message, { + subdomain: currentWorkspace?.subdomain, }), ); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 131c56207..2485b1699 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -19,8 +19,7 @@ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service' import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) @@ -29,9 +28,8 @@ export class MicrosoftAuthController { private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, private readonly domainManagerService: DomainManagerService, - private readonly environmentService: EnvironmentService, - @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, ) {} @Get() @@ -47,38 +45,60 @@ export class MicrosoftAuthController { @Req() req: MicrosoftRequest, @Res() res: Response, ) { + const { + firstName, + lastName, + email, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + workspaceId, + billingCheckoutSessionState, + } = req.user; + + const currentWorkspace = await this.authService.findWorkspaceForSignInUp({ + workspaceId, + workspaceInviteHash, + email, + authProvider: 'microsoft', + }); + try { - const signInUpParams = req.user; + const invitation = await this.authService.findInvitationForSignInUp({ + currentWorkspace, + workspacePersonalInviteToken, + email, + }); - if ( - this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && - (signInUpParams.targetWorkspaceSubdomain === - this.environmentService.get('DEFAULT_SUBDOMAIN') || - !signInUpParams.targetWorkspaceSubdomain) - ) { - const workspaceWithGoogleAuthActive = - await this.workspaceRepository.findOne({ - where: { - isMicrosoftAuthEnabled: true, - workspaceUsers: { - user: { - email: signInUpParams.email, - }, - }, - }, - relations: ['userWorkspaces', 'userWorkspaces.user'], - }); + const existingUser = await this.userRepository.findOne({ + where: { email }, + }); - if (workspaceWithGoogleAuthActive) { - signInUpParams.targetWorkspaceSubdomain = - workspaceWithGoogleAuthActive.subdomain; - } - } + const { userData } = this.authService.formatUserDataPayload( + { + firstName, + lastName, + email, + picture, + }, + existingUser, + ); + + await this.authService.checkAccessForSignIn({ + userData, + invitation, + workspaceInviteHash, + workspace: currentWorkspace, + }); const { user, workspace } = await this.authService.signInUp({ - ...signInUpParams, - fromSSO: true, - authProvider: 'microsoft', + invitation, + workspace: currentWorkspace, + userData, + authParams: { + provider: 'microsoft', + }, + billingCheckoutSessionState, }); const loginToken = await this.loginTokenService.generateLoginToken( @@ -87,20 +107,18 @@ export class MicrosoftAuthController { ); return res.redirect( - this.authService.computeRedirectURI( - loginToken.token, - workspace.subdomain, - signInUpParams.billingCheckoutSessionState, - ), + this.authService.computeRedirectURI({ + loginToken: loginToken.token, + subdomain: workspace.subdomain, + + billingCheckoutSessionState, + }), ); } catch (err) { if (err instanceof AuthException) { return res.redirect( - this.domainManagerService.computeRedirectErrorUrl({ - subdomain: - req.user.targetWorkspaceSubdomain ?? - this.environmentService.get('DEFAULT_SUBDOMAIN'), - errorMessage: err.message, + this.domainManagerService.computeRedirectErrorUrl(err.message, { + subdomain: currentWorkspace?.subdomain, }), ); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 394c86713..c7accf7cb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -30,9 +30,9 @@ import { IdentityProviderType, WorkspaceSSOIdentityProvider, } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; @Controller('auth') @UseFilters(AuthRestApiExceptionFilter) @@ -41,9 +41,10 @@ export class SSOAuthController { private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, private readonly domainManagerService: DomainManagerService, - private readonly userWorkspaceService: UserWorkspaceService, - private readonly environmentService: EnvironmentService, + private readonly userService: UserService, private readonly ssoService: SSOService, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, @InjectRepository(WorkspaceSSOIdentityProvider, 'core') private readonly workspaceSSOIdentityProviderRepository: Repository, ) {} @@ -81,50 +82,44 @@ export class SSOAuthController { @Get('oidc/callback') @UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard) async oidcAuthCallback(@Req() req: any, @Res() res: Response) { - try { - const { loginToken, identityProvider } = await this.generateLoginToken( - req.user, - ); - - return res.redirect( - this.authService.computeRedirectURI( - loginToken.token, - identityProvider.workspace.subdomain, - ), - ); - } catch (err) { - if (err instanceof AuthException) { - return res.redirect( - this.domainManagerService.computeRedirectErrorUrl({ - subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), - errorMessage: err.message, - }), - ); - } - throw err; - } + return this.authCallback(req, res); } @Post('saml/callback/:identityProviderId') @UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard) async samlAuthCallback(@Req() req: any, @Res() res: Response) { + return this.authCallback(req, res); + } + + private async authCallback(req: any, res: Response) { + const workspaceIdentityProvider = + await this.findWorkspaceIdentityProviderByIdentityProviderId( + req.user.identityProviderId, + ); + + if (!workspaceIdentityProvider) { + throw new AuthException( + 'Identity provider not found', + AuthExceptionCode.INVALID_DATA, + ); + } try { const { loginToken, identityProvider } = await this.generateLoginToken( req.user, + workspaceIdentityProvider, ); return res.redirect( - this.authService.computeRedirectURI( - loginToken.token, - identityProvider.workspace.subdomain, - ), + this.authService.computeRedirectURI({ + loginToken: loginToken.token, + subdomain: identityProvider.workspace.subdomain, + }), ); } catch (err) { if (err instanceof AuthException) { return res.redirect( - this.domainManagerService.computeRedirectErrorUrl({ - subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), - errorMessage: err.message, + this.domainManagerService.computeRedirectErrorUrl(err.message, { + subdomain: workspaceIdentityProvider.workspace.subdomain, }), ); } @@ -132,26 +127,19 @@ export class SSOAuthController { } } - private async generateLoginToken({ - user, - identityProviderId, - }: { - identityProviderId?: string; - user: { email: string } & Record; - }) { - if (!identityProviderId) { - throw new AuthException( - 'Identity provider ID is required', - AuthExceptionCode.INVALID_DATA, - ); - } - - const identityProvider = - await this.workspaceSSOIdentityProviderRepository.findOne({ - where: { id: identityProviderId }, - relations: ['workspace'], - }); + private async findWorkspaceIdentityProviderByIdentityProviderId( + identityProviderId: string, + ) { + return await this.workspaceSSOIdentityProviderRepository.findOne({ + where: { id: identityProviderId }, + relations: ['workspace'], + }); + } + private async generateLoginToken( + user: { email: string } & Record, + identityProvider: WorkspaceSSOIdentityProvider, + ) { if (!identityProvider) { throw new AuthException( 'Identity provider not found', @@ -159,28 +147,24 @@ export class SSOAuthController { ); } - await this.authService.signInUp({ - ...user, - ...(this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') - ? { - targetWorkspaceSubdomain: identityProvider.workspace.subdomain, - } - : {}), - fromSSO: true, + const existingUser = await this.userRepository.findOne({ + where: { + email: user.email, + }, }); - const isUserExistInWorkspace = - await this.userWorkspaceService.checkUserWorkspaceExistsByEmail( - user.email, - identityProvider.workspaceId, - ); + const { userData } = this.authService.formatUserDataPayload( + user, + existingUser, + ); - if (!isUserExistInWorkspace) { - throw new AuthException( - 'User not found in workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } + await this.authService.signInUp({ + userData, + workspace: identityProvider.workspace, + authParams: { + provider: 'sso', + }, + }); return { identityProvider, diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts index 4d952e0f2..0677274b0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/sign-up.input.ts @@ -14,6 +14,11 @@ export class SignUpInput { @IsString() password: string; + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + workspaceId?: string; + @Field(() => String, { nullable: true }) @IsString() @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts index 2cb7e93a1..f080dfe77 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts @@ -39,10 +39,10 @@ export class GoogleOauthGuard extends AuthGuard('google') { } if ( - request.query.workspaceSubdomain && - typeof request.query.workspaceSubdomain === 'string' + request.query.workspaceId && + typeof request.query.workspaceId === 'string' ) { - request.params.workspaceSubdomain = request.query.workspaceSubdomain; + request.params.workspaceId = request.query.workspaceId; } if ( diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index d527add80..6c99c2a47 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import bcrypt from 'bcrypt'; import { expect, jest } from '@jest/globals'; +import { Repository } from 'typeorm'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -16,6 +17,7 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; import { AuthService } from './auth.service'; @@ -31,6 +33,7 @@ const userWorkspaceAddUserToWorkspaceMock = jest.fn(); describe('AuthService', () => { let service: AuthService; + let appTokenRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -48,7 +51,14 @@ describe('AuthService', () => { }, { provide: getRepositoryToken(AppToken, 'core'), - useValue: {}, + useValue: { + createQueryBuilder: jest.fn().mockReturnValue({ + leftJoin: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockImplementation(() => null), + }), + }, }, { provide: SignInUpService, @@ -94,10 +104,18 @@ describe('AuthService', () => { validateInvitation: workspaceInvitationValidateInvitationMock, }, }, + { + provide: SocialSsoService, + useValue: {}, + }, ], }).compile(); service = module.get(AuthService); + + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); }); it('should be defined', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 9a17c69f8..83bb428ba 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -47,6 +47,15 @@ import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { + AuthProviderWithPasswordType, + ExistingUserOrNewUser, + SignInUpBaseParams, + SignInUpNewUserPayload, +} from 'src/engine/core-modules/auth/types/signInUp.type'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -57,6 +66,8 @@ export class AuthService { private readonly refreshTokenService: RefreshTokenService, private readonly userWorkspaceService: UserWorkspaceService, private readonly workspaceInvitationService: WorkspaceInvitationService, + private readonly socialSsoService: SocialSsoService, + private readonly userService: UserService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -149,41 +160,58 @@ export class AuthService { return user; } - async signInUp({ - email, - password, - workspaceInviteHash, - workspacePersonalInviteToken, - targetWorkspaceSubdomain, - firstName, - lastName, - picture, - fromSSO, - authProvider, - }: { - email: string; - password?: string; - firstName?: string | null; - lastName?: string | null; - workspaceInviteHash?: string; - workspacePersonalInviteToken?: string; - picture?: string | null; - fromSSO: boolean; - targetWorkspaceSubdomain?: string; - authProvider?: WorkspaceAuthProvider; - billingCheckoutSessionState?: string; - }) { + async signInUp( + params: SignInUpBaseParams & + ExistingUserOrNewUser & + AuthProviderWithPasswordType, + ) { + if ( + params.authParams.provider === 'password' && + params.userData.type === 'newUser' + ) { + params.userData.newUserPayload.passwordHash = + await this.signInUpService.generateHash(params.authParams.password); + } + + if ( + params.authParams.provider === 'password' && + params.userData.type === 'existingUser' + ) { + await this.signInUpService.validatePassword({ + password: params.authParams.password, + passwordHash: params.userData.existingUser.passwordHash, + }); + } + + if (params.workspace) { + workspaceValidator.isAuthEnabledOrThrow( + params.authParams.provider, + params.workspace, + ); + } + + if (params.userData.type === 'newUser') { + const partialUserWithPicture = + await this.signInUpService.computeParamsForNewUser( + params.userData.newUserPayload, + params.authParams, + ); + + return await this.signInUpService.signInUp({ + ...params, + userData: { + type: 'newUserWithPicture', + newUserWithPicture: partialUserWithPicture, + }, + }); + } + return await this.signInUpService.signInUp({ - email, - password, - firstName, - lastName, - workspaceInviteHash, - workspacePersonalInviteToken, - targetWorkspaceSubdomain, - picture, - fromSSO, - authProvider, + ...params, + userData: { + type: 'existingUser', + existingUser: params.userData.existingUser, + }, }); } @@ -414,11 +442,15 @@ export class AuthService { return workspace; } - computeRedirectURI( - loginToken: string, - subdomain?: string, - billingCheckoutSessionState?: string, - ) { + computeRedirectURI({ + loginToken, + subdomain, + billingCheckoutSessionState, + }: { + loginToken: string; + subdomain?: string; + billingCheckoutSessionState?: string; + }) { const url = this.domainManagerService.buildWorkspaceURL({ subdomain, pathname: '/verify', @@ -472,4 +504,118 @@ export class AuthService { ), })); } + + async findInvitationForSignInUp({ + currentWorkspace, + workspacePersonalInviteToken, + email, + }: { + currentWorkspace?: Workspace | null; + workspacePersonalInviteToken?: string; + email?: string; + }) { + if (!currentWorkspace) return undefined; + + const qr = this.appTokenRepository.createQueryBuilder('appToken'); + + qr.where('"appToken"."workspaceId" = :workspaceId', { + workspaceId: currentWorkspace.id, + }).andWhere('"appToken".type = :type', { + type: AppTokenType.InvitationToken, + }); + + if (email) { + qr.andWhere('"appToken".context->>\'email\' = :email', { + email, + }); + } + + if (workspacePersonalInviteToken) { + qr.andWhere('"appToken".value = :personalInviteToken', { + personalInviteToken: workspacePersonalInviteToken, + }); + } + + return (await qr.getOne()) ?? undefined; + } + + async findWorkspaceForSignInUp( + params: { + workspaceId?: string; + workspaceInviteHash?: string; + } & ( + | { + authProvider: Exclude; + email: string; + } + | { authProvider: Extract } + ), + ) { + if (params.workspaceInviteHash) { + return ( + (await this.workspaceRepository.findOneBy({ + inviteHash: params.workspaceInviteHash, + })) ?? undefined + ); + } + + if (params.authProvider !== 'password') { + return ( + (await this.socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + { + email: params.email, + authProvider: params.authProvider, + }, + params.workspaceId, + )) ?? undefined + ); + } + + if (params.authProvider === 'password' && params.workspaceId) { + return ( + (await this.workspaceRepository.findOneBy({ + id: params.workspaceId, + })) ?? undefined + ); + } + + return undefined; + } + + formatUserDataPayload( + newUserPayload: SignInUpNewUserPayload, + existingUser?: User | null, + ): ExistingUserOrNewUser { + return { + userData: existingUser + ? { type: 'existingUser', existingUser } + : { + type: 'newUser', + newUserPayload, + }, + }; + } + + async checkAccessForSignIn({ + userData, + invitation, + workspaceInviteHash, + workspace, + }: { + workspaceInviteHash?: string; + } & ExistingUserOrNewUser & + SignInUpBaseParams) { + if (!invitation && !workspaceInviteHash && workspace) { + if (userData.type === 'existingUser') { + await this.userService.hasUserAccessToWorkspaceOrThrow( + userData.existingUser.id, + workspace.id, + ); + } + throw new AuthException( + 'User does not have access to this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 3c6947624..a5caf1e95 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -1,90 +1,83 @@ -import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; +import { HttpService } from '@nestjs/axios'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { expect, jest } from '@jest/globals'; -import bcrypt from 'bcrypt'; +import { Repository } from 'typeorm'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; -import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; -import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; -import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { + AuthProviderWithPasswordType, + ExistingUserOrPartialUserWithPicture, + SignInUpBaseParams, +} from 'src/engine/core-modules/auth/types/signInUp.type'; import { Workspace, WorkspaceActivationStatus, } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; -jest.mock('bcrypt'); - -const UserFindOneMock = jest.fn(); -const workspaceInvitationValidateInvitationMock = jest.fn(); -const workspaceInvitationInvalidateWorkspaceInvitationMock = jest.fn(); -const workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock = - jest.fn(); -const userWorkspaceServiceAddUserToWorkspaceMock = jest.fn(); -const UserCreateMock = jest.fn(); -const UserSaveMock = jest.fn(); -const EnvironmentServiceGetMock = jest.fn(); -const WorkspaceCountMock = jest.fn(); -const WorkspaceCreateMock = jest.fn(); -const WorkspaceSaveMock = jest.fn(); -const WorkspaceFindOneMock = jest.fn(); +jest.mock('src/utils/image', () => { + return { + getImageBufferFromUrl: () => Promise.resolve(Buffer.from('')), + }; +}); describe('SignInUpService', () => { let service: SignInUpService; - - afterEach(() => { - jest.clearAllMocks(); - }); + let UserRepository: Repository; + let WorkspaceRepository: Repository; + let fileUploadService: FileUploadService; + let workspaceInvitationService: WorkspaceInvitationService; + let userWorkspaceService: UserWorkspaceService; + let onboardingService: OnboardingService; + let httpService: HttpService; + let environmentService: EnvironmentService; + let domainManagerService: DomainManagerService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SignInUpService, { - provide: FileUploadService, - useValue: {}, + provide: getRepositoryToken(User, 'core'), + useValue: { + create: jest.fn(), + save: jest.fn(), + }, }, { provide: getRepositoryToken(Workspace, 'core'), useValue: { - count: WorkspaceCountMock, - create: WorkspaceCreateMock, - save: WorkspaceSaveMock, - findOne: WorkspaceFindOneMock, + save: jest.fn(), + create: jest.fn(), + get: jest.fn(), + count: jest.fn(), }, }, { - provide: getRepositoryToken(User, 'core'), + provide: FileUploadService, useValue: { - findOne: UserFindOneMock, - create: UserCreateMock, - save: UserSaveMock, + uploadImage: jest.fn(), }, }, - { - provide: getRepositoryToken(AppToken, 'core'), - useValue: {}, - }, { provide: WorkspaceInvitationService, - useValue: {}, - }, - { - provide: WorkspaceService, - useValue: {}, + useValue: { + validateInvitation: jest.fn(), + invalidateWorkspaceInvitation: jest.fn(), + }, }, { provide: UserWorkspaceService, useValue: { - addUserToWorkspace: userWorkspaceServiceAddUserToWorkspaceMock, + addUserToWorkspace: jest.fn(), create: jest.fn(), }, }, @@ -93,7 +86,6 @@ describe('SignInUpService', () => { useValue: { setOnboardingConnectAccountPending: jest.fn(), setOnboardingInviteTeamPending: jest.fn(), - setOnboardingCreateProfilePending: jest.fn(), }, }, { @@ -103,410 +95,150 @@ describe('SignInUpService', () => { { provide: EnvironmentService, useValue: { - get: EnvironmentServiceGetMock, - }, - }, - { - provide: WorkspaceInvitationService, - useValue: { - validateInvitation: workspaceInvitationValidateInvitationMock, - invalidateWorkspaceInvitation: - workspaceInvitationInvalidateWorkspaceInvitationMock, - findInvitationByWorkspaceSubdomainAndUserEmail: - workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock, + get: jest.fn(), }, }, { provide: DomainManagerService, useValue: { - generateSubdomain: jest.fn().mockReturnValue('testSubDomain'), - getWorkspaceBySubdomainOrDefaultWorkspace: jest - .fn() - .mockReturnValue({}), - }, - }, - { - provide: UserService, - useValue: { - hasUserAccessToWorkspaceOrThrow: jest.fn(), + generateSubdomain: jest.fn(), }, }, ], }).compile(); service = module.get(SignInUpService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - it('signInUp - sso - new user', async () => { - const email = 'test@test.com'; - - UserFindOneMock.mockReturnValueOnce(false); - workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( - undefined, + UserRepository = module.get(getRepositoryToken(User, 'core')); + WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core')); + fileUploadService = module.get(FileUploadService); + workspaceInvitationService = module.get( + WorkspaceInvitationService, ); + userWorkspaceService = + module.get(UserWorkspaceService); + onboardingService = module.get(OnboardingService); + httpService = module.get(HttpService); + environmentService = module.get(EnvironmentService); + domainManagerService = + module.get(DomainManagerService); + }); - const spy = jest - .spyOn(service, 'signUpOnNewWorkspace') - .mockResolvedValueOnce({ user: {}, workspace: {} } as { - user: User; - workspace: Workspace; + it('should handle signInUp with valid personal invitation', async () => { + const params: SignInUpBaseParams & + ExistingUserOrPartialUserWithPicture & + AuthProviderWithPasswordType = { + invitation: { value: 'invitationToken' } as AppToken, + workspace: { + id: 'workspaceId', + activationStatus: WorkspaceActivationStatus.ACTIVE, + } as Workspace, + authParams: { provider: 'password', password: 'validPassword' }, + userData: { + type: 'existingUser', + existingUser: { email: 'test@example.com' } as User, + }, + }; + + jest + .spyOn(workspaceInvitationService, 'validateInvitation') + .mockResolvedValue({ + isValid: true, + workspace: params.workspace as Workspace, }); - await service.signInUp({ - email: 'test@test.com', - fromSSO: true, - targetWorkspaceSubdomain: 'testSubDomain', + jest + .spyOn(workspaceInvitationService, 'invalidateWorkspaceInvitation') + .mockResolvedValue(undefined); + + jest + .spyOn(userWorkspaceService, 'addUserToWorkspace') + .mockResolvedValue({} as User); + + const result = await service.signInUp(params); + + expect(result.workspace).toEqual(params.workspace); + expect(result.user).toBeDefined(); + expect(workspaceInvitationService.validateInvitation).toHaveBeenCalledWith({ + workspacePersonalInviteToken: 'invitationToken', + email: 'test@example.com', }); - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - email, - passwordHash: undefined, - firstName: expect.any(String), - lastName: expect.any(String), - picture: undefined, - }), - ); - }); - it('signInUp - sso - existing user', async () => { - const email = 'existing@test.com'; - const existingUser = { - id: 'user-id', - email, - passwordHash: undefined, - }; - - UserFindOneMock.mockReturnValueOnce(existingUser); - workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( - undefined, - ); - WorkspaceFindOneMock.mockReturnValueOnce({ - id: 'another-workspace', - }); - - const result = await service.signInUp({ - email, - fromSSO: true, - targetWorkspaceSubdomain: 'testSubDomain', - }); - - expect(result).toEqual({ user: existingUser, workspace: {} }); - }); - it('signInUp - sso - new user - existing invitation', async () => { - const email = 'newuser@test.com'; - const workspaceId = 'workspace-id'; - - UserFindOneMock.mockReturnValueOnce(null); - workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( - { - value: 'personal-token-value', - }, - ); - workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ - isValid: true, - workspace: { id: workspaceId }, - }); - - workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( - true, - ); - - const spySignInUpOnExistingWorkspace = jest - .spyOn(service, 'signInUpOnExistingWorkspace') - .mockResolvedValueOnce( - {} as Awaited< - ReturnType<(typeof service)['signInUpOnExistingWorkspace']> - >, - ); - - await service.signInUp({ - email, - fromSSO: true, - targetWorkspaceSubdomain: 'testSubDomain', - }); - - expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - email, - passwordHash: undefined, - workspace: expect.objectContaining({ - id: workspaceId, - }), - firstName: expect.any(String), - lastName: expect.any(String), - picture: undefined, - }), - ); - expect( - workspaceInvitationInvalidateWorkspaceInvitationMock, - ).toHaveBeenCalledWith(workspaceId, email); + workspaceInvitationService.invalidateWorkspaceInvitation, + ).toHaveBeenCalledWith( + (params.workspace as Workspace).id, + 'test@example.com', + ); }); - it('signInUp - sso - existing user - existing invitation', async () => { - const email = 'existinguser@test.com'; - const workspaceId = 'workspace-id'; - const existingUser = { - id: 'user-id', - email, - passwordHash: undefined, + + it('should handle signInUp on existing workspace without invitation', async () => { + const params: SignInUpBaseParams & + ExistingUserOrPartialUserWithPicture & + AuthProviderWithPasswordType = { + workspace: { + id: 'workspaceId', + activationStatus: WorkspaceActivationStatus.ACTIVE, + } as Workspace, + authParams: { provider: 'password', password: 'validPassword' }, + userData: { + type: 'existingUser', + existingUser: { email: 'test@example.com' } as User, + }, }; - const workspace = { - id: workspaceId, + jest + .spyOn(userWorkspaceService, 'addUserToWorkspace') + .mockResolvedValue({} as User); + + const result = await service.signInUp(params); + + expect(result.workspace).toEqual(params.workspace); + expect(result.user).toBeDefined(); + expect(userWorkspaceService.addUserToWorkspace).toHaveBeenCalled(); + }); + + it('should handle signUp on new workspace for a new user', async () => { + const params: SignInUpBaseParams & + ExistingUserOrPartialUserWithPicture & + AuthProviderWithPasswordType = { + authParams: { provider: 'password', password: 'validPassword' }, + userData: { + type: 'newUserWithPicture', + newUserWithPicture: { + email: 'newuser@example.com', + picture: 'pictureUrl', + }, + }, + }; + + jest.spyOn(environmentService, 'get').mockReturnValue(false); + jest.spyOn(WorkspaceRepository, 'count').mockResolvedValue(0); + jest.spyOn(WorkspaceRepository, 'create').mockReturnValue({} as Workspace); + jest.spyOn(WorkspaceRepository, 'save').mockResolvedValue({ + id: 'newWorkspaceId', activationStatus: WorkspaceActivationStatus.ACTIVE, - }; - - UserFindOneMock.mockReturnValueOnce(existingUser); - workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( - { - value: 'personal-token-value', - }, - ); - workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ - isValid: true, - workspace, + } as Workspace); + jest.spyOn(fileUploadService, 'uploadImage').mockResolvedValue({ + id: '', + mimeType: '', + paths: ['path/to/image'], }); + jest.spyOn(UserRepository, 'create').mockReturnValue({} as User); + jest + .spyOn(domainManagerService, 'generateSubdomain') + .mockResolvedValue('a-subdomain'); + jest + .spyOn(UserRepository, 'save') + .mockResolvedValue({ id: 'newUserId' } as User); + jest.spyOn(userWorkspaceService, 'create').mockResolvedValue({} as any); - workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( - true, - ); + const result = await service.signInUp(params); - userWorkspaceServiceAddUserToWorkspaceMock.mockReturnValueOnce({}); - - const result = await service.signInUp({ - email, - fromSSO: true, - targetWorkspaceSubdomain: 'testSubDomain', - }); - - expect(result).toEqual({ user: existingUser, workspace }); - expect(userWorkspaceServiceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1); - expect( - workspaceInvitationInvalidateWorkspaceInvitationMock, - ).toHaveBeenCalledWith(workspaceId, email); - }); - it('signInUp - sso - new user - personal invitation token', async () => { - const email = 'newuser@test.com'; - const workspaceId = 'workspace-id'; - const workspacePersonalInviteToken = 'personal-token-value'; - const workspace = { - id: workspaceId, - activationStatus: WorkspaceActivationStatus.ACTIVE, - }; - - UserFindOneMock.mockReturnValueOnce(null); - - workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ - isValid: true, - workspace, - }); - - workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( - true, - ); - - const spySignInUpOnExistingWorkspace = jest - .spyOn(service, 'signInUpOnExistingWorkspace') - .mockResolvedValueOnce( - {} as Awaited< - ReturnType<(typeof service)['signInUpOnExistingWorkspace']> - >, - ); - - await service.signInUp({ - email, - fromSSO: true, - workspacePersonalInviteToken, - targetWorkspaceSubdomain: 'testSubDomain', - }); - - expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - email, - passwordHash: undefined, - workspace: expect.objectContaining({ - id: workspaceId, - }), - firstName: expect.any(String), - lastName: expect.any(String), - picture: undefined, - }), - ); - - expect( - workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock, - ).not.toHaveBeenCalled(); - expect( - workspaceInvitationInvalidateWorkspaceInvitationMock, - ).toHaveBeenCalledWith(workspaceId, email); - }); - it('signInUp - sso - existing user - personal invitation token', async () => { - const email = 'existinguser@test.com'; - const workspaceId = 'workspace-id'; - const workspacePersonalInviteToken = 'personal-token-value'; - const existingUser = { - id: 'user-id', - email, - passwordHash: undefined, - }; - - UserFindOneMock.mockReturnValueOnce(existingUser); - workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ - isValid: true, - workspace: { - id: workspaceId, - activationStatus: WorkspaceActivationStatus.ACTIVE, - }, - }); - - workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( - true, - ); - - await service.signInUp({ - email, - fromSSO: true, - workspacePersonalInviteToken, - targetWorkspaceSubdomain: 'testSubDomain', - }); - - expect( - workspaceInvitationInvalidateWorkspaceInvitationMock, - ).toHaveBeenCalledWith(workspaceId, email); - }); - it('signInUp - credentials - existing user', async () => { - const email = 'existinguser@test.com'; - const password = 'validPassword123'; - const existingUser = { - id: 'user-id', - email, - passwordHash: 'hash-of-validPassword123', - }; - - UserFindOneMock.mockReturnValueOnce(existingUser); - - EnvironmentServiceGetMock.mockReturnValueOnce(false); - - WorkspaceFindOneMock.mockReturnValueOnce({ - id: 'another-workspace', - }); - - (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); - - await service.signInUp({ - email, - password, - fromSSO: false, - targetWorkspaceSubdomain: 'testSubDomain', - }); - - expect( - workspaceInvitationInvalidateWorkspaceInvitationMock, - ).not.toHaveBeenCalled(); - }); - it('signInUp - credentials - new user', async () => { - const email = 'newuser@test.com'; - const password = 'validPassword123'; - - UserFindOneMock.mockReturnValueOnce(null); - - UserCreateMock.mockReturnValueOnce({} as User); - UserSaveMock.mockReturnValueOnce({} as User); - - EnvironmentServiceGetMock.mockReturnValueOnce(true); - - WorkspaceCreateMock.mockReturnValueOnce({}); - WorkspaceSaveMock.mockReturnValueOnce({}); - - await service.signInUp({ - email, - password, - fromSSO: false, - targetWorkspaceSubdomain: 'testSubDomain', - }); - - expect(UserCreateMock).toHaveBeenCalledTimes(1); - expect(UserSaveMock).toHaveBeenCalledTimes(1); - - expect(WorkspaceSaveMock).toHaveBeenCalledTimes(1); - expect(WorkspaceCreateMock).toHaveBeenCalledTimes(1); - }); - it('signInUp - credentials - new user - personal invitation token', async () => { - const email = 'newuser@test.com'; - const password = 'validPassword123'; - const workspaceId = 'workspace-id'; - const workspacePersonalInviteToken = 'personal-token-value'; - - UserFindOneMock.mockReturnValueOnce(null); - workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ - isValid: true, - workspace: { - id: workspaceId, - activationStatus: WorkspaceActivationStatus.ACTIVE, - }, - }); - - workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( - true, - ); - - UserCreateMock.mockReturnValueOnce({} as User); - UserSaveMock.mockReturnValueOnce({} as User); - - await service.signInUp({ - email, - password, - fromSSO: false, - workspacePersonalInviteToken, - targetWorkspaceSubdomain: 'testSubDomain', - }); - - expect(UserCreateMock).toHaveBeenCalledTimes(1); - expect(UserSaveMock).toHaveBeenCalledTimes(1); - - expect( - workspaceInvitationInvalidateWorkspaceInvitationMock, - ).toHaveBeenCalledWith(workspaceId, email); - }); - it('signInUp - credentials - new user - public invitation token', async () => { - const email = 'newuser@test.com'; - const password = 'validPassword123'; - const workspaceId = 'workspace-id'; - const workspaceInviteHash = 'public-token-value'; - - UserFindOneMock.mockReturnValueOnce(null); - workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ - isValid: true, - workspace: { - id: workspaceId, - activationStatus: WorkspaceActivationStatus.ACTIVE, - }, - }); - - workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( - true, - ); - - UserCreateMock.mockReturnValueOnce({} as User); - UserSaveMock.mockReturnValueOnce({} as User); - - await service.signInUp({ - email, - password, - fromSSO: false, - targetWorkspaceSubdomain: 'testSubDomain', - workspaceInviteHash, - }); - - expect(UserCreateMock).toHaveBeenCalledTimes(1); - expect(UserSaveMock).toHaveBeenCalledTimes(1); - - expect( - workspaceInvitationInvalidateWorkspaceInvitationMock, - ).toHaveBeenCalledWith(workspaceId, email); + expect(result.workspace).toBeDefined(); + expect(result.user).toBeDefined(); + expect(WorkspaceRepository.create).toHaveBeenCalled(); + expect(WorkspaceRepository.save).toHaveBeenCalled(); + expect(UserRepository.create).toHaveBeenCalled(); + expect(UserRepository.save).toHaveBeenCalled(); + expect(fileUploadService.uploadImage).toHaveBeenCalled(); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 02d7d22bd..7429ac608 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -23,11 +23,8 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.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 { userValidator } from 'src/engine/core-modules/user/user.validate'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; -import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; import { Workspace, WorkspaceActivationStatus, @@ -35,21 +32,15 @@ import { import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; import { getImageBufferFromUrl } from 'src/utils/image'; -import { isDefined } from 'src/utils/is-defined'; import { isWorkEmail } from 'src/utils/is-work-email'; - -export type SignInUpServiceInput = { - email: string; - password?: string; - firstName?: string | null; - lastName?: string | null; - workspaceInviteHash?: string; - workspacePersonalInviteToken?: string; - picture?: string | null; - fromSSO: boolean; - targetWorkspaceSubdomain?: string; - authProvider?: WorkspaceAuthProvider; -}; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthProviderWithPasswordType, + ExistingUserOrPartialUserWithPicture, + PartialUserWithPicture, + SignInUpBaseParams, + SignInUpNewUserPayload, +} from 'src/engine/core-modules/auth/types/signInUp.type'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -66,23 +57,112 @@ export class SignInUpService { private readonly httpService: HttpService, private readonly environmentService: EnvironmentService, private readonly domainManagerService: DomainManagerService, - private readonly userService: UserService, ) {} - async signInUp({ - email, - workspacePersonalInviteToken, - workspaceInviteHash, + async computeParamsForNewUser( + newUserParams: SignInUpNewUserPayload, + authParams: AuthProviderWithPasswordType['authParams'], + ) { + if (!newUserParams.firstName) newUserParams.firstName = ''; + if (!newUserParams.lastName) newUserParams.lastName = ''; + + if (!newUserParams?.email) { + throw new AuthException( + 'Email is required', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if (authParams.provider === 'password') { + newUserParams.passwordHash = await this.generateHash(authParams.password); + } + + return newUserParams as PartialUserWithPicture; + } + + async signInUp( + params: SignInUpBaseParams & + ExistingUserOrPartialUserWithPicture & + AuthProviderWithPasswordType, + ) { + // with personal invitation flow + if (params.workspace && params.invitation) { + return { + workspace: params.workspace, + user: await this.signInUpWithPersonalInvitation({ + invitation: params.invitation, + userData: params.userData, + }), + }; + } + + // with global invitation flow + if (params.workspace) { + const updatedUser = await this.signInUpOnExistingWorkspace({ + workspace: params.workspace, + userData: params.userData, + }); + + return { user: updatedUser, workspace: params.workspace }; + } + + if (params.userData.type === 'newUserWithPicture') { + return await this.signUpOnNewWorkspace( + params.userData.newUserWithPicture, + ); + } + + // should never happen. + throw new Error('Invalid sign in up params'); + } + + async generateHash(password: string) { + const isPasswordValid = PASSWORD_REGEX.test(password); + + if (!isPasswordValid) { + throw new AuthException( + 'Password too weak', + AuthExceptionCode.INVALID_INPUT, + ); + } + + return await hashPassword(password); + } + + async validatePassword({ password, - firstName, - lastName, - picture, - fromSSO, - targetWorkspaceSubdomain, - authProvider, - }: SignInUpServiceInput) { - if (!firstName) firstName = ''; - if (!lastName) lastName = ''; + passwordHash, + }: { + password: string; + passwordHash: string; + }) { + const isValid = await compareHash( + await this.generateHash(password), + passwordHash, + ); + + if (!isValid) { + throw new AuthException( + 'Wrong password', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + } + + private async signInUpWithPersonalInvitation( + params: { invitation: AppToken } & ExistingUserOrPartialUserWithPicture, + ) { + if (!params.invitation) { + throw new AuthException( + 'Invitation not found', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const email = + params.userData.type === 'newUserWithPicture' + ? params.userData.newUserWithPicture.email + : params.userData.existingUser.email; if (!email) { throw new AuthException( @@ -91,264 +171,90 @@ export class SignInUpService { ); } - if (password) { - const isPasswordValid = PASSWORD_REGEX.test(password); - - if (!isPasswordValid) { - throw new AuthException( - 'Password too weak', - AuthExceptionCode.INVALID_INPUT, - ); - } - } - - const passwordHash = password ? await hashPassword(password) : undefined; - - const existingUser = await this.userRepository.findOne({ - where: { email }, - }); - - if (existingUser && !fromSSO) { - const isValid = await compareHash( - password || '', - existingUser.passwordHash, - ); - - if (!isValid) { - throw new AuthException( - 'Wrong password', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - } - - const signInUpWithInvitationResult = targetWorkspaceSubdomain - ? await this.signInUpWithInvitation({ - email, - workspacePersonalInviteToken, - workspaceInviteHash, - targetWorkspaceSubdomain, - fromSSO, - firstName, - lastName, - picture, - authProvider, - passwordHash, - existingUser, - }) - : undefined; - - if (isDefined(signInUpWithInvitationResult)) { - return signInUpWithInvitationResult; - } - - if (!existingUser) { - return await this.signUpOnNewWorkspace({ - email, - passwordHash, - firstName, - lastName, - picture, - }); - } - - const workspace = - await this.domainManagerService.getWorkspaceBySubdomainOrDefaultWorkspace( - targetWorkspaceSubdomain, - ); - - workspaceValidator.assertIsDefinedOrThrow(workspace); - - return await this.validateSignIn({ - user: existingUser, - workspace, - authProvider, - }); - } - - private async signInUpWithInvitation({ - email, - workspacePersonalInviteToken, - workspaceInviteHash, - firstName, - lastName, - picture, - fromSSO, - targetWorkspaceSubdomain, - authProvider, - passwordHash, - existingUser, - }: { - email: string; - workspacePersonalInviteToken?: string; - workspaceInviteHash?: string; - firstName: string; - lastName: string; - picture?: string | null; - authProvider?: WorkspaceAuthProvider; - passwordHash?: string; - existingUser: User | null; - fromSSO: boolean; - targetWorkspaceSubdomain: string; - }) { - const maybeInvitation = - fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash - ? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail( - { - subdomain: targetWorkspaceSubdomain, - email, - }, - ) - : undefined; - const invitationValidation = - workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation - ? await this.workspaceInvitationService.validateInvitation({ - workspacePersonalInviteToken: - workspacePersonalInviteToken ?? maybeInvitation?.value, - workspaceInviteHash, - email, - }) - : null; - - if ( - invitationValidation?.isValid === true && - invitationValidation.workspace - ) { - const updatedUser = await this.signInUpOnExistingWorkspace({ + await this.workspaceInvitationService.validateInvitation({ + workspacePersonalInviteToken: params.invitation.value, email, - passwordHash, - workspace: invitationValidation.workspace, - firstName, - lastName, - picture, - existingUser, - authProvider, }); - await this.workspaceInvitationService.invalidateWorkspaceInvitation( - invitationValidation.workspace.id, - email, - ); - - return { - user: updatedUser, - workspace: invitationValidation.workspace, - }; - } - } - - private async validateSignIn({ - user, - workspace, - authProvider, - }: { - user: User; - workspace: Workspace; - authProvider: SignInUpServiceInput['authProvider']; - }) { - if (authProvider) { - workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace); - } - - await this.userService.hasUserAccessToWorkspaceOrThrow( - user.id, - workspace.id, - ); - - return { user, workspace }; - } - - async signInUpOnExistingWorkspace({ - email, - passwordHash, - workspace, - firstName, - lastName, - picture, - existingUser, - authProvider, - }: { - email: string; - passwordHash: string | undefined; - workspace: Workspace; - firstName: string; - lastName: string; - picture: SignInUpServiceInput['picture']; - existingUser: User | null; - authProvider?: WorkspaceAuthProvider; - }) { - const isNewUser = !isDefined(existingUser); - let user = existingUser; - - workspaceValidator.assertIsDefinedOrThrow( - workspace, - new AuthException( - 'Workspace not found', + if (!invitationValidation?.isValid) { + throw new AuthException( + 'Invitation not found', AuthExceptionCode.FORBIDDEN_EXCEPTION, - ), + ); + } + + const updatedUser = await this.signInUpOnExistingWorkspace({ + workspace: invitationValidation.workspace, + userData: params.userData, + }); + + await this.workspaceInvitationService.invalidateWorkspaceInvitation( + invitationValidation.workspace.id, + email, ); + return updatedUser; + } + + private async persistNewUser( + newUser: PartialUserWithPicture, + workspace: Workspace, + ) { + const imagePath = await this.uploadPicture(newUser.picture, workspace.id); + + delete newUser.picture; + + const userToCreate = this.userRepository.create({ + ...newUser, + defaultAvatarUrl: imagePath, + canImpersonate: false, + } as Partial); + + return await this.userRepository.save(userToCreate); + } + + async signInUpOnExistingWorkspace( + params: { + workspace: Workspace; + } & ExistingUserOrPartialUserWithPicture, + ) { workspaceValidator.assertIsActive( - workspace, + params.workspace, new AuthException( 'Workspace is not ready to welcome new members', AuthExceptionCode.FORBIDDEN_EXCEPTION, ), ); - if (authProvider) { - workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace); - } - - if (isNewUser) { - const imagePath = await this.uploadPicture(picture, workspace.id); - - const userToCreate = this.userRepository.create({ - email: email, - firstName: firstName, - lastName: lastName, - defaultAvatarUrl: imagePath, - canImpersonate: false, - passwordHash, - }); - - user = await this.userRepository.save(userToCreate); - } - - userValidator.assertIsDefinedOrThrow( - user, - new AuthException( - 'User not found', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ), - ); + const currentUser = + params.userData.type === 'newUserWithPicture' + ? await this.persistNewUser( + params.userData.newUserWithPicture, + params.workspace, + ) + : params.userData.existingUser; const updatedUser = await this.userWorkspaceService.addUserToWorkspace( - user, - workspace, + currentUser, + params.workspace, ); - await this.activateOnboardingForUser(user, workspace, { - firstName, - lastName, - }); + const user = Object.assign(currentUser, updatedUser); - return Object.assign(user, updatedUser); + await this.activateOnboardingForUser(user, params.workspace); + + return user; } - private async activateOnboardingForUser( - user: User, - workspace: Workspace, - { firstName, lastName }: { firstName: string; lastName: string }, - ) { + private async activateOnboardingForUser(user: User, workspace: Workspace) { await this.onboardingService.setOnboardingConnectAccountPending({ userId: user.id, workspaceId: workspace.id, value: true, }); - if (firstName === '' && lastName === '') { + if (user.firstName === '' && user.lastName === '') { await this.onboardingService.setOnboardingCreateProfilePending({ userId: user.id, workspaceId: workspace.id, @@ -357,27 +263,19 @@ export class SignInUpService { } } - async signUpOnNewWorkspace({ - email, - passwordHash, - firstName, - lastName, - picture, - }: { - email: string; - passwordHash: string | undefined; - firstName: string; - lastName: string; - picture: SignInUpServiceInput['picture']; - }) { - const user: Partial = { - email, - firstName, - lastName, + async signUpOnNewWorkspace(partialUserWithPicture: PartialUserWithPicture) { + const user: PartialUserWithPicture = { + ...partialUserWithPicture, canImpersonate: false, - passwordHash, }; + if (!user.email) { + throw new AuthException( + 'Email is required', + AuthExceptionCode.INVALID_INPUT, + ); + } + if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { const workspacesCount = await this.workspaceRepository.count(); @@ -393,7 +291,7 @@ export class SignInUpService { } } - const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`; + const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(user.email)}`; const isLogoUrlValid = async () => { try { return ( @@ -406,7 +304,7 @@ export class SignInUpService { }; const logo = - isWorkEmail(email) && (await isLogoUrlValid()) ? logoUrl : undefined; + isWorkEmail(user.email) && (await isLogoUrlValid()) ? logoUrl : undefined; const workspaceToCreate = this.workspaceRepository.create({ subdomain: await this.domainManagerService.generateSubdomain(), @@ -419,7 +317,10 @@ export class SignInUpService { const workspace = await this.workspaceRepository.save(workspaceToCreate); - user.defaultAvatarUrl = await this.uploadPicture(picture, workspace.id); + user.defaultAvatarUrl = await this.uploadPicture( + partialUserWithPicture.picture, + workspace.id, + ); const userCreated = this.userRepository.create(user); @@ -427,10 +328,7 @@ export class SignInUpService { await this.userWorkspaceService.create(newUser.id, workspace.id); - await this.activateOnboardingForUser(newUser, workspace, { - firstName, - lastName, - }); + await this.activateOnboardingForUser(newUser, workspace); await this.onboardingService.setOnboardingInviteTeamPending({ workspaceId: workspace.id, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.service.ts new file mode 100644 index 000000000..1d8287215 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; + +@Injectable() +export class SocialSsoService { + constructor( + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + private readonly environmentService: EnvironmentService, + ) {} + + private getAuthProviderColumnNameByProvider( + authProvider: WorkspaceAuthProvider, + ) { + if (authProvider === 'google') { + return 'isGoogleAuthEnabled'; + } + + if (authProvider === 'microsoft') { + return 'isMicrosoftAuthEnabled'; + } + + if (authProvider === 'password') { + return 'isPasswordAuthEnabled'; + } + + throw new Error(`${authProvider} is not a valid auth provider.`); + } + + async findWorkspaceFromWorkspaceIdOrAuthProvider( + { + authProvider, + email, + }: { authProvider: WorkspaceAuthProvider; email: string }, + workspaceId?: string, + ) { + if ( + this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && + !workspaceId + ) { + // Multi-workspace enable mode but on non workspace url. + // so get the first workspace with the current auth method enable + const workspace = await this.workspaceRepository.findOne({ + where: { + [this.getAuthProviderColumnNameByProvider(authProvider)]: true, + workspaceUsers: { + user: { + email, + }, + }, + }, + relations: ['workspaceUsers', 'workspaceUsers.user'], + }); + + return workspace ?? undefined; + } + + return await this.workspaceRepository.findOneBy({ + id: workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts index b673e7449..358ad7e62 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts @@ -17,7 +17,7 @@ export type GoogleRequest = Omit< picture: string | null; workspaceInviteHash?: string; workspacePersonalInviteToken?: string; - targetWorkspaceSubdomain?: string; + workspaceId?: string; billingCheckoutSessionState?: string; }; }; @@ -39,7 +39,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { ...options, state: JSON.stringify({ workspaceInviteHash: req.params.workspaceInviteHash, - workspaceSubdomain: req.params.workspaceSubdomain, + workspaceId: req.params.workspaceId, ...(req.params.billingCheckoutSessionState ? { billingCheckoutSessionState: @@ -78,7 +78,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, workspacePersonalInviteToken: state.workspacePersonalInviteToken, - targetWorkspaceSubdomain: state.workspaceSubdomain, + workspaceId: state.workspaceId, billingCheckoutSessionState: state.billingCheckoutSessionState, }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts index 1ae2afb3c..4460d906b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts @@ -21,7 +21,7 @@ export type MicrosoftRequest = Omit< picture: string | null; workspaceInviteHash?: string; workspacePersonalInviteToken?: string; - targetWorkspaceSubdomain?: string; + workspaceId?: string; billingCheckoutSessionState?: string; }; }; @@ -43,7 +43,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { ...options, state: JSON.stringify({ workspaceInviteHash: req.params.workspaceInviteHash, - workspaceSubdomain: req.params.workspaceSubdomain, + workspaceId: req.params.workspaceId, ...(req.params.billingCheckoutSessionState ? { billingCheckoutSessionState: @@ -92,7 +92,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, workspacePersonalInviteToken: state.workspacePersonalInviteToken, - targetWorkspaceSubdomain: state.workspaceSubdomain, + workspaceId: state.workspaceId, billingCheckoutSessionState: state.billingCheckoutSessionState, }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts new file mode 100644 index 000000000..7dd623e2b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts @@ -0,0 +1,51 @@ +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +export type SignInUpBaseParams = { + invitation?: AppToken; + workspace?: Workspace | null; + billingCheckoutSessionState?: string | null; +}; + +export type SignInUpNewUserPayload = { + email: string; + firstName?: string | null; + lastName?: string | null; + picture?: string | null; + passwordHash?: string | null; +}; + +export type PartialUserWithPicture = { + picture?: string; +} & Partial; + +export type ExistingUserOrNewUser = { + userData: + | { type: 'existingUser'; existingUser: User } + | { + type: 'newUser'; + newUserPayload: SignInUpNewUserPayload; + }; +}; + +export type ExistingUserOrPartialUserWithPicture = { + userData: + | { type: 'existingUser'; existingUser: User } + | { + type: 'newUserWithPicture'; + newUserWithPicture: PartialUserWithPicture; + }; +}; + +export type AuthProviderWithPasswordType = { + authParams: + | { + provider: Extract; + password: string; + } + | { + provider: Exclude; + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts index 6176a403a..865320472 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/service/domain-manager.service.ts @@ -129,15 +129,16 @@ export class DomainManagerService { return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN'); } - computeRedirectErrorUrl({ - errorMessage, - subdomain, - }: { - errorMessage: string; - subdomain?: string; - }) { - const url = this.buildWorkspaceURL({ + computeRedirectErrorUrl( + errorMessage: string, + { subdomain, + }: { + subdomain?: string; + }, + ) { + const url = this.buildWorkspaceURL({ + subdomain: subdomain ?? this.environmentService.get('DEFAULT_SUBDOMAIN'), pathname: '/verify', searchParams: { errorMessage }, }); @@ -193,10 +194,12 @@ export class DomainManagerService { if (!isDefined(subdomain)) return; - return await this.workspaceRepository.findOne({ - where: { subdomain }, - relations: ['workspaceSSOIdentityProviders'], - }); + return ( + (await this.workspaceRepository.findOne({ + where: { subdomain }, + relations: ['workspaceSSOIdentityProviders'], + })) ?? undefined + ); } catch (e) { throw new WorkspaceException( 'Workspace not found', diff --git a/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts b/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts index 259fc76f2..c502be449 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/types/workspace.type.ts @@ -1 +1 @@ -export type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password'; +export type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password' | 'sso'; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts index 6affc9c91..7069dac55 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.validate.ts @@ -46,6 +46,7 @@ const isAuthEnabledOrThrow = ( if (provider === 'google' && workspace.isGoogleAuthEnabled) return true; if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled) return true; if (provider === 'password' && workspace.isPasswordAuthEnabled) return true; + if (provider === 'sso') return true; throw exceptionToThrowCustom; };