From 104f31625bad47e188775f0ed4540715ee29efbf Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 18 Feb 2025 10:37:37 +0100 Subject: [PATCH 01/16] feat(workspace): add support for trusted domains Introduced WorkspaceTrustedDomain entity, service, and module for managing trusted domains within workspaces. Updated Workspace entity to include a relation to trusted domains, enabling domain management tied to specific workspaces. This provides a foundation for validating and managing trusted domains. --- .../workspace-trusted-domain.service.ts | 26 ++++++++++++ .../workspace-trusted-domain.entity.ts | 41 +++++++++++++++++++ .../workspace-trusted-domain.module.ts | 23 +++++++++++ .../workspace/workspace.entity.ts | 25 +++++++---- 4 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts new file mode 100644 index 000000000000..b7d1ff6925c2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class WorkspaceTrustedDomainService { + constructor( + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + @InjectRepository(UserWorkspace, 'core') + private readonly userWorkspaceRepository: Repository, + private readonly environmentService: EnvironmentService, + private readonly emailService: EmailService, + private readonly onboardingService: OnboardingService, + private readonly domainManagerService: DomainManagerService, + ) {} +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts new file mode 100644 index 000000000000..c7b87a2c59a1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts @@ -0,0 +1,41 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Entity({ name: 'workspaceTrustedDomain', schema: 'core' }) +@ObjectType() +export class WorkspaceTrustedDomain { + @IDField(() => UUIDScalarType) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field() + @Column({ type: 'varchar', nullable: false }) + domain: string; + + @Field() + @Column({ type: 'boolean', default: false, nullable: false }) + isValidated: boolean; + + @Field() + @Column({ type: 'varchar', nullable: false }) + validationToken: string; + + @ManyToOne(() => Workspace, (workspace) => workspace.trustDomains, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts new file mode 100644 index 000000000000..1e1b33684286 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Module({ + imports: [ + DomainManagerModule, + NestjsQueryTypeOrmModule.forFeature( + [AppToken, UserWorkspace, Workspace], + 'core', + ), + ], + exports: [WorkspaceInvitationService], + providers: [WorkspaceInvitationService, WorkspaceInvitationResolver], +}) +export class WorkspaceTrustedDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index ff3c2f705f3e..a5bff9fe57f8 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -20,6 +20,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; registerEnumType(WorkspaceActivationStatus, { name: 'WorkspaceActivationStatus', @@ -28,6 +29,7 @@ registerEnumType(WorkspaceActivationStatus, { @Entity({ name: 'workspace', schema: 'core' }) @ObjectType() export class Workspace { + // Fields @IDField(() => UUIDScalarType) @PrimaryGeneratedColumn('uuid') id: string; @@ -56,6 +58,15 @@ export class Workspace { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; + @Field() + @Column({ default: true }) + allowImpersonation: boolean; + + @Field() + @Column({ default: true }) + isPublicInviteLinkEnabled: boolean; + + // Relations @OneToMany(() => AppToken, (appToken) => appToken.workspace, { cascade: true, }) @@ -71,17 +82,15 @@ export class Workspace { }) workspaceUsers: Relation; - @Field() - @Column({ default: true }) - allowImpersonation: boolean; - - @Field() - @Column({ default: true }) - isPublicInviteLinkEnabled: boolean; - @OneToMany(() => FeatureFlag, (featureFlag) => featureFlag.workspace) featureFlags: Relation; + @OneToMany( + () => WorkspaceTrustedDomain, + (trustDomain) => trustDomain.workspace, + ) + trustDomains: Relation; + @Field({ nullable: true }) workspaceMembersCount: number; From bdd1535587bbe8e8adc3d55c3e38edf865b6e857 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 18 Feb 2025 13:51:12 +0100 Subject: [PATCH 02/16] feat(workspace-trusted-domain): implement trusted domain management Introduce functionality for managing workspace trusted domains, including creation, validation, and deletion. Added corresponding migrations, services, DTOs, resolvers, validations, and tests to support the feature. --- .../emails/validate-trust-domain.email.tsx | 64 +++ packages/twenty-emails/src/index.ts | 1 + ...9882700556-add-workspace-trusted-domain.ts | 23 ++ .../src/database/typeorm/typeorm.service.ts | 2 + .../dtos/create-trusted-domain.input.ts | 10 + .../dtos/delete-trusted-domain.input.ts | 10 + ...trusted-domain-verification-email.input.ts | 14 + .../dtos/trusted-domain.dto.ts | 20 + .../workspace-trusted-domain.service.ts | 144 ++++++- .../services/workspace-trusted-domain.spec.ts | 376 ++++++++++++++++++ .../workspace-trusted-domain.entity.ts | 19 +- .../workspace-trusted-domain.exception.ts | 13 + .../workspace-trusted-domain.module.ts | 18 +- .../workspace-trusted-domain.resolver.ts | 68 ++++ .../workspace-trusted-domain.validate.ts | 26 ++ 15 files changed, 778 insertions(+), 30 deletions(-) create mode 100644 packages/twenty-emails/src/emails/validate-trust-domain.email.tsx create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1739882700556-add-workspace-trusted-domain.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts diff --git a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx b/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx new file mode 100644 index 000000000000..e791edd9bff5 --- /dev/null +++ b/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx @@ -0,0 +1,64 @@ +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { Img } from '@react-email/components'; +import { emailTheme } from 'src/common-style'; + +import { BaseEmail } from 'src/components/BaseEmail'; +import { CallToAction } from 'src/components/CallToAction'; +import { HighlightedContainer } from 'src/components/HighlightedContainer'; +import { HighlightedText } from 'src/components/HighlightedText'; +import { Link } from 'src/components/Link'; +import { MainText } from 'src/components/MainText'; +import { Title } from 'src/components/Title'; +import { WhatIsTwenty } from 'src/components/WhatIsTwenty'; +import { capitalize } from 'src/utils/capitalize'; +import { APP_LOCALES, getImageAbsoluteURI } from 'twenty-shared'; + +type SendTrustDomainValidationProps = { + link: string; + domain: string; + workspace: { name: string | undefined; logo: string | undefined }; + sender: { + email: string; + firstName: string; + lastName: string; + }; + serverUrl: string; + locale: keyof typeof APP_LOCALES; +}; + +export const SendTrustDomainValidation = ({ + link, + domain, + workspace, + sender, + serverUrl, + locale, +}: SendTrustDomainValidationProps) => { + const workspaceLogo = workspace.logo + ? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl }) + : null; + + return ( + + + <MainText> + {capitalize(sender.firstName)} ( + <Link + href={`mailto:${sender.email}`} + value={sender.email} + color={emailTheme.font.colors.blue} + /> + )<Trans>has added a trust domain: </Trans> + <b>{domain}</b> + <br /> + </MainText> + <HighlightedContainer> + {workspaceLogo && <Img src={workspaceLogo} width={40} height={40} />} + {workspace.name && <HighlightedText value={workspace.name} />} + <CallToAction href={link} value={t`Validate domain`} /> + </HighlightedContainer> + <WhatIsTwenty /> + </BaseEmail> + ); +}; diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts index 9c25bea49313..cd3da17239e2 100644 --- a/packages/twenty-emails/src/index.ts +++ b/packages/twenty-emails/src/index.ts @@ -4,3 +4,4 @@ export * from './emails/password-update-notify.email'; export * from './emails/send-email-verification-link.email'; export * from './emails/send-invite-link.email'; export * from './emails/warn-suspended-workspace.email'; +export * from './emails/validate-trust-domain.email'; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739882700556-add-workspace-trusted-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739882700556-add-workspace-trusted-domain.ts new file mode 100644 index 000000000000..230593ae9c05 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739882700556-add-workspace-trusted-domain.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWorkspaceTrustedDomain1739882700556 + implements MigrationInterface +{ + name = 'AddWorkspaceTrustedDomain1739882700556'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `CREATE TABLE "core"."workspaceTrustedDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "validationToken" character varying NOT NULL, "workspaceId" uuid NOT NULL, CONSTRAINT "PK_afa04c0f75f54a5e2c570c83cd6" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspaceTrustedDomain" ADD CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "core"."workspaceTrustedDomain" DROP CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e"`, + ); + await queryRunner.query(`DROP TABLE "core"."workspaceTrustedDomain"`); + } +} diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 3cf105e22f19..17ab3f3c7765 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -22,6 +22,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; +import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; @Injectable() export class TypeORMService implements OnModuleInit, OnModuleDestroy { private mainDataSource: DataSource; @@ -50,6 +51,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { BillingEntitlement, PostgresCredentials, WorkspaceSSOIdentityProvider, + WorkspaceTrustedDomain, TwoFactorMethod, ], metadataTableName: '_typeorm_generated_columns_and_materialized_views', diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts new file mode 100644 index 000000000000..db2975076269 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsString } from 'class-validator'; + +@InputType() +export class CreateTrustedDomainInput { + @Field() + @IsString() + domain: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts new file mode 100644 index 000000000000..096cbf256d10 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsString } from 'class-validator'; + +@InputType() +export class DeleteTrustedDomainInput { + @Field() + @IsString() + id: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts new file mode 100644 index 000000000000..8a9c254f0a52 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts @@ -0,0 +1,14 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsString } from 'class-validator'; + +@InputType() +export class SendTrustedDomainVerificationEmailInput { + @Field() + @IsString() + email: string; + + @Field() + @IsString() + trustedDomainId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts new file mode 100644 index 000000000000..30a0a239132f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts @@ -0,0 +1,20 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('WorkspaceTrustedDomain') +export class WorkspaceTrustedDomain { + @IDField(() => UUIDScalarType) + id: string; + + @Field({ nullable: false }) + domain: string; + + @Field({ nullable: false }) + isValidated: boolean; + + @Field() + createdAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts index b7d1ff6925c2..cf9b8a7e8974 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts @@ -1,26 +1,150 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import crypto from 'crypto'; + +import { render } from '@react-email/render'; import { Repository } from 'typeorm'; +import { APP_LOCALES } from 'twenty-shared'; +import { SendTrustDomainValidation } from 'twenty-emails'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { WorkspaceTrustedDomain as WorkspaceTrustedDomainEntity } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; +import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { isWorkDomain } from 'src/utils/is-work-email'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; -import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { workspaceTrustedDomainValidator } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate'; +import { + WorkspaceTrustedDomainException, + WorkspaceTrustedDomainExceptionCode, +} from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceTrustedDomainService { constructor( - @InjectRepository(AppToken, 'core') - private readonly appTokenRepository: Repository<AppToken>, - @InjectRepository(UserWorkspace, 'core') - private readonly userWorkspaceRepository: Repository<UserWorkspace>, - private readonly environmentService: EnvironmentService, + @InjectRepository(WorkspaceTrustedDomain, 'core') + private readonly workspaceTrustedDomainRepository: Repository<WorkspaceTrustedDomainEntity>, private readonly emailService: EmailService, - private readonly onboardingService: OnboardingService, + private readonly environmentService: EnvironmentService, private readonly domainManagerService: DomainManagerService, ) {} + + private checkIsVerified( + domain: string, + inWorkspace: Workspace, + fromUser: User, + ) { + if (!isWorkDomain(domain)) return false; + + if ( + domain === inWorkspace.customDomain && + inWorkspace.isCustomDomainEnabled + ) + return true; + + if (fromUser.email.endsWith(domain) && fromUser.isEmailVerified) + return true; + + return false; + } + + async sendTrustedDomainValidationEmail( + sender: User, + to: string, + workspace: Workspace, + trustedDomainId: string, + ) { + const trustedDomain = await this.workspaceTrustedDomainRepository.findOneBy( + { + id: trustedDomainId, + }, + ); + + workspaceTrustedDomainValidator.assertIsDefinedOrThrow(trustedDomain); + + if (trustedDomain.isValidated) { + throw new WorkspaceTrustedDomainException( + 'Trusted domain has already been validated', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED, + ); + } + + if (!to.endsWith(trustedDomain.domain)) { + throw new WorkspaceTrustedDomainException( + 'Trusted domain does not match validator email', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL, + ); + } + + const link = this.domainManagerService.buildWorkspaceURL({ + workspace, + pathname: `settings/security`, + searchParams: { + validationToken: trustedDomain.validationToken, + }, + }); + + const emailTemplate = SendTrustDomainValidation({ + link: link.toString(), + workspace: { name: workspace.displayName, logo: workspace.logo }, + domain: trustedDomain.domain, + sender: { + email: sender.email, + firstName: sender.firstName, + lastName: sender.lastName, + }, + serverUrl: this.environmentService.get('SERVER_URL'), + locale: 'en' as keyof typeof APP_LOCALES, + }); + const html = render(emailTemplate); + const text = render(emailTemplate, { + plainText: true, + }); + + await this.emailService.send({ + from: `${sender.firstName} ${sender.lastName} (via Twenty) <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, + to, + subject: 'Activate Your Trusted Domain', + text, + html, + }); + } + + async createTrustedDomain( + domain: string, + inWorkspace: Workspace, + fromUser: User, + ): Promise<WorkspaceTrustedDomain> { + return await this.workspaceTrustedDomainRepository.save({ + workspaceId: inWorkspace.id, + domain, + isVerified: this.checkIsVerified(domain, inWorkspace, fromUser), + validationToken: crypto.randomBytes(32).toString('hex'), + }); + } + + async deleteTrustedDomain(workspace: Workspace, trustedDomainId: string) { + const trustedDomain = await this.workspaceTrustedDomainRepository.findOneBy( + { + id: trustedDomainId, + workspaceId: workspace.id, + }, + ); + + workspaceTrustedDomainValidator.assertIsDefinedOrThrow(trustedDomain); + + await this.workspaceTrustedDomainRepository.delete(trustedDomain); + } + + async getAllTrustedDomainsByWorkspace(workspace: Workspace) { + return await this.workspaceTrustedDomainRepository.find({ + where: { + workspaceId: workspace.id, + }, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts new file mode 100644 index 000000000000..51ec6c6981a6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts @@ -0,0 +1,376 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { DeleteResult, Repository } from 'typeorm'; + +import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { + WorkspaceTrustedDomainException, + WorkspaceTrustedDomainExceptionCode, +} from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception'; + +import { WorkspaceTrustedDomainService } from './workspace-trusted-domain.service'; + +describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerified', () => { + let service: WorkspaceTrustedDomainService; + let workspaceTrustedDomainRepository: Repository<WorkspaceTrustedDomain>; + let emailService: EmailService; + let environmentService: EnvironmentService; + let domainManagerService: DomainManagerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceTrustedDomainService, + { + provide: getRepositoryToken(WorkspaceTrustedDomain, 'core'), + useValue: { + delete: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: EmailService, + useValue: { + send: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: DomainManagerService, + useValue: { + buildWorkspaceURL: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get<WorkspaceTrustedDomainService>( + WorkspaceTrustedDomainService, + ); + workspaceTrustedDomainRepository = module.get( + getRepositoryToken(WorkspaceTrustedDomain, 'core'), + ); + emailService = module.get<EmailService>(EmailService); + environmentService = module.get<EnvironmentService>(EnvironmentService); + domainManagerService = + module.get<DomainManagerService>(DomainManagerService); + }); + + describe('checkIsVerified', () => { + it('should mark the domain as verified if it is the workspace custom domain and custom domain is enabled', () => { + const domain = 'custom-domain.com'; + const inWorkspace = { + customDomain: domain, + isCustomDomainEnabled: true, + } as Workspace; + const fromUser = { + email: 'user@otherdomain.com', + isEmailVerified: true, + } as User; + + const result = (service as any).checkIsVerified( + domain, + inWorkspace, + fromUser, + ); + + expect(result).toBe(true); + }); + + it('should mark the domain as verified if the user email ends with the domain and the user email is verified', () => { + const domain = 'custom-domain.com'; + const inWorkspace = { + customDomain: null, + isCustomDomainEnabled: false, + } as Workspace; + const fromUser = { + email: 'user@custom-domain.com', + isEmailVerified: true, + } as User; + + const result = (service as any).checkIsVerified( + domain, + inWorkspace, + fromUser, + ); + + expect(result).toBe(true); + }); + + it('should not mark the domain as verified if it is not a work domain', () => { + const domain = 'gmail.com'; + const inWorkspace = { + customDomain: null, + isCustomDomainEnabled: false, + } as Workspace; + const fromUser = { + email: 'user@gmail.com', + isEmailVerified: true, + } as User; + + const result = (service as any).checkIsVerified( + domain, + inWorkspace, + fromUser, + ); + + expect(result).toBe(false); + }); + + it('should not mark the domain as verified if it is the workspace custom domain but custom domain is not enabled', () => { + const domain = 'custom-domain.com'; + const inWorkspace = { + customDomain: domain, + isCustomDomainEnabled: false, + } as Workspace; + const fromUser = { + email: 'user@otherdomain.com', + isEmailVerified: true, + } as User; + + const result = (service as any).checkIsVerified( + domain, + inWorkspace, + fromUser, + ); + + expect(result).toBe(false); + }); + + it('should not mark the domain as verified if the user email does not end with the domain or is not verified', () => { + const domain = 'example.com'; + const inWorkspace = { + customDomain: null, + isCustomDomainEnabled: false, + } as Workspace; + const fromUser = { + email: 'user@otherdomain.com', + isEmailVerified: false, + } as User; + + const result = (service as any).checkIsVerified( + domain, + inWorkspace, + fromUser, + ); + + expect(result).toBe(false); + }); + }); + + describe('createTrustedDomain', () => { + it('should successfully create a trusted domain and mark it as verified based on checkIsVerified', async () => { + const domain = 'custom-domain.com'; + const inWorkspace = { + id: 'workspace-id', + customDomain: null, + isCustomDomainEnabled: false, + } as Workspace; + const fromUser = { + email: 'user@custom-domain.com', + isEmailVerified: true, + } as User; + + const expectedTrustedDomain = { + workspaceId: 'workspace-id', + domain, + isVerified: true, + validationToken: expect.any(String), + }; + + jest + .spyOn(workspaceTrustedDomainRepository, 'save') + .mockResolvedValue( + expectedTrustedDomain as unknown as WorkspaceTrustedDomain, + ); + + const result = await service.createTrustedDomain( + domain, + inWorkspace, + fromUser, + ); + + expect(workspaceTrustedDomainRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-id', + domain, + isVerified: true, + validationToken: expect.any(String), + }), + ); + expect(result).toEqual(expectedTrustedDomain); + }); + }); + + describe('deleteTrustedDomain', () => { + it('should delete a trusted domain successfully', async () => { + const workspace: Workspace = { id: 'workspace-id' } as Workspace; + const trustedDomainId = 'trusted-domain-id'; + const trustedDomainEntity = { + id: trustedDomainId, + workspaceId: workspace.id, + } as WorkspaceTrustedDomain; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue(trustedDomainEntity); + jest + .spyOn(workspaceTrustedDomainRepository, 'delete') + .mockResolvedValue({} as unknown as DeleteResult); + + await service.deleteTrustedDomain(workspace, trustedDomainId); + + expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: trustedDomainId, + workspaceId: workspace.id, + }); + expect(workspaceTrustedDomainRepository.delete).toHaveBeenCalledWith( + trustedDomainEntity, + ); + }); + + it('should throw an error if the trusted domain does not exist', async () => { + const workspace: Workspace = { id: 'workspace-id' } as Workspace; + const trustedDomainId = 'trusted-domain-id'; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue(null); + + await expect( + service.deleteTrustedDomain(workspace, trustedDomainId), + ).rejects.toThrow(); + + expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: trustedDomainId, + workspaceId: workspace.id, + }); + expect(workspaceTrustedDomainRepository.delete).not.toHaveBeenCalled(); + }); + }); + + describe('sendTrustedDomainValidationEmail', () => { + it('should throw an exception if the trusted domain is already validated', async () => { + const trustedDomainId = 'trusted-domain-id'; + const sender = {} as User; + const workspace = {} as Workspace; + const email = 'validator@example.com'; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue({ + isValidated: true, + } as unknown as WorkspaceTrustedDomain); + + await expect( + service.sendTrustedDomainValidationEmail( + sender, + email, + workspace, + trustedDomainId, + ), + ).rejects.toThrowError( + new WorkspaceTrustedDomainException( + 'Trusted domain has already been validated', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED, + ), + ); + }); + + it('should throw an exception if the email does not match the trusted domain', async () => { + const trustedDomainId = 'trusted-domain-id'; + const sender = {} as User; + const workspace = {} as Workspace; + const email = 'validator@different.com'; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue({ + isValidated: false, + domain: 'example.com', + } as unknown as WorkspaceTrustedDomain); + + await expect( + service.sendTrustedDomainValidationEmail( + sender, + email, + workspace, + trustedDomainId, + ), + ).rejects.toThrowError( + new WorkspaceTrustedDomainException( + 'Trusted domain does not match validator email', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL, + ), + ); + }); + + it('should send a validation email if all conditions are met', async () => { + const trustedDomainId = 'trusted-domain-id'; + const sender = { + email: 'sender@example.com', + firstName: 'John', + lastName: 'Doe', + } as User; + const workspace = { + displayName: 'Test Workspace', + logo: '/logo.png', + } as Workspace; + const email = 'validator@custom-domain.com'; + const trustedDomain = { + isValidated: false, + domain: 'custom-domain.com', + validationToken: 'test-token', + } as WorkspaceTrustedDomain; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue(trustedDomain); + + jest + .spyOn(domainManagerService, 'buildWorkspaceURL') + .mockReturnValue(new URL('https://sub.twenty.com')); + + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + if (key === 'EMAIL_FROM_ADDRESS') return 'no-reply@example.com'; + if (key === 'SERVER_URL') return 'https://api.example.com'; + }); + + await service.sendTrustedDomainValidationEmail( + sender, + email, + workspace, + trustedDomainId, + ); + + expect(domainManagerService.buildWorkspaceURL).toHaveBeenCalledWith({ + workspace: workspace, + pathname: 'settings/security', + searchParams: { validationToken: 'test-token' }, + }); + + expect(emailService.send).toHaveBeenCalledWith({ + from: 'John Doe (via Twenty) <no-reply@example.com>', + to: email, + subject: 'Activate Your Trusted Domain', + text: expect.any(String), + html: expect.any(String), + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts index c7b87a2c59a1..80bb432c39fe 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts @@ -1,38 +1,43 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { ObjectType } from '@nestjs/graphql'; -import { IDField } from '@ptc-org/nestjs-query-graphql'; import { Column, + CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; -import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Entity({ name: 'workspaceTrustedDomain', schema: 'core' }) @ObjectType() export class WorkspaceTrustedDomain { - @IDField(() => UUIDScalarType) @PrimaryGeneratedColumn('uuid') id: string; - @Field() + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + @Column({ type: 'varchar', nullable: false }) domain: string; - @Field() @Column({ type: 'boolean', default: false, nullable: false }) isValidated: boolean; - @Field() @Column({ type: 'varchar', nullable: false }) validationToken: string; + @Column() + workspaceId: string; + @ManyToOne(() => Workspace, (workspace) => workspace.trustDomains, { onDelete: 'CASCADE', }) diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts new file mode 100644 index 000000000000..4ef04d577acc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts @@ -0,0 +1,13 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class WorkspaceTrustedDomainException extends CustomException { + constructor(message: string, code: WorkspaceTrustedDomainExceptionCode) { + super(message, code); + } +} + +export enum WorkspaceTrustedDomainExceptionCode { + WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND = 'WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND', + WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED = 'WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED', + WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL = 'WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL', +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts index 1e1b33684286..8908349ed547 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts @@ -2,22 +2,14 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; -import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; -import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; -import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; +import { WorkspaceTrustedDomainService } from 'src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service'; @Module({ imports: [ - DomainManagerModule, - NestjsQueryTypeOrmModule.forFeature( - [AppToken, UserWorkspace, Workspace], - 'core', - ), + NestjsQueryTypeOrmModule.forFeature([WorkspaceTrustedDomain], 'core'), ], - exports: [WorkspaceInvitationService], - providers: [WorkspaceInvitationService, WorkspaceInvitationResolver], + exports: [WorkspaceTrustedDomainService], + providers: [WorkspaceTrustedDomainService], }) export class WorkspaceTrustedDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts new file mode 100644 index 000000000000..0696d525025c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -0,0 +1,68 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { WorkspaceTrustedDomainService } from 'src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service'; +import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto'; +import { CreateTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { SendTrustedDomainVerificationEmailInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input'; +import { DeleteTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input'; + +@UseGuards(WorkspaceAuthGuard) +@Resolver() +export class WorkspaceTrustedDomainResolver { + constructor( + private readonly workspaceTrustedDomainService: WorkspaceTrustedDomainService, + ) {} + + @Mutation(() => WorkspaceTrustedDomain) + async create( + @Args() { domain }: CreateTrustedDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + @AuthUser() currentUser: User, + ): Promise<WorkspaceTrustedDomain> { + return this.workspaceTrustedDomainService.createTrustedDomain( + domain, + currentWorkspace, + currentUser, + ); + } + + @Mutation(() => null) + async sendTrustedDomainVerificationEmail( + @Args() { email, trustedDomainId }: SendTrustedDomainVerificationEmailInput, + @AuthWorkspace() currentWorkspace: Workspace, + @AuthUser() currentUser: User, + ): Promise<void> { + return await this.workspaceTrustedDomainService.sendTrustedDomainValidationEmail( + currentUser, + email, + currentWorkspace, + trustedDomainId, + ); + } + + @Mutation(() => null) + async deleteTrustedDomain( + @Args() { id }: DeleteTrustedDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise<void> { + return await this.workspaceTrustedDomainService.deleteTrustedDomain( + currentWorkspace, + id, + ); + } + + @Mutation(() => [WorkspaceTrustedDomain]) + async getAllTrustedDomains( + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise<Array<WorkspaceTrustedDomain>> { + return await this.workspaceTrustedDomainService.getAllTrustedDomainsByWorkspace( + currentWorkspace, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts new file mode 100644 index 000000000000..9c1b78c72c7a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts @@ -0,0 +1,26 @@ +import { isDefined } from 'twenty-shared'; + +import { CustomException } from 'src/utils/custom-exception'; +import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; +import { + WorkspaceTrustedDomainException, + WorkspaceTrustedDomainExceptionCode, +} from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception'; + +const assertIsDefinedOrThrow = ( + trustedDomain: WorkspaceTrustedDomain | undefined | null, + exceptionToThrow: CustomException = new WorkspaceTrustedDomainException( + 'Trusted domain not found', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND, + ), +): asserts trustedDomain is WorkspaceTrustedDomain => { + if (!isDefined(trustedDomain)) { + throw exceptionToThrow; + } +}; + +export const workspaceTrustedDomainValidator: { + assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow; +} = { + assertIsDefinedOrThrow, +}; From 9f7c0f746bf5c57927ae22331a2ec4ffbc35c090 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Tue, 18 Feb 2025 18:35:55 +0100 Subject: [PATCH 03/16] refactor(workspace-trusted-domain): improve validation flow Removed validation token field and related methods, replacing it with a secure hash-based validation approach. Introduced email validation during trusted domain creation, updating the workflow and database constraints to ensure uniqueness. Cleaned up unused input classes and streamlined service methods for better maintainability. --- ...9882700556-add-workspace-trusted-domain.ts | 23 ------- ...9900102019-add-workspace-trusted-domain.ts | 18 ++++++ .../dtos/create-trusted-domain.input.ts | 8 ++- ...trusted-domain-verification-email.input.ts | 14 ----- .../workspace-trusted-domain.service.ts | 62 +++++++++++++------ .../services/workspace-trusted-domain.spec.ts | 36 ++++++----- .../workspace-trusted-domain.entity.ts | 5 +- .../workspace-trusted-domain.resolver.ts | 20 +----- 8 files changed, 94 insertions(+), 92 deletions(-) delete mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1739882700556-add-workspace-trusted-domain.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1739900102019-add-workspace-trusted-domain.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739882700556-add-workspace-trusted-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739882700556-add-workspace-trusted-domain.ts deleted file mode 100644 index 230593ae9c05..000000000000 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739882700556-add-workspace-trusted-domain.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddWorkspaceTrustedDomain1739882700556 - implements MigrationInterface -{ - name = 'AddWorkspaceTrustedDomain1739882700556'; - - public async up(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query( - `CREATE TABLE "core"."workspaceTrustedDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "validationToken" character varying NOT NULL, "workspaceId" uuid NOT NULL, CONSTRAINT "PK_afa04c0f75f54a5e2c570c83cd6" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `ALTER TABLE "core"."workspaceTrustedDomain" ADD CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - } - - public async down(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query( - `ALTER TABLE "core"."workspaceTrustedDomain" DROP CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e"`, - ); - await queryRunner.query(`DROP TABLE "core"."workspaceTrustedDomain"`); - } -} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739900102019-add-workspace-trusted-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739900102019-add-workspace-trusted-domain.ts new file mode 100644 index 000000000000..0499709dce91 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739900102019-add-workspace-trusted-domain.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddWorkspaceTrustedDomain1739900102019 implements MigrationInterface { + name = 'AddWorkspaceTrustedDomain1739900102019' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`CREATE TABLE "core"."workspaceTrustedDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "PK_afa04c0f75f54a5e2c570c83cd6" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IndexOnDomainAndWorkspaceId" ON "core"."workspaceTrustedDomain" ("domain", "workspaceId") `); + await queryRunner.query(`ALTER TABLE "core"."workspaceTrustedDomain" ADD CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "core"."workspaceTrustedDomain" DROP CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e"`); + await queryRunner.query(`DROP INDEX "core"."IndexOnDomainAndWorkspaceId"`); + await queryRunner.query(`DROP TABLE "core"."workspaceTrustedDomain"`); + } + +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts index db2975076269..a2d9f559369c 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts @@ -4,7 +4,13 @@ import { IsString } from 'class-validator'; @InputType() export class CreateTrustedDomainInput { - @Field() + @Field(() => String) @IsString() + @IsNotEmpty() domain: string; + + @Field(() => String) + @IsEmail() + @IsNotEmpty() + email: string; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts deleted file mode 100644 index 8a9c254f0a52..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Field, InputType } from '@nestjs/graphql'; - -import { IsString } from 'class-validator'; - -@InputType() -export class SendTrustedDomainVerificationEmailInput { - @Field() - @IsString() - email: string; - - @Field() - @IsString() - trustedDomainId: string; -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts index cf9b8a7e8974..f8bde62ba41b 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts @@ -56,24 +56,16 @@ export class WorkspaceTrustedDomainService { sender: User, to: string, workspace: Workspace, - trustedDomainId: string, + workspaceTrustedDomain: WorkspaceTrustedDomainEntity, ) { - const trustedDomain = await this.workspaceTrustedDomainRepository.findOneBy( - { - id: trustedDomainId, - }, - ); - - workspaceTrustedDomainValidator.assertIsDefinedOrThrow(trustedDomain); - - if (trustedDomain.isValidated) { + if (workspaceTrustedDomain.isValidated) { throw new WorkspaceTrustedDomainException( 'Trusted domain has already been validated', WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED, ); } - if (!to.endsWith(trustedDomain.domain)) { + if (!to.endsWith(workspaceTrustedDomain.domain)) { throw new WorkspaceTrustedDomainException( 'Trusted domain does not match validator email', WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL, @@ -84,14 +76,14 @@ export class WorkspaceTrustedDomainService { workspace, pathname: `settings/security`, searchParams: { - validationToken: trustedDomain.validationToken, + validationToken: this.generateUniqueHash(workspaceTrustedDomain), }, }); const emailTemplate = SendTrustDomainValidation({ link: link.toString(), workspace: { name: workspace.displayName, logo: workspace.logo }, - domain: trustedDomain.domain, + domain: workspaceTrustedDomain.domain, sender: { email: sender.email, firstName: sender.firstName, @@ -114,17 +106,49 @@ export class WorkspaceTrustedDomainService { }); } + private generateUniqueHash( + workspaceTrustedDomain: WorkspaceTrustedDomainEntity, + ) { + return crypto + .createHash('sha256') + .update( + JSON.stringify({ + id: workspaceTrustedDomain.id, + domain: workspaceTrustedDomain.domain, + key: this.environmentService.get('APP_SECRET'), + }), + ) + .digest('hex'); + } + + compareHash( + hash: string, + workspaceTrustedDomain: WorkspaceTrustedDomainEntity, + ) { + return this.generateUniqueHash(workspaceTrustedDomain) === hash; + } + async createTrustedDomain( domain: string, inWorkspace: Workspace, fromUser: User, + emailToValidateDomain: string, ): Promise<WorkspaceTrustedDomain> { - return await this.workspaceTrustedDomainRepository.save({ - workspaceId: inWorkspace.id, - domain, - isVerified: this.checkIsVerified(domain, inWorkspace, fromUser), - validationToken: crypto.randomBytes(32).toString('hex'), - }); + const workspaceTrustedDomain = + await this.workspaceTrustedDomainRepository.save({ + workspaceId: inWorkspace.id, + domain, + isVerified: this.checkIsVerified(domain, inWorkspace, fromUser), + }); + + await this.sendTrustedDomainValidationEmail( + fromUser, + emailToValidateDomain, + inWorkspace, + workspaceTrustedDomain, + ); + + return workspaceTrustedDomain; } async deleteTrustedDomain(workspace: Workspace, trustedDomainId: string) { diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts index 51ec6c6981a6..82736115cabc 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts @@ -188,7 +188,6 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie workspaceId: 'workspace-id', domain, isVerified: true, - validationToken: expect.any(String), }; jest @@ -197,10 +196,15 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie expectedTrustedDomain as unknown as WorkspaceTrustedDomain, ); + jest + .spyOn(service, 'sendTrustedDomainValidationEmail') + .mockResolvedValue(); + const result = await service.createTrustedDomain( domain, inWorkspace, fromUser, + 'validator@custom-domain.com', ); expect(workspaceTrustedDomainRepository.save).toHaveBeenCalledWith( @@ -208,7 +212,6 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie workspaceId: 'workspace-id', domain, isVerified: true, - validationToken: expect.any(String), }), ); expect(result).toEqual(expectedTrustedDomain); @@ -269,18 +272,21 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie const workspace = {} as Workspace; const email = 'validator@example.com'; + const trustedDomain = { + id: trustedDomainId, + isValidated: true, + } as WorkspaceTrustedDomain; + jest .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue({ - isValidated: true, - } as unknown as WorkspaceTrustedDomain); + .mockResolvedValue(trustedDomain); await expect( service.sendTrustedDomainValidationEmail( sender, email, workspace, - trustedDomainId, + trustedDomain, ), ).rejects.toThrowError( new WorkspaceTrustedDomainException( @@ -295,20 +301,22 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie const sender = {} as User; const workspace = {} as Workspace; const email = 'validator@different.com'; + const trustedDomain = { + id: trustedDomainId, + isValidated: false, + domain: 'example.com', + } as WorkspaceTrustedDomain; jest .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue({ - isValidated: false, - domain: 'example.com', - } as unknown as WorkspaceTrustedDomain); + .mockResolvedValue(trustedDomain); await expect( service.sendTrustedDomainValidationEmail( sender, email, workspace, - trustedDomainId, + trustedDomain, ), ).rejects.toThrowError( new WorkspaceTrustedDomainException( @@ -319,7 +327,6 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie }); it('should send a validation email if all conditions are met', async () => { - const trustedDomainId = 'trusted-domain-id'; const sender = { email: 'sender@example.com', firstName: 'John', @@ -333,7 +340,6 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie const trustedDomain = { isValidated: false, domain: 'custom-domain.com', - validationToken: 'test-token', } as WorkspaceTrustedDomain; jest @@ -355,13 +361,13 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie sender, email, workspace, - trustedDomainId, + trustedDomain, ); expect(domainManagerService.buildWorkspaceURL).toHaveBeenCalledWith({ workspace: workspace, pathname: 'settings/security', - searchParams: { validationToken: 'test-token' }, + searchParams: { validationToken: expect.any(String) }, }); expect(emailService.send).toHaveBeenCalledWith({ diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts index 80bb432c39fe..ec9608b416d1 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts @@ -8,6 +8,7 @@ import { ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, + Unique, } from 'typeorm'; import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; @@ -16,6 +17,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Entity({ name: 'workspaceTrustedDomain', schema: 'core' }) @ObjectType() +@Unique('IndexOnDomainAndWorkspaceId', ['domain', 'workspaceId']) export class WorkspaceTrustedDomain { @PrimaryGeneratedColumn('uuid') id: string; @@ -32,9 +34,6 @@ export class WorkspaceTrustedDomain { @Column({ type: 'boolean', default: false, nullable: false }) isValidated: boolean; - @Column({ type: 'varchar', nullable: false }) - validationToken: string; - @Column() workspaceId: string; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts index 0696d525025c..82e8b87c0dcb 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -9,7 +9,6 @@ import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-truste import { CreateTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { SendTrustedDomainVerificationEmailInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/send-trusted-domain-verification-email.input'; import { DeleteTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input'; @UseGuards(WorkspaceAuthGuard) @@ -21,34 +20,21 @@ export class WorkspaceTrustedDomainResolver { @Mutation(() => WorkspaceTrustedDomain) async create( - @Args() { domain }: CreateTrustedDomainInput, + @Args('input') { domain, email }: CreateTrustedDomainInput, @AuthWorkspace() currentWorkspace: Workspace, @AuthUser() currentUser: User, ): Promise<WorkspaceTrustedDomain> { - return this.workspaceTrustedDomainService.createTrustedDomain( + return await this.workspaceTrustedDomainService.createTrustedDomain( domain, currentWorkspace, currentUser, - ); - } - - @Mutation(() => null) - async sendTrustedDomainVerificationEmail( - @Args() { email, trustedDomainId }: SendTrustedDomainVerificationEmailInput, - @AuthWorkspace() currentWorkspace: Workspace, - @AuthUser() currentUser: User, - ): Promise<void> { - return await this.workspaceTrustedDomainService.sendTrustedDomainValidationEmail( - currentUser, email, - currentWorkspace, - trustedDomainId, ); } @Mutation(() => null) async deleteTrustedDomain( - @Args() { id }: DeleteTrustedDomainInput, + @Args('input') { id }: DeleteTrustedDomainInput, @AuthWorkspace() currentWorkspace: Workspace, ): Promise<void> { return await this.workspaceTrustedDomainService.deleteTrustedDomain( From 123f647d08431ab96be84808b09dfc34f9793c9b Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Tue, 18 Feb 2025 18:38:37 +0100 Subject: [PATCH 04/16] feat(workspace-trusted-domain): enhance module with new features Added support for input validation, resolver methods, and domain manager integration within the WorkspaceTrustedDomain module. Removed unused SSO IDP input and adjusted resolver method names for clarity and consistency. Updated the core engine module to include WorkspaceTrustedDomainModule. --- .../engine/core-modules/core-engine.module.ts | 2 ++ .../sso/dtos/find-available-SSO-IDP.input.ts | 13 ------------ .../dtos/create-trusted-domain.input.ts | 2 +- .../workspace-trusted-domain.module.ts | 5 ++++- .../workspace-trusted-domain.resolver.ts | 20 ++++++++++--------- 5 files changed, 18 insertions(+), 24 deletions(-) delete mode 100644 packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 80275bd85689..74c21ae191a0 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -46,6 +46,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; +import { WorkspaceTrustedDomainModule } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -68,6 +69,7 @@ import { FileModule } from './file/file.module'; WorkspaceModule, WorkspaceInvitationModule, WorkspaceSSOModule, + WorkspaceTrustedDomainModule, PostgresCredentialsModule, WorkflowApiModule, WorkspaceEventEmitterModule, diff --git a/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts b/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts deleted file mode 100644 index 3cd5c91df79f..000000000000 --- a/packages/twenty-server/src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* @license Enterprise */ - -import { Field, InputType } from '@nestjs/graphql'; - -import { IsEmail, IsNotEmpty } from 'class-validator'; - -@InputType() -export class FindAvailableSSOIDPInput { - @Field(() => String) - @IsNotEmpty() - @IsEmail() - email: string; -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts index a2d9f559369c..2e69839cd6f0 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts @@ -1,6 +1,6 @@ import { Field, InputType } from '@nestjs/graphql'; -import { IsString } from 'class-validator'; +import { IsString, IsEmail, IsNotEmpty } from 'class-validator'; @InputType() export class CreateTrustedDomainInput { diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts index 8908349ed547..595bd16848d7 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts @@ -4,12 +4,15 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; import { WorkspaceTrustedDomainService } from 'src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { WorkspaceTrustedDomainResolver } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver'; @Module({ imports: [ + DomainManagerModule, NestjsQueryTypeOrmModule.forFeature([WorkspaceTrustedDomain], 'core'), ], exports: [WorkspaceTrustedDomainService], - providers: [WorkspaceTrustedDomainService], + providers: [WorkspaceTrustedDomainService, WorkspaceTrustedDomainResolver], }) export class WorkspaceTrustedDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts index 82e8b87c0dcb..1b78bfb1f081 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -1,5 +1,5 @@ import { UseGuards } from '@nestjs/common'; -import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { Args, Mutation, Resolver, Query } from '@nestjs/graphql'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -19,12 +19,12 @@ export class WorkspaceTrustedDomainResolver { ) {} @Mutation(() => WorkspaceTrustedDomain) - async create( + async createWorkspaceTrustedDomain( @Args('input') { domain, email }: CreateTrustedDomainInput, @AuthWorkspace() currentWorkspace: Workspace, @AuthUser() currentUser: User, ): Promise<WorkspaceTrustedDomain> { - return await this.workspaceTrustedDomainService.createTrustedDomain( + return this.workspaceTrustedDomainService.createTrustedDomain( domain, currentWorkspace, currentUser, @@ -32,19 +32,21 @@ export class WorkspaceTrustedDomainResolver { ); } - @Mutation(() => null) - async deleteTrustedDomain( + @Mutation(() => Boolean) + async deleteWorkspaceTrustedDomain( @Args('input') { id }: DeleteTrustedDomainInput, @AuthWorkspace() currentWorkspace: Workspace, - ): Promise<void> { - return await this.workspaceTrustedDomainService.deleteTrustedDomain( + ): Promise<boolean> { + await this.workspaceTrustedDomainService.deleteTrustedDomain( currentWorkspace, id, ); + + return true; } - @Mutation(() => [WorkspaceTrustedDomain]) - async getAllTrustedDomains( + @Query(() => [WorkspaceTrustedDomain]) + async getAllWorkspaceTrustedDomains( @AuthWorkspace() currentWorkspace: Workspace, ): Promise<Array<WorkspaceTrustedDomain>> { return await this.workspaceTrustedDomainService.getAllTrustedDomainsByWorkspace( From fbdb66389c393b6651dbbc5e04ee50faff1b5d3e Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Wed, 19 Feb 2025 10:03:16 +0100 Subject: [PATCH 05/16] refactor(migration): update workspace trusted domain migration Replaces the existing migration for workspace trusted domains with an updated version. Introduces a unique constraint on the domain and workspaceId combination to ensure data integrity. Other migration logic remains unchanged. --- ...9900102019-add-workspace-trusted-domain.ts | 18 --------------- ...9955345446-add-workspace-trusted-domain.ts | 23 +++++++++++++++++++ 2 files changed, 23 insertions(+), 18 deletions(-) delete mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1739900102019-add-workspace-trusted-domain.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739900102019-add-workspace-trusted-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739900102019-add-workspace-trusted-domain.ts deleted file mode 100644 index 0499709dce91..000000000000 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739900102019-add-workspace-trusted-domain.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class AddWorkspaceTrustedDomain1739900102019 implements MigrationInterface { - name = 'AddWorkspaceTrustedDomain1739900102019' - - public async up(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query(`CREATE TABLE "core"."workspaceTrustedDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "PK_afa04c0f75f54a5e2c570c83cd6" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE INDEX "IndexOnDomainAndWorkspaceId" ON "core"."workspaceTrustedDomain" ("domain", "workspaceId") `); - await queryRunner.query(`ALTER TABLE "core"."workspaceTrustedDomain" ADD CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); - } - - public async down(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query(`ALTER TABLE "core"."workspaceTrustedDomain" DROP CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e"`); - await queryRunner.query(`DROP INDEX "core"."IndexOnDomainAndWorkspaceId"`); - await queryRunner.query(`DROP TABLE "core"."workspaceTrustedDomain"`); - } - -} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts new file mode 100644 index 000000000000..93966d5266be --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWorkspaceTrustedDomain1739955345446 + implements MigrationInterface +{ + name = 'AddWorkspaceTrustedDomain1739955345446'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `CREATE TABLE "core"."workspaceTrustedDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "IndexOnDomainAndWorkspaceId" UNIQUE ("domain", "workspaceId"), CONSTRAINT "PK_afa04c0f75f54a5e2c570c83cd6" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspaceTrustedDomain" ADD CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "core"."workspaceTrustedDomain" DROP CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e"`, + ); + await queryRunner.query(`DROP TABLE "core"."workspaceTrustedDomain"`); + } +} From 27032821cb3fb1ab51fb7f93080a114f55958ac4 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Wed, 19 Feb 2025 15:58:19 +0100 Subject: [PATCH 06/16] feat(workspace-trusted-domain): add validation flow Introduce validation logic for workspace trusted domains, including a new mutation to validate domains using a token. Updated GraphQL schema, service, resolver, DTOs, and tests to support this functionality. --- .../twenty-front/src/generated/graphql.tsx | 41 +++++++++ .../dtos/validate-trusted-domain.input.ts | 16 ++++ .../workspace-trusted-domain.service.ts | 31 ++++++- .../services/workspace-trusted-domain.spec.ts | 92 ++++++++++++++++++- .../workspace-trusted-domain.exception.ts | 1 + .../workspace-trusted-domain.resolver.ts | 14 +++ 6 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index a03fb87ab688..c7d33375e451 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -331,6 +331,11 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe<Scalars['Float']>; }; +export type CreateTrustedDomainInput = { + domain: Scalars['String']; + email: Scalars['String']; +}; + export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']; @@ -384,6 +389,10 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']; }; +export type DeleteTrustedDomainInput = { + id: Scalars['String']; +}; + export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']; @@ -772,6 +781,7 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; + createWorkspaceTrustedDomain: WorkspaceTrustedDomain; deactivateWorkflowVersion: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -781,6 +791,7 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']; + deleteWorkspaceTrustedDomain: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -817,6 +828,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']; uploadWorkspaceLogo: Scalars['String']; userLookupAdminPanel: UserLookup; + validateWorkspaceTrustedDomain: Scalars['Boolean']; }; @@ -885,6 +897,11 @@ export type MutationCreateWorkflowVersionStepArgs = { }; +export type MutationCreateWorkspaceTrustedDomainArgs = { + input: CreateTrustedDomainInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']; }; @@ -920,6 +937,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; +export type MutationDeleteWorkspaceTrustedDomainArgs = { + input: DeleteTrustedDomainInput; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1092,6 +1114,11 @@ export type MutationUserLookupAdminPanelArgs = { userIdentifier: Scalars['String']; }; + +export type MutationValidateWorkspaceTrustedDomainArgs = { + input: ValidateTrustedDomainInput; +}; + export type Object = { __typename?: 'Object'; createdAt: Scalars['DateTime']; @@ -1257,6 +1284,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; + getAllWorkspaceTrustedDomains: Array<WorkspaceTrustedDomain>; getAvailablePackages: Scalars['JSON']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -1896,6 +1924,11 @@ export type ValidatePasswordResetToken = { id: Scalars['String']; }; +export type ValidateTrustedDomainInput = { + validationToken: Scalars['String']; + workspaceTrustedDomainId: Scalars['String']; +}; + export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']; @@ -2029,6 +2062,14 @@ export type WorkspaceNameAndId = { id: Scalars['String']; }; +export type WorkspaceTrustedDomain = { + __typename?: 'WorkspaceTrustedDomain'; + createdAt: Scalars['DateTime']; + domain: Scalars['String']; + id: Scalars['UUID']; + isValidated: Scalars['Boolean']; +}; + export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts new file mode 100644 index 000000000000..7af2d422d440 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts @@ -0,0 +1,16 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsString, IsNotEmpty } from 'class-validator'; + +@InputType() +export class ValidateTrustedDomainInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + validationToken: string; + + @Field(() => String) + @IsString() + @IsNotEmpty() + workspaceTrustedDomainId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts index f8bde62ba41b..0c622f650ef8 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts @@ -76,6 +76,7 @@ export class WorkspaceTrustedDomainService { workspace, pathname: `settings/security`, searchParams: { + wtdId: workspaceTrustedDomain.id, validationToken: this.generateUniqueHash(workspaceTrustedDomain), }, }); @@ -121,11 +122,31 @@ export class WorkspaceTrustedDomainService { .digest('hex'); } - compareHash( - hash: string, - workspaceTrustedDomain: WorkspaceTrustedDomainEntity, - ) { - return this.generateUniqueHash(workspaceTrustedDomain) === hash; + async validateTrustedDomain({ + validationToken, + workspaceTrustedDomainId, + }: { + validationToken: string; + workspaceTrustedDomainId: string; + }) { + const workspaceTrustedDomain = + await this.workspaceTrustedDomainRepository.findOneBy({ + id: workspaceTrustedDomainId, + }); + + workspaceTrustedDomainValidator.assertIsDefinedOrThrow( + workspaceTrustedDomain, + ); + + const isHashValid = + this.generateUniqueHash(workspaceTrustedDomain) === validationToken; + + if (!isHashValid) { + throw new WorkspaceTrustedDomainException( + 'Invalid trusted domain validation token', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, + ); + } } async createTrustedDomain( diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts index 82736115cabc..df44861e2419 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts @@ -16,7 +16,7 @@ import { import { WorkspaceTrustedDomainService } from './workspace-trusted-domain.service'; -describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerified', () => { +describe('WorkspaceTrustedDomainService', () => { let service: WorkspaceTrustedDomainService; let workspaceTrustedDomainRepository: Repository<WorkspaceTrustedDomain>; let emailService: EmailService; @@ -379,4 +379,94 @@ describe('WorkspaceTrustedDomainService - createTrustedDomain and checkIsVerifie }); }); }); + + describe('validateTrustedDomain', () => { + it('should validate the trusted domain when the validation token is correct', async () => { + const trustedDomainId = 'trusted-domain-id'; + const validationToken = 'valid-token'; + + const trustedDomain = { + id: trustedDomainId, + domain: 'example.com', + } as WorkspaceTrustedDomain; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue(trustedDomain); + + jest + .spyOn(service as any, 'generateUniqueHash') + .mockReturnValue(validationToken); + + await expect( + service.validateTrustedDomain({ + validationToken, + workspaceTrustedDomainId: trustedDomainId, + }), + ).resolves.not.toThrow(); + + expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: trustedDomainId, + }); + }); + + it('should throw an exception if the trusted domain does not exist', async () => { + const trustedDomainId = 'trusted-domain-id'; + const validationToken = 'invalid-token'; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue(null); + + await expect( + service.validateTrustedDomain({ + validationToken, + workspaceTrustedDomainId: trustedDomainId, + }), + ).rejects.toThrowError( + new WorkspaceTrustedDomainException( + 'Trusted domain not found', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND, + ), + ); + + expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: trustedDomainId, + }); + }); + + it('should throw an exception if the validation token is invalid', async () => { + const trustedDomainId = 'trusted-domain-id'; + const validationToken = 'invalid-token'; + + const trustedDomain = { + id: trustedDomainId, + domain: 'example.com', + } as WorkspaceTrustedDomain; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue(trustedDomain); + + jest + .spyOn(service as any, 'generateUniqueHash') + .mockReturnValue('valid-token'); + + await expect( + service.validateTrustedDomain({ + validationToken, + workspaceTrustedDomainId: trustedDomainId, + }), + ).rejects.toThrowError( + new WorkspaceTrustedDomainException( + 'Invalid trusted domain validation token', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, + ), + ); + + expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: trustedDomainId, + }); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts index 4ef04d577acc..1a6827fb1291 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts @@ -10,4 +10,5 @@ export enum WorkspaceTrustedDomainExceptionCode { WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND = 'WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND', WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED = 'WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED', WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL = 'WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL', + WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID = 'WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID', } diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts index 1b78bfb1f081..b970d07c1d00 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -10,6 +10,7 @@ import { CreateTrustedDomainInput } from 'src/engine/core-modules/workspace-trus import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { User } from 'src/engine/core-modules/user/user.entity'; import { DeleteTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input'; +import { ValidateTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input'; @UseGuards(WorkspaceAuthGuard) @Resolver() @@ -45,6 +46,19 @@ export class WorkspaceTrustedDomainResolver { return true; } + @Mutation(() => Boolean) + async validateWorkspaceTrustedDomain( + @Args('input') + { validationToken, workspaceTrustedDomainId }: ValidateTrustedDomainInput, + ): Promise<boolean> { + await this.workspaceTrustedDomainService.validateTrustedDomain({ + validationToken, + workspaceTrustedDomainId, + }); + + return true; + } + @Query(() => [WorkspaceTrustedDomain]) async getAllWorkspaceTrustedDomains( @AuthWorkspace() currentWorkspace: Workspace, From 11c45aa17bec8f6b19f7216d3bcaaa18c0204cb4 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Wed, 19 Feb 2025 15:58:42 +0100 Subject: [PATCH 07/16] feat(graphql): add support for workspace trusted domains Introduced types, inputs, and mutations for managing and validating workspace trusted domains. Added health status types and queries for system and worker queue monitoring, along with updates to roles and permissions handling. --- .../src/generated-metadata/graphql.ts | 110 +++++++++++++++++- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 14e6a7aef402..b8130064b725 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1,6 +1,5 @@ /* eslint-disable */ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; -import { PermissionsOnAllObjectRecords } from 'twenty-shared'; export type Maybe<T> = T | null; export type InputMaybe<T> = Maybe<T>; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; @@ -33,6 +32,33 @@ export type ActivateWorkspaceInput = { displayName?: InputMaybe<Scalars['String']['input']>; }; +export type AdminPanelHealthServiceData = { + __typename?: 'AdminPanelHealthServiceData'; + details?: Maybe<Scalars['String']['output']>; + queues?: Maybe<Array<AdminPanelWorkerQueueHealth>>; + status: AdminPanelHealthServiceStatus; +}; + +export enum AdminPanelHealthServiceStatus { + OPERATIONAL = 'OPERATIONAL', + OUTAGE = 'OUTAGE' +} + +export enum AdminPanelIndicatorHealthStatusInputEnum { + DATABASE = 'DATABASE', + MESSAGE_SYNC = 'MESSAGE_SYNC', + REDIS = 'REDIS', + WORKER = 'WORKER' +} + +export type AdminPanelWorkerQueueHealth = { + __typename?: 'AdminPanelWorkerQueueHealth'; + metrics: WorkerQueueMetrics; + name: Scalars['String']['output']; + status: AdminPanelHealthServiceStatus; + workers: Scalars['Float']['output']; +}; + export type Analytics = { __typename?: 'Analytics'; /** Boolean that confirms query was dispatched */ @@ -257,6 +283,7 @@ export type ClientConfig = { debugMode: Scalars['Boolean']['output']; defaultSubdomain?: Maybe<Scalars['String']['output']>; frontDomain: Scalars['String']['output']; + isAttachmentPreviewEnabled: Scalars['Boolean']['output']; isEmailVerificationRequired: Scalars['Boolean']['output']; isGoogleCalendarEnabled: Scalars['Boolean']['output']; isGoogleMessagingEnabled: Scalars['Boolean']['output']; @@ -367,6 +394,11 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe<Scalars['Float']['input']>; }; +export type CreateTrustedDomainInput = { + domain: Scalars['String']['input']; + email: Scalars['String']['input']; +}; + export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']['input']; @@ -425,6 +457,10 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']['output']; }; +export type DeleteTrustedDomainInput = { + id: Scalars['String']['input']; +}; + export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']['input']; @@ -809,6 +845,7 @@ export type Mutation = { activateWorkspace: Workspace; authorizeApp: AuthorizeApp; buildDraftServerlessFunction: ServerlessFunction; + checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']['output']; createDraftFromWorkflowVersion: WorkflowVersion; @@ -821,6 +858,7 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; + createWorkspaceTrustedDomain: WorkspaceTrustedDomain; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -832,6 +870,7 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']['output']; + deleteWorkspaceTrustedDomain: Scalars['Boolean']['output']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -872,6 +911,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output']; userLookupAdminPanel: UserLookup; + validateWorkspaceTrustedDomain: Scalars['Boolean']['output']; }; @@ -960,6 +1000,11 @@ export type MutationCreateWorkflowVersionStepArgs = { }; +export type MutationCreateWorkspaceTrustedDomainArgs = { + input: CreateTrustedDomainInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']['input']; }; @@ -1005,6 +1050,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; +export type MutationDeleteWorkspaceTrustedDomainArgs = { + input: DeleteTrustedDomainInput; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1197,6 +1247,11 @@ export type MutationUserLookupAdminPanelArgs = { userIdentifier: Scalars['String']['input']; }; + +export type MutationValidateWorkspaceTrustedDomainArgs = { + input: ValidateTrustedDomainInput; +}; + export type Object = { __typename?: 'Object'; createdAt: Scalars['DateTime']['output']; @@ -1305,6 +1360,13 @@ export type PageInfo = { startCursor?: Maybe<Scalars['ConnectionCursor']['output']>; }; +export enum PermissionsOnAllObjectRecords { + DESTROY_ALL_OBJECT_RECORDS = 'DESTROY_ALL_OBJECT_RECORDS', + READ_ALL_OBJECT_RECORDS = 'READ_ALL_OBJECT_RECORDS', + SOFT_DELETE_ALL_OBJECT_RECORDS = 'SOFT_DELETE_ALL_OBJECT_RECORDS', + UPDATE_ALL_OBJECT_RECORDS = 'UPDATE_ALL_OBJECT_RECORDS' +} + export type PostgresCredentials = { __typename?: 'PostgresCredentials'; id: Scalars['UUID']['output']; @@ -1343,7 +1405,6 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; billingPortalSession: BillingSessionOutput; - checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>; checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -1359,13 +1420,16 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; + getAllWorkspaceTrustedDomains: Array<WorkspaceTrustedDomain>; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; + getIndicatorHealthStatus: AdminPanelHealthServiceData; getPostgresCredentials?: Maybe<PostgresCredentials>; getProductPrices: BillingProductPricesOutput; getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput; getRoles: Array<Role>; getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']['output']>; + getSystemHealthStatus: SystemHealth; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; @@ -1443,6 +1507,11 @@ export type QueryGetAvailablePackagesArgs = { }; +export type QueryGetIndicatorHealthStatusArgs = { + indicatorName: AdminPanelIndicatorHealthStatusInputEnum; +}; + + export type QueryGetProductPricesArgs = { product: Scalars['String']['input']; }; @@ -1633,6 +1702,10 @@ export type ResendEmailVerificationTokenOutput = { export type Role = { __typename?: 'Role'; + canDestroyAllObjectRecords: Scalars['Boolean']['output']; + canReadAllObjectRecords: Scalars['Boolean']['output']; + canSoftDeleteAllObjectRecords: Scalars['Boolean']['output']; + canUpdateAllObjectRecords: Scalars['Boolean']['output']; canUpdateAllSettings: Scalars['Boolean']['output']; description?: Maybe<Scalars['String']['output']>; id: Scalars['String']['output']; @@ -1798,6 +1871,14 @@ export type Support = { supportFrontChatId?: Maybe<Scalars['String']['output']>; }; +export type SystemHealth = { + __typename?: 'SystemHealth'; + database: AdminPanelHealthServiceData; + messageSync: AdminPanelHealthServiceData; + redis: AdminPanelHealthServiceData; + worker: AdminPanelHealthServiceData; +}; + export type TimelineCalendarEvent = { __typename?: 'TimelineCalendarEvent'; conferenceLink: LinksMetadata; @@ -2050,8 +2131,8 @@ export type UserWorkspace = { createdAt: Scalars['DateTime']['output']; deletedAt?: Maybe<Scalars['DateTime']['output']>; id: Scalars['UUID']['output']; - settingsPermissions?: Maybe<Array<SettingsFeatures>>; objectRecordsPermissions?: Maybe<Array<PermissionsOnAllObjectRecords>>; + settingsPermissions?: Maybe<Array<SettingsFeatures>>; updatedAt: Scalars['DateTime']['output']; user: User; userId: Scalars['String']['output']; @@ -2065,6 +2146,21 @@ export type ValidatePasswordResetToken = { id: Scalars['String']['output']; }; +export type ValidateTrustedDomainInput = { + validationToken: Scalars['String']['input']; + workspaceTrustedDomainId: Scalars['String']['input']; +}; + +export type WorkerQueueMetrics = { + __typename?: 'WorkerQueueMetrics'; + active: Scalars['Float']['output']; + completed: Scalars['Float']['output']; + delayed: Scalars['Float']['output']; + failed: Scalars['Float']['output']; + prioritized: Scalars['Float']['output']; + waiting: Scalars['Float']['output']; +}; + export type WorkflowAction = { __typename?: 'WorkflowAction'; id: Scalars['UUID']['output']; @@ -2188,6 +2284,14 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; +export type WorkspaceTrustedDomain = { + __typename?: 'WorkspaceTrustedDomain'; + createdAt: Scalars['DateTime']['output']; + domain: Scalars['String']['output']; + id: Scalars['UUID']['output']; + isValidated: Scalars['Boolean']['output']; +}; + export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']['output']; From c820e96c8df0f609bfb71d227ab6e9b409b2ae43 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Wed, 19 Feb 2025 16:56:16 +0100 Subject: [PATCH 08/16] feat(workspace-trusted-domain): enhance validation logic Added a new validation check to prevent re-validation of trusted domains. Updated exception codes and messages for better clarity and consistency. Enhanced test cases to cover these new scenarios. --- .../workspace-trusted-domain.service.ts | 11 ++- .../services/workspace-trusted-domain.spec.ts | 74 ++++++++++++------- .../workspace-trusted-domain.exception.ts | 3 +- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts index 0c622f650ef8..af541470af64 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts @@ -68,7 +68,7 @@ export class WorkspaceTrustedDomainService { if (!to.endsWith(workspaceTrustedDomain.domain)) { throw new WorkspaceTrustedDomainException( 'Trusted domain does not match validator email', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL, + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, ); } @@ -138,6 +138,13 @@ export class WorkspaceTrustedDomainService { workspaceTrustedDomain, ); + if (workspaceTrustedDomain.isValidated) { + throw new WorkspaceTrustedDomainException( + 'Trusted domain has already been validated', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED, + ); + } + const isHashValid = this.generateUniqueHash(workspaceTrustedDomain) === validationToken; @@ -147,6 +154,8 @@ export class WorkspaceTrustedDomainService { WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, ); } + workspaceTrustedDomain.isValidated = true; + this.workspaceTrustedDomainRepository.save(workspaceTrustedDomain); } async createTrustedDomain( diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts index df44861e2419..8d2d615ec0f5 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts @@ -321,7 +321,7 @@ describe('WorkspaceTrustedDomainService', () => { ).rejects.toThrowError( new WorkspaceTrustedDomainException( 'Trusted domain does not match validator email', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL, + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, ), ); }); @@ -381,38 +381,39 @@ describe('WorkspaceTrustedDomainService', () => { }); describe('validateTrustedDomain', () => { - it('should validate the trusted domain when the validation token is correct', async () => { - const trustedDomainId = 'trusted-domain-id'; + it('should validate the trusted domain successfully with a correct token', async () => { + const trustedDomainId = 'domain-id'; const validationToken = 'valid-token'; - - const trustedDomain = { + const mockTrustedDomain = { id: trustedDomainId, domain: 'example.com', + isValidated: false, } as WorkspaceTrustedDomain; jest .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomain); - + .mockResolvedValue(mockTrustedDomain); jest .spyOn(service as any, 'generateUniqueHash') .mockReturnValue(validationToken); + const saveSpy = jest.spyOn(workspaceTrustedDomainRepository, 'save'); - await expect( - service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }), - ).resolves.not.toThrow(); + await service.validateTrustedDomain({ + validationToken, + workspaceTrustedDomainId: trustedDomainId, + }); expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ id: trustedDomainId, }); + expect(saveSpy).toHaveBeenCalledWith( + expect.objectContaining({ isValidated: true }), + ); }); - it('should throw an exception if the trusted domain does not exist', async () => { - const trustedDomainId = 'trusted-domain-id'; - const validationToken = 'invalid-token'; + it('should throw an error if the trusted domain does not exist', async () => { + const trustedDomainId = 'invalid-domain-id'; + const validationToken = 'valid-token'; jest .spyOn(workspaceTrustedDomainRepository, 'findOneBy') @@ -429,25 +430,20 @@ describe('WorkspaceTrustedDomainService', () => { WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND, ), ); - - expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: trustedDomainId, - }); }); - it('should throw an exception if the validation token is invalid', async () => { - const trustedDomainId = 'trusted-domain-id'; + it('should throw an error if the validation token is invalid', async () => { + const trustedDomainId = 'domain-id'; const validationToken = 'invalid-token'; - - const trustedDomain = { + const mockTrustedDomain = { id: trustedDomainId, domain: 'example.com', + isValidated: false, } as WorkspaceTrustedDomain; jest .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomain); - + .mockResolvedValue(mockTrustedDomain); jest .spyOn(service as any, 'generateUniqueHash') .mockReturnValue('valid-token'); @@ -463,10 +459,32 @@ describe('WorkspaceTrustedDomainService', () => { WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, ), ); + }); - expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ + it('should throw an error if the trusted domain is already validated', async () => { + const trustedDomainId = 'domain-id'; + const validationToken = 'valid-token'; + const mockTrustedDomain = { id: trustedDomainId, - }); + domain: 'example.com', + isValidated: true, + } as WorkspaceTrustedDomain; + + jest + .spyOn(workspaceTrustedDomainRepository, 'findOneBy') + .mockResolvedValue(mockTrustedDomain); + + await expect( + service.validateTrustedDomain({ + validationToken, + workspaceTrustedDomainId: trustedDomainId, + }), + ).rejects.toThrowError( + new WorkspaceTrustedDomainException( + 'Trusted domain has already been validated', + WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED, + ), + ); }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts index 1a6827fb1291..f82d5226673a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts @@ -9,6 +9,7 @@ export class WorkspaceTrustedDomainException extends CustomException { export enum WorkspaceTrustedDomainExceptionCode { WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND = 'WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND', WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED = 'WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED', - WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL = 'WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_VALIDATOR_EMAIL', + WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL = 'WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL', WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID = 'WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID', + WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED = 'WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED', } From 444003a266d8fd56f838dc2f4631863a9974bd42 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Wed, 19 Feb 2025 17:50:39 +0100 Subject: [PATCH 09/16] feat(graphql): add support for workspace trusted domains Introduced types and mutations for managing workspace trusted domains, including creation, deletion, validation, and retrieval. This enhancement enables better control and flexibility for workspace domain management. --- .../src/generated-metadata/graphql.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 59805ee23cd2..d91a7c4d9a6c 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -394,6 +394,11 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe<Scalars['Float']['input']>; }; +export type CreateTrustedDomainInput = { + domain: Scalars['String']['input']; + email: Scalars['String']['input']; +}; + export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']['input']; @@ -452,6 +457,10 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']['output']; }; +export type DeleteTrustedDomainInput = { + id: Scalars['String']['input']; +}; + export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']['input']; @@ -849,6 +858,7 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; + createWorkspaceTrustedDomain: WorkspaceTrustedDomain; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -860,6 +870,7 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']['output']; + deleteWorkspaceTrustedDomain: Scalars['Boolean']['output']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -900,6 +911,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output']; userLookupAdminPanel: UserLookup; + validateWorkspaceTrustedDomain: Scalars['Boolean']['output']; }; @@ -988,6 +1000,11 @@ export type MutationCreateWorkflowVersionStepArgs = { }; +export type MutationCreateWorkspaceTrustedDomainArgs = { + input: CreateTrustedDomainInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']['input']; }; @@ -1033,6 +1050,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; +export type MutationDeleteWorkspaceTrustedDomainArgs = { + input: DeleteTrustedDomainInput; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1225,6 +1247,11 @@ export type MutationUserLookupAdminPanelArgs = { userIdentifier: Scalars['String']['input']; }; + +export type MutationValidateWorkspaceTrustedDomainArgs = { + input: ValidateTrustedDomainInput; +}; + export type Object = { __typename?: 'Object'; createdAt: Scalars['DateTime']['output']; @@ -1393,6 +1420,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; + getAllWorkspaceTrustedDomains: Array<WorkspaceTrustedDomain>; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2118,6 +2146,11 @@ export type ValidatePasswordResetToken = { id: Scalars['String']['output']; }; +export type ValidateTrustedDomainInput = { + validationToken: Scalars['String']['input']; + workspaceTrustedDomainId: Scalars['String']['input']; +}; + export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']['output']; @@ -2251,6 +2284,14 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; +export type WorkspaceTrustedDomain = { + __typename?: 'WorkspaceTrustedDomain'; + createdAt: Scalars['DateTime']['output']; + domain: Scalars['String']['output']; + id: Scalars['UUID']['output']; + isValidated: Scalars['Boolean']['output']; +}; + export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']['output']; From 15fcb9639f8dd8e80f5b37683460617a628f60bb Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Thu, 20 Feb 2025 10:48:45 +0100 Subject: [PATCH 10/16] refactor(workspace-trusted-domain): improve validation flow Changed mutation to return the validated domain instead of a boolean, streamlined domain validation logic, and clarified error messages. Removed redundant utility function and adjusted email template for better user guidance. These changes enhance clarity, reduce complexity, and improve user feedback when validating trusted domains. --- .../emails/validate-trust-domain.email.tsx | 7 ++-- .../workspace-trusted-domain.service.ts | 32 ++++--------------- .../workspace-trusted-domain.resolver.ts | 8 ++--- 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx b/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx index e791edd9bff5..77d9c9664e85 100644 --- a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx +++ b/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx @@ -49,8 +49,11 @@ export const SendTrustDomainValidation = ({ value={sender.email} color={emailTheme.font.colors.blue} /> - )<Trans>has added a trust domain: </Trans> - <b>{domain}</b> + ) + <Trans> + Please validate this domain to allow users with <b>@{domain}</b> email + addresses to join your workspace without requiring an invitation. + </Trans> <br /> </MainText> <HighlightedContainer> diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts index af541470af64..9efe1f1ef966 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts @@ -12,7 +12,6 @@ import { WorkspaceTrustedDomain as WorkspaceTrustedDomainEntity } from 'src/engi import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { isWorkDomain } from 'src/utils/is-work-email'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; @@ -33,25 +32,6 @@ export class WorkspaceTrustedDomainService { private readonly domainManagerService: DomainManagerService, ) {} - private checkIsVerified( - domain: string, - inWorkspace: Workspace, - fromUser: User, - ) { - if (!isWorkDomain(domain)) return false; - - if ( - domain === inWorkspace.customDomain && - inWorkspace.isCustomDomainEnabled - ) - return true; - - if (fromUser.email.endsWith(domain) && fromUser.isEmailVerified) - return true; - - return false; - } - async sendTrustedDomainValidationEmail( sender: User, to: string, @@ -65,9 +45,9 @@ export class WorkspaceTrustedDomainService { ); } - if (!to.endsWith(workspaceTrustedDomain.domain)) { + if (to.split('@')[1] !== workspaceTrustedDomain.domain) { throw new WorkspaceTrustedDomainException( - 'Trusted domain does not match validator email', + 'Trusted domain does not match email domain', WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, ); } @@ -154,8 +134,11 @@ export class WorkspaceTrustedDomainService { WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, ); } - workspaceTrustedDomain.isValidated = true; - this.workspaceTrustedDomainRepository.save(workspaceTrustedDomain); + + return await this.workspaceTrustedDomainRepository.save({ + ...workspaceTrustedDomain, + isValidated: true, + }); } async createTrustedDomain( @@ -168,7 +151,6 @@ export class WorkspaceTrustedDomainService { await this.workspaceTrustedDomainRepository.save({ workspaceId: inWorkspace.id, domain, - isVerified: this.checkIsVerified(domain, inWorkspace, fromUser), }); await this.sendTrustedDomainValidationEmail( diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts index b970d07c1d00..10c1ac10f68f 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts @@ -46,17 +46,15 @@ export class WorkspaceTrustedDomainResolver { return true; } - @Mutation(() => Boolean) + @Mutation(() => WorkspaceTrustedDomain) async validateWorkspaceTrustedDomain( @Args('input') { validationToken, workspaceTrustedDomainId }: ValidateTrustedDomainInput, - ): Promise<boolean> { - await this.workspaceTrustedDomainService.validateTrustedDomain({ + ): Promise<WorkspaceTrustedDomain> { + return await this.workspaceTrustedDomainService.validateTrustedDomain({ validationToken, workspaceTrustedDomainId, }); - - return true; } @Query(() => [WorkspaceTrustedDomain]) From 846d95ebd308407b098c79cc6e1f8781afecfe67 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Thu, 20 Feb 2025 10:52:32 +0100 Subject: [PATCH 11/16] refactor(tests): streamline workspace trusted domain spec Removed redundant tests for checkIsVerified functionality to simplify the test suite. Updated createTrustedDomain tests to reflect changes in domain validation logic and naming conventions. This improves maintainability and aligns with updated business logic. --- .../services/workspace-trusted-domain.spec.ts | 109 +----------------- 1 file changed, 3 insertions(+), 106 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts index 8d2d615ec0f5..bbec5ebb7f88 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts @@ -69,110 +69,8 @@ describe('WorkspaceTrustedDomainService', () => { module.get<DomainManagerService>(DomainManagerService); }); - describe('checkIsVerified', () => { - it('should mark the domain as verified if it is the workspace custom domain and custom domain is enabled', () => { - const domain = 'custom-domain.com'; - const inWorkspace = { - customDomain: domain, - isCustomDomainEnabled: true, - } as Workspace; - const fromUser = { - email: 'user@otherdomain.com', - isEmailVerified: true, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(true); - }); - - it('should mark the domain as verified if the user email ends with the domain and the user email is verified', () => { - const domain = 'custom-domain.com'; - const inWorkspace = { - customDomain: null, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@custom-domain.com', - isEmailVerified: true, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(true); - }); - - it('should not mark the domain as verified if it is not a work domain', () => { - const domain = 'gmail.com'; - const inWorkspace = { - customDomain: null, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@gmail.com', - isEmailVerified: true, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(false); - }); - - it('should not mark the domain as verified if it is the workspace custom domain but custom domain is not enabled', () => { - const domain = 'custom-domain.com'; - const inWorkspace = { - customDomain: domain, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@otherdomain.com', - isEmailVerified: true, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(false); - }); - - it('should not mark the domain as verified if the user email does not end with the domain or is not verified', () => { - const domain = 'example.com'; - const inWorkspace = { - customDomain: null, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@otherdomain.com', - isEmailVerified: false, - } as User; - - const result = (service as any).checkIsVerified( - domain, - inWorkspace, - fromUser, - ); - - expect(result).toBe(false); - }); - }); - describe('createTrustedDomain', () => { - it('should successfully create a trusted domain and mark it as verified based on checkIsVerified', async () => { + it('should successfully create a trusted domain', async () => { const domain = 'custom-domain.com'; const inWorkspace = { id: 'workspace-id', @@ -187,7 +85,7 @@ describe('WorkspaceTrustedDomainService', () => { const expectedTrustedDomain = { workspaceId: 'workspace-id', domain, - isVerified: true, + isValidated: true, }; jest @@ -211,7 +109,6 @@ describe('WorkspaceTrustedDomainService', () => { expect.objectContaining({ workspaceId: 'workspace-id', domain, - isVerified: true, }), ); expect(result).toEqual(expectedTrustedDomain); @@ -320,7 +217,7 @@ describe('WorkspaceTrustedDomainService', () => { ), ).rejects.toThrowError( new WorkspaceTrustedDomainException( - 'Trusted domain does not match validator email', + 'Trusted domain does not match email domain', WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, ), ); From 90ae412dca20591e1d46d68317d4bfd04ffbe902 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Thu, 20 Feb 2025 11:51:00 +0100 Subject: [PATCH 12/16] refactor(database): rename WorkspaceTrustedDomain to ApprovedAccessDomain Replaced all instances of WorkspaceTrustedDomain with ApprovedAccessDomain across the application. Updated related entities, migrations, services, resolvers, and schemas to reflect the new naming convention, improving clarity and alignment with domain naming standards. --- packages/twenty-front/codegen-metadata.cjs | 1 + packages/twenty-front/codegen.cjs | 1 + .../src/generated-metadata/graphql.ts | 8 +- .../twenty-front/src/generated/graphql.tsx | 74 ++-- ...9955345446-add-workspace-trusted-domain.ts | 23 -- ...740048555744-add-approved-access-domain.ts | 16 + .../src/database/typeorm/typeorm.service.ts | 4 +- .../approved-access-domain.entity.ts} | 6 +- .../approved-access-domain.exception.ts | 15 + .../approved-access-domain.module.ts | 18 + .../approved-access-domain.resolver.ts | 71 ++++ .../approved-access-domain.validate.ts | 26 ++ .../dtos/approved-access-domain.dto.ts} | 4 +- .../create-approved-access.domain.input.ts} | 2 +- .../delete-approved-access-domain.input.ts} | 2 +- .../validate-approved-access-domain.input.ts} | 4 +- .../approved-access-domain.service.ts | 184 +++++++++ .../services/approved-access-domain.spec.ts | 390 ++++++++++++++++++ .../engine/core-modules/core-engine.module.ts | 4 +- .../workspace-trusted-domain.service.ts | 186 --------- .../services/workspace-trusted-domain.spec.ts | 387 ----------------- .../workspace-trusted-domain.exception.ts | 15 - .../workspace-trusted-domain.module.ts | 18 - .../workspace-trusted-domain.resolver.ts | 68 --- .../workspace-trusted-domain.validate.ts | 26 -- .../workspace/workspace.entity.ts | 8 +- 26 files changed, 780 insertions(+), 781 deletions(-) delete mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/workspace-trusted-domain.entity.ts => approved-access-domain/approved-access-domain.entity.ts} (84%) create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/dtos/trusted-domain.dto.ts => approved-access-domain/dtos/approved-access-domain.dto.ts} (84%) rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/dtos/create-trusted-domain.input.ts => approved-access-domain/dtos/create-approved-access.domain.input.ts} (85%) rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/dtos/delete-trusted-domain.input.ts => approved-access-domain/dtos/delete-approved-access-domain.input.ts} (76%) rename packages/twenty-server/src/engine/core-modules/{workspace-trusted-domain/dtos/validate-trusted-domain.input.ts => approved-access-domain/dtos/validate-approved-access-domain.input.ts} (75%) create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index 53429715ca0f..d95a37eb71b6 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -1,3 +1,4 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; module.exports = { schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + diff --git a/packages/twenty-front/codegen.cjs b/packages/twenty-front/codegen.cjs index 05effffb7a22..24269a37e30f 100644 --- a/packages/twenty-front/codegen.cjs +++ b/packages/twenty-front/codegen.cjs @@ -1,3 +1,4 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; module.exports = { schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d91a7c4d9a6c..d0904013bfd0 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -858,7 +858,7 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; - createWorkspaceTrustedDomain: WorkspaceTrustedDomain; + createWorkspaceTrustedDomain: ApprovedAccessDomain; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -1420,7 +1420,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; - getAllWorkspaceTrustedDomains: Array<WorkspaceTrustedDomain>; + getAllWorkspaceTrustedDomains: Array<ApprovedAccessDomain>; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2284,8 +2284,8 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; -export type WorkspaceTrustedDomain = { - __typename?: 'WorkspaceTrustedDomain'; +export type ApprovedAccessDomain = { + __typename?: 'ApprovedAccessDomain'; createdAt: Scalars['DateTime']['output']; domain: Scalars['String']['output']; id: Scalars['UUID']['output']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 227c9d2631ad..9f5b1b3b7c8d 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -95,6 +95,14 @@ export type AppTokenEdge = { node: AppToken; }; +export type ApprovedAccessDomain = { + __typename?: 'ApprovedAccessDomain'; + createdAt: Scalars['DateTime']; + domain: Scalars['String']; + id: Scalars['UUID']; + isValidated: Scalars['Boolean']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']; @@ -294,6 +302,11 @@ export type ComputeStepOutputSchemaInput = { step: Scalars['JSON']; }; +export type CreateApprovedAccessDomainInput = { + domain: Scalars['String']; + email: Scalars['String']; +}; + export type CreateDraftFromWorkflowVersionInput = { /** Workflow ID */ workflowId: Scalars['String']; @@ -331,11 +344,6 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe<Scalars['Float']>; }; -export type CreateTrustedDomainInput = { - domain: Scalars['String']; - email: Scalars['String']; -}; - export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']; @@ -370,6 +378,10 @@ export type CustomDomainValidRecords = { records: Array<CustomDomainRecord>; }; +export type DeleteApprovedAccessDomainInput = { + id: Scalars['String']; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']; @@ -389,10 +401,6 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']; }; -export type DeleteTrustedDomainInput = { - id: Scalars['String']; -}; - export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']; @@ -773,6 +781,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']; + createApprovedAccessDomain: ApprovedAccessDomain; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; @@ -781,8 +790,8 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; - createWorkspaceTrustedDomain: WorkspaceTrustedDomain; deactivateWorkflowVersion: Scalars['Boolean']; + deleteApprovedAccessDomain: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; deleteOneObject: Object; @@ -791,7 +800,6 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']; - deleteWorkspaceTrustedDomain: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -828,7 +836,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']; uploadWorkspaceLogo: Scalars['String']; userLookupAdminPanel: UserLookup; - validateWorkspaceTrustedDomain: Scalars['Boolean']; + validateApprovedAccessDomain: ApprovedAccessDomain; }; @@ -867,6 +875,11 @@ export type MutationComputeStepOutputSchemaArgs = { }; +export type MutationCreateApprovedAccessDomainArgs = { + input: CreateApprovedAccessDomainInput; +}; + + export type MutationCreateDraftFromWorkflowVersionArgs = { input: CreateDraftFromWorkflowVersionInput; }; @@ -897,13 +910,13 @@ export type MutationCreateWorkflowVersionStepArgs = { }; -export type MutationCreateWorkspaceTrustedDomainArgs = { - input: CreateTrustedDomainInput; +export type MutationDeactivateWorkflowVersionArgs = { + workflowVersionId: Scalars['String']; }; -export type MutationDeactivateWorkflowVersionArgs = { - workflowVersionId: Scalars['String']; +export type MutationDeleteApprovedAccessDomainArgs = { + input: DeleteApprovedAccessDomainInput; }; @@ -937,11 +950,6 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; -export type MutationDeleteWorkspaceTrustedDomainArgs = { - input: DeleteTrustedDomainInput; -}; - - export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1115,8 +1123,8 @@ export type MutationUserLookupAdminPanelArgs = { }; -export type MutationValidateWorkspaceTrustedDomainArgs = { - input: ValidateTrustedDomainInput; +export type MutationValidateApprovedAccessDomainArgs = { + input: ValidateApprovedAccessDomainInput; }; export type Object = { @@ -1284,7 +1292,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; - getAllWorkspaceTrustedDomains: Array<WorkspaceTrustedDomain>; + getAllApprovedAccessDomains: Array<ApprovedAccessDomain>; getAvailablePackages: Scalars['JSON']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -1918,17 +1926,17 @@ export type UserWorkspace = { workspaceId: Scalars['String']; }; +export type ValidateApprovedAccessDomainInput = { + approvedAccessDomainId: Scalars['String']; + validationToken: Scalars['String']; +}; + export type ValidatePasswordResetToken = { __typename?: 'ValidatePasswordResetToken'; email: Scalars['String']; id: Scalars['String']; }; -export type ValidateTrustedDomainInput = { - validationToken: Scalars['String']; - workspaceTrustedDomainId: Scalars['String']; -}; - export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']; @@ -2062,14 +2070,6 @@ export type WorkspaceNameAndId = { id: Scalars['String']; }; -export type WorkspaceTrustedDomain = { - __typename?: 'WorkspaceTrustedDomain'; - createdAt: Scalars['DateTime']; - domain: Scalars['String']; - id: Scalars['UUID']; - isValidated: Scalars['Boolean']; -}; - export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts deleted file mode 100644 index 93966d5266be..000000000000 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1739955345446-add-workspace-trusted-domain.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddWorkspaceTrustedDomain1739955345446 - implements MigrationInterface -{ - name = 'AddWorkspaceTrustedDomain1739955345446'; - - public async up(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query( - `CREATE TABLE "core"."workspaceTrustedDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "IndexOnDomainAndWorkspaceId" UNIQUE ("domain", "workspaceId"), CONSTRAINT "PK_afa04c0f75f54a5e2c570c83cd6" PRIMARY KEY ("id"))`, - ); - await queryRunner.query( - `ALTER TABLE "core"."workspaceTrustedDomain" ADD CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); - } - - public async down(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query( - `ALTER TABLE "core"."workspaceTrustedDomain" DROP CONSTRAINT "FK_130f179c3608a3d8cde9d355d2e"`, - ); - await queryRunner.query(`DROP TABLE "core"."workspaceTrustedDomain"`); - } -} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts new file mode 100644 index 000000000000..84e6cc98c15d --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddApprovedAccessDomain1740048555744 implements MigrationInterface { + name = 'AddApprovedAccessDomain1740048555744' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`CREATE TABLE "core"."approvedAccessDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "IndexOnDomainAndWorkspaceId" UNIQUE ("domain", "workspaceId"), CONSTRAINT "PK_523281ce57c84e1a039f4538c19" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "core"."approvedAccessDomain" ADD CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "core"."approvedAccessDomain" DROP CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc"`); + await queryRunner.query(`DROP TABLE "core"."approvedAccessDomain"`); + } + +} diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 17ab3f3c7765..9c498c49712e 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -22,7 +22,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; @Injectable() export class TypeORMService implements OnModuleInit, OnModuleDestroy { private mainDataSource: DataSource; @@ -51,7 +51,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { BillingEntitlement, PostgresCredentials, WorkspaceSSOIdentityProvider, - WorkspaceTrustedDomain, + ApprovedAccessDomain, TwoFactorMethod, ], metadataTableName: '_typeorm_generated_columns_and_materialized_views', diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts index ec9608b416d1..bfbab3a4b672 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.entity.ts @@ -15,10 +15,10 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -@Entity({ name: 'workspaceTrustedDomain', schema: 'core' }) +@Entity({ name: 'approvedAccessDomain', schema: 'core' }) @ObjectType() @Unique('IndexOnDomainAndWorkspaceId', ['domain', 'workspaceId']) -export class WorkspaceTrustedDomain { +export class ApprovedAccessDomain { @PrimaryGeneratedColumn('uuid') id: string; @@ -37,7 +37,7 @@ export class WorkspaceTrustedDomain { @Column() workspaceId: string; - @ManyToOne(() => Workspace, (workspace) => workspace.trustDomains, { + @ManyToOne(() => Workspace, (workspace) => workspace.approvedAccessDomains, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'workspaceId' }) diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts new file mode 100644 index 000000000000..433407aa7ebd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.exception.ts @@ -0,0 +1,15 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class ApprovedAccessDomainException extends CustomException { + constructor(message: string, code: ApprovedAccessDomainExceptionCode) { + super(message, code); + } +} + +export enum ApprovedAccessDomainExceptionCode { + APPROVED_ACCESS_DOMAIN_NOT_FOUND = 'APPROVED_ACCESS_DOMAIN_NOT_FOUND', + APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED = 'APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED', + APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL = 'APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL', + APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID = 'APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID', + APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED = 'APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED', +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts new file mode 100644 index 000000000000..ee7654fede3d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { ApprovedAccessDomainResolver } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.resolver'; +import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; + +@Module({ + imports: [ + DomainManagerModule, + NestjsQueryTypeOrmModule.forFeature([ApprovedAccessDomain], 'core'), + ], + exports: [ApprovedAccessDomainService], + providers: [ApprovedAccessDomainService, ApprovedAccessDomainResolver], +}) +export class ApprovedAccessDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts new file mode 100644 index 000000000000..1c9b3a3f4c4d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts @@ -0,0 +1,71 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver, Query } from '@nestjs/graphql'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto'; +import { CreateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input'; +import { DeleteApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input'; +import { ValidateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input'; + +@UseGuards(WorkspaceAuthGuard) +@Resolver() +export class ApprovedAccessDomainResolver { + constructor( + private readonly approvedAccessDomainService: ApprovedAccessDomainService, + ) {} + + @Mutation(() => ApprovedAccessDomain) + async createApprovedAccessDomain( + @Args('input') { domain, email }: CreateApprovedAccessDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + @AuthUser() currentUser: User, + ): Promise<ApprovedAccessDomain> { + return this.approvedAccessDomainService.createApprovedAccessDomain( + domain, + currentWorkspace, + currentUser, + email, + ); + } + + @Mutation(() => Boolean) + async deleteApprovedAccessDomain( + @Args('input') { id }: DeleteApprovedAccessDomainInput, + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise<boolean> { + await this.approvedAccessDomainService.deleteApprovedAccessDomain( + currentWorkspace, + id, + ); + + return true; + } + + @Mutation(() => ApprovedAccessDomain) + async validateApprovedAccessDomain( + @Args('input') + { + validationToken, + approvedAccessDomainId, + }: ValidateApprovedAccessDomainInput, + ): Promise<ApprovedAccessDomain> { + return await this.approvedAccessDomainService.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId, + }); + } + + @Query(() => [ApprovedAccessDomain]) + async getAllApprovedAccessDomains( + @AuthWorkspace() currentWorkspace: Workspace, + ): Promise<Array<ApprovedAccessDomain>> { + return await this.approvedAccessDomainService.getAllApprovedAccessDomains( + currentWorkspace, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts new file mode 100644 index 000000000000..8d038f619bae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.validate.ts @@ -0,0 +1,26 @@ +import { isDefined } from 'twenty-shared'; + +import { CustomException } from 'src/utils/custom-exception'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { + ApprovedAccessDomainException, + ApprovedAccessDomainExceptionCode, +} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; + +const assertIsDefinedOrThrow = ( + approvedAccessDomain: ApprovedAccessDomain | undefined | null, + exceptionToThrow: CustomException = new ApprovedAccessDomainException( + 'Approved access domain not found', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_NOT_FOUND, + ), +): asserts approvedAccessDomain is ApprovedAccessDomain => { + if (!isDefined(approvedAccessDomain)) { + throw exceptionToThrow; + } +}; + +export const approvedAccessDomainValidator: { + assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow; +} = { + assertIsDefinedOrThrow, +}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts index 30a0a239132f..3ac698369621 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto.ts @@ -4,8 +4,8 @@ import { IDField } from '@ptc-org/nestjs-query-graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; -@ObjectType('WorkspaceTrustedDomain') -export class WorkspaceTrustedDomain { +@ObjectType('ApprovedAccessDomain') +export class ApprovedAccessDomain { @IDField(() => UUIDScalarType) id: string; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts similarity index 85% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts index 2e69839cd6f0..e1af1b1ed980 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts @@ -3,7 +3,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { IsString, IsEmail, IsNotEmpty } from 'class-validator'; @InputType() -export class CreateTrustedDomainInput { +export class CreateApprovedAccessDomainInput { @Field(() => String) @IsString() @IsNotEmpty() diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts similarity index 76% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts index 096cbf256d10..586c3acd34a0 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input.ts @@ -3,7 +3,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { IsString } from 'class-validator'; @InputType() -export class DeleteTrustedDomainInput { +export class DeleteApprovedAccessDomainInput { @Field() @IsString() id: string; diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts similarity index 75% rename from packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts rename to packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts index 7af2d422d440..0ef691b1a129 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input.ts @@ -3,7 +3,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { IsString, IsNotEmpty } from 'class-validator'; @InputType() -export class ValidateTrustedDomainInput { +export class ValidateApprovedAccessDomainInput { @Field(() => String) @IsString() @IsNotEmpty() @@ -12,5 +12,5 @@ export class ValidateTrustedDomainInput { @Field(() => String) @IsString() @IsNotEmpty() - workspaceTrustedDomainId: string; + approvedAccessDomainId: string; } diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts new file mode 100644 index 000000000000..2c473aadf00d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -0,0 +1,184 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import crypto from 'crypto'; + +import { render } from '@react-email/render'; +import { Repository } from 'typeorm'; +import { APP_LOCALES } from 'twenty-shared'; +import { SendTrustDomainValidation } from 'twenty-emails'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { ApprovedAccessDomain as ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { approvedAccessDomainValidator } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.validate'; +import { + ApprovedAccessDomainException, + ApprovedAccessDomainExceptionCode, +} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; + +@Injectable() +// eslint-disable-next-line @nx/workspace-inject-workspace-repository +export class ApprovedAccessDomainService { + constructor( + @InjectRepository(ApprovedAccessDomainEntity, 'core') + private readonly approvedAccessDomainRepository: Repository<ApprovedAccessDomainEntity>, + private readonly emailService: EmailService, + private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, + ) {} + + async sendApprovedAccessDomainValidationEmail( + sender: User, + to: string, + workspace: Workspace, + approvedAccessDomain: ApprovedAccessDomainEntity, + ) { + if (approvedAccessDomain.isValidated) { + throw new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED, + ); + } + + if (to.split('@')[1] !== approvedAccessDomain.domain) { + throw new ApprovedAccessDomainException( + 'Approved access domain does not match email domain', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, + ); + } + + const link = this.domainManagerService.buildWorkspaceURL({ + workspace, + pathname: `settings/security`, + searchParams: { + wtdId: approvedAccessDomain.id, + validationToken: this.generateUniqueHash(approvedAccessDomain), + }, + }); + + const emailTemplate = SendTrustDomainValidation({ + link: link.toString(), + workspace: { name: workspace.displayName, logo: workspace.logo }, + domain: approvedAccessDomain.domain, + sender: { + email: sender.email, + firstName: sender.firstName, + lastName: sender.lastName, + }, + serverUrl: this.environmentService.get('SERVER_URL'), + locale: 'en' as keyof typeof APP_LOCALES, + }); + const html = render(emailTemplate); + const text = render(emailTemplate, { + plainText: true, + }); + + await this.emailService.send({ + from: `${sender.firstName} ${sender.lastName} (via Twenty) <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, + to, + subject: 'Approve your access domain', + text, + html, + }); + } + + private generateUniqueHash(approvedAccessDomain: ApprovedAccessDomainEntity) { + return crypto + .createHash('sha256') + .update( + JSON.stringify({ + id: approvedAccessDomain.id, + domain: approvedAccessDomain.domain, + key: this.environmentService.get('APP_SECRET'), + }), + ) + .digest('hex'); + } + + async validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId, + }: { + validationToken: string; + approvedAccessDomainId: string; + }) { + const approvedAccessDomain = + await this.approvedAccessDomainRepository.findOneBy({ + id: approvedAccessDomainId, + }); + + approvedAccessDomainValidator.assertIsDefinedOrThrow(approvedAccessDomain); + + if (approvedAccessDomain.isValidated) { + throw new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED, + ); + } + + const isHashValid = + this.generateUniqueHash(approvedAccessDomain) === validationToken; + + if (!isHashValid) { + throw new ApprovedAccessDomainException( + 'Invalid approved access domain validation token', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID, + ); + } + + return await this.approvedAccessDomainRepository.save({ + ...approvedAccessDomain, + isValidated: true, + }); + } + + async createApprovedAccessDomain( + domain: string, + inWorkspace: Workspace, + fromUser: User, + emailToValidateDomain: string, + ): Promise<ApprovedAccessDomainEntity> { + const approvedAccessDomain = await this.approvedAccessDomainRepository.save( + { + workspaceId: inWorkspace.id, + domain, + }, + ); + + await this.sendApprovedAccessDomainValidationEmail( + fromUser, + emailToValidateDomain, + inWorkspace, + approvedAccessDomain, + ); + + return approvedAccessDomain; + } + + async deleteApprovedAccessDomain( + workspace: Workspace, + approvedAccessDomainId: string, + ) { + const approvedAccessDomain = + await this.approvedAccessDomainRepository.findOneBy({ + id: approvedAccessDomainId, + workspaceId: workspace.id, + }); + + approvedAccessDomainValidator.assertIsDefinedOrThrow(approvedAccessDomain); + + await this.approvedAccessDomainRepository.delete(approvedAccessDomain); + } + + async getAllApprovedAccessDomains(workspace: Workspace) { + return await this.approvedAccessDomainRepository.find({ + where: { + workspaceId: workspace.id, + }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts new file mode 100644 index 000000000000..bc506c63b41c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts @@ -0,0 +1,390 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { DeleteResult, Repository } from 'typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { + ApprovedAccessDomainException, + ApprovedAccessDomainExceptionCode, +} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception'; + +import { ApprovedAccessDomainService } from './approved-access-domain.service'; + +describe('ApprovedAccessDomainService', () => { + let service: ApprovedAccessDomainService; + let approvedAccessDomainRepository: Repository<ApprovedAccessDomain>; + let emailService: EmailService; + let environmentService: EnvironmentService; + let domainManagerService: DomainManagerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApprovedAccessDomainService, + { + provide: getRepositoryToken(ApprovedAccessDomain, 'core'), + useValue: { + delete: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: EmailService, + useValue: { + send: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: DomainManagerService, + useValue: { + buildWorkspaceURL: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get<ApprovedAccessDomainService>( + ApprovedAccessDomainService, + ); + approvedAccessDomainRepository = module.get( + getRepositoryToken(ApprovedAccessDomain, 'core'), + ); + emailService = module.get<EmailService>(EmailService); + environmentService = module.get<EnvironmentService>(EnvironmentService); + domainManagerService = + module.get<DomainManagerService>(DomainManagerService); + }); + + describe('createApprovedAccessDomain', () => { + it('should successfully create an approved access domain', async () => { + const domain = 'custom-domain.com'; + const inWorkspace = { + id: 'workspace-id', + customDomain: null, + isCustomDomainEnabled: false, + } as Workspace; + const fromUser = { + email: 'user@custom-domain.com', + isEmailVerified: true, + } as User; + + const expectedApprovedAccessDomain = { + workspaceId: 'workspace-id', + domain, + isValidated: true, + }; + + jest + .spyOn(approvedAccessDomainRepository, 'save') + .mockResolvedValue( + expectedApprovedAccessDomain as unknown as ApprovedAccessDomain, + ); + + jest + .spyOn(service, 'sendApprovedAccessDomainValidationEmail') + .mockResolvedValue(); + + const result = await service.createApprovedAccessDomain( + domain, + inWorkspace, + fromUser, + 'validator@custom-domain.com', + ); + + expect(approvedAccessDomainRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-id', + domain, + }), + ); + expect(result).toEqual(expectedApprovedAccessDomain); + }); + }); + + describe('deleteApprovedAccessDomain', () => { + it('should delete an approved access domain successfully', async () => { + const workspace: Workspace = { id: 'workspace-id' } as Workspace; + const approvedAccessDomainId = 'approved-access-domain-id'; + const approvedAccessDomainEntity = { + id: approvedAccessDomainId, + workspaceId: workspace.id, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomainEntity); + jest + .spyOn(approvedAccessDomainRepository, 'delete') + .mockResolvedValue({} as unknown as DeleteResult); + + await service.deleteApprovedAccessDomain( + workspace, + approvedAccessDomainId, + ); + + expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: approvedAccessDomainId, + workspaceId: workspace.id, + }); + expect(approvedAccessDomainRepository.delete).toHaveBeenCalledWith( + approvedAccessDomainEntity, + ); + }); + + it('should throw an error if the approved access domain does not exist', async () => { + const workspace: Workspace = { id: 'workspace-id' } as Workspace; + const approvedAccessDomainId = 'approved-access-domain-id'; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(null); + + await expect( + service.deleteApprovedAccessDomain(workspace, approvedAccessDomainId), + ).rejects.toThrow(); + + expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: approvedAccessDomainId, + workspaceId: workspace.id, + }); + expect(approvedAccessDomainRepository.delete).not.toHaveBeenCalled(); + }); + }); + + describe('sendApprovedAccessDomainValidationEmail', () => { + it('should throw an exception if the approved access domain is already validated', async () => { + const approvedAccessDomainId = 'approved-access-domain-id'; + const sender = {} as User; + const workspace = {} as Workspace; + const email = 'validator@example.com'; + + const approvedAccessDomain = { + id: approvedAccessDomainId, + isValidated: true, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + await expect( + service.sendApprovedAccessDomainValidationEmail( + sender, + email, + workspace, + approvedAccessDomain, + ), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VERIFIED, + ), + ); + }); + + it('should throw an exception if the email does not match the approved access domain', async () => { + const approvedAccessDomainId = 'approved-access-domain-id'; + const sender = {} as User; + const workspace = {} as Workspace; + const email = 'validator@different.com'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + isValidated: false, + domain: 'example.com', + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + await expect( + service.sendApprovedAccessDomainValidationEmail( + sender, + email, + workspace, + approvedAccessDomain, + ), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain does not match email domain', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, + ), + ); + }); + + it('should send a validation email if all conditions are met', async () => { + const sender = { + email: 'sender@example.com', + firstName: 'John', + lastName: 'Doe', + } as User; + const workspace = { + displayName: 'Test Workspace', + logo: '/logo.png', + } as Workspace; + const email = 'validator@custom-domain.com'; + const approvedAccessDomain = { + isValidated: false, + domain: 'custom-domain.com', + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + jest + .spyOn(domainManagerService, 'buildWorkspaceURL') + .mockReturnValue(new URL('https://sub.twenty.com')); + + jest + .spyOn(environmentService, 'get') + .mockImplementation((key: string) => { + if (key === 'EMAIL_FROM_ADDRESS') return 'no-reply@example.com'; + if (key === 'SERVER_URL') return 'https://api.example.com'; + }); + + await service.sendApprovedAccessDomainValidationEmail( + sender, + email, + workspace, + approvedAccessDomain, + ); + + expect(domainManagerService.buildWorkspaceURL).toHaveBeenCalledWith({ + workspace: workspace, + pathname: 'settings/security', + searchParams: { validationToken: expect.any(String) }, + }); + + expect(emailService.send).toHaveBeenCalledWith({ + from: 'John Doe (via Twenty) <no-reply@example.com>', + to: email, + subject: 'Approve your access domain', + text: expect.any(String), + html: expect.any(String), + }); + }); + }); + + describe('validateApprovedAccessDomain', () => { + it('should validate the approved access domain successfully with a correct token', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'valid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: false, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + jest + .spyOn(service as any, 'generateUniqueHash') + .mockReturnValue(validationToken); + const saveSpy = jest.spyOn(approvedAccessDomainRepository, 'save'); + + await service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }); + + expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ + id: approvedAccessDomainId, + }); + expect(saveSpy).toHaveBeenCalledWith( + expect.objectContaining({ isValidated: true }), + ); + }); + + it('should throw an error if the approved access domain does not exist', async () => { + const approvedAccessDomainId = 'invalid-domain-id'; + const validationToken = 'valid-token'; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(null); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain not found', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_NOT_FOUND, + ), + ); + }); + + it('should throw an error if the validation token is invalid', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'invalid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: false, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + jest + .spyOn(service as any, 'generateUniqueHash') + .mockReturnValue('valid-token'); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Invalid approved access domain validation token', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID, + ), + ); + }); + + it('should throw an error if the approved access domain is already validated', async () => { + const approvedAccessDomainId = 'domain-id'; + const validationToken = 'valid-token'; + const approvedAccessDomain = { + id: approvedAccessDomainId, + domain: 'example.com', + isValidated: true, + } as ApprovedAccessDomain; + + jest + .spyOn(approvedAccessDomainRepository, 'findOneBy') + .mockResolvedValue(approvedAccessDomain); + + await expect( + service.validateApprovedAccessDomain({ + validationToken, + approvedAccessDomainId: approvedAccessDomainId, + }), + ).rejects.toThrowError( + new ApprovedAccessDomainException( + 'Approved access domain has already been validated', + ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_VALIDATED, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 74c21ae191a0..6926e75a317b 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -46,7 +46,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; -import { WorkspaceTrustedDomainModule } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module'; +import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { ClientConfigModule } from './client-config/client-config.module'; @@ -69,7 +69,7 @@ import { FileModule } from './file/file.module'; WorkspaceModule, WorkspaceInvitationModule, WorkspaceSSOModule, - WorkspaceTrustedDomainModule, + ApprovedAccessDomainModule, PostgresCredentialsModule, WorkflowApiModule, WorkspaceEventEmitterModule, diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts deleted file mode 100644 index 9efe1f1ef966..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import crypto from 'crypto'; - -import { render } from '@react-email/render'; -import { Repository } from 'typeorm'; -import { APP_LOCALES } from 'twenty-shared'; -import { SendTrustDomainValidation } from 'twenty-emails'; - -import { WorkspaceTrustedDomain as WorkspaceTrustedDomainEntity } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { workspaceTrustedDomainValidator } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate'; -import { - WorkspaceTrustedDomainException, - WorkspaceTrustedDomainExceptionCode, -} from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception'; - -@Injectable() -// eslint-disable-next-line @nx/workspace-inject-workspace-repository -export class WorkspaceTrustedDomainService { - constructor( - @InjectRepository(WorkspaceTrustedDomain, 'core') - private readonly workspaceTrustedDomainRepository: Repository<WorkspaceTrustedDomainEntity>, - private readonly emailService: EmailService, - private readonly environmentService: EnvironmentService, - private readonly domainManagerService: DomainManagerService, - ) {} - - async sendTrustedDomainValidationEmail( - sender: User, - to: string, - workspace: Workspace, - workspaceTrustedDomain: WorkspaceTrustedDomainEntity, - ) { - if (workspaceTrustedDomain.isValidated) { - throw new WorkspaceTrustedDomainException( - 'Trusted domain has already been validated', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED, - ); - } - - if (to.split('@')[1] !== workspaceTrustedDomain.domain) { - throw new WorkspaceTrustedDomainException( - 'Trusted domain does not match email domain', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, - ); - } - - const link = this.domainManagerService.buildWorkspaceURL({ - workspace, - pathname: `settings/security`, - searchParams: { - wtdId: workspaceTrustedDomain.id, - validationToken: this.generateUniqueHash(workspaceTrustedDomain), - }, - }); - - const emailTemplate = SendTrustDomainValidation({ - link: link.toString(), - workspace: { name: workspace.displayName, logo: workspace.logo }, - domain: workspaceTrustedDomain.domain, - sender: { - email: sender.email, - firstName: sender.firstName, - lastName: sender.lastName, - }, - serverUrl: this.environmentService.get('SERVER_URL'), - locale: 'en' as keyof typeof APP_LOCALES, - }); - const html = render(emailTemplate); - const text = render(emailTemplate, { - plainText: true, - }); - - await this.emailService.send({ - from: `${sender.firstName} ${sender.lastName} (via Twenty) <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, - to, - subject: 'Activate Your Trusted Domain', - text, - html, - }); - } - - private generateUniqueHash( - workspaceTrustedDomain: WorkspaceTrustedDomainEntity, - ) { - return crypto - .createHash('sha256') - .update( - JSON.stringify({ - id: workspaceTrustedDomain.id, - domain: workspaceTrustedDomain.domain, - key: this.environmentService.get('APP_SECRET'), - }), - ) - .digest('hex'); - } - - async validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId, - }: { - validationToken: string; - workspaceTrustedDomainId: string; - }) { - const workspaceTrustedDomain = - await this.workspaceTrustedDomainRepository.findOneBy({ - id: workspaceTrustedDomainId, - }); - - workspaceTrustedDomainValidator.assertIsDefinedOrThrow( - workspaceTrustedDomain, - ); - - if (workspaceTrustedDomain.isValidated) { - throw new WorkspaceTrustedDomainException( - 'Trusted domain has already been validated', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED, - ); - } - - const isHashValid = - this.generateUniqueHash(workspaceTrustedDomain) === validationToken; - - if (!isHashValid) { - throw new WorkspaceTrustedDomainException( - 'Invalid trusted domain validation token', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, - ); - } - - return await this.workspaceTrustedDomainRepository.save({ - ...workspaceTrustedDomain, - isValidated: true, - }); - } - - async createTrustedDomain( - domain: string, - inWorkspace: Workspace, - fromUser: User, - emailToValidateDomain: string, - ): Promise<WorkspaceTrustedDomain> { - const workspaceTrustedDomain = - await this.workspaceTrustedDomainRepository.save({ - workspaceId: inWorkspace.id, - domain, - }); - - await this.sendTrustedDomainValidationEmail( - fromUser, - emailToValidateDomain, - inWorkspace, - workspaceTrustedDomain, - ); - - return workspaceTrustedDomain; - } - - async deleteTrustedDomain(workspace: Workspace, trustedDomainId: string) { - const trustedDomain = await this.workspaceTrustedDomainRepository.findOneBy( - { - id: trustedDomainId, - workspaceId: workspace.id, - }, - ); - - workspaceTrustedDomainValidator.assertIsDefinedOrThrow(trustedDomain); - - await this.workspaceTrustedDomainRepository.delete(trustedDomain); - } - - async getAllTrustedDomainsByWorkspace(workspace: Workspace) { - return await this.workspaceTrustedDomainRepository.find({ - where: { - workspaceId: workspace.id, - }, - }); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts deleted file mode 100644 index bbec5ebb7f88..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.spec.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { DeleteResult, Repository } from 'typeorm'; - -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; -import { - WorkspaceTrustedDomainException, - WorkspaceTrustedDomainExceptionCode, -} from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception'; - -import { WorkspaceTrustedDomainService } from './workspace-trusted-domain.service'; - -describe('WorkspaceTrustedDomainService', () => { - let service: WorkspaceTrustedDomainService; - let workspaceTrustedDomainRepository: Repository<WorkspaceTrustedDomain>; - let emailService: EmailService; - let environmentService: EnvironmentService; - let domainManagerService: DomainManagerService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - WorkspaceTrustedDomainService, - { - provide: getRepositoryToken(WorkspaceTrustedDomain, 'core'), - useValue: { - delete: jest.fn(), - findOneBy: jest.fn(), - find: jest.fn(), - save: jest.fn(), - }, - }, - { - provide: EmailService, - useValue: { - send: jest.fn(), - }, - }, - { - provide: EnvironmentService, - useValue: { - get: jest.fn(), - }, - }, - { - provide: DomainManagerService, - useValue: { - buildWorkspaceURL: jest.fn(), - }, - }, - ], - }).compile(); - - service = module.get<WorkspaceTrustedDomainService>( - WorkspaceTrustedDomainService, - ); - workspaceTrustedDomainRepository = module.get( - getRepositoryToken(WorkspaceTrustedDomain, 'core'), - ); - emailService = module.get<EmailService>(EmailService); - environmentService = module.get<EnvironmentService>(EnvironmentService); - domainManagerService = - module.get<DomainManagerService>(DomainManagerService); - }); - - describe('createTrustedDomain', () => { - it('should successfully create a trusted domain', async () => { - const domain = 'custom-domain.com'; - const inWorkspace = { - id: 'workspace-id', - customDomain: null, - isCustomDomainEnabled: false, - } as Workspace; - const fromUser = { - email: 'user@custom-domain.com', - isEmailVerified: true, - } as User; - - const expectedTrustedDomain = { - workspaceId: 'workspace-id', - domain, - isValidated: true, - }; - - jest - .spyOn(workspaceTrustedDomainRepository, 'save') - .mockResolvedValue( - expectedTrustedDomain as unknown as WorkspaceTrustedDomain, - ); - - jest - .spyOn(service, 'sendTrustedDomainValidationEmail') - .mockResolvedValue(); - - const result = await service.createTrustedDomain( - domain, - inWorkspace, - fromUser, - 'validator@custom-domain.com', - ); - - expect(workspaceTrustedDomainRepository.save).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-id', - domain, - }), - ); - expect(result).toEqual(expectedTrustedDomain); - }); - }); - - describe('deleteTrustedDomain', () => { - it('should delete a trusted domain successfully', async () => { - const workspace: Workspace = { id: 'workspace-id' } as Workspace; - const trustedDomainId = 'trusted-domain-id'; - const trustedDomainEntity = { - id: trustedDomainId, - workspaceId: workspace.id, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomainEntity); - jest - .spyOn(workspaceTrustedDomainRepository, 'delete') - .mockResolvedValue({} as unknown as DeleteResult); - - await service.deleteTrustedDomain(workspace, trustedDomainId); - - expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: trustedDomainId, - workspaceId: workspace.id, - }); - expect(workspaceTrustedDomainRepository.delete).toHaveBeenCalledWith( - trustedDomainEntity, - ); - }); - - it('should throw an error if the trusted domain does not exist', async () => { - const workspace: Workspace = { id: 'workspace-id' } as Workspace; - const trustedDomainId = 'trusted-domain-id'; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(null); - - await expect( - service.deleteTrustedDomain(workspace, trustedDomainId), - ).rejects.toThrow(); - - expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: trustedDomainId, - workspaceId: workspace.id, - }); - expect(workspaceTrustedDomainRepository.delete).not.toHaveBeenCalled(); - }); - }); - - describe('sendTrustedDomainValidationEmail', () => { - it('should throw an exception if the trusted domain is already validated', async () => { - const trustedDomainId = 'trusted-domain-id'; - const sender = {} as User; - const workspace = {} as Workspace; - const email = 'validator@example.com'; - - const trustedDomain = { - id: trustedDomainId, - isValidated: true, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomain); - - await expect( - service.sendTrustedDomainValidationEmail( - sender, - email, - workspace, - trustedDomain, - ), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Trusted domain has already been validated', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED, - ), - ); - }); - - it('should throw an exception if the email does not match the trusted domain', async () => { - const trustedDomainId = 'trusted-domain-id'; - const sender = {} as User; - const workspace = {} as Workspace; - const email = 'validator@different.com'; - const trustedDomain = { - id: trustedDomainId, - isValidated: false, - domain: 'example.com', - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomain); - - await expect( - service.sendTrustedDomainValidationEmail( - sender, - email, - workspace, - trustedDomain, - ), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Trusted domain does not match email domain', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL, - ), - ); - }); - - it('should send a validation email if all conditions are met', async () => { - const sender = { - email: 'sender@example.com', - firstName: 'John', - lastName: 'Doe', - } as User; - const workspace = { - displayName: 'Test Workspace', - logo: '/logo.png', - } as Workspace; - const email = 'validator@custom-domain.com'; - const trustedDomain = { - isValidated: false, - domain: 'custom-domain.com', - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(trustedDomain); - - jest - .spyOn(domainManagerService, 'buildWorkspaceURL') - .mockReturnValue(new URL('https://sub.twenty.com')); - - jest - .spyOn(environmentService, 'get') - .mockImplementation((key: string) => { - if (key === 'EMAIL_FROM_ADDRESS') return 'no-reply@example.com'; - if (key === 'SERVER_URL') return 'https://api.example.com'; - }); - - await service.sendTrustedDomainValidationEmail( - sender, - email, - workspace, - trustedDomain, - ); - - expect(domainManagerService.buildWorkspaceURL).toHaveBeenCalledWith({ - workspace: workspace, - pathname: 'settings/security', - searchParams: { validationToken: expect.any(String) }, - }); - - expect(emailService.send).toHaveBeenCalledWith({ - from: 'John Doe (via Twenty) <no-reply@example.com>', - to: email, - subject: 'Activate Your Trusted Domain', - text: expect.any(String), - html: expect.any(String), - }); - }); - }); - - describe('validateTrustedDomain', () => { - it('should validate the trusted domain successfully with a correct token', async () => { - const trustedDomainId = 'domain-id'; - const validationToken = 'valid-token'; - const mockTrustedDomain = { - id: trustedDomainId, - domain: 'example.com', - isValidated: false, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(mockTrustedDomain); - jest - .spyOn(service as any, 'generateUniqueHash') - .mockReturnValue(validationToken); - const saveSpy = jest.spyOn(workspaceTrustedDomainRepository, 'save'); - - await service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }); - - expect(workspaceTrustedDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: trustedDomainId, - }); - expect(saveSpy).toHaveBeenCalledWith( - expect.objectContaining({ isValidated: true }), - ); - }); - - it('should throw an error if the trusted domain does not exist', async () => { - const trustedDomainId = 'invalid-domain-id'; - const validationToken = 'valid-token'; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(null); - - await expect( - service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Trusted domain not found', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND, - ), - ); - }); - - it('should throw an error if the validation token is invalid', async () => { - const trustedDomainId = 'domain-id'; - const validationToken = 'invalid-token'; - const mockTrustedDomain = { - id: trustedDomainId, - domain: 'example.com', - isValidated: false, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(mockTrustedDomain); - jest - .spyOn(service as any, 'generateUniqueHash') - .mockReturnValue('valid-token'); - - await expect( - service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Invalid trusted domain validation token', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID, - ), - ); - }); - - it('should throw an error if the trusted domain is already validated', async () => { - const trustedDomainId = 'domain-id'; - const validationToken = 'valid-token'; - const mockTrustedDomain = { - id: trustedDomainId, - domain: 'example.com', - isValidated: true, - } as WorkspaceTrustedDomain; - - jest - .spyOn(workspaceTrustedDomainRepository, 'findOneBy') - .mockResolvedValue(mockTrustedDomain); - - await expect( - service.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId: trustedDomainId, - }), - ).rejects.toThrowError( - new WorkspaceTrustedDomainException( - 'Trusted domain has already been validated', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED, - ), - ); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts deleted file mode 100644 index f82d5226673a..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { CustomException } from 'src/utils/custom-exception'; - -export class WorkspaceTrustedDomainException extends CustomException { - constructor(message: string, code: WorkspaceTrustedDomainExceptionCode) { - super(message, code); - } -} - -export enum WorkspaceTrustedDomainExceptionCode { - WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND = 'WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND', - WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED = 'WORKSPACE_TRUSTED_DOMAIN_ALREADY_VERIFIED', - WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL = 'WORKSPACE_TRUSTED_DOMAIN_DOES_NOT_MATCH_DOMAIN_EMAIL', - WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID = 'WORKSPACE_TRUSTED_DOMAIN_VALIDATION_TOKEN_INVALID', - WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED = 'WORKSPACE_TRUSTED_DOMAIN_ALREADY_VALIDATED', -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts deleted file mode 100644 index 595bd16848d7..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; - -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; -import { WorkspaceTrustedDomainService } from 'src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service'; -import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; -import { WorkspaceTrustedDomainResolver } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver'; - -@Module({ - imports: [ - DomainManagerModule, - NestjsQueryTypeOrmModule.forFeature([WorkspaceTrustedDomain], 'core'), - ], - exports: [WorkspaceTrustedDomainService], - providers: [WorkspaceTrustedDomainService, WorkspaceTrustedDomainResolver], -}) -export class WorkspaceTrustedDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts deleted file mode 100644 index 10c1ac10f68f..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.resolver.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { UseGuards } from '@nestjs/common'; -import { Args, Mutation, Resolver, Query } from '@nestjs/graphql'; - -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -import { WorkspaceTrustedDomainService } from 'src/engine/core-modules/workspace-trusted-domain/services/workspace-trusted-domain.service'; -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/dtos/trusted-domain.dto'; -import { CreateTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/create-trusted-domain.input'; -import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { DeleteTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/delete-trusted-domain.input'; -import { ValidateTrustedDomainInput } from 'src/engine/core-modules/workspace-trusted-domain/dtos/validate-trusted-domain.input'; - -@UseGuards(WorkspaceAuthGuard) -@Resolver() -export class WorkspaceTrustedDomainResolver { - constructor( - private readonly workspaceTrustedDomainService: WorkspaceTrustedDomainService, - ) {} - - @Mutation(() => WorkspaceTrustedDomain) - async createWorkspaceTrustedDomain( - @Args('input') { domain, email }: CreateTrustedDomainInput, - @AuthWorkspace() currentWorkspace: Workspace, - @AuthUser() currentUser: User, - ): Promise<WorkspaceTrustedDomain> { - return this.workspaceTrustedDomainService.createTrustedDomain( - domain, - currentWorkspace, - currentUser, - email, - ); - } - - @Mutation(() => Boolean) - async deleteWorkspaceTrustedDomain( - @Args('input') { id }: DeleteTrustedDomainInput, - @AuthWorkspace() currentWorkspace: Workspace, - ): Promise<boolean> { - await this.workspaceTrustedDomainService.deleteTrustedDomain( - currentWorkspace, - id, - ); - - return true; - } - - @Mutation(() => WorkspaceTrustedDomain) - async validateWorkspaceTrustedDomain( - @Args('input') - { validationToken, workspaceTrustedDomainId }: ValidateTrustedDomainInput, - ): Promise<WorkspaceTrustedDomain> { - return await this.workspaceTrustedDomainService.validateTrustedDomain({ - validationToken, - workspaceTrustedDomainId, - }); - } - - @Query(() => [WorkspaceTrustedDomain]) - async getAllWorkspaceTrustedDomains( - @AuthWorkspace() currentWorkspace: Workspace, - ): Promise<Array<WorkspaceTrustedDomain>> { - return await this.workspaceTrustedDomainService.getAllTrustedDomainsByWorkspace( - currentWorkspace, - ); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts b/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts deleted file mode 100644 index 9c1b78c72c7a..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.validate.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { isDefined } from 'twenty-shared'; - -import { CustomException } from 'src/utils/custom-exception'; -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; -import { - WorkspaceTrustedDomainException, - WorkspaceTrustedDomainExceptionCode, -} from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.exception'; - -const assertIsDefinedOrThrow = ( - trustedDomain: WorkspaceTrustedDomain | undefined | null, - exceptionToThrow: CustomException = new WorkspaceTrustedDomainException( - 'Trusted domain not found', - WorkspaceTrustedDomainExceptionCode.WORKSPACE_TRUSTED_DOMAIN_NOT_FOUND, - ), -): asserts trustedDomain is WorkspaceTrustedDomain => { - if (!isDefined(trustedDomain)) { - throw exceptionToThrow; - } -}; - -export const workspaceTrustedDomainValidator: { - assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow; -} = { - assertIsDefinedOrThrow, -}; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index a5bff9fe57f8..6a6b041b52e2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -20,7 +20,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { WorkspaceTrustedDomain } from 'src/engine/core-modules/workspace-trusted-domain/workspace-trusted-domain.entity'; +import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; registerEnumType(WorkspaceActivationStatus, { name: 'WorkspaceActivationStatus', @@ -86,10 +86,10 @@ export class Workspace { featureFlags: Relation<FeatureFlag[]>; @OneToMany( - () => WorkspaceTrustedDomain, - (trustDomain) => trustDomain.workspace, + () => ApprovedAccessDomain, + (approvedAccessDomain) => approvedAccessDomain.workspace, ) - trustDomains: Relation<WorkspaceTrustedDomain[]>; + approvedAccessDomains: Relation<ApprovedAccessDomain[]>; @Field({ nullable: true }) workspaceMembersCount: number; From 2c60e6602637e7dcfcd96d332e764980312b6f49 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Thu, 20 Feb 2025 11:51:45 +0100 Subject: [PATCH 13/16] feat(graphql): rename and restructure domain management types Renamed "TrustedDomain" to "ApprovedAccessDomain" for better clarity and consistency. Updated related types, inputs, and mutations accordingly to reflect the new naming convention. Removed redundant exports and adjusted queries and mutations to align with the changes. --- .../src/generated-metadata/graphql.ts | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d0904013bfd0..3447343532c5 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -102,6 +102,14 @@ export type AppTokenEdge = { node: AppToken; }; +export type ApprovedAccessDomain = { + __typename?: 'ApprovedAccessDomain'; + createdAt: Scalars['DateTime']['output']; + domain: Scalars['String']['output']; + id: Scalars['UUID']['output']; + isValidated: Scalars['Boolean']['output']; +}; + export type AuthProviders = { __typename?: 'AuthProviders'; google: Scalars['Boolean']['output']; @@ -305,6 +313,11 @@ export type CreateAppTokenInput = { expiresAt: Scalars['DateTime']['input']; }; +export type CreateApprovedAccessDomainInput = { + domain: Scalars['String']['input']; + email: Scalars['String']['input']; +}; + export type CreateDraftFromWorkflowVersionInput = { /** Workflow ID */ workflowId: Scalars['String']['input']; @@ -394,11 +407,6 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe<Scalars['Float']['input']>; }; -export type CreateTrustedDomainInput = { - domain: Scalars['String']['input']; - email: Scalars['String']['input']; -}; - export type CreateWorkflowVersionStepInput = { /** New step type */ stepType: Scalars['String']['input']; @@ -433,6 +441,10 @@ export type CustomDomainValidRecords = { records: Array<CustomDomainRecord>; }; +export type DeleteApprovedAccessDomainInput = { + id: Scalars['String']['input']; +}; + export type DeleteOneFieldInput = { /** The id of the field to delete. */ id: Scalars['UUID']['input']; @@ -457,10 +469,6 @@ export type DeleteSsoOutput = { identityProviderId: Scalars['String']['output']; }; -export type DeleteTrustedDomainInput = { - id: Scalars['String']['input']; -}; - export type DeleteWorkflowVersionStepInput = { /** Step to delete ID */ stepId: Scalars['String']['input']; @@ -848,6 +856,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>; checkoutSession: BillingSessionOutput; computeStepOutputSchema: Scalars['JSON']['output']; + createApprovedAccessDomain: ApprovedAccessDomain; createDraftFromWorkflowVersion: WorkflowVersion; createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; @@ -858,8 +867,8 @@ export type Mutation = { createOneServerlessFunction: ServerlessFunction; createSAMLIdentityProvider: SetupSsoOutput; createWorkflowVersionStep: WorkflowAction; - createWorkspaceTrustedDomain: ApprovedAccessDomain; deactivateWorkflowVersion: Scalars['Boolean']['output']; + deleteApprovedAccessDomain: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; deleteOneObject: Object; @@ -870,7 +879,6 @@ export type Mutation = { deleteUser: User; deleteWorkflowVersionStep: WorkflowAction; deleteWorkspaceInvitation: Scalars['String']['output']; - deleteWorkspaceTrustedDomain: Scalars['Boolean']['output']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -911,7 +919,7 @@ export type Mutation = { uploadProfilePicture: Scalars['String']['output']; uploadWorkspaceLogo: Scalars['String']['output']; userLookupAdminPanel: UserLookup; - validateWorkspaceTrustedDomain: Scalars['Boolean']['output']; + validateApprovedAccessDomain: ApprovedAccessDomain; }; @@ -950,6 +958,11 @@ export type MutationComputeStepOutputSchemaArgs = { }; +export type MutationCreateApprovedAccessDomainArgs = { + input: CreateApprovedAccessDomainInput; +}; + + export type MutationCreateDraftFromWorkflowVersionArgs = { input: CreateDraftFromWorkflowVersionInput; }; @@ -1000,13 +1013,13 @@ export type MutationCreateWorkflowVersionStepArgs = { }; -export type MutationCreateWorkspaceTrustedDomainArgs = { - input: CreateTrustedDomainInput; +export type MutationDeactivateWorkflowVersionArgs = { + workflowVersionId: Scalars['String']['input']; }; -export type MutationDeactivateWorkflowVersionArgs = { - workflowVersionId: Scalars['String']['input']; +export type MutationDeleteApprovedAccessDomainArgs = { + input: DeleteApprovedAccessDomainInput; }; @@ -1050,11 +1063,6 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; -export type MutationDeleteWorkspaceTrustedDomainArgs = { - input: DeleteTrustedDomainInput; -}; - - export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1248,8 +1256,8 @@ export type MutationUserLookupAdminPanelArgs = { }; -export type MutationValidateWorkspaceTrustedDomainArgs = { - input: ValidateTrustedDomainInput; +export type MutationValidateApprovedAccessDomainArgs = { + input: ValidateApprovedAccessDomainInput; }; export type Object = { @@ -1420,7 +1428,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; - getAllWorkspaceTrustedDomains: Array<ApprovedAccessDomain>; + getAllApprovedAccessDomains: Array<ApprovedAccessDomain>; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2140,17 +2148,17 @@ export type UserWorkspace = { workspaceId: Scalars['String']['output']; }; +export type ValidateApprovedAccessDomainInput = { + approvedAccessDomainId: Scalars['String']['input']; + validationToken: Scalars['String']['input']; +}; + export type ValidatePasswordResetToken = { __typename?: 'ValidatePasswordResetToken'; email: Scalars['String']['output']; id: Scalars['String']['output']; }; -export type ValidateTrustedDomainInput = { - validationToken: Scalars['String']['input']; - workspaceTrustedDomainId: Scalars['String']['input']; -}; - export type WorkerQueueMetrics = { __typename?: 'WorkerQueueMetrics'; active: Scalars['Float']['output']; @@ -2284,14 +2292,6 @@ export type WorkspaceNameAndId = { id: Scalars['String']['output']; }; -export type ApprovedAccessDomain = { - __typename?: 'ApprovedAccessDomain'; - createdAt: Scalars['DateTime']['output']; - domain: Scalars['String']['output']; - id: Scalars['UUID']['output']; - isValidated: Scalars['Boolean']['output']; -}; - export type WorkspaceUrlsAndId = { __typename?: 'WorkspaceUrlsAndId'; id: Scalars['String']['output']; From 21e4e12595a7b1942353f9acb3cf790c353669be Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Thu, 20 Feb 2025 16:11:46 +0100 Subject: [PATCH 14/16] chore: remove insecure TLS bypass configuration Removed the process.env.NODE_TLS_REJECT_UNAUTHORIZED setting, which bypassed TLS verification. This improves security by preventing the acceptance of unauthorized or self-signed certificates. --- packages/twenty-front/codegen-metadata.cjs | 1 - packages/twenty-front/codegen.cjs | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index d95a37eb71b6..53429715ca0f 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -1,4 +1,3 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; module.exports = { schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + diff --git a/packages/twenty-front/codegen.cjs b/packages/twenty-front/codegen.cjs index 24269a37e30f..05effffb7a22 100644 --- a/packages/twenty-front/codegen.cjs +++ b/packages/twenty-front/codegen.cjs @@ -1,4 +1,3 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; module.exports = { schema: (process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') + From 70ec62809f2897462fcf1cb64f897ea999afc4c4 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Thu, 20 Feb 2025 16:22:31 +0100 Subject: [PATCH 15/16] refactor(database): adjust migration formatting for clarity Updated the formatting of the AddApprovedAccessDomain migration for better readability and consistency. No changes were made to the functionality or logic of the migration. --- ...740048555744-add-approved-access-domain.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts index 84e6cc98c15d..170a1c653ece 100644 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1740048555744-add-approved-access-domain.ts @@ -1,16 +1,23 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddApprovedAccessDomain1740048555744 implements MigrationInterface { - name = 'AddApprovedAccessDomain1740048555744' +export class AddApprovedAccessDomain1740048555744 + implements MigrationInterface +{ + name = 'AddApprovedAccessDomain1740048555744'; - public async up(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query(`CREATE TABLE "core"."approvedAccessDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "IndexOnDomainAndWorkspaceId" UNIQUE ("domain", "workspaceId"), CONSTRAINT "PK_523281ce57c84e1a039f4538c19" PRIMARY KEY ("id"))`); - await queryRunner.query(`ALTER TABLE "core"."approvedAccessDomain" ADD CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); - } - - public async down(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query(`ALTER TABLE "core"."approvedAccessDomain" DROP CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc"`); - await queryRunner.query(`DROP TABLE "core"."approvedAccessDomain"`); - } + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `CREATE TABLE "core"."approvedAccessDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "IndexOnDomainAndWorkspaceId" UNIQUE ("domain", "workspaceId"), CONSTRAINT "PK_523281ce57c84e1a039f4538c19" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."approvedAccessDomain" ADD CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "core"."approvedAccessDomain" DROP CONSTRAINT "FK_73d3e340b6ce0716a25a86361fc"`, + ); + await queryRunner.query(`DROP TABLE "core"."approvedAccessDomain"`); + } } From 605073b372bcfb3e86836e6ffaf3a3f4ac3ac1bf Mon Sep 17 00:00:00 2001 From: Antoine Moreaux <moreaux.antoine@gmail.com> Date: Fri, 21 Feb 2025 16:53:49 +0100 Subject: [PATCH 16/16] feat(trusted-domain): add frontend management (without ui) (#10337) Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com> --- ...validate-approved-access-domain.email.tsx} | 6 +- packages/twenty-emails/src/index.ts | 2 +- .../src/generated-metadata/graphql.ts | 3 +- .../twenty-front/src/generated/graphql.tsx | 169 ++++++++++++++- .../modules/app/components/SettingsRoutes.tsx | 13 ++ .../SettingsSSOIdentitiesProvidersForm.tsx | 4 +- ...SettingsSSOIdentitiesProvidersListCard.tsx | 2 +- ...sSSOIdentitiesProvidersListCardWrapper.tsx | 2 +- ...gsSSOIdentityProviderRowRightContainer.tsx | 2 +- .../{ => SSO}/SettingsSSOOIDCForm.tsx | 0 .../{ => SSO}/SettingsSSOSAMLForm.tsx | 0 .../SettingsSecuritySSORowDropdownMenu.tsx | 0 ...tingsSecurityAuthProvidersOptionsList.tsx} | 2 +- .../SettingsApprovedAccessDomainsListCard.tsx | 73 +++++++ ...ityApprovedAccessDomainRowDropdownMenu.tsx | 84 ++++++++ ...tyApprovedAccessDomainValidationEffect.tsx | 44 ++++ .../mutations/createApprovedAccessDomain.ts | 14 ++ .../mutations/deleteApprovedAccessDomain.ts | 9 + .../mutations/validateApprovedAccessDomain.ts | 14 ++ .../queries/getApprovedAccessDomains.ts | 12 ++ .../states/ApprovedAccessDomainsState.ts | 9 + .../src/modules/types/SettingsPath.ts | 4 +- .../settings/security/SettingsSecurity.tsx | 28 ++- .../SettingsSecurityApprovedAccessDomain.tsx | 152 ++++++++++++++ .../SettingsSecuritySSOIdentifyProvider.tsx | 9 +- .../settings/workspace/SettingsDomain.tsx | 2 +- .../typeorm-seeds/core/feature-flags.ts | 5 + .../approved-access-domain.resolver.ts | 4 +- .../create-approved-access.domain.input.ts | 2 +- .../approved-access-domain.service.ts | 6 +- .../engine/core-modules/auth/auth.module.ts | 4 +- ...ial-sso.service.ts => auth-sso.service.ts} | 15 +- .../{social-sso.spec.ts => auth-sso.spec.ts} | 37 ++-- .../auth/services/auth.service.spec.ts | 197 ++++++++++-------- .../auth/services/auth.service.ts | 27 ++- .../enums/feature-flag-key.enum.ts | 1 + 36 files changed, 819 insertions(+), 138 deletions(-) rename packages/twenty-emails/src/emails/{validate-trust-domain.email.tsx => validate-approved-access-domain.email.tsx} (93%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOIdentitiesProvidersForm.tsx (98%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOIdentitiesProvidersListCard.tsx (96%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOIdentitiesProvidersListCardWrapper.tsx (94%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOIdentityProviderRowRightContainer.tsx (94%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOOIDCForm.tsx (100%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSSOSAMLForm.tsx (100%) rename packages/twenty-front/src/modules/settings/security/components/{ => SSO}/SettingsSecuritySSORowDropdownMenu.tsx (100%) rename packages/twenty-front/src/modules/settings/security/components/{SettingsSecurityOptionsList.tsx => SettingsSecurityAuthProvidersOptionsList.tsx} (98%) create mode 100644 packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts create mode 100644 packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts create mode 100644 packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts create mode 100644 packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx rename packages/twenty-server/src/engine/core-modules/auth/services/{social-sso.service.ts => auth-sso.service.ts} (85%) rename packages/twenty-server/src/engine/core-modules/auth/services/{social-sso.spec.ts => auth-sso.spec.ts} (75%) diff --git a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx b/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx similarity index 93% rename from packages/twenty-emails/src/emails/validate-trust-domain.email.tsx rename to packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx index 77d9c9664e85..40759b453f6d 100644 --- a/packages/twenty-emails/src/emails/validate-trust-domain.email.tsx +++ b/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx @@ -14,7 +14,7 @@ import { WhatIsTwenty } from 'src/components/WhatIsTwenty'; import { capitalize } from 'src/utils/capitalize'; import { APP_LOCALES, getImageAbsoluteURI } from 'twenty-shared'; -type SendTrustDomainValidationProps = { +type SendApprovedAccessDomainValidationProps = { link: string; domain: string; workspace: { name: string | undefined; logo: string | undefined }; @@ -27,14 +27,14 @@ type SendTrustDomainValidationProps = { locale: keyof typeof APP_LOCALES; }; -export const SendTrustDomainValidation = ({ +export const SendApprovedAccessDomainValidation = ({ link, domain, workspace, sender, serverUrl, locale, -}: SendTrustDomainValidationProps) => { +}: SendApprovedAccessDomainValidationProps) => { const workspaceLogo = workspace.logo ? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl }) : null; diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts index cd3da17239e2..07ff30d92338 100644 --- a/packages/twenty-emails/src/index.ts +++ b/packages/twenty-emails/src/index.ts @@ -4,4 +4,4 @@ export * from './emails/password-update-notify.email'; export * from './emails/send-email-verification-link.email'; export * from './emails/send-invite-link.email'; export * from './emails/warn-suspended-workspace.email'; -export * from './emails/validate-trust-domain.email'; +export * from './emails/validate-approved-access-domain.email'; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3447343532c5..cce851177752 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -567,6 +567,7 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled', IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', @@ -1428,7 +1429,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; - getAllApprovedAccessDomains: Array<ApprovedAccessDomain>; + getApprovedAccessDomains: Array<ApprovedAccessDomain>; getAvailablePackages: Scalars['JSON']['output']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 9f5b1b3b7c8d..1a745e664d44 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -499,6 +499,7 @@ export enum FeatureFlagKey { IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled', IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled', IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled', + IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled', IsBillingPlansEnabled = 'IsBillingPlansEnabled', IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled', IsCopilotEnabled = 'IsCopilotEnabled', @@ -1292,7 +1293,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array<WorkspaceInvitation>; - getAllApprovedAccessDomains: Array<ApprovedAccessDomain>; + getApprovedAccessDomains: Array<ApprovedAccessDomain>; getAvailablePackages: Scalars['JSON']; getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getIndicatorHealthStatus: AdminPanelHealthServiceData; @@ -2377,6 +2378,13 @@ export type GetRolesQueryVariables = Exact<{ [key: string]: never; }>; export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> }> }; +export type CreateApprovedAccessDomainMutationVariables = Exact<{ + input: CreateApprovedAccessDomainInput; +}>; + + +export type CreateApprovedAccessDomainMutation = { __typename?: 'Mutation', createApprovedAccessDomain: { __typename?: 'ApprovedAccessDomain', id: any, domain: string, isValidated: boolean, createdAt: string } }; + export type CreateOidcIdentityProviderMutationVariables = Exact<{ input: SetupOidcSsoInput; }>; @@ -2391,6 +2399,13 @@ export type CreateSamlIdentityProviderMutationVariables = Exact<{ export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type DeleteApprovedAccessDomainMutationVariables = Exact<{ + input: DeleteApprovedAccessDomainInput; +}>; + + +export type DeleteApprovedAccessDomainMutation = { __typename?: 'Mutation', deleteApprovedAccessDomain: boolean }; + export type DeleteSsoIdentityProviderMutationVariables = Exact<{ input: DeleteSsoInput; }>; @@ -2405,6 +2420,18 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type ValidateApprovedAccessDomainMutationVariables = Exact<{ + input: ValidateApprovedAccessDomainInput; +}>; + + +export type ValidateApprovedAccessDomainMutation = { __typename?: 'Mutation', validateApprovedAccessDomain: { __typename?: 'ApprovedAccessDomain', id: any, isValidated: boolean, domain: string, createdAt: string } }; + +export type GetApprovedAccessDomainsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetApprovedAccessDomainsQuery = { __typename?: 'Query', getApprovedAccessDomains: Array<{ __typename?: 'ApprovedAccessDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> }; + export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never; }>; @@ -4293,6 +4320,42 @@ export function useGetRolesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<G export type GetRolesQueryHookResult = ReturnType<typeof useGetRolesQuery>; export type GetRolesLazyQueryHookResult = ReturnType<typeof useGetRolesLazyQuery>; export type GetRolesQueryResult = Apollo.QueryResult<GetRolesQuery, GetRolesQueryVariables>; +export const CreateApprovedAccessDomainDocument = gql` + mutation CreateApprovedAccessDomain($input: CreateApprovedAccessDomainInput!) { + createApprovedAccessDomain(input: $input) { + id + domain + isValidated + createdAt + } +} + `; +export type CreateApprovedAccessDomainMutationFn = Apollo.MutationFunction<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>; + +/** + * __useCreateApprovedAccessDomainMutation__ + * + * To run a mutation, you first call `useCreateApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateApprovedAccessDomainMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createApprovedAccessDomainMutation, { data, loading, error }] = useCreateApprovedAccessDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>(CreateApprovedAccessDomainDocument, options); + } +export type CreateApprovedAccessDomainMutationHookResult = ReturnType<typeof useCreateApprovedAccessDomainMutation>; +export type CreateApprovedAccessDomainMutationResult = Apollo.MutationResult<CreateApprovedAccessDomainMutation>; +export type CreateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>; export const CreateOidcIdentityProviderDocument = gql` mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { createOIDCIdentityProvider(input: $input) { @@ -4367,6 +4430,37 @@ export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.Mutat export type CreateSamlIdentityProviderMutationHookResult = ReturnType<typeof useCreateSamlIdentityProviderMutation>; export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult<CreateSamlIdentityProviderMutation>; export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>; +export const DeleteApprovedAccessDomainDocument = gql` + mutation DeleteApprovedAccessDomain($input: DeleteApprovedAccessDomainInput!) { + deleteApprovedAccessDomain(input: $input) +} + `; +export type DeleteApprovedAccessDomainMutationFn = Apollo.MutationFunction<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>; + +/** + * __useDeleteApprovedAccessDomainMutation__ + * + * To run a mutation, you first call `useDeleteApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteApprovedAccessDomainMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteApprovedAccessDomainMutation, { data, loading, error }] = useDeleteApprovedAccessDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDeleteApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>(DeleteApprovedAccessDomainDocument, options); + } +export type DeleteApprovedAccessDomainMutationHookResult = ReturnType<typeof useDeleteApprovedAccessDomainMutation>; +export type DeleteApprovedAccessDomainMutationResult = Apollo.MutationResult<DeleteApprovedAccessDomainMutation>; +export type DeleteApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>; export const DeleteSsoIdentityProviderDocument = gql` mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { deleteSSOIdentityProvider(input: $input) { @@ -4437,6 +4531,79 @@ export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.Mutation export type EditSsoIdentityProviderMutationHookResult = ReturnType<typeof useEditSsoIdentityProviderMutation>; export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult<EditSsoIdentityProviderMutation>; export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>; +export const ValidateApprovedAccessDomainDocument = gql` + mutation ValidateApprovedAccessDomain($input: ValidateApprovedAccessDomainInput!) { + validateApprovedAccessDomain(input: $input) { + id + isValidated + domain + createdAt + } +} + `; +export type ValidateApprovedAccessDomainMutationFn = Apollo.MutationFunction<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>; + +/** + * __useValidateApprovedAccessDomainMutation__ + * + * To run a mutation, you first call `useValidateApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useValidateApprovedAccessDomainMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [validateApprovedAccessDomainMutation, { data, loading, error }] = useValidateApprovedAccessDomainMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useValidateApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>(ValidateApprovedAccessDomainDocument, options); + } +export type ValidateApprovedAccessDomainMutationHookResult = ReturnType<typeof useValidateApprovedAccessDomainMutation>; +export type ValidateApprovedAccessDomainMutationResult = Apollo.MutationResult<ValidateApprovedAccessDomainMutation>; +export type ValidateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>; +export const GetApprovedAccessDomainsDocument = gql` + query GetApprovedAccessDomains { + getApprovedAccessDomains { + id + createdAt + domain + isValidated + } +} + `; + +/** + * __useGetApprovedAccessDomainsQuery__ + * + * To run a query within a React component, call `useGetApprovedAccessDomainsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetApprovedAccessDomainsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetApprovedAccessDomainsQuery({ + * variables: { + * }, + * }); + */ +export function useGetApprovedAccessDomainsQuery(baseOptions?: Apollo.QueryHookOptions<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>(GetApprovedAccessDomainsDocument, options); + } +export function useGetApprovedAccessDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>(GetApprovedAccessDomainsDocument, options); + } +export type GetApprovedAccessDomainsQueryHookResult = ReturnType<typeof useGetApprovedAccessDomainsQuery>; +export type GetApprovedAccessDomainsLazyQueryHookResult = ReturnType<typeof useGetApprovedAccessDomainsLazyQuery>; +export type GetApprovedAccessDomainsQueryResult = Apollo.QueryResult<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>; export const GetSsoIdentityProvidersDocument = gql` query GetSSOIdentityProviders { getSSOIdentityProviders { diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index d3e6448e164e..4d549ccad4c8 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -234,6 +234,14 @@ const SettingsSecuritySSOIdentifyProvider = lazy(() => ), ); +const SettingsSecurityApprovedAccessDomain = lazy(() => + import('~/pages/settings/security/SettingsSecurityApprovedAccessDomain').then( + (module) => ({ + default: module.SettingsSecurityApprovedAccessDomain, + }), + ), +); + const SettingsAdmin = lazy(() => import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({ default: module.SettingsAdmin, @@ -408,6 +416,11 @@ export const SettingsRoutes = ({ path={SettingsPath.NewSSOIdentityProvider} element={<SettingsSecuritySSOIdentifyProvider />} /> + <Route + path={SettingsPath.NewApprovedAccessDomain} + element={<SettingsSecurityApprovedAccessDomain />} + /> + {isAdminPageEnabled && ( <> <Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} /> diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx similarity index 98% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx index a54dd760e430..508dc6fbc2e2 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm.tsx @@ -2,8 +2,8 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsRadioCardContainer } from '@/settings/components/SettingsRadioCardContainer'; -import { SettingsSSOOIDCForm } from '@/settings/security/components/SettingsSSOOIDCForm'; -import { SettingsSSOSAMLForm } from '@/settings/security/components/SettingsSSOSAMLForm'; +import { SettingsSSOOIDCForm } from '@/settings/security/components/SSO/SettingsSSOOIDCForm'; +import { SettingsSSOSAMLForm } from '@/settings/security/components/SSO/SettingsSSOSAMLForm'; import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; import { TextInput } from '@/ui/input/components/TextInput'; import styled from '@emotion/styled'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx similarity index 96% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx index 2d7343664731..6b2349ca5f41 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard.tsx @@ -6,7 +6,7 @@ import { SettingsPath } from '@/types/SettingsPath'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SettingsCard } from '@/settings/components/SettingsCard'; -import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper'; +import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx similarity index 94% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx index 5c2ee3ce8835..c6dddf82febe 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper.tsx @@ -1,7 +1,7 @@ /* @license Enterprise */ import { SettingsListCard } from '@/settings/components/SettingsListCard'; -import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer'; +import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; import { SettingsPath } from '@/types/SettingsPath'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx similarity index 94% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx index fb55040682c3..d90a72ebc8d2 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentityProviderRowRightContainer.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer.tsx @@ -1,6 +1,6 @@ /* @license Enterprise */ -import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SettingsSecuritySSORowDropdownMenu'; +import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { getColorBySSOIdentityProviderStatus } from '@/settings/security/utils/getColorBySSOIdentityProviderStatus'; import { Status } from 'twenty-ui'; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOOIDCForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOOIDCForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOSAMLForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOSAMLForm.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSSOSAMLForm.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSSOSAMLForm.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu.tsx similarity index 100% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx rename to packages/twenty-front/src/modules/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu.tsx diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx similarity index 98% rename from packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx rename to packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx index ceb45356dab0..5684e081019a 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityAuthProvidersOptionsList.tsx @@ -24,7 +24,7 @@ const StyledSettingsSecurityOptionsList = styled.div` gap: ${({ theme }) => theme.spacing(4)}; `; -export const SettingsSecurityOptionsList = () => { +export const SettingsSecurityAuthProvidersOptionsList = () => { const { t } = useLingui(); const { enqueueSnackBar } = useSnackBar(); diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx new file mode 100644 index 000000000000..c2de573b22fe --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard.tsx @@ -0,0 +1,73 @@ +import { Link, useNavigate } from 'react-router-dom'; + +import { SettingsPath } from '@/types/SettingsPath'; + +import { SettingsCard } from '@/settings/components/SettingsCard'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilState } from 'recoil'; +import { IconAt, IconMailCog } from 'twenty-ui'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { SettingsListCard } from '@/settings/components/SettingsListCard'; +import { approvedAccessDomainsState } from '@/settings/security/states/ApprovedAccessDomainsState'; +import { SettingsSecurityApprovedAccessDomainRowDropdownMenu } from '@/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu'; +import { SettingsSecurityApprovedAccessDomainValidationEffect } from '@/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect'; +import { useGetApprovedAccessDomainsQuery } from '~/generated/graphql'; + +const StyledLink = styled(Link)` + text-decoration: none; +`; + +export const SettingsApprovedAccessDomainsListCard = () => { + const { enqueueSnackBar } = useSnackBar(); + const navigate = useNavigate(); + const { t } = useLingui(); + + const [approvedAccessDomains, setApprovedAccessDomains] = useRecoilState( + approvedAccessDomainsState, + ); + + const { loading } = useGetApprovedAccessDomainsQuery({ + fetchPolicy: 'network-only', + onCompleted: (data) => { + setApprovedAccessDomains(data?.getApprovedAccessDomains ?? []); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + + return loading || !approvedAccessDomains.length ? ( + <StyledLink to={getSettingsPath(SettingsPath.NewApprovedAccessDomain)}> + <SettingsCard + title={t`Add Approved Access Domain`} + Icon={<IconMailCog />} + /> + </StyledLink> + ) : ( + <> + <SettingsSecurityApprovedAccessDomainValidationEffect /> + <SettingsListCard + items={approvedAccessDomains} + getItemLabel={(approvedAccessDomain) => + `${approvedAccessDomain.domain} - ${approvedAccessDomain.createdAt}` + } + RowIcon={IconAt} + RowRightComponent={({ item: approvedAccessDomain }) => ( + <SettingsSecurityApprovedAccessDomainRowDropdownMenu + approvedAccessDomain={approvedAccessDomain} + /> + )} + hasFooter + footerButtonLabel="Add Approved Access Domain" + onFooterButtonClick={() => + navigate(getSettingsPath(SettingsPath.NewApprovedAccessDomain)) + } + /> + </> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx new file mode 100644 index 000000000000..4629defdd7f8 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu.tsx @@ -0,0 +1,84 @@ +import { + IconDotsVertical, + IconTrash, + LightIconButton, + MenuItem, +} from 'twenty-ui'; + +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { UnwrapRecoilValue, useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared'; +import { useDeleteApprovedAccessDomainMutation } from '~/generated/graphql'; +import { approvedAccessDomainsState } from '@/settings/security/states/ApprovedAccessDomainsState'; + +type SettingsSecurityApprovedAccessDomainRowDropdownMenuProps = { + approvedAccessDomain: UnwrapRecoilValue<typeof approvedAccessDomainsState>[0]; +}; + +export const SettingsSecurityApprovedAccessDomainRowDropdownMenu = ({ + approvedAccessDomain, +}: SettingsSecurityApprovedAccessDomainRowDropdownMenuProps) => { + const dropdownId = `settings-approved-access-domain-row-${approvedAccessDomain.id}`; + + const setApprovedAccessDomains = useSetRecoilState( + approvedAccessDomainsState, + ); + + const { enqueueSnackBar } = useSnackBar(); + + const { closeDropdown } = useDropdown(dropdownId); + + const [deleteApprovedAccessDomain] = useDeleteApprovedAccessDomainMutation(); + + const handleDeleteApprovedAccessDomain = async () => { + const result = await deleteApprovedAccessDomain({ + variables: { + input: { + id: approvedAccessDomain.id, + }, + }, + onCompleted: () => { + setApprovedAccessDomains((approvedAccessDomains) => { + return approvedAccessDomains.filter( + ({ id }) => id !== approvedAccessDomain.id, + ); + }); + }, + }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error deleting approved access domain', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + return ( + <Dropdown + dropdownId={dropdownId} + dropdownPlacement="right-start" + dropdownHotkeyScope={{ scope: dropdownId }} + clickableComponent={ + <LightIconButton Icon={IconDotsVertical} accent="tertiary" /> + } + dropdownMenuWidth={160} + dropdownComponents={ + <DropdownMenuItemsContainer> + <MenuItem + accent="danger" + LeftIcon={IconTrash} + text="Delete" + onClick={() => { + handleDeleteApprovedAccessDomain(); + closeDropdown(); + }} + /> + </DropdownMenuItemsContainer> + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx new file mode 100644 index 000000000000..a37761aca7ab --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared'; +import { useValidateApprovedAccessDomainMutation } from '~/generated/graphql'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useSearchParams } from 'react-router-dom'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; + +export const SettingsSecurityApprovedAccessDomainValidationEffect = () => { + const [validateApprovedAccessDomainMutation] = + useValidateApprovedAccessDomainMutation(); + const { enqueueSnackBar } = useSnackBar(); + const [searchParams] = useSearchParams(); + const approvedAccessDomainId = searchParams.get('wtdId'); + const validationToken = searchParams.get('validationToken'); + + useEffect(() => { + if (isDefined(validationToken) && isDefined(approvedAccessDomainId)) { + validateApprovedAccessDomainMutation({ + variables: { + input: { + validationToken, + approvedAccessDomainId, + }, + }, + onCompleted: () => { + enqueueSnackBar('Approved access domain validated', { + dedupeKey: 'approved-access-domain-validation-dedupe-key', + variant: SnackBarVariant.Success, + }); + }, + onError: () => { + enqueueSnackBar('Error validating approved access domain', { + dedupeKey: 'approved-access-domain-validation-error-dedupe-key', + variant: SnackBarVariant.Error, + }); + }, + }); + } + // Validate approved access domain only needs to run once at mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <></>; +}; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts new file mode 100644 index 000000000000..6d23aa3a3325 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createApprovedAccessDomain.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const CREATE_APPROVED_ACCESS_DOMAIN = gql` + mutation CreateApprovedAccessDomain( + $input: CreateApprovedAccessDomainInput! + ) { + createApprovedAccessDomain(input: $input) { + id + domain + isValidated + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts new file mode 100644 index 000000000000..a410bbed2152 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteApprovedAccessDomain.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const DELETE_APPROVED_ACCESS_DOMAIN = gql` + mutation DeleteApprovedAccessDomain( + $input: DeleteApprovedAccessDomainInput! + ) { + deleteApprovedAccessDomain(input: $input) + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts new file mode 100644 index 000000000000..76b910dc2de4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/validateApprovedAccessDomain.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const VALIDATE_APPROVED_ACCESS_DOMAIN = gql` + mutation ValidateApprovedAccessDomain( + $input: ValidateApprovedAccessDomainInput! + ) { + validateApprovedAccessDomain(input: $input) { + id + isValidated + domain + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts new file mode 100644 index 000000000000..056ca9630c85 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/queries/getApprovedAccessDomains.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const GET_ALL_APPROVED_ACCESS_DOMAINS = gql` + query GetApprovedAccessDomains { + getApprovedAccessDomains { + id + createdAt + domain + isValidated + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts b/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts new file mode 100644 index 000000000000..ca0ddc79f6fa --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/states/ApprovedAccessDomainsState.ts @@ -0,0 +1,9 @@ +import { createState } from '@ui/utilities/state/utils/createState'; +import { ApprovedAccessDomain } from '~/generated/graphql'; + +export const approvedAccessDomainsState = createState< + Omit<ApprovedAccessDomain, '__typename'>[] +>({ + key: 'ApprovedAccessDomainsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 6130640aec5e..6cebac0d7a62 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -29,7 +29,9 @@ export enum SettingsPath { IntegrationNewDatabaseConnection = 'integrations/:databaseKey/new', Security = 'security', NewSSOIdentityProvider = 'security/sso/new', - EditSSOIdentityProvider = 'security/sso/:identityProviderId', + NewApprovedAccessDomain = 'security/approved-access-domain/new', + Webhooks = 'webhooks', + DevelopersNewWebhook = 'developers/webhooks/new', DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId', Releases = 'releases', AdminPanel = 'admin-panel', diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 3e361abac1bd..8a2becdc39ba 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -4,11 +4,14 @@ import { H2Title, IconLock, Section, Tag } from 'twenty-ui'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton'; -import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCard'; -import { SettingsSecurityOptionsList } from '@/settings/security/components/SettingsSecurityOptionsList'; +import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; +import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; const StyledContainer = styled.div` width: 100%; @@ -21,13 +24,17 @@ const StyledMainContent = styled.div` min-height: 200px; `; -const StyledSSOSection = styled(Section)` +const StyledSection = styled(Section)` flex-shrink: 0; `; export const SettingsSecurity = () => { const { t } = useLingui(); + const IsApprovedAccessDomainsEnabled = useIsFeatureEnabled( + FeatureFlagKey.IsApprovedAccessDomainsEnabled, + ); + return ( <SubMenuTopBarContainer title={t`Security`} @@ -42,7 +49,7 @@ export const SettingsSecurity = () => { > <SettingsPageContainer> <StyledMainContent> - <StyledSSOSection> + <StyledSection> <H2Title title={t`SSO`} description={t`Configure an SSO connection`} @@ -56,14 +63,23 @@ export const SettingsSecurity = () => { } /> <SettingsSSOIdentitiesProvidersListCard /> - </StyledSSOSection> + </StyledSection> + {IsApprovedAccessDomainsEnabled && ( + <StyledSection> + <H2Title + title={t`Approved Email Domain`} + description={t`Anyone with an email address at these domains is allowed to sign up for this workspace.`} + /> + <SettingsApprovedAccessDomainsListCard /> + </StyledSection> + )} <Section> <StyledContainer> <H2Title title={t`Authentication`} description={t`Customize your workspace security`} /> - <SettingsSecurityOptionsList /> + <SettingsSecurityAuthProvidersOptionsList /> </StyledContainer> </Section> </StyledMainContent> diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx new file mode 100644 index 000000000000..9934eb959553 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurityApprovedAccessDomain.tsx @@ -0,0 +1,152 @@ +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPath } from '@/types/SettingsPath'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { z } from 'zod'; +import { H2Title, Section } from 'twenty-ui'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { useCreateApprovedAccessDomainMutation } from '~/generated/graphql'; + +export const SettingsSecurityApprovedAccessDomain = () => { + const navigate = useNavigateSettings(); + + const { t } = useLingui(); + + const { enqueueSnackBar } = useSnackBar(); + + const [createApprovedAccessDomain] = useCreateApprovedAccessDomainMutation(); + + const formConfig = useForm<{ domain: string; email: string }>({ + mode: 'onSubmit', + resolver: zodResolver( + z + .object({ + domain: z + .string() + .regex( + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/, + { + message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`, + }, + ) + .max(256), + email: z.string().min(1, { + message: t`Email can not be empty`, + }), + }) + .strict(), + ), + defaultValues: { + email: '', + domain: '', + }, + }); + + const domain = formConfig.watch('domain'); + + const handleSave = async () => { + try { + if (!formConfig.formState.isValid) { + return; + } + createApprovedAccessDomain({ + variables: { + input: { + domain: formConfig.getValues('domain'), + email: + formConfig.getValues('email') + + '@' + + formConfig.getValues('domain'), + }, + }, + onCompleted: () => { + enqueueSnackBar(t`Domain added successfully.`, { + variant: SnackBarVariant.Success, + }); + navigate(SettingsPath.Security); + }, + onError: (error) => { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + }, + }); + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + } + }; + + return ( + <SubMenuTopBarContainer + title="New Approved Access Domain" + actionButton={ + <SaveAndCancelButtons + onCancel={() => navigate(SettingsPath.Security)} + onSave={formConfig.handleSubmit(handleSave)} + /> + } + links={[ + { + children: <Trans>Workspace</Trans>, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: <Trans>Security</Trans>, + href: getSettingsPath(SettingsPath.Security), + }, + { children: <Trans>New Approved Access Domain</Trans> }, + ]} + > + <SettingsPageContainer> + <Section> + <H2Title title="Domain" description="The name of your Domain" /> + <Controller + name="domain" + control={formConfig.control} + render={({ field: { onChange, value }, fieldState: { error } }) => ( + <TextInput + autoComplete="off" + value={value} + onChange={(domain: string) => { + onChange(domain); + }} + fullWidth + placeholder="yourdomain.com" + error={error?.message} + /> + )} + /> + </Section> + <Section> + <H2Title + title="Email verification" + description="We will send your a link to verify domain ownership" + /> + <Controller + name="email" + control={formConfig.control} + render={({ field: { onChange, value }, fieldState: { error } }) => ( + <TextInput + autoComplete="off" + value={value.split('@')[0]} + onChange={onChange} + fullWidth + error={error?.message} + /> + )} + /> + {domain} + </Section> + </SettingsPageContainer> + </SubMenuTopBarContainer> + ); +}; diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx index d44b164a7c60..f54d5f557ce9 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecuritySSOIdentifyProvider.tsx @@ -1,7 +1,7 @@ /* @license Enterprise */ import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; -import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SettingsSSOIdentitiesProvidersForm'; +import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm'; import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider'; import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; import { sSOIdentityProviderDefaultValues } from '@/settings/security/utils/sSOIdentityProviderDefaultValues'; @@ -11,6 +11,7 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/react/macro'; import pick from 'lodash.pick'; import { FormProvider, useForm } from 'react-hook-form'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; @@ -64,14 +65,14 @@ export const SettingsSecuritySSOIdentifyProvider = () => { } links={[ { - children: 'Workspace', + children: <Trans>Workspace</Trans>, href: getSettingsPath(SettingsPath.Workspace), }, { - children: 'Security', + children: <Trans>Security</Trans>, href: getSettingsPath(SettingsPath.Security), }, - { children: 'New' }, + { children: <Trans>New SSO provider</Trans> }, ]} > <FormProvider diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 45801daddad8..107bf63acea4 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -45,7 +45,7 @@ export const SettingsDomain = () => { .regex( /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/, { - message: t`Invalid custom domain. Custom domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`, + message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`, }, ) .max(256) diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index 349b49097194..c3e24dbd7b91 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -50,6 +50,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: false, }, + { + key: FeatureFlagKey.IsApprovedAccessDomainsEnabled, + workspaceId: workspaceId, + value: true, + }, { key: FeatureFlagKey.IsBillingPlansEnabled, workspaceId: workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts index 1c9b3a3f4c4d..17bad8786e1a 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.resolver.ts @@ -61,10 +61,10 @@ export class ApprovedAccessDomainResolver { } @Query(() => [ApprovedAccessDomain]) - async getAllApprovedAccessDomains( + async getApprovedAccessDomains( @AuthWorkspace() currentWorkspace: Workspace, ): Promise<Array<ApprovedAccessDomain>> { - return await this.approvedAccessDomainService.getAllApprovedAccessDomains( + return await this.approvedAccessDomainService.getApprovedAccessDomains( currentWorkspace, ); } diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts index e1af1b1ed980..abd26c50bba7 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input.ts @@ -1,4 +1,4 @@ -import { Field, InputType } from '@nestjs/graphql'; +import { InputType, Field } from '@nestjs/graphql'; import { IsString, IsEmail, IsNotEmpty } from 'class-validator'; diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts index 2c473aadf00d..6fb6de59e793 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -6,7 +6,7 @@ import crypto from 'crypto'; import { render } from '@react-email/render'; import { Repository } from 'typeorm'; import { APP_LOCALES } from 'twenty-shared'; -import { SendTrustDomainValidation } from 'twenty-emails'; +import { SendApprovedAccessDomainValidation } from 'twenty-emails'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -60,7 +60,7 @@ export class ApprovedAccessDomainService { }, }); - const emailTemplate = SendTrustDomainValidation({ + const emailTemplate = SendApprovedAccessDomainValidation({ link: link.toString(), workspace: { name: workspace.displayName, logo: workspace.logo }, domain: approvedAccessDomain.domain, @@ -174,7 +174,7 @@ export class ApprovedAccessDomainService { await this.approvedAccessDomainRepository.delete(approvedAccessDomain); } - async getAllApprovedAccessDomains(workspace: Workspace) { + async getApprovedAccessDomains(workspace: Workspace) { return await this.approvedAccessDomainRepository.find({ where: { workspaceId: workspace.id, 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 5bbc57e9c55b..ed10713e7b64 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 @@ -17,7 +17,7 @@ import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/micr // import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; 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'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; @@ -114,7 +114,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; ResetPasswordService, TransientTokenService, ApiKeyService, - SocialSsoService, + AuthSsoService, // reenable when working on: https://github.com/twentyhq/twenty/issues/9143 // OAuthService, ], 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/auth-sso.service.ts similarity index 85% rename from packages/twenty-server/src/engine/core-modules/auth/services/social-sso.service.ts rename to packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts index 1d8287215633..30ef9996a598 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.service.ts @@ -8,7 +8,7 @@ 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 { +export class AuthSsoService { constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository<Workspace>, @@ -55,14 +55,21 @@ export class SocialSsoService { }, }, }, - relations: ['workspaceUsers', 'workspaceUsers.user'], + relations: [ + 'workspaceUsers', + 'workspaceUsers.user', + 'approvedAccessDomains', + ], }); return workspace ?? undefined; } - return await this.workspaceRepository.findOneBy({ - id: workspaceId, + return await this.workspaceRepository.findOne({ + where: { + id: workspaceId, + }, + relations: ['approvedAccessDomains'], }); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts similarity index 75% rename from packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts rename to packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts index b03074726494..7f2ff2ecaf8b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/social-sso.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth-sso.spec.ts @@ -3,22 +3,24 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -describe('SocialSsoService', () => { - let socialSsoService: SocialSsoService; +describe('AuthSsoService', () => { + let authSsoService: AuthSsoService; let workspaceRepository: Repository<Workspace>; let environmentService: EnvironmentService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - SocialSsoService, + AuthSsoService, { provide: getRepositoryToken(Workspace, 'core'), - useClass: Repository, + useValue: { + findOne: jest.fn(), + }, }, { provide: EnvironmentService, @@ -29,7 +31,7 @@ describe('SocialSsoService', () => { ], }).compile(); - socialSsoService = module.get<SocialSsoService>(SocialSsoService); + authSsoService = module.get<AuthSsoService>(AuthSsoService); workspaceRepository = module.get<Repository<Workspace>>( getRepositoryToken(Workspace, 'core'), ); @@ -42,18 +44,21 @@ describe('SocialSsoService', () => { const mockWorkspace = { id: workspaceId } as Workspace; jest - .spyOn(workspaceRepository, 'findOneBy') + .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { authProvider: 'google', email: 'test@example.com' }, workspaceId, ); expect(result).toEqual(mockWorkspace); - expect(workspaceRepository.findOneBy).toHaveBeenCalledWith({ - id: workspaceId, + expect(workspaceRepository.findOne).toHaveBeenCalledWith({ + where: { + id: workspaceId, + }, + relations: ['approvedAccessDomains'], }); }); @@ -68,7 +73,7 @@ describe('SocialSsoService', () => { .mockResolvedValue(mockWorkspace); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider, email, }); @@ -83,7 +88,11 @@ describe('SocialSsoService', () => { }, }, }, - relations: ['workspaceUsers', 'workspaceUsers.user'], + relations: [ + 'workspaceUsers', + 'workspaceUsers.user', + 'approvedAccessDomains', + ], }); }); @@ -92,7 +101,7 @@ describe('SocialSsoService', () => { jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); const result = - await socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider: 'google', email: 'notfound@example.com', }); @@ -104,7 +113,7 @@ describe('SocialSsoService', () => { jest.spyOn(environmentService, 'get').mockReturnValue(true); await expect( - socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ + authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({ authProvider: 'invalid-provider' as any, email: 'test@example.com', }), 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 6812aa94ace1..563b0ec3d27d 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 @@ -10,7 +10,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type'; @@ -28,7 +28,7 @@ import { AuthService } from './auth.service'; jest.mock('bcrypt'); const UserFindOneMock = jest.fn(); -const UserWorkspaceFindOneByMock = jest.fn(); +const UserWorkspacefindOneMock = jest.fn(); const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn(); const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn(); @@ -41,7 +41,7 @@ describe('AuthService', () => { let service: AuthService; let userService: UserService; let workspaceRepository: Repository<Workspace>; - let socialSsoService: SocialSsoService; + let authSsoService: AuthSsoService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -50,7 +50,7 @@ describe('AuthService', () => { { provide: getRepositoryToken(Workspace, 'core'), useValue: { - findOneBy: jest.fn(), + findOne: jest.fn(), }, }, { @@ -120,7 +120,7 @@ describe('AuthService', () => { }, }, { - provide: SocialSsoService, + provide: AuthSsoService, useValue: { findWorkspaceFromWorkspaceIdOrAuthProvider: jest.fn(), }, @@ -130,7 +130,7 @@ describe('AuthService', () => { service = module.get<AuthService>(AuthService); userService = module.get<UserService>(UserService); - socialSsoService = module.get<SocialSsoService>(SocialSsoService); + authSsoService = module.get<AuthSsoService>(AuthSsoService); workspaceRepository = module.get<Repository<Workspace>>( getRepositoryToken(Workspace, 'core'), ); @@ -160,7 +160,7 @@ describe('AuthService', () => { captchaToken: user.captchaToken, }); - UserWorkspaceFindOneByMock.mockReturnValueOnce({}); + UserWorkspacefindOneMock.mockReturnValueOnce({}); userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({}); @@ -245,7 +245,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(1); @@ -269,7 +270,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }), ).rejects.toThrow(new Error('Access denied')); @@ -292,7 +294,8 @@ describe('AuthService', () => { workspace: { id: 'workspace-id', isPublicInviteLinkEnabled: false, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }), ).rejects.toThrow( new AuthException( @@ -356,7 +359,7 @@ describe('AuthService', () => { } as ExistingUserOrNewUser['userData'], invitation: {} as AppToken, workspaceInviteHash: undefined, - workspace: {} as Workspace, + workspace: { approvedAccessDomains: [] } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(0); @@ -376,99 +379,127 @@ describe('AuthService', () => { workspaceInviteHash: 'workspaceInviteHash', workspace: { isPublicInviteLinkEnabled: true, - } as Workspace, + approvedAccessDomains: [], + } as unknown as Workspace, }); expect(spy).toHaveBeenCalledTimes(0); }); + + it('checkAccessForSignIn - allow signup for new user who target a workspace with valid trusted domain', async () => { + expect(async () => { + await service.checkAccessForSignIn({ + userData: { + type: 'newUser', + newUserPayload: { + email: 'email@domain.com', + }, + } as ExistingUserOrNewUser['userData'], + invitation: undefined, + workspaceInviteHash: 'workspaceInviteHash', + workspace: { + isPublicInviteLinkEnabled: true, + approvedAccessDomains: [ + { domain: 'domain.com', isValidated: true }, + ], + } as unknown as Workspace, + }); + }).not.toThrow(); + }); }); - it('findWorkspaceForSignInUp - signup password auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + describe('findWorkspaceForSignInUp', () => { + it('findWorkspaceForSignInUp - signup password auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); + + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', + expect(result).toBeUndefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => { + const spyWorkspaceRepository = jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ + approvedAccessDomains: [], + } as unknown as Workspace); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); - expect(result).toBeUndefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup password auth with workspaceInviteHash', async () => { - const spyWorkspaceRepository = jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + workspaceInviteHash: 'workspaceInviteHash', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', - workspaceInviteHash: 'workspaceInviteHash', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup social sso auth with workspaceInviteHash', async () => { + const spyWorkspaceRepository = jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue({ + approvedAccessDomains: [], + } as unknown as Workspace); + const spyAuthSsoService = jest.spyOn( + authSsoService, + 'findWorkspaceFromWorkspaceIdOrAuthProvider', + ); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup social sso auth with workspaceInviteHash', async () => { - const spyWorkspaceRepository = jest - .spyOn(workspaceRepository, 'findOneBy') - .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest.spyOn( - socialSsoService, - 'findWorkspaceFromWorkspaceIdOrAuthProvider', - ); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'password', + workspaceId: 'workspaceId', + workspaceInviteHash: 'workspaceInviteHash', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'password', - workspaceId: 'workspaceId', - workspaceInviteHash: 'workspaceInviteHash', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); + expect(spyAuthSsoService).toHaveBeenCalledTimes(0); }); + it('findWorkspaceForSignInUp - signup social sso auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(1); - expect(spySocialSsoService).toHaveBeenCalledTimes(0); - }); - it('findWorkspaceForSignInUp - signup social sso auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); + const spyAuthSsoService = jest + .spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') + .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest - .spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') - .mockResolvedValue({} as Workspace); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'google', + workspaceId: 'workspaceId', + email: 'email', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'google', - workspaceId: 'workspaceId', - email: 'email', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); + it('findWorkspaceForSignInUp - sso auth', async () => { + const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOne'); - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(1); - }); - it('findWorkspaceForSignInUp - sso auth', async () => { - const spyWorkspaceRepository = jest.spyOn(workspaceRepository, 'findOneBy'); + const spyAuthSsoService = jest + .spyOn(authSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') + .mockResolvedValue({} as Workspace); - const spySocialSsoService = jest - .spyOn(socialSsoService, 'findWorkspaceFromWorkspaceIdOrAuthProvider') - .mockResolvedValue({} as Workspace); + const result = await service.findWorkspaceForSignInUp({ + authProvider: 'sso', + workspaceId: 'workspaceId', + email: 'email', + }); - const result = await service.findWorkspaceForSignInUp({ - authProvider: 'sso', - workspaceId: 'workspaceId', - email: 'email', + expect(result).toBeDefined(); + expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); + expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); - - expect(result).toBeDefined(); - expect(spyWorkspaceRepository).toHaveBeenCalledTimes(0); - expect(spySocialSsoService).toHaveBeenCalledTimes(1); }); }); 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 202b44ed94c8..6268ca0c2b4b 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 @@ -36,7 +36,7 @@ import { } from 'src/engine/core-modules/auth/dto/user-exists.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service'; +import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { @@ -67,7 +67,7 @@ export class AuthService { private readonly refreshTokenService: RefreshTokenService, private readonly userWorkspaceService: UserWorkspaceService, private readonly workspaceInvitationService: WorkspaceInvitationService, - private readonly socialSsoService: SocialSsoService, + private readonly authSsoService: AuthSsoService, private readonly userService: UserService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') @@ -518,15 +518,18 @@ export class AuthService { ) { if (params.workspaceInviteHash) { return ( - (await this.workspaceRepository.findOneBy({ - inviteHash: params.workspaceInviteHash, + (await this.workspaceRepository.findOne({ + where: { + inviteHash: params.workspaceInviteHash, + }, + relations: ['approvedAccessDomains'], })) ?? undefined ); } if (params.authProvider !== 'password') { return ( - (await this.socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( + (await this.authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider( { email: params.email, authProvider: params.authProvider, @@ -568,6 +571,20 @@ export class AuthService { const isTargetAnExistingWorkspace = !!workspace; const isAnExistingUser = userData.type === 'existingUser'; + const email = + userData.type === 'newUser' + ? userData.newUserPayload.email + : userData.existingUser.email; + + if ( + workspace?.approvedAccessDomains.some( + (trustDomain) => + trustDomain.isValidated && trustDomain.domain === email.split('@')[1], + ) + ) { + return; + } + if ( hasPublicInviteLink && !hasPersonalInvitation && diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 105d4da170fd..82e2a7e3e004 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -11,6 +11,7 @@ export enum FeatureFlagKey { IsCommandMenuV2Enabled = 'IS_COMMAND_MENU_V2_ENABLED', IsJsonFilterEnabled = 'IS_JSON_FILTER_ENABLED', IsCustomDomainEnabled = 'IS_CUSTOM_DOMAIN_ENABLED', + IsApprovedAccessDomainsEnabled = 'IS_APPROVED_ACCESS_DOMAINS_ENABLED', IsBillingPlansEnabled = 'IS_BILLING_PLANS_ENABLED', IsRichTextV2Enabled = 'IS_RICH_TEXT_V2_ENABLED', IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',