diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index 387dca2d7..712dcc5f9 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -82,7 +82,7 @@ export const SignInUpGlobalScopeForm = () => { const token = await readCaptchaToken(); await checkUserExists.checkUserExistsQuery({ variables: { - email: form.getValues('email'), + email: form.getValues('email').toLowerCase().trim(), captchaToken: token, }, onError: (error) => { diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx index fc27a4b48..ca8482e9a 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx @@ -9,10 +9,10 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { TextInput } from '@/ui/input/components/TextInput'; import { sanitizeEmailList } from '@/workspace/utils/sanitizeEmailList'; import { useLingui } from '@lingui/react/macro'; -import { useCreateWorkspaceInvitation } from '../../workspace-invitation/hooks/useCreateWorkspaceInvitation'; import { isDefined } from 'twenty-shared/utils'; -import { Button } from 'twenty-ui/input'; import { IconSend } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { useCreateWorkspaceInvitation } from '../../workspace-invitation/hooks/useCreateWorkspaceInvitation'; const StyledContainer = styled.div` display: flex; diff --git a/packages/twenty-front/src/modules/workspace/utils/__tests__/sanitizeEmailList.test.ts b/packages/twenty-front/src/modules/workspace/utils/__tests__/sanitizeEmailList.test.ts index 0a7706aca..e080209bb 100644 --- a/packages/twenty-front/src/modules/workspace/utils/__tests__/sanitizeEmailList.test.ts +++ b/packages/twenty-front/src/modules/workspace/utils/__tests__/sanitizeEmailList.test.ts @@ -21,4 +21,11 @@ describe('sanitizeEmailList', () => { 'toto@toto.com', ]); }); + + it('should lowercase emails', () => { + expect(sanitizeEmailList(['TOTO@toto.com', 'TOTO2@toto.com'])).toEqual([ + 'toto@toto.com', + 'toto2@toto.com', + ]); + }); }); diff --git a/packages/twenty-front/src/modules/workspace/utils/sanitizeEmailList.ts b/packages/twenty-front/src/modules/workspace/utils/sanitizeEmailList.ts index 075367a19..c2d9d7881 100644 --- a/packages/twenty-front/src/modules/workspace/utils/sanitizeEmailList.ts +++ b/packages/twenty-front/src/modules/workspace/utils/sanitizeEmailList.ts @@ -2,7 +2,7 @@ export const sanitizeEmailList = (emailList: string[]): string[] => { return Array.from( new Set( emailList - .map((email) => email.trim()) + .map((email) => email.trim().toLowerCase()) .filter((email) => email.length > 0), ), ); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-lowercase-user-and-invitation-emails.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-lowercase-user-and-invitation-emails.command.ts new file mode 100644 index 000000000..6e04d4c0b --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-lowercase-user-and-invitation-emails.command.ts @@ -0,0 +1,113 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Raw, Repository } from 'typeorm'; + +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +@Command({ + name: 'upgrade:0-54:lowercase-user-and-invitation-emails', + description: 'Lowercase user and invitation emails', +}) +export class LowercaseUserAndInvitationEmailsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(User, 'core') + protected readonly userRepository: Repository, + @InjectRepository(AppToken, 'core') + protected readonly appTokenRepository: Repository, + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace({ + index, + total, + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + this.logger.log( + `Running command for workspace ${workspaceId} ${index + 1}/${total}`, + ); + + await this.lowercaseUserEmails(workspaceId, !!options.dryRun); + await this.lowercaseInvitationEmails(workspaceId, !!options.dryRun); + } + + private async lowercaseUserEmails(workspaceId: string, dryRun: boolean) { + const users = await this.userRepository.find({ + where: { + workspaces: { + workspaceId, + }, + email: Raw((alias) => `LOWER(${alias}) != ${alias}`), + }, + }); + + if (users.length === 0) return; + + for (const user of users) { + if (!dryRun) { + await this.userRepository.update( + { + id: user.id, + }, + { + email: user.email.toLowerCase(), + }, + ); + } + + this.logger.log( + `Lowercased user email ${user.email} for workspace ${workspaceId}`, + ); + } + } + + private async lowercaseInvitationEmails( + workspaceId: string, + dryRun: boolean, + ) { + const appTokens = await this.appTokenRepository.find({ + where: { + workspaceId, + type: AppTokenType.InvitationToken, + context: Raw((_) => `LOWER(context->>'email') != context->>'email'`), + }, + }); + + if (appTokens.length === 0) return; + + for (const appToken of appTokens) { + if (!dryRun) { + await this.appTokenRepository.update( + { + id: appToken.id, + }, + { + context: { + ...appToken.context, + email: appToken.context?.email.toLowerCase(), + }, + }, + ); + } + + this.logger.log( + `Lowercased invitation email ${appToken.context?.email} for workspace ${workspaceId}`, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module.ts index e205a792b..aa1409fb3 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module.ts @@ -4,7 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CleanNotFoundFilesCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-clean-not-found-files.command'; import { FixCreatedByDefaultValueCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-created-by-default-value.command'; import { FixStandardSelectFieldsPositionCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-fix-standard-select-fields-position.command'; +import { LowercaseUserAndInvitationEmailsCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-lowercase-user-and-invitation-emails.command'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { FileModule } from 'src/engine/core-modules/file/file.module'; +import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -14,7 +17,7 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor @Module({ imports: [ - TypeOrmModule.forFeature([Workspace], 'core'), + TypeOrmModule.forFeature([Workspace, AppToken, User], 'core'), TypeOrmModule.forFeature( [FieldMetadataEntity, ObjectMetadataEntity], 'metadata', @@ -28,11 +31,13 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor FixStandardSelectFieldsPositionCommand, FixCreatedByDefaultValueCommand, CleanNotFoundFilesCommand, + LowercaseUserAndInvitationEmailsCommand, ], exports: [ FixStandardSelectFieldsPositionCommand, FixCreatedByDefaultValueCommand, CleanNotFoundFilesCommand, + LowercaseUserAndInvitationEmailsCommand, ], }) export class V0_54_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts index a7b28da5a..875cd3187 100644 --- a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts @@ -1,7 +1,10 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { BeforeCreateOne, IDField } from '@ptc-org/nestjs-query-graphql'; +import { isDefined } from 'twenty-shared/utils'; import { + BeforeInsert, + BeforeUpdate, Column, CreateDateColumn, Entity, @@ -78,6 +81,14 @@ export class AppToken { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; + @BeforeInsert() + @BeforeUpdate() + formatEmail?() { + if (isDefined(this.context?.email)) { + this.context.email = this.context.email.toLowerCase(); + } + } + @Column({ nullable: true, type: 'jsonb' }) context: { email: string } | null; } 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 0bf51dbc3..ea7b3ae6f 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 @@ -27,6 +27,7 @@ import { import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input'; import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output'; import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input'; +import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output'; import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; @@ -52,7 +53,6 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; -import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output'; import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input'; import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input'; @@ -92,7 +92,9 @@ export class AuthResolver { async checkUserExists( @Args() checkUserExistsInput: CheckUserExistsInput, ): Promise { - return await this.authService.checkUserExists(checkUserExistsInput.email); + return await this.authService.checkUserExists( + checkUserExistsInput.email.toLowerCase(), + ); } @Mutation(() => GetAuthorizationUrlForSSOOutput) 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 8c33e9ef1..c3e5bcf55 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 @@ -18,9 +18,9 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/ 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/services/domain-manager.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) @@ -48,7 +48,7 @@ export class GoogleAuthController { const { firstName, lastName, - email, + email: rawEmail, picture, workspaceInviteHash, workspaceId, @@ -56,6 +56,8 @@ export class GoogleAuthController { locale, } = req.user; + const email = rawEmail.toLowerCase(); + const currentWorkspace = await this.authService.findWorkspaceForSignInUp({ workspaceId, workspaceInviteHash, 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 7553c6ac0..414951dc7 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 @@ -17,9 +17,9 @@ import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guar 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/services/domain-manager.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) @@ -49,7 +49,7 @@ export class MicrosoftAuthController { const { firstName, lastName, - email, + email: rawEmail, picture, workspaceInviteHash, workspaceId, @@ -57,6 +57,8 @@ export class MicrosoftAuthController { locale, } = req.user; + const email = rawEmail.toLowerCase(); + const currentWorkspace = await this.authService.findWorkspaceForSignInUp({ workspaceId, workspaceInviteHash, diff --git a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts index ac10d4a1f..ea513286f 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts @@ -2,6 +2,8 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { IDField } from '@ptc-org/nestjs-query-graphql'; import { + BeforeInsert, + BeforeUpdate, Column, CreateDateColumn, DeleteDateColumn, @@ -45,6 +47,12 @@ export class User { @Column({ default: '' }) lastName: string; + @BeforeInsert() + @BeforeUpdate() + formatEmail?() { + this.email = this.email.toLowerCase(); + } + @Field() @Column() email: string;