From 3b9ea309f9ab569ad87cd42a5ff3345599e2f0e5 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 28 Oct 2024 16:11:15 +0100 Subject: [PATCH 01/24] feat(front): allow usage of subdomain in dev env # Conflicts: # packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx # Conflicts: # packages/twenty-front/vite.config.ts --- .../twenty-front/src/generated/graphql.tsx | 81 +++++++++--- ...vailableAuthMethodsByWorkspaceSubdomain.ts | 16 +++ .../hooks/useWorkspaceUnauthenticatedData.ts | 28 ++++ .../components/ClientConfigProviderEffect.tsx | 10 +- .../graphql/queries/getClientConfig.ts | 6 - .../twenty-front/src/pages/auth/SignInUp.tsx | 19 ++- packages/twenty-front/vite.config.ts | 2 +- .../commands/database-command.module.ts | 1 + .../0-33/0-33-generate-subdomain.command.ts | 125 ++++++++++++++++++ .../0-33/0-33-upgrade-version.module.ts | 11 ++ .../1730137590546-addSubdomainToWorkspace.ts | 28 ++++ .../client-config/client-config.entity.ts | 21 --- .../client-config/client-config.resolver.ts | 7 - .../dtos/available-auth-mehtods.output.ts | 28 ++++ .../workspace/services/workspace.service.ts | 18 +++ .../workspace/workspace.entity.ts | 4 + .../workspace/workspace.exception.ts | 13 ++ .../workspace/workspace.resolver.ts | 37 +++++- .../decorators/auth/host-header.decorator.ts | 11 ++ ...l-hydrate-request-from-token.middleware.ts | 1 + .../developers/self-hosting/upgrade-guide.mdx | 17 ++- 21 files changed, 414 insertions(+), 70 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/graphql/queries/getAvailableAuthMethodsByWorkspaceSubdomain.ts create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceUnauthenticatedData.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-33/0-33-generate-subdomain.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version/0-33/0-33-upgrade-version.module.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/dtos/available-auth-mehtods.output.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts create mode 100644 packages/twenty-server/src/engine/decorators/auth/host-header.decorator.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index de35163609bc..e9c345cf88ca 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -68,15 +68,6 @@ export type AppTokenEdge = { node: AppToken; }; -export type AuthProviders = { - __typename?: 'AuthProviders'; - google: Scalars['Boolean']; - magicLink: Scalars['Boolean']; - microsoft: Scalars['Boolean']; - password: Scalars['Boolean']; - sso: Scalars['Boolean']; -}; - export type AuthToken = { __typename?: 'AuthToken'; expiresAt: Scalars['DateTime']; @@ -99,6 +90,21 @@ export type AuthorizeApp = { redirectUrl: Scalars['String']; }; +export type AvailableAuthMethodsOutput = { + __typename?: 'AvailableAuthMethodsOutput'; + authProviders: AvailableAuthProviders; + id: Scalars['Boolean']; +}; + +export type AvailableAuthProviders = { + __typename?: 'AvailableAuthProviders'; + google: Scalars['Boolean']; + magicLink: Scalars['Boolean']; + microsoft: Scalars['Boolean']; + password: Scalars['Boolean']; + sso: Scalars['Boolean']; +}; + export type Billing = { __typename?: 'Billing'; billingFreeTrialDurationInDays?: Maybe; @@ -154,7 +160,6 @@ export type ClientConfig = { __typename?: 'ClientConfig'; analyticsEnabled: Scalars['Boolean']; api: ApiConfig; - authProviders: AuthProviders; billing: Billing; captcha: Captcha; chromeExtensionId?: Maybe; @@ -796,6 +801,7 @@ export type Query = { findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; + getAvailableAuthMethodsByWorkspaceSubdomain: AvailableAuthMethodsOutput; getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; @@ -1300,6 +1306,7 @@ export type Workspace = { isPublicInviteLinkEnabled: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; + subdomain: Scalars['String']; updatedAt: Scalars['DateTime']; workspaceMembersCount?: Maybe; }; @@ -1743,6 +1750,11 @@ export type CheckUserExistsQueryVariables = Exact<{ export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean } }; +export type GetAvailableAuthMethodsByWorkspaceSubdomainQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetAvailableAuthMethodsByWorkspaceSubdomainQuery = { __typename?: 'Query', getAvailableAuthMethodsByWorkspaceSubdomain: { __typename?: 'AvailableAuthMethodsOutput', id: boolean, authProviders: { __typename?: 'AvailableAuthProviders', sso: boolean, google: boolean, magicLink: boolean, password: boolean, microsoft: boolean } } }; + export type ValidatePasswordResetTokenQueryVariables = Exact<{ token: Scalars['String']; }>; @@ -1780,7 +1792,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -2901,6 +2913,47 @@ export function useCheckUserExistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOp export type CheckUserExistsQueryHookResult = ReturnType; export type CheckUserExistsLazyQueryHookResult = ReturnType; export type CheckUserExistsQueryResult = Apollo.QueryResult; +export const GetAvailableAuthMethodsByWorkspaceSubdomainDocument = gql` + query GetAvailableAuthMethodsByWorkspaceSubdomain { + getAvailableAuthMethodsByWorkspaceSubdomain { + id + authProviders { + sso + google + magicLink + password + microsoft + } + } +} + `; + +/** + * __useGetAvailableAuthMethodsByWorkspaceSubdomainQuery__ + * + * To run a query within a React component, call `useGetAvailableAuthMethodsByWorkspaceSubdomainQuery` and pass it any options that fit your needs. + * When your component renders, `useGetAvailableAuthMethodsByWorkspaceSubdomainQuery` 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 } = useGetAvailableAuthMethodsByWorkspaceSubdomainQuery({ + * variables: { + * }, + * }); + */ +export function useGetAvailableAuthMethodsByWorkspaceSubdomainQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAvailableAuthMethodsByWorkspaceSubdomainDocument, options); + } +export function useGetAvailableAuthMethodsByWorkspaceSubdomainLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAvailableAuthMethodsByWorkspaceSubdomainDocument, options); + } +export type GetAvailableAuthMethodsByWorkspaceSubdomainQueryHookResult = ReturnType; +export type GetAvailableAuthMethodsByWorkspaceSubdomainLazyQueryHookResult = ReturnType; +export type GetAvailableAuthMethodsByWorkspaceSubdomainQueryResult = Apollo.QueryResult; export const ValidatePasswordResetTokenDocument = gql` query ValidatePasswordResetToken($token: String!) { validatePasswordResetToken(passwordResetToken: $token) { @@ -3084,12 +3137,6 @@ export type UpdateBillingSubscriptionMutationOptions = Apollo.BaseMutationOption export const GetClientConfigDocument = gql` query GetClientConfig { clientConfig { - authProviders { - google - password - microsoft - sso - } billing { isBillingEnabled billingUrl diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/getAvailableAuthMethodsByWorkspaceSubdomain.ts b/packages/twenty-front/src/modules/auth/graphql/queries/getAvailableAuthMethodsByWorkspaceSubdomain.ts new file mode 100644 index 000000000000..36e818c37748 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/queries/getAvailableAuthMethodsByWorkspaceSubdomain.ts @@ -0,0 +1,16 @@ +import { gql } from '@apollo/client'; + +export const GET_AVAILABLE_AUTH_METHODS_BY_WORKSPACE_SUBDOMAIN = gql` + query GetAvailableAuthMethodsByWorkspaceSubdomain { + getAvailableAuthMethodsByWorkspaceSubdomain { + id + authProviders { + sso + google + magicLink + password + microsoft + } + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceUnauthenticatedData.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceUnauthenticatedData.ts new file mode 100644 index 000000000000..88b7c217d565 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceUnauthenticatedData.ts @@ -0,0 +1,28 @@ +import { useGetAvailableAuthMethodsByWorkspaceSubdomainQuery } from '~/generated/graphql'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import { useSetRecoilState } from 'recoil'; + +export const useWorkspaceUnauthenticatedData = () => { + const { enqueueSnackBar } = useSnackBar(); + + const setAuthProviders = useSetRecoilState(authProvidersState); + + const { loading } = useGetAvailableAuthMethodsByWorkspaceSubdomainQuery({ + onCompleted: (data) => { + setAuthProviders( + data?.getAvailableAuthMethodsByWorkspaceSubdomain?.authProviders, + ); + }, + onError: (error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); + + return { + loading, + }; +}; diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index bf11d6713c2c..25ffd055b9b4 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -16,7 +16,6 @@ import { useGetClientConfigQuery } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; export const ClientConfigProviderEffect = () => { - const setAuthProviders = useSetRecoilState(authProvidersState); const setIsDebugMode = useSetRecoilState(isDebugModeState); const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState); @@ -44,13 +43,7 @@ export const ClientConfigProviderEffect = () => { useEffect(() => { if (!loading && isDefined(data?.clientConfig)) { setIsClientConfigLoaded(true); - setAuthProviders({ - google: data?.clientConfig.authProviders.google, - microsoft: data?.clientConfig.authProviders.microsoft, - password: data?.clientConfig.authProviders.password, - magicLink: false, - sso: data?.clientConfig.authProviders.sso, - }); + setIsDebugMode(data?.clientConfig.debugMode); setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); setIsSignInPrefilled(data?.clientConfig.signInPrefilled); @@ -75,7 +68,6 @@ export const ClientConfigProviderEffect = () => { } }, [ data, - setAuthProviders, setIsDebugMode, setIsSignInPrefilled, setIsSignUpDisabled, diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index eaf55e4d0a93..aa5da6bc8dc6 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -3,12 +3,6 @@ import { gql } from '@apollo/client'; export const GET_CLIENT_CONFIG = gql` query GetClientConfig { clientConfig { - authProviders { - google - password - microsoft - sso - } billing { isBillingEnabled billingUrl diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 712712c01e51..a9ee6aeaebc3 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -12,6 +12,8 @@ import { IconLockCustom } from '@ui/display/icon/components/IconLock'; import { AnimatedEaseIn } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; import { SSOWorkspaceSelection } from './SSOWorkspaceSelection'; +import { useWorkspaceUnauthenticatedData } from '@/auth/sign-in-up/hooks/useWorkspaceUnauthenticatedData'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; export const SignInUp = () => { const { form } = useSignInUpForm(); @@ -19,6 +21,9 @@ export const SignInUp = () => { const { signInUpStep, signInUpMode } = useSignInUp(form); + const { loading } = useWorkspaceUnauthenticatedData(); + const authProviders = useRecoilValue(authProvidersState); + const title = useMemo(() => { if ( signInUpStep === SignInUpStep.Init || @@ -47,11 +52,15 @@ export const SignInUp = () => { )} - {title} - {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( - - ) : ( - + {!loading && ( + <> + {title} + {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( + + ) : ( + + )} + )} ); diff --git a/packages/twenty-front/vite.config.ts b/packages/twenty-front/vite.config.ts index 4239d483e6f1..abc62f50da2f 100644 --- a/packages/twenty-front/vite.config.ts +++ b/packages/twenty-front/vite.config.ts @@ -66,7 +66,7 @@ export default defineConfig(({ command, mode }) => { server: { port, - host: 'localhost', + host: 'loclahost', fs: { allow: [ searchForWorkspaceRoot(process.cwd()), diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index 9adcaf195da9..84693d8e3699 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -25,6 +25,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module'; +import { UpgradeTo0_33CommandModule } from 'src/database/commands/upgrade-version/0-33/0-33-upgrade-version.module'; @Module({ imports: [ diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-33/0-33-generate-subdomain.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-33/0-33-generate-subdomain.command.ts new file mode 100644 index 000000000000..eb90139543eb --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-33/0-33-generate-subdomain.command.ts @@ -0,0 +1,125 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Repository, In } from 'typeorm'; + +import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { BaseCommandOptions } from 'src/database/commands/base.command'; + +// For DX only +type WorkspaceId = string; + +type Subdomain = string; + +@Command({ + name: 'upgrade-0.33', + description: 'Upgrade to 0.33', +}) +export class UpgradeTo0_33Command extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + ) { + super(workspaceRepository); + } + + private generatePayloadForQuery({ + id, + subdomain, + domainName, + displayName, + }: Workspace) { + const result = { id, subdomain }; + + if (domainName) { + const subdomain = domainName.split('.')[0]; + + if (subdomain.length > 0) { + result.subdomain = subdomain; + } + } + + if (!domainName && displayName) { + const displayNameWords = displayName.match(/(\w| |\d)+/); + + if (displayNameWords) { + result.subdomain = displayNameWords + .join('-') + .replace(/ /g, '') + .toLowerCase(); + } + } + + return result; + } + + private groupBySubdomainName( + acc: Record>, + workspace: Workspace, + ) { + const payload = this.generatePayloadForQuery(workspace); + + acc[payload.subdomain] = acc[payload.subdomain] + ? acc[payload.subdomain].concat([payload.id]) + : [payload.id]; + + return acc; + } + + private deduplicateAndSave( + subdomain: Subdomain, + workspaceIds: Array, + options: BaseCommandOptions, + ) { + return workspaceIds.map(async (workspaceId, index) => { + const subdomainDeduplicated = + index === 0 ? subdomain : `${subdomain}-${index}`; + + this.logger.log( + `Updating workspace ${workspaceId} with subdomain ${subdomainDeduplicated}`, + ); + + if (!options.dryRun) { + await this.workspaceRepository.update(workspaceId, { + subdomain: subdomainDeduplicated, + }); + } + }); + } + + async executeActiveWorkspacesCommand( + passedParam: string[], + options: BaseCommandOptions, + activeWorkspaceIds: string[], + ): Promise { + const workspaces = await this.workspaceRepository.find( + activeWorkspaceIds.length > 0 + ? { + where: { + id: In(activeWorkspaceIds), + }, + } + : undefined, + ); + + if (workspaces.length === 0) { + this.logger.log('No workspaces found'); + + return; + } + + await Promise.all( + Object.entries( + workspaces.reduce( + (acc, workspace) => this.groupBySubdomainName(acc, workspace), + {} as ReturnType, + ), + ) + .map(([subdomain, workspaceIds]) => + this.deduplicateAndSave(subdomain, workspaceIds, options), + ) + .flat(2), + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-33/0-33-upgrade-version.module.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-33/0-33-upgrade-version.module.ts new file mode 100644 index 000000000000..3eb193757aa3 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-33/0-33-upgrade-version.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UpgradeTo0_33Command } from 'src/database/commands/upgrade-version/0-33/0-33-generate-subdomain.command'; + +@Module({ + imports: [TypeOrmModule.forFeature([Workspace], 'core')], + providers: [UpgradeTo0_33Command], +}) +export class UpgradeTo0_33CommandModule {} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts new file mode 100644 index 000000000000..5cc9c76a9c05 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSubdomainToWorkspace1730137590546 + implements MigrationInterface +{ + name = 'AddSubdomainToWorkspace1730137590546'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "subdomain" varchar NULL`, + ); + await queryRunner.query( + `UPDATE "core"."workspace" SET "subdomain" = LOWER(REPLACE("id", '-', ''))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ALTER COLUMN "subdomain" SET NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX workspace_subdomain_unique_index ON "core"."workspace" (subdomain)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "subdomain"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 00e2c3cb4304..f56c8d52354a 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -2,24 +2,6 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; -@ObjectType() -class AuthProviders { - @Field(() => Boolean) - google: boolean; - - @Field(() => Boolean) - magicLink: boolean; - - @Field(() => Boolean) - password: boolean; - - @Field(() => Boolean) - microsoft: boolean; - - @Field(() => Boolean) - sso: boolean; -} - @ObjectType() class Telemetry { @Field(() => Boolean) @@ -76,9 +58,6 @@ class ApiConfig { @ObjectType() export class ClientConfig { - @Field(() => AuthProviders, { nullable: false }) - authProviders: AuthProviders; - @Field(() => Billing, { nullable: false }) billing: Billing; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index 9f2660876568..bf065f6c4a25 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -11,13 +11,6 @@ export class ClientConfigResolver { @Query(() => ClientConfig) async clientConfig(): Promise { const clientConfig: ClientConfig = { - authProviders: { - google: this.environmentService.get('AUTH_GOOGLE_ENABLED'), - magicLink: false, - password: this.environmentService.get('AUTH_PASSWORD_ENABLED'), - microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'), - sso: this.environmentService.get('AUTH_SSO_ENABLED'), - }, billing: { isBillingEnabled: this.environmentService.get('IS_BILLING_ENABLED'), billingUrl: this.environmentService.get('BILLING_PLAN_REQUIRED_LINK'), diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/available-auth-mehtods.output.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/available-auth-mehtods.output.ts new file mode 100644 index 000000000000..a83dbb29fb3e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/available-auth-mehtods.output.ts @@ -0,0 +1,28 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +class AvailableAuthProviders { + @Field(() => Boolean) + sso: boolean; + + @Field(() => Boolean) + google: boolean; + + @Field(() => Boolean) + magicLink: boolean; + + @Field(() => Boolean) + password: boolean; + + @Field(() => Boolean) + microsoft: boolean; +} + +@ObjectType() +export class AvailableAuthMethodsOutput { + @Field(() => String) + id: string; + + @Field(() => AvailableAuthProviders) + authProviders: AvailableAuthProviders; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 38befbf6e12d..31d2a16cdf1e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -19,6 +19,10 @@ import { } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags'; +import { + WorkspaceException, + WorkspaceExceptionCode, +} from 'src/engine/core-modules/workspace/workspace.exception'; // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { @@ -160,4 +164,18 @@ export class WorkspaceService extends TypeOrmQueryService { ); } } + + async getWorkspaceByOrigin(origin: string) { + try { + const { host } = new URL(origin); + const subdomain = host.split('.')[0]; + + return this.workspaceRepository.findOneBy({ subdomain }); + } catch (e) { + throw new WorkspaceException( + 'Subdomain not found', + WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND, + ); + } + } } 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 ac5c38287104..8d123889067f 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 @@ -140,4 +140,8 @@ export class Workspace { @Field() @Column({ default: '' }) databaseSchema: string; + + @Field() + @Column() + subdomain: string; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts new file mode 100644 index 000000000000..233e4eb4ada9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.exception.ts @@ -0,0 +1,13 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class WorkspaceException extends CustomException { + code: WorkspaceExceptionCode; + constructor(message: string, code: WorkspaceExceptionCode) { + super(message, code); + } +} + +export enum WorkspaceExceptionCode { + SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND', + WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index ae3798c20cc1..19a63a7f4894 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -29,12 +29,17 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { assert } from 'src/utils/assert'; import { isDefined } from 'src/utils/is-defined'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { + WorkspaceException, + WorkspaceExceptionCode, +} from 'src/engine/core-modules/workspace/workspace.exception'; +import { AvailableAuthMethodsOutput } from 'src/engine/core-modules/workspace/dtos/available-auth-mehtods.output'; +import { OriginHeader } from 'src/engine/decorators/auth/host-header.decorator'; import { Workspace } from './workspace.entity'; import { WorkspaceService } from './services/workspace.service'; -@UseGuards(WorkspaceAuthGuard) @Resolver(() => Workspace) export class WorkspaceResolver { constructor( @@ -47,6 +52,7 @@ export class WorkspaceResolver { ) {} @Query(() => Workspace) + @UseGuards(WorkspaceAuthGuard) async currentWorkspace(@AuthWorkspace() { id }: Workspace) { const workspace = await this.workspaceService.findById(id); @@ -65,6 +71,7 @@ export class WorkspaceResolver { } @Mutation(() => Workspace) + @UseGuards(WorkspaceAuthGuard) async updateWorkspace( @Args('data') data: UpdateWorkspaceInput, @AuthWorkspace() workspace: Workspace, @@ -73,6 +80,7 @@ export class WorkspaceResolver { } @Mutation(() => String) + @UseGuards(WorkspaceAuthGuard) async uploadWorkspaceLogo( @AuthWorkspace() { id }: Workspace, @Args({ name: 'file', type: () => GraphQLUpload }) @@ -101,8 +109,8 @@ export class WorkspaceResolver { return `${paths[0]}?token=${workspaceLogoToken}`; } - @UseGuards(DemoEnvGuard) @Mutation(() => Workspace) + @UseGuards(DemoEnvGuard, WorkspaceAuthGuard) async deleteCurrentWorkspace(@AuthWorkspace() { id }: Workspace) { return this.workspaceService.deleteWorkspace(id); } @@ -144,4 +152,29 @@ export class WorkspaceResolver { hasValidEntrepriseKey(): boolean { return isDefined(this.environmentService.get('ENTERPRISE_KEY')); } + + @Query(() => AvailableAuthMethodsOutput) + async getAvailableAuthMethodsByWorkspaceSubdomain( + @OriginHeader() origin: string, + ) { + const workspace = await this.workspaceService.getWorkspaceByOrigin(origin); + + if (!workspace) { + return new WorkspaceException( + 'Workspace not found', + WorkspaceExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + + return { + id: workspace.id, + authProviders: { + google: this.environmentService.get('AUTH_GOOGLE_ENABLED'), + magicLink: false, + password: this.environmentService.get('AUTH_PASSWORD_ENABLED'), + microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'), + sso: this.environmentService.get('AUTH_SSO_ENABLED'), + }, + }; + } } diff --git a/packages/twenty-server/src/engine/decorators/auth/host-header.decorator.ts b/packages/twenty-server/src/engine/decorators/auth/host-header.decorator.ts new file mode 100644 index 000000000000..eda5286c5748 --- /dev/null +++ b/packages/twenty-server/src/engine/decorators/auth/host-header.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +import { getRequest } from 'src/utils/extract-request'; + +export const OriginHeader = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = getRequest(ctx); + + return request.headers['origin']; + }, +); diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 1b6b19e7aba5..d683cc7fae07 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -57,6 +57,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware 'ExchangeAuthorizationCode', 'GetAuthorizationUrl', 'FindAvailableSSOIdentityProviders', + 'GetAvailableAuthMethodsByWorkspaceSubdomain', ]; if ( diff --git a/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx b/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx index 0c153739b113..d24783175f96 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx @@ -103,7 +103,7 @@ The `yarn command:prod upgrade-0.31` takes care of the data migration of all wor Upgrade your Twenty instance to use v0.32.0 image -**Schema and data migration**: +**Schema and data migration**: ``` yarn database:migrate:prod yarn command:prod upgrade-0.32 @@ -128,4 +128,17 @@ We have also simplifed the way we handle the JWT tokens. Update your `.env` file to use the new `APP_SECRET` variable instead of the individual tokens secrets (you can use the same secret as before or generate a new random string) - \ No newline at end of file + + +# v0.32.0 to v0.33.0 + +Upgrade your Twenty instance to use v0.33.0 image + +**Schema and data migration**: +``` +yarn database:migrate:prod +yarn command:prod upgrade-0.33 +``` + +The `yarn database:migrate:prod` command will apply the migrations to the database structure (core and metadata schemas) +The `yarn command:prod upgrade-0.33` takes care of the data migration of all workspaces. From d614adc365385a1ca1f2e9ecbbce66903db70155 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 29 Oct 2024 18:33:53 +0100 Subject: [PATCH 02/24] feat(*): allow to use subdomain # Conflicts: # packages/twenty-front/src/generated/graphql.tsx --- packages/twenty-front/.env.example | 3 +- .../modules/app/components/SettingsRoutes.tsx | 9 +- .../auth/graphql/mutations/generateJWT.ts | 24 - .../auth/graphql/mutations/switchWorkspace.ts | 17 + ...vailableAuthMethodsByWorkspaceSubdomain.ts | 16 - .../getPublicWorkspaceDataBySubdomain.ts | 19 + .../src/modules/auth/hooks/useAuth.ts | 16 + ...catedData.ts => useWorkspacePublicData.ts} | 17 +- .../auth/states/currentWorkspaceState.ts | 1 + .../states/lastAuthenticateWorkspaceState.ts | 17 + .../src/modules/auth/states/tokenPairState.ts | 7 +- .../auth/states/workspacePublicDataState.ts | 8 + .../states/authProvidersState.ts | 4 +- ...ColumnDefinitionsFromFieldMetadata.test.ts | 1 + .../SettingsSSOIdentitiesProvidersForm.tsx | 18 +- .../security/types/SSOIdentityProvider.ts | 7 +- .../utils/sSOIdentityProviderDefaultValues.ts | 4 +- .../components/WorkspaceLogoUploader.tsx | 4 +- .../src/modules/types/SettingsPath.ts | 1 + .../layout/page/components/DefaultLayout.tsx | 24 + .../MultiWorkspaceDropdownButton.tsx | 20 +- .../components/NavigationDrawerHeader.tsx | 23 +- .../hooks/useWorkspaceSwitching.ts | 95 ++-- .../graphql/fragments/userQueryFragment.ts | 1 + .../graphql/mutations/updateWorkspace.ts | 1 + .../twenty-front/src/pages/auth/Invite.tsx | 2 +- .../twenty-front/src/pages/auth/SignInUp.tsx | 18 +- .../src/pages/settings/SettingsWorkspace.tsx | 20 +- .../settings/workspace/SettingsDomain.tsx | 150 ++++++ .../src/testing/mock-data/users.ts | 5 + .../src/utils/image/getImageAbsoluteURI.ts | 19 +- .../twenty-front/src/utils/recoil-effects.ts | 13 +- .../src/utils/workspace-url.helper.ts | 31 ++ packages/twenty-front/vite.config.ts | 17 +- ...6367-addAuthProvidersColumnsToWorkspace.ts | 37 ++ .../engine/core-modules/auth/auth.module.ts | 4 +- .../engine/core-modules/auth/auth.resolver.ts | 85 ++-- .../google-apis-auth.controller.ts | 27 +- .../controllers/google-auth.controller.ts | 9 +- .../controllers/microsoft-auth.controller.ts | 7 +- .../auth/controllers/sso-auth.controller.ts | 14 +- .../auth/dto/generateJWT.output.ts | 43 -- ...jwt.input.ts => switch-workspace.input.ts} | 2 +- .../auth/guards/google-oauth.guard.ts | 7 + .../auth/services/auth.service.spec.ts | 129 +++++- .../auth/services/auth.service.ts | 73 ++- .../auth/services/sign-in-up.service.spec.ts | 432 +++++++++++++++++- .../auth/services/sign-in-up.service.ts | 146 +++--- .../auth/services/switch-workspace.service.ts | 57 ++- .../auth/strategies/google.auth.strategy.ts | 3 + .../core-modules/auth/token/token.module.ts | 2 + .../workspace-sso-identity-provider.entity.ts | 2 +- .../user-workspace/user-workspace.resolver.ts | 1 + .../user-workspace/user-workspace.service.ts | 34 +- .../services/workspace-invitation.service.ts | 217 +++++++-- .../workspace-invitation.module.ts | 8 +- .../dtos/available-auth-mehtods.output.ts | 28 -- .../dtos/public-workspace-data.output.ts | 39 ++ .../workspace/dtos/update-workspace-input.ts | 5 + .../workspace/services/workspace.service.ts | 48 +- .../workspace/workspace.entity.ts | 16 + .../workspace/workspace.module.ts | 2 +- .../workspace/workspace.resolver.ts | 26 +- ...ecorator.ts => origin-header.decorator.ts} | 0 ...l-hydrate-request-from-token.middleware.ts | 2 +- .../get-workspace-subdomain-by-origin.spec.ts | 19 + .../get-workspace-subdomain-by-origin.ts | 13 + .../__tests__/workspace-url.utils.spec.ts | 37 ++ .../src/utils/workspace-url.utils.ts | 44 ++ .../test/utils/createTestingService.utils.ts | 63 +++ 70 files changed, 1821 insertions(+), 492 deletions(-) delete mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts create mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts delete mode 100644 packages/twenty-front/src/modules/auth/graphql/queries/getAvailableAuthMethodsByWorkspaceSubdomain.ts create mode 100644 packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts rename packages/twenty-front/src/modules/auth/sign-in-up/hooks/{useWorkspaceUnauthenticatedData.ts => useWorkspacePublicData.ts} (50%) create mode 100644 packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts create mode 100644 packages/twenty-front/src/modules/auth/states/workspacePublicDataState.ts create mode 100644 packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx create mode 100644 packages/twenty-front/src/utils/workspace-url.helper.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1730298416367-addAuthProvidersColumnsToWorkspace.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts rename packages/twenty-server/src/engine/core-modules/auth/dto/{generate-jwt.input.ts => switch-workspace.input.ts} (84%) delete mode 100644 packages/twenty-server/src/engine/core-modules/workspace/dtos/available-auth-mehtods.output.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts rename packages/twenty-server/src/engine/decorators/auth/{host-header.decorator.ts => origin-header.decorator.ts} (100%) create mode 100644 packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts create mode 100644 packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts create mode 100644 packages/twenty-server/src/utils/__tests__/workspace-url.utils.spec.ts create mode 100644 packages/twenty-server/src/utils/workspace-url.utils.ts create mode 100644 packages/twenty-server/test/utils/createTestingService.utils.ts diff --git a/packages/twenty-front/.env.example b/packages/twenty-front/.env.example index 345d0fb92ad7..ed478d193ade 100644 --- a/packages/twenty-front/.env.example +++ b/packages/twenty-front/.env.example @@ -5,4 +5,5 @@ GENERATE_SOURCEMAP=false # REACT_APP_PORT=3001 # CHROMATIC_PROJECT_TOKEN= # VITE_DISABLE_TYPESCRIPT_CHECKER=true -# VITE_DISABLE_ESLINT_CHECKER=true \ No newline at end of file +# VITE_DISABLE_ESLINT_CHECKER=true +REACT_WEB_APP_BASE_URL=http://localhost:3001 \ No newline at end of file diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index b758acdc1177..4dddfbd25e9a 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -105,6 +105,12 @@ const SettingsWorkspace = lazy(() => })), ); +const SettingsDomain = lazy(() => + import('~/pages/settings/workspace/SettingsDomain').then((module) => ({ + default: module.SettingsDomain, + })), +); + const SettingsWorkspaceMembers = lazy(() => import('~/pages/settings/SettingsWorkspaceMembers').then((module) => ({ default: module.SettingsWorkspaceMembers, @@ -272,11 +278,12 @@ export const SettingsRoutes = ({ {isBillingEnabled && ( } /> )} + } /> + } /> } /> - } /> } /> { const [, setTokenPair] = useRecoilState(tokenPairState); const setCurrentUser = useSetRecoilState(currentUserState); + const setLastAuthenticateWorkspaceState = useSetRecoilState( + lastAuthenticateWorkspaceState, + ); const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); @@ -157,6 +162,10 @@ export const useAuth = () => { const workspace = user.defaultWorkspace ?? null; setCurrentWorkspace(workspace); + setLastAuthenticateWorkspaceState({ + id: workspace?.id, + subdomain: workspace?.subdomain, + }); if (isDefined(verifyResult.data?.verify.user.workspaces)) { const validWorkspaces = verifyResult.data?.verify.user.workspaces @@ -183,6 +192,7 @@ export const useAuth = () => { setCurrentWorkspace, setCurrentWorkspaceMembers, setCurrentWorkspaceMember, + setLastAuthenticateWorkspaceState, setDateTimeFormat, setWorkspaces, ], @@ -312,6 +322,12 @@ export const useAuth = () => { if (isDefined(params.workspacePersonalInviteToken)) { url.searchParams.set('inviteToken', params.workspacePersonalInviteToken); } + const subdomain = getWorkspaceSubdomain(); + + if (isDefined(subdomain)) { + url.searchParams.set('workspaceSubdomain', subdomain); + } + return url.toString(); }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceUnauthenticatedData.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspacePublicData.ts similarity index 50% rename from packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceUnauthenticatedData.ts rename to packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspacePublicData.ts index 88b7c217d565..970f2c898c09 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceUnauthenticatedData.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspacePublicData.ts @@ -1,19 +1,24 @@ -import { useGetAvailableAuthMethodsByWorkspaceSubdomainQuery } from '~/generated/graphql'; +import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { useSetRecoilState } from 'recoil'; +import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; -export const useWorkspaceUnauthenticatedData = () => { +export const useWorkspacePublicData = () => { const { enqueueSnackBar } = useSnackBar(); const setAuthProviders = useSetRecoilState(authProvidersState); + const setWorkspacePublicDataState = useSetRecoilState( + workspacePublicDataState, + ); - const { loading } = useGetAvailableAuthMethodsByWorkspaceSubdomainQuery({ + const { loading } = useGetPublicWorkspaceDataBySubdomainQuery({ onCompleted: (data) => { - setAuthProviders( - data?.getAvailableAuthMethodsByWorkspaceSubdomain?.authProviders, - ); + const publicWorkspaceDataBySubdomain = + data.getPublicWorkspaceDataBySubdomain; + setAuthProviders(publicWorkspaceDataBySubdomain.authProviders); + setWorkspacePublicDataState(publicWorkspaceDataBySubdomain); }, onError: (error) => { enqueueSnackBar(error.message, { diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index fde9761138e6..df7d87519971 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -15,6 +15,7 @@ export type CurrentWorkspace = Pick< | 'workspaceMembersCount' | 'isPublicInviteLinkEnabled' | 'hasValidEntrepriseKey' + | 'subdomain' | 'metadataVersion' >; diff --git a/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts new file mode 100644 index 000000000000..fb8f880d4bf1 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts @@ -0,0 +1,17 @@ +import { cookieStorageEffect } from '~/utils/recoil-effects'; +import { Workspace } from '~/generated/graphql'; +import { createState } from 'twenty-ui'; +import { getWorkspaceMaindomain } from '~/utils/workspace-url.helper'; + +export const lastAuthenticateWorkspaceState = createState | null>({ + key: 'lastAuthenticateWorkspaceState', + defaultValue: null, + effects: [ + cookieStorageEffect('lastAuthenticateWorkspace', { + domain: `.${getWorkspaceMaindomain()}`, + }), + ], +}); diff --git a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts index f6262b5aef32..8dc2cd72ab9e 100644 --- a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts +++ b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts @@ -6,5 +6,10 @@ import { cookieStorageEffect } from '~/utils/recoil-effects'; export const tokenPairState = createState({ key: 'tokenPairState', defaultValue: null, - effects: [cookieStorageEffect('tokenPair')], + effects: [ + cookieStorageEffect('tokenPair', undefined, { + validateInitFn: (payload: AuthTokenPair) => + Boolean(payload['accessToken']), + }), + ], }); diff --git a/packages/twenty-front/src/modules/auth/states/workspacePublicDataState.ts b/packages/twenty-front/src/modules/auth/states/workspacePublicDataState.ts new file mode 100644 index 000000000000..f4866fa653ca --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/workspacePublicDataState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; +import { PublicWorkspaceDataOutput } from '~/generated/graphql'; + +export const workspacePublicDataState = + createState({ + key: 'workspacePublicDataState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts index ef37f22cf9ff..08d830eb8944 100644 --- a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts +++ b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts @@ -5,9 +5,9 @@ import { AuthProviders } from '~/generated/graphql'; export const authProvidersState = createState({ key: 'authProvidersState', defaultValue: { - google: false, + google: true, magicLink: false, - password: false, + password: true, microsoft: false, sso: false, }, diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 2205cd82af99..9ce782b539b8 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -15,6 +15,7 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({ id: '1', featureFlags: [], allowImpersonation: false, + subdomain: 'test', activationStatus: WorkspaceActivationStatus.Active, hasValidEntrepriseKey: false, metadataVersion: 1, diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx index 4d28c837fa5b..05b5e9cdb6ef 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersForm.tsx @@ -10,7 +10,7 @@ import styled from '@emotion/styled'; import { ReactElement } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { H2Title, IconComponent, IconKey, Section } from 'twenty-ui'; -import { IdpType } from '~/generated/graphql'; +import { IdentityProviderType } from '~/generated/graphql'; const StyledInputsContainer = styled.div` display: grid; @@ -30,8 +30,8 @@ export const SettingsSSOIdentitiesProvidersForm = () => { const { control, getValues } = useFormContext(); - const IdpMap: Record< - IdpType, + const IdentitiesProvidersMap: Record< + IdentityProviderType, { form: ReactElement; option: { @@ -62,12 +62,12 @@ export const SettingsSSOIdentitiesProvidersForm = () => { }, }; - const getFormByType = (type: Uppercase | undefined) => { + const getFormByType = (type: Uppercase | undefined) => { switch (type) { - case IdpType.Oidc: - return IdpMap.OIDC.form; - case IdpType.Saml: - return IdpMap.SAML.form; + case IdentityProviderType.Oidc: + return IdentitiesProvidersMap.OIDC.form; + case IdentityProviderType.Saml: + return IdentitiesProvidersMap.SAML.form; default: return null; } @@ -106,7 +106,7 @@ export const SettingsSSOIdentitiesProvidersForm = () => { render={({ field: { onChange, value } }) => ( identityProviderType.option, )} onChange={onChange} diff --git a/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts index fe7226c9d2c4..ff17752f09e1 100644 --- a/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts +++ b/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts @@ -2,12 +2,15 @@ import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema'; import { z } from 'zod'; -import { IdpType, SsoIdentityProviderStatus } from '~/generated/graphql'; +import { + IdentityProviderType, + SsoIdentityProviderStatus, +} from '~/generated/graphql'; export type SSOIdentityProvider = { __typename: 'SSOIdentityProvider'; id: string; - type: IdpType; + type: IdentityProviderType; issuer: string; name?: string | null; status: SsoIdentityProviderStatus; diff --git a/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts index a5358e948b86..ca16bf75ec2b 100644 --- a/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts +++ b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts @@ -1,10 +1,10 @@ /* @license Enterprise */ import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; -import { IdpType } from '~/generated/graphql'; +import { IdentityProviderType } from '~/generated/graphql'; export const sSOIdentityProviderDefaultValues: Record< - IdpType, + IdentityProviderType, () => SettingSecurityNewSSOIdentityFormValues > = { SAML: () => ({ diff --git a/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx b/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx index 4cff97476501..430205c52c8f 100644 --- a/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx +++ b/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx @@ -10,7 +10,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const WorkspaceLogoUploader = () => { const [uploadLogo] = useUploadWorkspaceLogoMutation(); - const [updateWorkspce] = useUpdateWorkspaceMutation(); + const [updateWorkspace] = useUpdateWorkspaceMutation(); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, ); @@ -39,7 +39,7 @@ export const WorkspaceLogoUploader = () => { if (!currentWorkspace?.id) { throw new Error('Workspace id not found'); } - await updateWorkspce({ + await updateWorkspace({ variables: { input: { logo: null, diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index dc8f3c1cb8cc..5f1f678e52c1 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -19,6 +19,7 @@ export enum SettingsPath { ServerlessFunctionDetail = 'functions/:serverlessFunctionId', WorkspaceMembersPage = 'workspace-members', Workspace = 'workspace', + Domain = 'domain', CRMMigration = 'crm-migration', Developers = 'developers', ServerlessFunctions = 'functions', diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 94821fa81b90..91bf4910be48 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -15,6 +15,14 @@ import styled from '@emotion/styled'; import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; import { Outlet } from 'react-router-dom'; import { useScreenSize } from 'twenty-ui'; +import { + buildWorkspaceUrl, + isTwentyHomePage, + isTwentyHosting, +} from '~/utils/workspace-url.helper'; +import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; +import { useRecoilState } from 'recoil'; +import { useEffect } from 'react'; const StyledLayout = styled.div` background: ${({ theme }) => theme.background.noisy}; @@ -65,6 +73,22 @@ export const DefaultLayout = () => { const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); + const [lastAuthenticateWorkspace] = useRecoilState( + lastAuthenticateWorkspaceState, + ); + + useEffect(() => { + if ( + isTwentyHosting === true && + isTwentyHomePage === true && + lastAuthenticateWorkspace + ) { + window.location.href = buildWorkspaceUrl( + lastAuthenticateWorkspace.subdomain, + ); + } + }, [lastAuthenticateWorkspace]); + return ( <> {currentWorkspace?.displayName ?? ''} @@ -123,13 +119,9 @@ export const MultiWorkspaceDropdownButton = ({ text={workspace.displayName ?? ''} avatar={ } selected={currentWorkspace?.id === workspace.id} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx index 52477ff39f93..1f700f59d776 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx @@ -11,6 +11,8 @@ import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigat import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { isNonEmptyString } from '@sniptt/guards'; import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton'; +import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; +import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI'; const StyledContainer = styled.div` align-items: center; @@ -54,8 +56,8 @@ type NavigationDrawerHeaderProps = { }; export const NavigationDrawerHeader = ({ - name = DEFAULT_WORKSPACE_NAME, - logo = DEFAULT_WORKSPACE_LOGO, + name, + logo, showCollapseButton, }: NavigationDrawerHeaderProps) => { const isMobile = useIsMobile(); @@ -65,17 +67,26 @@ export const NavigationDrawerHeader = ({ isNavigationDrawerExpandedState, ); + const workspacePublicData = useRecoilValue(workspacePublicDataState); + const displayName = + name ?? workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME; + const displayLogo = getImageAbsoluteURI( + isNonEmptyString(logo) + ? logo + : isNonEmptyString(workspacePublicData?.logo) + ? workspacePublicData?.logo + : DEFAULT_WORKSPACE_LOGO, + ); + return ( {isMultiWorkspace ? ( ) : ( - + - {name} + {displayName} )} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index b7e7abf9f325..9e1f9c20c17d 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -1,22 +1,20 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { AppPath } from '@/types/AppPath'; import { useAuth } from '@/auth/hooks/useAuth'; import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { - SignInUpStep, - signInUpStepState, -} from '@/auth/states/signInUpStepState'; +import { signInUpStepState } from '@/auth/states/signInUpStepState'; import { tokenPairState } from '@/auth/states/tokenPairState'; -import { AppPath } from '@/types/AppPath'; -import { useGenerateJwtMutation } from '~/generated/graphql'; +import { useSwitchWorkspaceMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; -import { sleep } from '~/utils/sleep'; +import { buildWorkspaceUrl } from '~/utils/workspace-url.helper'; export const useWorkspaceSwitching = () => { const setTokenPair = useSetRecoilState(tokenPairState); - const [generateJWT] = useGenerateJwtMutation(); + + const [switchWorkspaceMutation] = useSwitchWorkspaceMutation(); const { redirectToSSOLoginPage } = useSSO(); const currentWorkspace = useRecoilValue(currentWorkspaceState); const setAvailableWorkspacesForSSOState = useSetRecoilState( @@ -27,48 +25,65 @@ export const useWorkspaceSwitching = () => { const switchWorkspace = async (workspaceId: string) => { if (currentWorkspace?.id === workspaceId) return; - const jwt = await generateJWT({ + + const { data, errors } = await switchWorkspaceMutation({ variables: { workspaceId, }, }); - if (isDefined(jwt.errors)) { - throw jwt.errors; + if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) { + return (window.location.href = AppPath.Index); } - if (!isDefined(jwt.data?.generateJWT)) { - throw new Error('could not create token'); - } - - if ( - jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' && - 'availableSSOIDPs' in jwt.data.generateJWT - ) { - if (jwt.data.generateJWT.availableSSOIDPs.length === 1) { - redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id); - } + const url = buildWorkspaceUrl(data.switchWorkspace.subdomain); - if (jwt.data.generateJWT.availableSSOIDPs.length > 1) { - await signOut(); - setAvailableWorkspacesForSSOState( - jwt.data.generateJWT.availableSSOIDPs, - ); - setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); - } + window.location.href = url; - return; - } + // window.location.href = `https://${data.switchWorkspace.subdomain}.twenty.work`; - if ( - jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' && - 'authTokens' in jwt.data.generateJWT - ) { - const { tokens } = jwt.data.generateJWT.authTokens; - setTokenPair(tokens); - await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. - window.location.href = AppPath.Index; - } + // const jwt = await generateJWT({ + // variables: { + // workspaceId, + // }, + // }); + // + // if (isDefined(jwt.errors)) { + // throw jwt.errors; + // } + // + // if (!isDefined(jwt.data?.generateJWT)) { + // throw new Error('could not create token'); + // } + // + // if ( + // jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' && + // 'availableSSOIDPs' in jwt.data.generateJWT + // ) { + // if (jwt.data.generateJWT.availableSSOIDPs.length === 1) { + // redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id); + // } + // + // if (jwt.data.generateJWT.availableSSOIDPs.length > 1) { + // await signOut(); + // setAvailableWorkspacesForSSOState( + // jwt.data.generateJWT.availableSSOIDPs, + // ); + // setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); + // } + // + // return; + // } + // + // if ( + // jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' && + // 'authTokens' in jwt.data.generateJWT + // ) { + // const { tokens } = jwt.data.generateJWT.authTokens; + // setTokenPair(tokens); + // await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. + // window.location.href = AppPath.Index; + // } }; return { switchWorkspace }; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 9930b6d24f3a..a83879a16e20 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -32,6 +32,7 @@ export const USER_QUERY_FRAGMENT = gql` allowImpersonation activationStatus isPublicInviteLinkEnabled + subdomain hasValidEntrepriseKey featureFlags { id diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts index 1d9a9b9fbed2..fd4efa797923 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts @@ -5,6 +5,7 @@ export const UPDATE_WORKSPACE = gql` updateWorkspace(data: $input) { id domainName + subdomain displayName logo allowImpersonation diff --git a/packages/twenty-front/src/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx index d83e857901ea..a657ef942029 100644 --- a/packages/twenty-front/src/pages/auth/Invite.tsx +++ b/packages/twenty-front/src/pages/auth/Invite.tsx @@ -77,7 +77,7 @@ export const Invite = () => { {title} - {isDefined(currentWorkspace) ? ( + {isDefined(workspaceFromInviteHash) ? ( <> { const { form } = useSignInUpForm(); @@ -21,23 +21,23 @@ export const SignInUp = () => { const { signInUpStep, signInUpMode } = useSignInUp(form); - const { loading } = useWorkspaceUnauthenticatedData(); - const authProviders = useRecoilValue(authProvidersState); + const { loading } = useWorkspacePublicData(); + const workspacePublicData = useRecoilValue(workspacePublicDataState); const title = useMemo(() => { if ( signInUpStep === SignInUpStep.Init || signInUpStep === SignInUpStep.Email ) { - return 'Welcome to Twenty'; + return `Welcome to ${workspacePublicData?.displayName ?? 'Twenty'}`; } if (signInUpStep === SignInUpStep.SSOWorkspaceSelection) { return 'Choose SSO connection'; } return signInUpMode === SignInUpMode.SignIn - ? 'Sign in to Twenty' - : 'Sign up to Twenty'; - }, [signInUpMode, signInUpStep]); + ? `Sign in to ${workspacePublicData?.displayName ?? 'Twenty'}` + : `Sign up to ${workspacePublicData?.displayName ?? 'Twenty'}`; + }, [signInUpMode, signInUpStep, workspacePublicData]); if (isDefined(currentWorkspace)) { return <>; @@ -49,7 +49,7 @@ export const SignInUp = () => { {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( ) : ( - + )} {!loading && ( diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx index 66aed5927935..9a11b0af6906 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx @@ -1,4 +1,7 @@ -import { GithubVersionLink, H2Title, Section } from 'twenty-ui'; +import { GithubVersionLink, H2Title, Section, IconWorld } from 'twenty-ui'; +import { Link } from 'react-router-dom'; + +import styled from '@emotion/styled'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { DeleteWorkspace } from '@/settings/profile/components/DeleteWorkspace'; @@ -9,6 +12,12 @@ import { WorkspaceLogoUploader } from '@/settings/workspace/components/Workspace import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import packageJson from '../../../package.json'; +import { SettingsCard } from '@/settings/components/SettingsCard'; + +const StyledLink = styled(Link)` + text-decoration: none; +`; + export const SettingsWorkspace = () => ( ( +
+ + + } /> + +
; + +const StyledDomainFromWrapper = styled.div` + align-items: center; + display: flex; +`; + +const StyledDomain = styled.h2` + color: ${({ theme }) => theme.font.color.secondary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + margin-left: 8px; +`; + +export const SettingsDomain = () => { + const navigate = useNavigate(); + + const { enqueueSnackBar } = useSnackBar(); + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + + const handleSave = async () => { + try { + const values = getValues(); + + if (!values || !isValid || !currentWorkspace) { + throw new Error('Invalid form values'); + } + + await updateWorkspace({ + variables: { + input: { + subdomain: values.subdomain, + }, + }, + }); + + setCurrentWorkspace({ + ...currentWorkspace, + subdomain: values.subdomain, + }); + + window.location.href = buildWorkspaceUrl(values.subdomain); + } catch (error) { + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + } + }; + + const { + control, + getValues, + formState: { isValid }, + } = useForm
({ + mode: 'onChange', + defaultValues: { + subdomain: currentWorkspace?.subdomain ?? '', + }, + resolver: zodResolver(validationSchema), + }); + + return ( + navigate(getSettingsPagePath(SettingsPath.Workspace))} + onSave={handleSave} + /> + } + > + +
+ + {currentWorkspace?.subdomain && ( + + ( + + )} + /> + .{twentyHostname} + + )} +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 7c80f64b8e2f..f422fca2a734 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -36,6 +36,7 @@ export const workspaceLogoUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII='; export const mockDefaultWorkspace: Workspace = { + subdomain: 'twenty.twenty.com', id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w', displayName: 'Twenty', domainName: 'twenty.com', @@ -45,6 +46,10 @@ export const mockDefaultWorkspace: Workspace = { allowImpersonation: true, activationStatus: WorkspaceActivationStatus.Active, hasValidEntrepriseKey: false, + isGoogleAuthEnabled: true, + isSSOAuthEnabled: false, + isPasswordAuthEnabled: true, + isMicrosoftAuthEnabled: false, featureFlags: [ { id: '1492de61-5018-4368-8923-4f1eeaf988c4', diff --git a/packages/twenty-front/src/utils/image/getImageAbsoluteURI.ts b/packages/twenty-front/src/utils/image/getImageAbsoluteURI.ts index 819567796773..db067afeb310 100644 --- a/packages/twenty-front/src/utils/image/getImageAbsoluteURI.ts +++ b/packages/twenty-front/src/utils/image/getImageAbsoluteURI.ts @@ -1,15 +1,20 @@ +import { isNonEmptyString } from '@sniptt/guards'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; -export const getImageAbsoluteURI = (imageUrl?: string | null) => { - if (!imageUrl) { - return null; +type GetImageAbsoluteURIReturnType = T extends undefined | null | '' + ? null + : `http${string}`; + +export const getImageAbsoluteURI = ( + imageUrl: T, +): GetImageAbsoluteURIReturnType => { + if (!imageUrl || !isNonEmptyString(imageUrl)) { + return null as GetImageAbsoluteURIReturnType; } if (imageUrl?.startsWith('https:') || imageUrl?.startsWith('http:')) { - return imageUrl; + return imageUrl as GetImageAbsoluteURIReturnType; } - const serverFilesUrl = REACT_APP_SERVER_BASE_URL; - - return `${serverFilesUrl}/files/${imageUrl}`; + return `${REACT_APP_SERVER_BASE_URL}/files/${imageUrl}` as GetImageAbsoluteURIReturnType; }; diff --git a/packages/twenty-front/src/utils/recoil-effects.ts b/packages/twenty-front/src/utils/recoil-effects.ts index 7f20e24c5062..e47a13737ab7 100644 --- a/packages/twenty-front/src/utils/recoil-effects.ts +++ b/packages/twenty-front/src/utils/recoil-effects.ts @@ -20,12 +20,20 @@ export const localStorageEffect = }; export const cookieStorageEffect = - (key: string): AtomEffect => + ( + key: string, + attributes?: Cookies.CookieAttributes, + hooks?: { + validateInitFn?: (payload: T) => boolean; + }, + ): AtomEffect => ({ setSelf, onSet }) => { const savedValue = cookieStorage.getItem(key); + if ( isDefined(savedValue) && - isDefined(JSON.parse(savedValue)['accessToken']) + (!isDefined(hooks?.validateInitFn) || + hooks.validateInitFn(JSON.parse(savedValue))) ) { setSelf(JSON.parse(savedValue)); } @@ -39,6 +47,7 @@ export const cookieStorageEffect = ? cookieStorage.removeItem(key) : cookieStorage.setItem(key, JSON.stringify(newValue), { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + ...attributes, }); }); }; diff --git a/packages/twenty-front/src/utils/workspace-url.helper.ts b/packages/twenty-front/src/utils/workspace-url.helper.ts new file mode 100644 index 000000000000..f4ba438b93bd --- /dev/null +++ b/packages/twenty-front/src/utils/workspace-url.helper.ts @@ -0,0 +1,31 @@ +export const twentyHostname = process.env.REACT_WEB_APP_BASE_URL + ? new URL(process.env.REACT_WEB_APP_BASE_URL).hostname + : 'twenty.com'; + +export const isTwentyHosting = + window.location.hostname.endsWith(twentyHostname); + +export const isTwentyHomePage = + window.location.hostname === `app.${twentyHostname}`; + +export const isTwentyWorkspaceSubdomain = isTwentyHosting && !isTwentyHomePage; + +export const getWorkspaceMaindomain = () => { + return isTwentyHosting ? twentyHostname : window.location.hostname; +}; + +export const getWorkspaceSubdomain = () => { + return isTwentyWorkspaceSubdomain + ? window.location.hostname.replace(`.${twentyHostname}`, '') + : null; +}; + +export const buildWorkspaceUrl = (withSubdomain?: string) => { + const url = new URL(window.location.href); + + if (isTwentyHosting === true && Boolean(withSubdomain)) { + url.hostname = `${withSubdomain}.${twentyHostname}`; + } + + return url.toString(); +}; diff --git a/packages/twenty-front/vite.config.ts b/packages/twenty-front/vite.config.ts index abc62f50da2f..095f1cc40934 100644 --- a/packages/twenty-front/vite.config.ts +++ b/packages/twenty-front/vite.config.ts @@ -18,10 +18,13 @@ export default defineConfig(({ command, mode }) => { VITE_BUILD_SOURCEMAP, VITE_DISABLE_TYPESCRIPT_CHECKER, VITE_DISABLE_ESLINT_CHECKER, - REACT_APP_PORT + REACT_WEB_APP_BASE_URL, + REACT_APP_PORT, } = env; - const port = isNonEmptyString(REACT_APP_PORT) ? parseInt(REACT_APP_PORT) : 3001; + const port = isNonEmptyString(REACT_APP_PORT) + ? parseInt(REACT_APP_PORT) + : 3001; const isBuildCommand = command === 'build'; @@ -60,13 +63,18 @@ export default defineConfig(({ command, mode }) => { }; } + const { hostname, protocol } = new URL( + REACT_WEB_APP_BASE_URL ?? 'http://localhost', + ); + return { root: __dirname, cacheDir: '../../node_modules/.vite/packages/twenty-front', server: { - port, - host: 'loclahost', + port: port, + host: hostname, + protocol: protocol.slice(0, -1) as 'http' | 'https', fs: { allow: [ searchForWorkspaceRoot(process.cwd()), @@ -124,6 +132,7 @@ export default defineConfig(({ command, mode }) => { define: { 'process.env': { REACT_APP_SERVER_BASE_URL, + REACT_WEB_APP_BASE_URL, }, }, css: { diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1730298416367-addAuthProvidersColumnsToWorkspace.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1730298416367-addAuthProvidersColumnsToWorkspace.ts new file mode 100644 index 000000000000..19097ad68798 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1730298416367-addAuthProvidersColumnsToWorkspace.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAuthProvidersColumnsToWorkspace1730298416367 + implements MigrationInterface +{ + name = 'AddAuthProvidersColumnsToWorkspace1730298416367'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isMicrosoftAuthEnabled" BOOLEAN DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isGoogleAuthEnabled" BOOLEAN DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isPasswordAuthEnabled" BOOLEAN DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "isSSOAuthEnabled" BOOLEAN DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isMicrosoftAuthEnabled"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isGoogleAuthEnabled"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isPasswordAuthEnabled"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "isSSOAuthEnabled"`, + ); + } +} 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 2d6fc31b65a0..9bf2c91ecf5f 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 @@ -68,12 +68,12 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; 'core', ), HttpModule, - TokenModule, UserWorkspaceModule, WorkspaceModule, OnboardingModule, WorkspaceDataSourceModule, - WorkspaceInvitationModule, + forwardRef(() => TokenModule), + forwardRef(() => WorkspaceInvitationModule), ConnectedAccountModule, WorkspaceSSOModule, FeatureFlagModule, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index d819bc84c582..b7d5b8e2f530 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -9,12 +9,6 @@ import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-p import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input'; import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; -import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input'; -import { - GenerateJWTOutput, - GenerateJWTOutputWithAuthTokens, - GenerateJWTOutputWithSSOAUTH, -} from 'src/engine/core-modules/auth/dto/generateJWT.output'; import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity'; import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input'; @@ -36,6 +30,14 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input'; +import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; import { ChallengeInput } from './dto/challenge.input'; import { ImpersonateInput } from './dto/impersonate.input'; @@ -49,6 +51,7 @@ import { VerifyInput } from './dto/verify.input'; import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { AuthService } from './services/auth.service'; +import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-subdomain-by-origin'; @Resolver() @UseFilters(AuthGraphqlApiExceptionFilter) @@ -58,6 +61,7 @@ export class AuthResolver { private renewTokenService: RenewTokenService, private userService: UserService, private apiKeyService: ApiKeyService, + private workspaceService: WorkspaceService, private resetPasswordService: ResetPasswordService, private loginTokenService: LoginTokenService, private switchWorkspaceService: SwitchWorkspaceService, @@ -97,8 +101,19 @@ export class AuthResolver { @UseGuards(CaptchaGuard) @Mutation(() => LoginToken) - async challenge(@Args() challengeInput: ChallengeInput): Promise { - const user = await this.authService.challenge(challengeInput); + async challenge( + @Args() challengeInput: ChallengeInput, + @OriginHeader() origin: string, + ): Promise { + const workspace = await this.workspaceService.getWorkspaceByOrigin(origin); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + const user = await this.authService.challenge(challengeInput, workspace); const loginToken = await this.loginTokenService.generateLoginToken( user.email, ); @@ -108,9 +123,13 @@ export class AuthResolver { @UseGuards(CaptchaGuard) @Mutation(() => LoginToken) - async signUp(@Args() signUpInput: SignUpInput): Promise { + async signUp( + @Args() signUpInput: SignUpInput, + @OriginHeader() origin: string, + ): Promise { const user = await this.authService.signInUp({ ...signUpInput, + targetWorkspaceSubdomain: getWorkspaceSubdomainByOrigin(origin), fromSSO: false, }); @@ -125,11 +144,9 @@ export class AuthResolver { async exchangeAuthorizationCode( @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput, ) { - const tokens = await this.oauthService.verifyAuthorizationCode( + return await this.oauthService.verifyAuthorizationCode( exchangeAuthCodeInput, ); - - return tokens; } @Mutation(() => TransientToken) @@ -162,9 +179,7 @@ export class AuthResolver { verifyInput.loginToken, ); - const result = await this.authService.verify(email); - - return result; + return await this.authService.verify(email); } @Mutation(() => AuthorizeApp) @@ -173,50 +188,22 @@ export class AuthResolver { @Args() authorizeAppInput: AuthorizeAppInput, @AuthUser() user: User, ): Promise { - const authorizedApp = await this.authService.generateAuthorizationCode( + return await this.authService.generateAuthorizationCode( authorizeAppInput, user, ); - - return authorizedApp; } - @Mutation(() => GenerateJWTOutput) + @Mutation(() => PublicWorkspaceDataOutput) @UseGuards(WorkspaceAuthGuard, UserAuthGuard) - async generateJWT( + async switchWorkspace( @AuthUser() user: User, - @Args() args: GenerateJwtInput, - ): Promise { - const result = await this.switchWorkspaceService.switchWorkspace( + @Args() args: SwitchWorkspaceInput, + ): Promise { + return await this.switchWorkspaceService.switchWorkspace( user, args.workspaceId, ); - - if (result.useSSOAuth) { - return { - success: true, - reason: 'WORKSPACE_USE_SSO_AUTH', - availableSSOIDPs: result.availableSSOIdentityProviders.map( - (identityProvider) => ({ - ...identityProvider, - workspace: { - id: result.workspace.id, - displayName: result.workspace.displayName, - }, - }), - ), - }; - } - - return { - success: true, - reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH', - authTokens: - await this.switchWorkspaceService.generateSwitchWorkspaceToken( - user, - result.workspace, - ), - }; } @Mutation(() => AuthTokens) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 13d6d83dc65a..c2771f784752 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -6,8 +6,10 @@ import { UseFilters, UseGuards, } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { Response } from 'express'; +import { Repository } from 'typeorm'; import { AuthException, @@ -21,6 +23,9 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; +import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Controller('auth/google-apis') @UseFilters(AuthRestApiExceptionFilter) @@ -29,7 +34,10 @@ export class GoogleAPIsAuthController { private readonly googleAPIsService: GoogleAPIsService, private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, + private readonly workspaceService: WorkspaceService, private readonly onboardingService: OnboardingService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, ) {} @Get() @@ -96,10 +104,23 @@ export class GoogleAPIsAuthController { }); } + const workspace = await this.workspaceRepository.findOneBy({ + id: workspaceId, + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + return res.redirect( - `${this.environmentService.get('FRONT_BASE_URL')}${ - redirectLocation || '/settings/accounts' - }`, + buildWorkspaceURL( + this.environmentService.get('FRONT_BASE_URL'), + { workspace }, + { withPathname: redirectLocation || '/settings/accounts' }, + ).toString(), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index f12e28237362..a1acd4bcfd26 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -43,6 +43,7 @@ export class GoogleAuthController { picture, workspaceInviteHash, workspacePersonalInviteToken, + targetWorkspaceSubdomain, } = req.user; const user = await this.authService.signInUp({ @@ -52,6 +53,7 @@ export class GoogleAuthController { picture, workspaceInviteHash, workspacePersonalInviteToken, + targetWorkspaceSubdomain, fromSSO: true, }); @@ -59,6 +61,11 @@ export class GoogleAuthController { user.email, ); - return res.redirect(this.authService.computeRedirectURI(loginToken.token)); + return res.redirect( + await this.authService.computeRedirectURI( + loginToken.token, + user.defaultWorkspace.subdomain, + ), + ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index fdfd319fff2e..fb023723c056 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -60,6 +60,11 @@ export class MicrosoftAuthController { user.email, ); - return res.redirect(this.authService.computeRedirectURI(loginToken.token)); + return res.redirect( + await this.authService.computeRedirectURI( + loginToken.token, + user.defaultWorkspace.subdomain, + ), + ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 0224defaabba..e0c79e3e2f77 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -41,7 +41,6 @@ export class SSOAuthController { private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, private readonly workspaceInvitationService: WorkspaceInvitationService, - private readonly environmentService: EnvironmentService, private readonly userWorkspaceService: UserWorkspaceService, private readonly ssoService: SSOService, @InjectRepository(WorkspaceSSOIdentityProvider, 'core') @@ -84,10 +83,12 @@ export class SSOAuthController { const loginToken = await this.generateLoginToken(req.user); return res.redirect( - this.authService.computeRedirectURI(loginToken.token), + await this.authService.computeRedirectURI( + loginToken.token, + req.user.defaultWorkspace.subdomain, + ), ); } catch (err) { - // TODO: improve error management res.status(403).send(err.message); } } @@ -99,12 +100,13 @@ export class SSOAuthController { const loginToken = await this.generateLoginToken(req.user); return res.redirect( - this.authService.computeRedirectURI(loginToken.token), + await this.authService.computeRedirectURI( + loginToken.token, + req.user.defaultWorkspace.subdomain, + ), ); } catch (err) { - // TODO: improve error management res.status(403).send(err.message); - res.redirect(`${this.environmentService.get('FRONT_BASE_URL')}/verify`); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts deleted file mode 100644 index cc27d8c6c000..000000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/generateJWT.output.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Field, ObjectType, createUnionType } from '@nestjs/graphql'; - -import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; -import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; - -@ObjectType() -export class GenerateJWTOutputWithAuthTokens { - @Field(() => Boolean) - success: boolean; - - @Field(() => String) - reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH'; - - @Field(() => AuthTokens) - authTokens: AuthTokens; -} - -@ObjectType() -export class GenerateJWTOutputWithSSOAUTH { - @Field(() => Boolean) - success: boolean; - - @Field(() => String) - reason: 'WORKSPACE_USE_SSO_AUTH'; - - @Field(() => [FindAvailableSSOIDPOutput]) - availableSSOIDPs: Array; -} - -export const GenerateJWTOutput = createUnionType({ - name: 'GenerateJWT', - types: () => [GenerateJWTOutputWithAuthTokens, GenerateJWTOutputWithSSOAUTH], - resolveType(value) { - if (value.reason === 'WORKSPACE_AVAILABLE_FOR_SWITCH') { - return GenerateJWTOutputWithAuthTokens; - } - if (value.reason === 'WORKSPACE_USE_SSO_AUTH') { - return GenerateJWTOutputWithSSOAUTH; - } - - return null; - }, -}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/generate-jwt.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/switch-workspace.input.ts similarity index 84% rename from packages/twenty-server/src/engine/core-modules/auth/dto/generate-jwt.input.ts rename to packages/twenty-server/src/engine/core-modules/auth/dto/switch-workspace.input.ts index cd8b4461ec68..4024deb7f18d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/generate-jwt.input.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/switch-workspace.input.ts @@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsString } from 'class-validator'; @ArgsType() -export class GenerateJwtInput { +export class SwitchWorkspaceInput { @Field(() => String) @IsNotEmpty() @IsString() diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts index f4675888b2e8..8f2f6b95c02f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-oauth.guard.ts @@ -38,6 +38,13 @@ export class GoogleOauthGuard extends AuthGuard('google') { workspacePersonalInviteToken; } + if ( + request.query.workspaceSubdomain && + typeof request.query.workspaceSubdomain === 'string' + ) { + request.params.workspaceSubdomain = request.query.workspaceSubdomain; + } + return (await super.canActivate(context)) as boolean; } } 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 5d7ebc9a4ebf..ec2960443216 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 @@ -1,17 +1,33 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import bcrypt from 'bcrypt'; +import { expect, jest } from '@jest/globals'; + import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { EmailService } from 'src/engine/core-modules/email/email.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 { EmailService } from 'src/engine/core-modules/email/email.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { AuthService } from './auth.service'; +jest.mock('bcrypt'); + +const UserFindOneMock = jest.fn(); +const UserWorkspaceFindOneByMock = jest.fn(); + +const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn(); +const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn(); +const workspaceInvitationValidateInvitationMock = jest.fn(); +const userWorkspaceAddUserToWorkspaceMock = jest.fn(); + describe('AuthService', () => { let service: AuthService; @@ -25,7 +41,9 @@ describe('AuthService', () => { }, { provide: getRepositoryToken(User, 'core'), - useValue: {}, + useValue: { + findOne: UserFindOneMock, + }, }, { provide: getRepositoryToken(AppToken, 'core'), @@ -51,13 +69,112 @@ describe('AuthService', () => { provide: RefreshTokenService, useValue: {}, }, + { + provide: UserWorkspaceService, + useValue: { + checkUserWorkspaceExists: + userWorkspaceServiceCheckUserWorkspaceExistsMock, + addUserToWorkspace: userWorkspaceAddUserToWorkspaceMock, + }, + }, + { + provide: UserService, + useValue: {}, + }, + { + provide: WorkspaceInvitationService, + useValue: { + getOneWorkspaceInvitation: + workspaceInvitationGetOneWorkspaceInvitationMock, + validateInvitation: workspaceInvitationValidateInvitationMock, + }, + }, ], }).compile(); service = module.get(AuthService); }); - it('should be defined', () => { + it('should be defined', async () => { expect(service).toBeDefined(); }); + + it('challenge - user already member of workspace', async () => { + const workspace = {} as Workspace; + const user = { + email: 'email', + password: 'password', + captchaToken: 'captchaToken', + }; + + (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); + + UserFindOneMock.mockReturnValueOnce({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + }); + + UserWorkspaceFindOneByMock.mockReturnValueOnce({}); + + userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({}); + + const response = await service.challenge( + { + email: 'email', + password: 'password', + captchaToken: 'captchaToken', + }, + workspace, + ); + + expect(response).toStrictEqual({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + }); + }); + + it('challenge - user who have an invitation', async () => { + const user = { + email: 'email', + password: 'password', + captchaToken: 'captchaToken', + }; + + UserFindOneMock.mockReturnValueOnce({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + }); + + (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); + userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce(false); + + workspaceInvitationGetOneWorkspaceInvitationMock.mockReturnValueOnce({}); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({}); + userWorkspaceAddUserToWorkspaceMock.mockReturnValueOnce({}); + + const response = await service.challenge( + { + email: 'email', + password: 'password', + captchaToken: 'captchaToken', + }, + {} as Workspace, + ); + + expect(response).toStrictEqual({ + email: user.email, + passwordHash: 'passwordHash', + captchaToken: user.captchaToken, + }); + + expect( + workspaceInvitationGetOneWorkspaceInvitationMock, + ).toHaveBeenCalledTimes(1); + expect(workspaceInvitationValidateInvitationMock).toHaveBeenCalledTimes(1); + expect(userWorkspaceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1); + expect(UserFindOneMock).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 8a99656954a5..f5412cd63966 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 @@ -38,12 +38,17 @@ import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; @Injectable() export class AuthService { constructor( private readonly accessTokenService: AccessTokenService, private readonly refreshTokenService: RefreshTokenService, + private readonly userWorkspaceService: UserWorkspaceService, + private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -55,9 +60,47 @@ export class AuthService { private readonly appTokenRepository: Repository, ) {} - async challenge(challengeInput: ChallengeInput) { - const user = await this.userRepository.findOneBy({ - email: challengeInput.email, + private async checkAccessAndUseInvitationOrThrow( + workspace: Workspace, + user: User, + ) { + if ( + await this.userWorkspaceService.checkUserWorkspaceExists( + user.id, + workspace.id, + ) + ) { + return; + } + + const invitation = + await this.workspaceInvitationService.getOneWorkspaceInvitation( + workspace.id, + user.email, + ); + + if (invitation) { + await this.workspaceInvitationService.validateInvitation({ + workspacePersonalInviteToken: invitation.value, + email: user.email, + }); + await this.userWorkspaceService.addUserToWorkspace(user, workspace); + + return; + } + + throw new AuthException( + "You're not member of this workspace.", + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + async challenge(challengeInput: ChallengeInput, targetWorkspace: Workspace) { + const user = await this.userRepository.findOne({ + where: { + email: challengeInput.email, + }, + relations: ['workspaces'], }); if (!user) { @@ -67,6 +110,8 @@ export class AuthService { ); } + await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user); + if (!user.passwordHash) { throw new AuthException( 'Incorrect login method', @@ -94,6 +139,7 @@ export class AuthService { password, workspaceInviteHash, workspacePersonalInviteToken, + targetWorkspaceSubdomain, firstName, lastName, picture, @@ -103,10 +149,11 @@ export class AuthService { password?: string; firstName?: string | null; lastName?: string | null; - workspaceInviteHash?: string | null; - workspacePersonalInviteToken?: string | null; + workspaceInviteHash?: string; + workspacePersonalInviteToken?: string; picture?: string | null; fromSSO: boolean; + targetWorkspaceSubdomain?: string; }) { return await this.signInUpService.signInUp({ email, @@ -115,6 +162,7 @@ export class AuthService { lastName, workspaceInviteHash, workspacePersonalInviteToken, + targetWorkspaceSubdomain, picture, fromSSO, }); @@ -399,9 +447,16 @@ export class AuthService { return workspace; } - computeRedirectURI(loginToken: string): string { - return `${this.environmentService.get( - 'FRONT_BASE_URL', - )}/verify?loginToken=${loginToken}`; + async computeRedirectURI(loginToken: string, subdomain: string) { + const url = buildWorkspaceURL( + this.environmentService.get('FRONT_BASE_URL'), + { subdomain }, + { + withPathname: '/verify', + withSearchParams: { loginToken }, + }, + ); + + return url.toString(); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 639f6cb68931..9638d9c7d6d9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -2,14 +2,32 @@ import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import bcrypt from 'bcrypt'; +import { expect, jest } from '@jest/globals'; + import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { + Workspace, + WorkspaceActivationStatus, +} from 'src/engine/core-modules/workspace/workspace.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; + +jest.mock('bcrypt'); + +const UserFindOneMock = jest.fn(); +const workspaceInvitationValidateInvitationMock = jest.fn(); +const workspaceInvitationInvalidateWorkspaceInvitationMock = jest.fn(); +const workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock = + jest.fn(); +const userWorkspaceServiceAddUserToWorkspaceMock = jest.fn(); +const UserCreateMock = jest.fn(); +const UserSaveMock = jest.fn(); describe('SignInUpService', () => { let service: SignInUpService; @@ -28,7 +46,11 @@ describe('SignInUpService', () => { }, { provide: getRepositoryToken(User, 'core'), - useValue: {}, + useValue: { + findOne: UserFindOneMock, + create: UserCreateMock, + save: UserSaveMock, + }, }, { provide: getRepositoryToken(AppToken, 'core'), @@ -36,11 +58,16 @@ describe('SignInUpService', () => { }, { provide: UserWorkspaceService, - useValue: {}, + useValue: { + addUserToWorkspace: userWorkspaceServiceAddUserToWorkspaceMock, + }, }, { provide: OnboardingService, - useValue: {}, + useValue: { + setOnboardingConnectAccountPending: jest.fn(), + setOnboardingCreateProfilePending: jest.fn(), + }, }, { provide: HttpService, @@ -50,6 +77,16 @@ describe('SignInUpService', () => { provide: EnvironmentService, useValue: {}, }, + { + provide: WorkspaceInvitationService, + useValue: { + validateInvitation: workspaceInvitationValidateInvitationMock, + invalidateWorkspaceInvitation: + workspaceInvitationInvalidateWorkspaceInvitationMock, + findInvitationByWorkspaceSubdomainAndUserEmail: + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock, + }, + }, ], }).compile(); @@ -59,4 +96,391 @@ describe('SignInUpService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('signInUp - sso - new user', async () => { + const email = 'test@test.com'; + + UserFindOneMock.mockReturnValueOnce(false); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + undefined, + ); + + const spy = jest + .spyOn(service, 'signUpOnNewWorkspace') + .mockResolvedValueOnce({} as User); + + await service.signInUp({ + email: 'test@test.com', + fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + email, + passwordHash: undefined, + firstName: expect.any(String), + lastName: expect.any(String), + picture: undefined, + }), + ); + }); + it('signInUp - sso - existing user', async () => { + const email = 'existing@test.com'; + const existingUser = { + id: 'user-id', + email, + passwordHash: undefined, + defaultWorkspace: { id: 'workspace-id' }, + }; + + UserFindOneMock.mockReturnValueOnce(existingUser); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + undefined, + ); + + const result = await service.signInUp({ + email, + fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(result).toEqual(existingUser); + }); + it('signInUp - sso - new user - existing invitation', async () => { + const email = 'newuser@test.com'; + const workspaceId = 'workspace-id'; + + UserFindOneMock.mockReturnValueOnce(null); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + { + value: 'personal-token-value', + }, + ); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { id: workspaceId }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + const spySignInUpOnExistingWorkspace = jest + .spyOn(service, 'signInUpOnExistingWorkspace') + .mockResolvedValueOnce( + {} as Awaited< + ReturnType<(typeof service)['signInUpOnExistingWorkspace']> + >, + ); + + await service.signInUp({ + email, + fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + email, + passwordHash: undefined, + workspace: expect.objectContaining({ + id: workspaceId, + }), + firstName: expect.any(String), + lastName: expect.any(String), + picture: undefined, + }), + ); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - sso - existing user - existing invitation', async () => { + const email = 'existinguser@test.com'; + const workspaceId = 'workspace-id'; + const existingUser = { + id: 'user-id', + email, + passwordHash: undefined, + defaultWorkspace: { id: 'workspace-id' }, + }; + + UserFindOneMock.mockReturnValueOnce(existingUser); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + { + value: 'personal-token-value', + }, + ); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + userWorkspaceServiceAddUserToWorkspaceMock.mockReturnValueOnce({}); + + const result = await service.signInUp({ + email, + fromSSO: true, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(result).toEqual(existingUser); + expect(userWorkspaceServiceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1); + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - sso - new user - personal invitation token', async () => { + const email = 'newuser@test.com'; + const workspaceId = 'workspace-id'; + const workspacePersonalInviteToken = 'personal-token-value'; + + UserFindOneMock.mockReturnValueOnce(null); + + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + const spySignInUpOnExistingWorkspace = jest + .spyOn(service, 'signInUpOnExistingWorkspace') + .mockResolvedValueOnce( + {} as Awaited< + ReturnType<(typeof service)['signInUpOnExistingWorkspace']> + >, + ); + + await service.signInUp({ + email, + fromSSO: true, + workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + email, + passwordHash: undefined, + workspace: expect.objectContaining({ + id: workspaceId, + }), + firstName: expect.any(String), + lastName: expect.any(String), + picture: undefined, + }), + ); + + expect( + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock, + ).not.toHaveBeenCalled(); + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - sso - existing user - personal invitation token', async () => { + const email = 'existinguser@test.com'; + const workspaceId = 'workspace-id'; + const workspacePersonalInviteToken = 'personal-token-value'; + const existingUser = { + id: 'user-id', + email, + passwordHash: undefined, + defaultWorkspace: { id: 'workspace-id' }, + }; + + UserFindOneMock.mockReturnValueOnce(existingUser); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + await service.signInUp({ + email, + fromSSO: true, + workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - credentials - existing user - invitation', async () => { + const email = 'existinguser@test.com'; + const password = 'validPassword123'; + const workspaceId = 'workspace-id'; + const existingUser = { + id: 'user-id', + email, + passwordHash: 'hash-of-validPassword123', + defaultWorkspace: { id: 'workspace-id' }, + }; + + UserFindOneMock.mockReturnValueOnce(existingUser); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + { + value: 'personal-token-value', + }, + ); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + (bcrypt.compare as jest.Mock).mockReturnValueOnce(true); + + await service.signInUp({ + email, + password, + fromSSO: false, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - credentials - new user - invitation', async () => { + const email = 'newuser@test.com'; + const password = 'validPassword123'; + const workspaceId = 'workspace-id'; + + UserFindOneMock.mockReturnValueOnce(null); + workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( + { + value: 'personal-token-value', + }, + ); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + UserCreateMock.mockReturnValueOnce({} as User); + UserSaveMock.mockReturnValueOnce({} as User); + + await service.signInUp({ + email, + password, + fromSSO: false, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(UserCreateMock).toHaveBeenCalledTimes(1); + expect(UserSaveMock).toHaveBeenCalledTimes(1); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - credentials - new user - personal invitation token', async () => { + const email = 'newuser@test.com'; + const password = 'validPassword123'; + const workspaceId = 'workspace-id'; + const workspacePersonalInviteToken = 'personal-token-value'; + + UserFindOneMock.mockReturnValueOnce(null); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + UserCreateMock.mockReturnValueOnce({} as User); + UserSaveMock.mockReturnValueOnce({} as User); + + await service.signInUp({ + email, + password, + fromSSO: false, + workspacePersonalInviteToken, + targetWorkspaceSubdomain: 'tartanpion', + }); + + expect(UserCreateMock).toHaveBeenCalledTimes(1); + expect(UserSaveMock).toHaveBeenCalledTimes(1); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); + it('signInUp - credentials - new user - public invitation token', async () => { + const email = 'newuser@test.com'; + const password = 'validPassword123'; + const workspaceId = 'workspace-id'; + const workspaceInviteHash = 'public-token-value'; + + UserFindOneMock.mockReturnValueOnce(null); + workspaceInvitationValidateInvitationMock.mockReturnValueOnce({ + isValid: true, + workspace: { + id: workspaceId, + activationStatus: WorkspaceActivationStatus.ACTIVE, + }, + }); + + workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( + true, + ); + + UserCreateMock.mockReturnValueOnce({} as User); + UserSaveMock.mockReturnValueOnce({} as User); + + await service.signInUp({ + email, + password, + fromSSO: false, + workspaceInviteHash, + }); + + expect(UserCreateMock).toHaveBeenCalledTimes(1); + expect(UserSaveMock).toHaveBeenCalledTimes(1); + + expect( + workspaceInvitationInvalidateWorkspaceInvitationMock, + ).toHaveBeenCalledWith(workspaceId, email); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index c5cb98f0c659..2f2775a5accf 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -14,9 +14,9 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { - PASSWORD_REGEX, compareHash, hashPassword, + PASSWORD_REGEX, } from 'src/engine/core-modules/auth/auth.util'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @@ -28,30 +28,30 @@ import { } from 'src/engine/core-modules/workspace/workspace.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { getImageBufferFromUrl } from 'src/utils/image'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; export type SignInUpServiceInput = { email: string; password?: string; firstName?: string | null; lastName?: string | null; - workspaceInviteHash?: string | null; - workspacePersonalInviteToken?: string | null; + workspaceInviteHash?: string; + workspacePersonalInviteToken?: string; picture?: string | null; fromSSO: boolean; + targetWorkspaceSubdomain?: string; }; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class SignInUpService { constructor( - private readonly fileUploadService: FileUploadService, - @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, - @InjectRepository(AppToken, 'core') - private readonly appTokenRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + private readonly fileUploadService: FileUploadService, + private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly userWorkspaceService: UserWorkspaceService, private readonly onboardingService: OnboardingService, private readonly httpService: HttpService, @@ -60,13 +60,14 @@ export class SignInUpService { async signInUp({ email, - workspaceInviteHash, workspacePersonalInviteToken, + workspaceInviteHash, password, firstName, lastName, picture, fromSSO, + targetWorkspaceSubdomain, }: SignInUpServiceInput) { if (!firstName) firstName = ''; if (!lastName) lastName = ''; @@ -92,9 +93,7 @@ export class SignInUpService { const passwordHash = password ? await hashPassword(password) : undefined; const existingUser = await this.userRepository.findOne({ - where: { - email: email, - }, + where: { email }, relations: ['defaultWorkspace'], }); @@ -112,18 +111,50 @@ export class SignInUpService { } } - if (workspaceInviteHash) { - return await this.signInUpOnExistingWorkspace({ + const maybeInvitation = + targetWorkspaceSubdomain && + !workspacePersonalInviteToken && + !workspaceInviteHash + ? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail( + { + subdomain: targetWorkspaceSubdomain, + email, + }, + ) + : undefined; + + const invitationValidation = + workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation + ? await this.workspaceInvitationService.validateInvitation({ + workspacePersonalInviteToken: + workspacePersonalInviteToken ?? maybeInvitation?.value, + workspaceInviteHash, + email, + }) + : null; + + if ( + invitationValidation?.isValid === true && + invitationValidation.workspace + ) { + const updatedUser = await this.signInUpOnExistingWorkspace({ email, passwordHash, - workspaceInviteHash, - workspacePersonalInviteToken, + workspace: invitationValidation.workspace, firstName, lastName, picture, existingUser, }); + + await this.workspaceInvitationService.invalidateWorkspaceInvitation( + invitationValidation.workspace.id, + email, + ); + + return updatedUser; } + if (!existingUser) { return await this.signUpOnNewWorkspace({ email, @@ -137,11 +168,10 @@ export class SignInUpService { return existingUser; } - private async signInUpOnExistingWorkspace({ + async signInUpOnExistingWorkspace({ email, passwordHash, - workspaceInviteHash, - workspacePersonalInviteToken, + workspace, firstName, lastName, picture, @@ -149,8 +179,7 @@ export class SignInUpService { }: { email: string; passwordHash: string | undefined; - workspaceInviteHash: string | null; - workspacePersonalInviteToken: string | null | undefined; + workspace: Workspace; firstName: string; lastName: string; picture: SignInUpServiceInput['picture']; @@ -159,19 +188,6 @@ export class SignInUpService { const isNewUser = !isDefined(existingUser); let user = existingUser; - const workspace = await this.findWorkspaceAndValidateInvitation({ - workspacePersonalInviteToken, - workspaceInviteHash, - email, - }); - - if (!workspace) { - throw new AuthException( - 'Workspace not found', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - if (!(workspace.activationStatus === WorkspaceActivationStatus.ACTIVE)) { throw new AuthException( 'Workspace is not ready to welcome new members', @@ -202,12 +218,10 @@ export class SignInUpService { ); } - const updatedUser = workspacePersonalInviteToken - ? await this.userWorkspaceService.addUserToWorkspaceByInviteToken( - workspacePersonalInviteToken, - user, - ) - : await this.userWorkspaceService.addUserToWorkspace(user, workspace); + const updatedUser = await this.userWorkspaceService.addUserToWorkspace( + user, + workspace, + ); if (isNewUser) { await this.activateOnboardingForNewUser(user, workspace, { @@ -219,53 +233,6 @@ export class SignInUpService { return Object.assign(user, updatedUser); } - private async findWorkspaceAndValidateInvitation({ - workspacePersonalInviteToken, - workspaceInviteHash, - email, - }) { - if (!workspacePersonalInviteToken && !workspaceInviteHash) { - throw new AuthException( - 'No invite token or hash provided', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - const workspace = await this.workspaceRepository.findOneBy({ - inviteHash: workspaceInviteHash, - }); - - if (!workspace) { - throw new AuthException( - 'Workspace not found', - AuthExceptionCode.WORKSPACE_NOT_FOUND, - ); - } - - if (!workspacePersonalInviteToken && !workspace.isPublicInviteLinkEnabled) { - throw new AuthException( - 'Workspace does not allow public invites', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - if (workspacePersonalInviteToken && workspace.isPublicInviteLinkEnabled) { - try { - await this.userWorkspaceService.validateInvitation( - workspacePersonalInviteToken, - email, - ); - } catch (err) { - throw new AuthException( - err.message, - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - } - - return workspace; - } - private async activateOnboardingForNewUser( user: User, workspace: Workspace, @@ -286,7 +253,7 @@ export class SignInUpService { } } - private async signUpOnNewWorkspace({ + async signUpOnNewWorkspace({ email, passwordHash, firstName, @@ -307,6 +274,7 @@ export class SignInUpService { } const workspaceToCreate = this.workspaceRepository.create({ + subdomain: v4().replace(/-/g, ''), displayName: '', domainName: '', inviteHash: v4(), diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts index 88dce7ce76cc..68e5ac5405b2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts @@ -7,12 +7,13 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; 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 { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; +import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; @Injectable() export class SwitchWorkspaceService { @@ -22,6 +23,7 @@ export class SwitchWorkspaceService { @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, private readonly ssoService: SSOService, + private readonly workspaceService: WorkspaceService, private readonly accessTokenService: AccessTokenService, private readonly refreshTokenService: RefreshTokenService, ) {} @@ -59,31 +61,38 @@ export class SwitchWorkspaceService { ); } - if (workspace.workspaceSSOIdentityProviders.length > 0) { - return { - useSSOAuth: true, - workspace, - availableSSOIdentityProviders: - await this.ssoService.listSSOIdentityProvidersByWorkspaceId( - workspaceId, - ), - } as { - useSSOAuth: true; - workspace: Workspace; - availableSSOIdentityProviders: Awaited< - ReturnType< - typeof this.ssoService.listSSOIdentityProvidersByWorkspaceId - > - >; - }; - } + await this.userRepository.save({ + id: user.id, + defaultWorkspace: workspace, + }); + + // if (workspace.workspaceSSOIdentityProviders.length > 0) { + // return { + // useSSOAuth: true, + // workspace, + // availableSSOIdentityProviders: + // await this.sSSOService.listSSOIdentityProvidersByWorkspaceId( + // workspaceId, + // ), + // } as { + // useSSOAuth: true; + // workspace: Workspace; + // availableSSOIdentityProviders: Awaited< + // ReturnType< + // typeof this.sSSOService.listSSOIdentityProvidersByWorkspaceId + // > + // >; + // }; + // } return { - useSSOAuth: false, - workspace, - } as { - useSSOAuth: false; - workspace: Workspace; + id: workspace.id, + subdomain: workspace.subdomain, + logo: workspace.logo, + displayName: workspace.displayName, + authProviders: await this.workspaceService.getAuthProvidersByWorkspaceId( + workspace.id, + ), }; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts index 932e4c4e3e37..5e20e1a600f7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/google.auth.strategy.ts @@ -17,6 +17,7 @@ export type GoogleRequest = Omit< picture: string | null; workspaceInviteHash?: string; workspacePersonalInviteToken?: string; + targetWorkspaceSubdomain?: string; }; }; @@ -37,6 +38,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { ...options, state: JSON.stringify({ workspaceInviteHash: req.params.workspaceInviteHash, + workspaceSubdomain: req.params.workspaceSubdomain, ...(req.params.workspacePersonalInviteToken ? { workspacePersonalInviteToken: @@ -69,6 +71,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, workspacePersonalInviteToken: state.workspacePersonalInviteToken, + targetWorkspaceSubdomain: state.workspaceSubdomain, }; done(null, user); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index c39ac8aa5739..43f926c69152 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -15,6 +15,7 @@ import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s TypeOrmModule.forFeature([User, AppToken, Workspace], 'core'), TypeORMModule, DataSourceModule, + WorkspaceModule, EmailModule, WorkspaceSSOModule, ], diff --git a/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts b/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts index b860353314c1..3247f46090e3 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/workspace-sso-identity-provider.entity.ts @@ -31,7 +31,7 @@ export enum OIDCResponseType { } registerEnumType(IdentityProviderType, { - name: 'IdpType', + name: 'IdentityProviderType', }); export enum SSOIdentityProviderStatus { diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts index 338b6e9499f6..4437fa7eb1ad 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts @@ -45,6 +45,7 @@ export class UserWorkspaceResolver { return await this.userWorkspaceService.addUserToWorkspace(user, workspace); } + // This mutation should be used only on a instance without subdomains @Mutation(() => User) async addUserToWorkspaceByInviteToken( @AuthUser() user: User, diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 0590033fb8a3..2bd3d4ab359a 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -107,39 +107,25 @@ export class UserWorkspaceService extends TypeOrmQueryService { await this.createWorkspaceMember(workspace.id, user); } - return await this.userRepository.save({ + const savedUser = await this.userRepository.save({ id: user.id, defaultWorkspace: workspace, updatedAt: new Date().toISOString(), }); - } - - async validateInvitation(inviteToken: string, email: string) { - const appToken = await this.appTokenRepository.findOne({ - where: { - value: inviteToken, - type: AppTokenType.InvitationToken, - }, - relations: ['workspace'], - }); - - if (!appToken) { - throw new Error('Invalid invitation token'); - } - if (!appToken.context?.email && appToken.context?.email !== email) { - throw new Error('Email does not match the invitation'); - } - - if (new Date(appToken.expiresAt) < new Date()) { - throw new Error('Invitation expired'); - } + await this.workspaceInvitationService.invalidateWorkspaceInvitation( + workspace.id, + user.email, + ); - return appToken; + return savedUser; } async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) { - const appToken = await this.validateInvitation(inviteToken, user.email); + const appToken = await this.workspaceInvitationService.validateInvitation({ + workspacePersonalInviteToken: inviteToken, + email: user.email, + }); await this.workspaceInvitationService.invalidateWorkspaceInvitation( appToken.workspace.id, diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index 0e1025e8e1fc..4995b00f5d5c 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -28,6 +28,7 @@ import { WorkspaceInvitationExceptionCode, } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -35,6 +36,8 @@ export class WorkspaceInvitationService { constructor( @InjectRepository(AppToken, 'core') private readonly appTokenRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, private readonly environmentService: EnvironmentService, private readonly emailService: EmailService, @InjectRepository(UserWorkspace, 'core') @@ -42,19 +45,7 @@ export class WorkspaceInvitationService { private readonly onboardingService: OnboardingService, ) {} - async getOneWorkspaceInvitation(workspaceId: string, email: string) { - return await this.appTokenRepository - .createQueryBuilder('appToken') - .where('"appToken"."workspaceId" = :workspaceId', { - workspaceId, - }) - .andWhere('"appToken".type = :type', { - type: AppTokenType.InvitationToken, - }) - .andWhere('"appToken".context->>\'email\' = :email', { email }) - .getOne(); - } - + // UTILS METHODS castAppTokenToWorkspaceInvitation(appToken: AppToken) { if (appToken.type !== AppTokenType.InvitationToken) { throw new WorkspaceInvitationException( @@ -77,6 +68,183 @@ export class WorkspaceInvitationService { }; } + private getInvitationByType({ + workspacePersonalInviteToken, + workspaceInviteHash, + }) { + if (workspacePersonalInviteToken) { + return { + type: 'PERSONAL_INVITATION' as const, + workspacePersonalInviteToken, + workspaceInviteHash, + }; + } + + if (workspaceInviteHash) { + return { type: 'PUBLIC_INVITATION' as const, workspaceInviteHash }; + } + + return { type: 'INVALID_INVITATION' }; + } + + // VALIDATIONS METHODS + private async validatePublicInvitation( + invitationByType: ReturnType, + ) { + const workspace = await this.workspaceRepository.findOne({ + where: { + inviteHash: invitationByType.workspaceInviteHash, + }, + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + + if (!workspace.isPublicInviteLinkEnabled) { + throw new AuthException( + 'Workspace does not allow public invites', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return { isValid: true, workspace }; + } + + private async validatePersonalInvitation( + invitationByType: ReturnType, + email: string, + ) { + try { + const appToken = await this.appTokenRepository.findOne({ + where: { + value: invitationByType.workspacePersonalInviteToken, + type: AppTokenType.InvitationToken, + }, + relations: ['workspace'], + }); + + if (!appToken) { + throw new Error('Invalid invitation token'); + } + + if (!appToken.context?.email && appToken.context?.email !== email) { + throw new Error('Email does not match the invitation'); + } + + if (new Date(appToken.expiresAt) < new Date()) { + throw new Error('Invitation expired'); + } + + if (!appToken) { + throw new Error('Invalid invitation token'); + } + + return { isValid: true, workspace: appToken.workspace }; + } catch (err) { + throw new AuthException( + err.message, + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + } + + async validateInvitation({ + workspacePersonalInviteToken, + workspaceInviteHash, + email, + }: { + workspacePersonalInviteToken?: string; + workspaceInviteHash?: string; + email: string; + }) { + const invitationByType = this.getInvitationByType({ + workspacePersonalInviteToken, + workspaceInviteHash, + }); + + if (invitationByType.type === 'PUBLIC_INVITATION') { + return await this.validatePublicInvitation(invitationByType); + } + + if (invitationByType.type === 'PERSONAL_INVITATION') { + return await this.validatePersonalInvitation(invitationByType, email); + } + + throw new AuthException( + 'Invitation invalid', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + // QUERY METHODS + async findInvitationByWorkspaceSubdomainAndUserEmail({ + subdomain, + email, + }: { + subdomain: string; + email: string; + }) { + const workspace = await this.workspaceRepository.findOneBy({ + subdomain, + }); + + if (!workspace) return; + + return await this.getOneWorkspaceInvitation(workspace.id, email); + } + + async getOneWorkspaceInvitation(workspaceId: string, email: string) { + return await this.appTokenRepository + .createQueryBuilder('appToken') + .where('"appToken"."workspaceId" = :workspaceId', { + workspaceId, + }) + .andWhere('"appToken".type = :type', { + type: AppTokenType.InvitationToken, + }) + .andWhere('"appToken".context->>\'email\' = :email', { email }) + .getOne(); + } + + async getAppTokenByInvitationToken(invitationToken: string) { + const appToken = await this.appTokenRepository.findOne({ + where: { + value: invitationToken, + type: AppTokenType.InvitationToken, + }, + relations: ['workspace'], + }); + + if (!appToken) { + throw new WorkspaceInvitationException( + 'Invalid invitation token', + WorkspaceInvitationExceptionCode.INVALID_INVITATION, + ); + } + + return appToken; + } + + async loadWorkspaceInvitations(workspace: Workspace) { + const appTokens = await this.appTokenRepository.find({ + where: { + workspaceId: workspace.id, + type: AppTokenType.InvitationToken, + deletedAt: IsNull(), + }, + select: { + value: false, + }, + }); + + return appTokens.map(this.castAppTokenToWorkspaceInvitation); + } + + // MUTATIONS METHODS async createWorkspaceInvitation(email: string, workspace: Workspace) { const maybeWorkspaceInvitation = await this.getOneWorkspaceInvitation( workspace.id, @@ -112,21 +280,6 @@ export class WorkspaceInvitationService { return this.generateInvitationToken(workspace.id, email); } - async loadWorkspaceInvitations(workspace: Workspace) { - const appTokens = await this.appTokenRepository.find({ - where: { - workspaceId: workspace.id, - type: AppTokenType.InvitationToken, - deletedAt: IsNull(), - }, - select: { - value: false, - }, - }); - - return appTokens.map(this.castAppTokenToWorkspaceInvitation); - } - async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) { const appToken = await this.appTokenRepository.findOne({ where: { @@ -221,15 +374,19 @@ export class WorkspaceInvitationService { }), ); - const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); + const link = buildWorkspaceURL( + this.environmentService.get('FRONT_BASE_URL'), + { workspace }, + ); for (const invitation of invitationsPr) { if (invitation.status === 'fulfilled') { - const link = new URL(`${frontBaseURL}/invite/${workspace?.inviteHash}`); + link.pathname = `/invite/${workspace?.inviteHash}`; if (invitation.value.isPersonalInvitation) { link.searchParams.set('inviteToken', invitation.value.appToken.value); } + const emailData = { link: link.toString(), workspace: { name: workspace.displayName, logo: workspace.logo }, diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts index f09bb770a39d..9efac12fcbe4 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts @@ -3,16 +3,18 @@ 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 { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.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: [ - NestjsQueryTypeOrmModule.forFeature([AppToken, UserWorkspace], 'core'), - TokenModule, + NestjsQueryTypeOrmModule.forFeature( + [AppToken, UserWorkspace, Workspace], + 'core', + ), OnboardingModule, ], exports: [WorkspaceInvitationService], diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/available-auth-mehtods.output.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/available-auth-mehtods.output.ts deleted file mode 100644 index a83dbb29fb3e..000000000000 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/available-auth-mehtods.output.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ObjectType, Field } from '@nestjs/graphql'; - -@ObjectType() -class AvailableAuthProviders { - @Field(() => Boolean) - sso: boolean; - - @Field(() => Boolean) - google: boolean; - - @Field(() => Boolean) - magicLink: boolean; - - @Field(() => Boolean) - password: boolean; - - @Field(() => Boolean) - microsoft: boolean; -} - -@ObjectType() -export class AvailableAuthMethodsOutput { - @Field(() => String) - id: string; - - @Field(() => AvailableAuthProviders) - authProviders: AvailableAuthProviders; -} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts new file mode 100644 index 000000000000..de60b94fd24b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts @@ -0,0 +1,39 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@ObjectType() +export class AuthProviders { + @Field(() => Boolean) + sso: boolean; + + @Field(() => Boolean) + google: boolean; + + @Field(() => Boolean) + magicLink: boolean; + + @Field(() => Boolean) + password: boolean; + + @Field(() => Boolean) + microsoft: boolean; +} + +@ObjectType() +export class PublicWorkspaceDataOutput { + @Field(() => String) + id: string; + + @Field(() => AuthProviders) + authProviders: AuthProviders; + + @Field(() => String, { nullable: true }) + logo: Workspace['logo']; + + @Field(() => String, { nullable: true }) + displayName: Workspace['displayName']; + + @Field(() => String) + subdomain: Workspace['subdomain']; +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index f56a8a54c6ff..352857bdd736 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -9,6 +9,11 @@ export class UpdateWorkspaceInput { @IsOptional() domainName?: string; + @Field({ nullable: true }) + @IsString() + @IsOptional() + subdomain?: string; + @Field({ nullable: true }) @IsString() @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 31d2a16cdf1e..a77f220f9377 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -23,10 +23,11 @@ import { WorkspaceException, WorkspaceExceptionCode, } from 'src/engine/core-modules/workspace/workspace.exception'; +import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-subdomain-by-origin'; // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { - private userWorkspaceService: UserWorkspaceService; + private readonly userWorkspaceService: UserWorkspaceService; constructor( @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -45,6 +46,21 @@ export class WorkspaceService extends TypeOrmQueryService { }); } + private async generateSubdomain(displayName: string) { + const displayNameWords = displayName.match(/(\w| |\d)+/); + let subdomain = ''; + + if (displayNameWords) { + subdomain = displayNameWords.join('-').replace(/ /g, '').toLowerCase(); + } + + const existingWorkspaceCount = await this.workspaceRepository.countBy({ + subdomain, + }); + + return `${subdomain}${existingWorkspaceCount > 0 ? Math.random().toString(36).substring(2, 10) : ''}`; + } + async activateWorkspace(user: User, data: ActivateWorkspaceInput) { if (!data.displayName || !data.displayName.length) { throw new BadRequestException("'displayName' not provided"); @@ -86,7 +102,11 @@ export class WorkspaceService extends TypeOrmQueryService { user.defaultWorkspaceId, user, ); + + const subdomain = await this.generateSubdomain(data.displayName); + await this.workspaceRepository.update(user.defaultWorkspaceId, { + subdomain, displayName: data.displayName, activationStatus: WorkspaceActivationStatus.ACTIVE, }); @@ -165,15 +185,35 @@ export class WorkspaceService extends TypeOrmQueryService { } } + async getAuthProvidersByWorkspaceId(workspaceId: string) { + const workspace = await this.workspaceRepository.findOneBy({ + id: workspaceId, + }); + + if (!workspace) { + throw new WorkspaceException( + 'Workspace not found', + WorkspaceExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + + return { + google: workspace.isGoogleAuthEnabled, + magicLink: false, + password: workspace.isPasswordAuthEnabled, + microsoft: workspace.isMicrosoftAuthEnabled, + sso: workspace.isSSOAuthEnabled, + }; + } + async getWorkspaceByOrigin(origin: string) { try { - const { host } = new URL(origin); - const subdomain = host.split('.')[0]; + const subdomain = getWorkspaceSubdomainByOrigin(origin); return this.workspaceRepository.findOneBy({ subdomain }); } catch (e) { throw new WorkspaceException( - 'Subdomain not found', + 'Workspace not found', WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND, ); } 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 8d123889067f..3d8d0f867183 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 @@ -144,4 +144,20 @@ export class Workspace { @Field() @Column() subdomain: string; + + @Field() + @Column({ default: true }) + isGoogleAuthEnabled: boolean; + + @Field() + @Column({ default: true }) + isPasswordAuthEnabled: boolean; + + @Field() + @Column({ default: false }) + isMicrosoftAuthEnabled: boolean; + + @Field() + @Column({ default: false }) + isSSOAuthEnabled: boolean; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index bd03d0e12c88..0bc987d458a6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -13,7 +13,6 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener'; import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; @@ -24,6 +23,7 @@ import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { Workspace } from './workspace.entity'; import { WorkspaceService } from './services/workspace.service'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; @Module({ imports: [ diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 19a63a7f4894..7cf762165e68 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -33,9 +33,9 @@ import { WorkspaceException, WorkspaceExceptionCode, } from 'src/engine/core-modules/workspace/workspace.exception'; -import { AvailableAuthMethodsOutput } from 'src/engine/core-modules/workspace/dtos/available-auth-mehtods.output'; -import { OriginHeader } from 'src/engine/decorators/auth/host-header.decorator'; +import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; +import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; import { Workspace } from './workspace.entity'; import { WorkspaceService } from './services/workspace.service'; @@ -153,11 +153,14 @@ export class WorkspaceResolver { return isDefined(this.environmentService.get('ENTERPRISE_KEY')); } - @Query(() => AvailableAuthMethodsOutput) - async getAvailableAuthMethodsByWorkspaceSubdomain( + @Query(() => PublicWorkspaceDataOutput) + async getPublicWorkspaceDataBySubdomain( @OriginHeader() origin: string, + @Args('workspaceId', { nullable: true }) workspaceId?: string, ) { - const workspace = await this.workspaceService.getWorkspaceByOrigin(origin); + const workspace = workspaceId + ? await this.workspaceService.findById(workspaceId) + : await this.workspaceService.getWorkspaceByOrigin(origin); if (!workspace) { return new WorkspaceException( @@ -168,13 +171,12 @@ export class WorkspaceResolver { return { id: workspace.id, - authProviders: { - google: this.environmentService.get('AUTH_GOOGLE_ENABLED'), - magicLink: false, - password: this.environmentService.get('AUTH_PASSWORD_ENABLED'), - microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'), - sso: this.environmentService.get('AUTH_SSO_ENABLED'), - }, + logo: workspace.logo, + displayName: workspace.displayName, + subdomain: workspace.subdomain, + authProviders: await this.workspaceService.getAuthProvidersByWorkspaceId( + workspace.id, + ), }; } } diff --git a/packages/twenty-server/src/engine/decorators/auth/host-header.decorator.ts b/packages/twenty-server/src/engine/decorators/auth/origin-header.decorator.ts similarity index 100% rename from packages/twenty-server/src/engine/decorators/auth/host-header.decorator.ts rename to packages/twenty-server/src/engine/decorators/auth/origin-header.decorator.ts diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index d683cc7fae07..471d3ca8d81c 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -57,7 +57,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware 'ExchangeAuthorizationCode', 'GetAuthorizationUrl', 'FindAvailableSSOIdentityProviders', - 'GetAvailableAuthMethodsByWorkspaceSubdomain', + 'GetPublicWorkspaceDataBySubdomain', ]; if ( diff --git a/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts b/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts new file mode 100644 index 000000000000..dafc40c96fd5 --- /dev/null +++ b/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts @@ -0,0 +1,19 @@ +import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-subdomain-by-origin'; + +describe('getWorkspaceSubdomainByOrigin', () => { + it('should return subdomain from origin', () => { + const origin = 'https://subdomain.example.com'; + const result = getWorkspaceSubdomainByOrigin(origin); + expect(result).toBe('subdomain'); + }); + it('should return undefined if origin is not a subdomain', () => { + const origin = 'https://example.com'; + const result = getWorkspaceSubdomainByOrigin(origin); + expect(result).toBe(undefined); + }); + it('should return undefined if subdomain is app', () => { + const origin = 'https://app.example.com'; + const result = getWorkspaceSubdomainByOrigin(origin); + expect(result).toBe(undefined); + }); +}); diff --git a/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts b/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts new file mode 100644 index 000000000000..32c8d5b3ffea --- /dev/null +++ b/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts @@ -0,0 +1,13 @@ +export const getWorkspaceSubdomainByOrigin = (origin: string) => { + const { hostname } = new URL(origin); + + const hostParts = hostname.split('.'); + + if (hostParts.length <= 2) return; + + const subdomain = hostParts[0]; + + if (subdomain === 'app') return; + + return subdomain; +}; diff --git a/packages/twenty-server/src/utils/__tests__/workspace-url.utils.spec.ts b/packages/twenty-server/src/utils/__tests__/workspace-url.utils.spec.ts new file mode 100644 index 000000000000..33f69492eed8 --- /dev/null +++ b/packages/twenty-server/src/utils/__tests__/workspace-url.utils.spec.ts @@ -0,0 +1,37 @@ +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { buildWorkspaceURL } from '../workspace-url.utils'; + +describe('buildWorkspaceURL', () => { + it('should build workspace URL with subdomain', () => { + const baseUrl = 'https://example.com'; + const subdomain = 'subdomain'; + const url = buildWorkspaceURL(baseUrl, { subdomain }); + + expect(url.toString()).toBe(`https://${subdomain}.example.com/`); + }); + it('should build workspace URL with workspace', () => { + const baseUrl = 'https://example.com'; + const workspace = { subdomain: 'subdomain' } as unknown as Workspace; + const url = buildWorkspaceURL(baseUrl, { workspace }); + + expect(url.toString()).toBe(`https://${workspace.subdomain}.example.com/`); + }); + it('should build workspace URL with pathname', () => { + const baseUrl = 'https://example.com'; + const subdomain = 'subdomain'; + const withPathname = '/settings/accounts'; + const url = buildWorkspaceURL(baseUrl, { subdomain }, { withPathname }); + + expect(url.toString()).toBe( + `https://${subdomain}.example.com${withPathname}`, + ); + }); + it('should build workspace URL with search params', () => { + const baseUrl = 'https://example.com'; + const subdomain = 'subdomain'; + const withSearchParams = { key: 'value' }; + const url = buildWorkspaceURL(baseUrl, { subdomain }, { withSearchParams }); + + expect(url.toString()).toBe(`https://${subdomain}.example.com/?key=value`); + }); +}); diff --git a/packages/twenty-server/src/utils/workspace-url.utils.ts b/packages/twenty-server/src/utils/workspace-url.utils.ts new file mode 100644 index 000000000000..2af4717eb871 --- /dev/null +++ b/packages/twenty-server/src/utils/workspace-url.utils.ts @@ -0,0 +1,44 @@ +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +export const buildWorkspaceURL = ( + baseUrl: string, + params: + | { + workspace: Workspace; + } + | { subdomain: string }, + { + withPathname, + withSearchParams, + }: { + withPathname?: string; + withSearchParams?: Record; + } = {}, +) => { + const subdomain = + 'subdomain' in params + ? params.subdomain + : 'workspace' in params + ? params.workspace.subdomain + : null; + + if (!subdomain) { + throw new Error('Subdomain not found'); + } + + const url = new URL(baseUrl); + + url.hostname = `${subdomain}.${url.hostname}`; + + if (withPathname) { + url.pathname = withPathname; + } + + if (withSearchParams) { + Object.entries(withSearchParams).forEach(([key, value]) => { + url.searchParams.set(key, value.toString()); + }); + } + + return url; +}; diff --git a/packages/twenty-server/test/utils/createTestingService.utils.ts b/packages/twenty-server/test/utils/createTestingService.utils.ts new file mode 100644 index 000000000000..ba43d6f6aebd --- /dev/null +++ b/packages/twenty-server/test/utils/createTestingService.utils.ts @@ -0,0 +1,63 @@ +import { Provider, Type } from '@nestjs/common'; +import { TestingModule, Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; + +function getDependencies(service: Type) { + const dependencies = Reflect.getMetadata('design:paramtypes', service); + // @ts-ignore + console.log('>>>>>>>>>>>>>>', service.context); + if (!dependencies) { + return []; + } + + return dependencies.reduce( + (acc, dep) => { + if (dep.name === 'Repository') { + const name = getRepositoryToken(dep); + console.log( + '>>>>>>>>>>>>>>', + Object.getOwnPropertyNames(dep.prototype), + ); + console.log('>>>>>>>>>>>>>>', dep.metadata); + acc.repositories['frite'] = dep; + } else { + acc.services[dep.name] = dep; + } + return acc; + }, + { + repositories: {}, + services: {}, + }, + ); +} + +export const createTestingService = async >( + service: T, + override?: { + repositories?: Record; + services?: Record; + }, +): Promise => { + const { services, repositories } = getDependencies(service); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + service, + // ...(Object.entries(repositories).map(([name, repository]) => ({ + // provide: repository, + // useValue: override?.repositories?.[name] ?? {}, + // })) as Provider[]), + ...(Object.entries(services).map(([name, service]) => ({ + provide: service, + useValue: override?.services?.[name] ?? {}, + })) as Provider[]), + ], + }).compile(); + + // @ts-ignore + return module.get(service); +}; + +createTestingService(AuthService); From f4bff011f5c0580254b40038bc49fdb087b7e767 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 7 Nov 2024 15:33:59 +0100 Subject: [PATCH 03/24] feat(settings): allow to choose auth methods # Conflicts: # packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx --- .../modules/app/components/SettingsRoutes.tsx | 12 +- .../auth/states/currentWorkspaceState.ts | 3 + ...ColumnDefinitionsFromFieldMetadata.test.ts | 3 + .../SettingsNavigationDrawerItems.tsx | 13 +- .../components/SettingsOptionCardContent.tsx | 9 ++ .../SettingsSecurityOptionsList.tsx | 148 ++++++++++++++++-- .../graphql/fragments/userQueryFragment.ts | 3 + .../settings/security/SettingsSecurity.tsx | 37 +++-- .../controllers/google-auth.controller.ts | 11 ++ .../controllers/microsoft-auth.controller.ts | 11 ++ .../auth/services/auth.service.ts | 7 + .../workspace/dtos/update-workspace-input.ts | 15 ++ .../display/icon/components/TablerIcons.ts | 1 + .../twenty-ui/src/input/components/Toggle.tsx | 3 + 14 files changed, 232 insertions(+), 44 deletions(-) diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 4dddfbd25e9a..c3295eaa8d5e 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -373,14 +373,12 @@ export const SettingsRoutes = ({ element={} /> } /> + } /> {isSSOEnabled && ( - <> - } /> - } - /> - + } + /> )} diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index df7d87519971..c06276c126ed 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -14,6 +14,9 @@ export type CurrentWorkspace = Pick< | 'currentBillingSubscription' | 'workspaceMembersCount' | 'isPublicInviteLinkEnabled' + | 'isGoogleAuthEnabled' + | 'isMicrosoftAuthEnabled' + | 'isPasswordAuthEnabled' | 'hasValidEntrepriseKey' | 'subdomain' | 'metadataVersion' diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 9ce782b539b8..88abe1563ff0 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -20,6 +20,9 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({ hasValidEntrepriseKey: false, metadataVersion: 1, isPublicInviteLinkEnabled: false, + isGoogleAuthEnabled: true, + isMicrosoftAuthEnabled: false, + isPasswordAuthEnabled: true, }); }, }); diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index f762df6e8cab..14a23960b134 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -80,7 +80,6 @@ export const SettingsNavigationDrawerItems = () => { ); const isFreeAccessEnabled = useIsFeatureEnabled('IS_FREE_ACCESS_ENABLED'); const isCRMMigrationEnabled = useIsFeatureEnabled('IS_CRM_MIGRATION_ENABLED'); - const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED'); const isBillingPageEnabled = billing?.isBillingEnabled && !isFreeAccessEnabled; @@ -188,13 +187,11 @@ export const SettingsNavigationDrawerItems = () => { Icon={IconCode} /> )} - {isSSOEnabled && ( - - )} + {isAdvancedModeEnabled && ( diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptionCardContent.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptionCardContent.tsx index 3628119fb20b..d925db44a02c 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptionCardContent.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptionCardContent.tsx @@ -23,6 +23,15 @@ const StyledCardContent = styled(CardContent)` &:hover { background: ${({ theme }) => theme.background.transparent.lighter}; } + + &:not(:last-child)::before { + content: ''; + position: absolute; + bottom: 0; + left: ${({ theme }) => theme.spacing(4)}; + right: ${({ theme }) => theme.spacing(4)}; + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + } `; const StyledTitle = styled.div` diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx index ac5eef959f3e..0cc8315c3c2f 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx @@ -2,12 +2,41 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionCardContent'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useRecoilState } from 'recoil'; -import { Card, IconLink, isDefined } from 'twenty-ui'; +import styled from '@emotion/styled'; +import { useTheme } from '@emotion/react'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { + IconLink, + Toggle, + Card, + isDefined, + IconGoogle, + IconMicrosoft, + IconPassword, +} from 'twenty-ui'; import { useUpdateWorkspaceMutation } from '~/generated/graphql'; +import { AuthProviders } from '~/generated-metadata/graphql'; +import { capitalize } from '~/utils/string/capitalize'; +import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState'; +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; + +const StyledToggle = styled(Toggle)` + margin-left: auto; +`; + +const StyledSettingsSecurityOptionsList = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; export const SettingsSecurityOptionsList = () => { const { enqueueSnackBar } = useSnackBar(); + const theme = useTheme(); + + const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState); + const toggleColor = isAdvancedModeEnabled ? theme.color.yellow : undefined; + const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, @@ -20,6 +49,62 @@ export const SettingsSecurityOptionsList = () => { const [updateWorkspace] = useUpdateWorkspaceMutation(); + const isValidAuthProvider = ( + key: string, + ): key is Exclude => { + if (!currentWorkspace) return false; + return Reflect.has(currentWorkspace, key); + }; + + const toggleAuthMethod = async ( + authProvider: keyof Omit, + ) => { + if (!currentWorkspace?.id) { + throw new Error('User is not logged in'); + } + + const key = `is${capitalize(authProvider)}AuthEnabled`; + + if (!isValidAuthProvider(key)) { + throw new Error('Invalid auth provider'); + } + + if ( + SSOIdentitiesProviders.length === 0 && + currentWorkspace[key] && + Object.entries(currentWorkspace).filter( + ([key, value]) => + key.startsWith('is') && key.endsWith('AuthEnabled') && value, + ).length <= 1 + ) { + return enqueueSnackBar( + 'At least one authentication method must be enabled', + { + variant: SnackBarVariant.Error, + }, + ); + } + + setCurrentWorkspace({ + ...currentWorkspace, + [key]: !currentWorkspace[key], + }); + + updateWorkspace({ + variables: { + input: { + [key]: !currentWorkspace[key], + }, + }, + }).catch(() => { + // rollback optimistic update if err + setCurrentWorkspace({ + ...currentWorkspace, + [key]: !currentWorkspace[key], + }); + }); + }; + const handleChange = async (value: boolean) => { try { if (!currentWorkspace?.id) { @@ -44,16 +129,53 @@ export const SettingsSecurityOptionsList = () => { }; return ( - - - handleChange(!currentWorkspace.isPublicInviteLinkEnabled) - } - /> - + + + toggleAuthMethod('google')} + > + + + toggleAuthMethod('microsoft')} + > + + + toggleAuthMethod('password')} + > + + + + + + handleChange(!currentWorkspace.isPublicInviteLinkEnabled) + } + /> + + ); }; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index a83879a16e20..9dad7ee9e3c7 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -32,6 +32,9 @@ export const USER_QUERY_FRAGMENT = gql` allowImpersonation activationStatus isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled subdomain hasValidEntrepriseKey featureFlags { diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 3e61db0915ac..3336c7104eed 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -7,8 +7,11 @@ import { SettingsSecurityOptionsList } from '@/settings/security/components/Sett import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; export const SettingsSecurity = () => { + const isSSOEnabled = useIsFeatureEnabled('IS_SSO_ENABLED'); + return ( { ]} > + {isSSOEnabled && ( +
+ + } + /> + +
+ )}
- } - /> - -
-
- diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index a1acd4bcfd26..896e89a3d1ca 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -16,6 +16,10 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) @@ -57,6 +61,13 @@ export class GoogleAuthController { fromSSO: true, }); + if (!user.defaultWorkspace.isGoogleAuthEnabled) { + throw new AuthException( + 'Google auth is not enabled for this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + const loginToken = await this.loginTokenService.generateLoginToken( user.email, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index fb023723c056..26fea2832c60 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -15,6 +15,10 @@ import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guar import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) @@ -56,6 +60,13 @@ export class MicrosoftAuthController { fromSSO: true, }); + if (!user.defaultWorkspace.isMicrosoftAuthEnabled) { + throw new AuthException( + 'Microsoft auth is not enabled for this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + const loginToken = await this.loginTokenService.generateLoginToken( user.email, ); 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 f5412cd63966..1ba8a5b67d57 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 @@ -96,6 +96,13 @@ export class AuthService { } async challenge(challengeInput: ChallengeInput, targetWorkspace: Workspace) { + if (!targetWorkspace.isPasswordAuthEnabled) { + throw new AuthException( + 'Email/Password auth is not enabled for this workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + const user = await this.userRepository.findOne({ where: { email: challengeInput.email, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index 352857bdd736..7c2ef7556f85 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -38,4 +38,19 @@ export class UpdateWorkspaceInput { @IsBoolean() @IsOptional() allowImpersonation?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isGoogleAuthEnabled?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isMicrosoftAuthEnabled?: boolean; + + @Field({ nullable: true }) + @IsBoolean() + @IsOptional() + isPasswordAuthEnabled?: boolean; } diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 49e07cc7ad24..f9786c781379 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -236,6 +236,7 @@ export { IconWand, IconWorld, IconX, + IconPassword, } from '@tabler/icons-react'; export type { TablerIconsProps } from '@tabler/icons-react'; diff --git a/packages/twenty-ui/src/input/components/Toggle.tsx b/packages/twenty-ui/src/input/components/Toggle.tsx index c9f95385d2c5..aa3c5dd3a58b 100644 --- a/packages/twenty-ui/src/input/components/Toggle.tsx +++ b/packages/twenty-ui/src/input/components/Toggle.tsx @@ -74,6 +74,9 @@ export const Toggle = ({ type="checkbox" checked={value} disabled={disabled} + onClick={(event) => { + event.stopPropagation(); + }} onChange={(event) => { onChange?.(event.target.checked); }} From b3d6dd05f1f2deb334fbdc7042e550147759b0e6 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 7 Nov 2024 18:05:04 +0100 Subject: [PATCH 04/24] feat(subdomain): remove lastAuthenticateWorkspace on logout --- .../src/modules/ui/layout/page/components/DefaultLayout.tsx | 4 ++-- .../modules/workspace/graphql/mutations/updateWorkspace.ts | 4 ++++ packages/twenty-front/src/utils/cookie-storage.ts | 4 ++-- packages/twenty-front/src/utils/recoil-effects.ts | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 91bf4910be48..ced934d83190 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -21,7 +21,7 @@ import { isTwentyHosting, } from '~/utils/workspace-url.helper'; import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; -import { useRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; import { useEffect } from 'react'; const StyledLayout = styled.div` @@ -73,7 +73,7 @@ export const DefaultLayout = () => { const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); - const [lastAuthenticateWorkspace] = useRecoilState( + const lastAuthenticateWorkspace = useRecoilValue( lastAuthenticateWorkspaceState, ); diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts index fd4efa797923..a8a97eecc3b6 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/updateWorkspace.ts @@ -9,6 +9,10 @@ export const UPDATE_WORKSPACE = gql` displayName logo allowImpersonation + isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled } } `; diff --git a/packages/twenty-front/src/utils/cookie-storage.ts b/packages/twenty-front/src/utils/cookie-storage.ts index 1261a209dc63..183d0da52f1f 100644 --- a/packages/twenty-front/src/utils/cookie-storage.ts +++ b/packages/twenty-front/src/utils/cookie-storage.ts @@ -16,9 +16,9 @@ class CookieStorage { Cookies.set(key, value, attributes); } - removeItem(key: string): void { + removeItem(key: string, attributes?: Cookies.CookieAttributes): void { this.keys.delete(key); - Cookies.remove(key); + Cookies.remove(key, attributes); } clear(): void { diff --git a/packages/twenty-front/src/utils/recoil-effects.ts b/packages/twenty-front/src/utils/recoil-effects.ts index e47a13737ab7..31937e38a968 100644 --- a/packages/twenty-front/src/utils/recoil-effects.ts +++ b/packages/twenty-front/src/utils/recoil-effects.ts @@ -40,11 +40,11 @@ export const cookieStorageEffect = onSet((newValue, _, isReset) => { if (!newValue) { - cookieStorage.removeItem(key); + cookieStorage.removeItem(key, attributes); return; } isReset - ? cookieStorage.removeItem(key) + ? cookieStorage.removeItem(key, attributes) : cookieStorage.setItem(key, JSON.stringify(newValue), { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), ...attributes, From 0df3615e02d5c4971e68c9b709a1c45cf852c484 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 7 Nov 2024 18:17:25 +0100 Subject: [PATCH 05/24] feat(front): fix eslint --- .../components/ClientConfigProviderEffect.tsx | 1 - .../layout/page/components/DefaultLayout.tsx | 7 +- .../hooks/useWorkspaceSwitching.ts | 64 +------------------ 3 files changed, 6 insertions(+), 66 deletions(-) diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 25ffd055b9b4..8a0365d4113c 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -1,5 +1,4 @@ import { apiConfigState } from '@/client-config/states/apiConfigState'; -import { authProvidersState } from '@/client-config/states/authProvidersState'; import { billingState } from '@/client-config/states/billingState'; import { captchaProviderState } from '@/client-config/states/captchaProviderState'; import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index ced934d83190..680ee3af8c50 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -23,6 +23,7 @@ import { import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; import { useRecoilValue } from 'recoil'; import { useEffect } from 'react'; +import { isDefined } from '~/utils/isDefined'; const StyledLayout = styled.div` background: ${({ theme }) => theme.background.noisy}; @@ -79,9 +80,9 @@ export const DefaultLayout = () => { useEffect(() => { if ( - isTwentyHosting === true && - isTwentyHomePage === true && - lastAuthenticateWorkspace + isTwentyHosting && + isTwentyHomePage && + isDefined(lastAuthenticateWorkspace) ) { window.location.href = buildWorkspaceUrl( lastAuthenticateWorkspace.subdomain, diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index 9e1f9c20c17d..a24d8ea3925b 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -1,27 +1,14 @@ -import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; import { AppPath } from '@/types/AppPath'; -import { useAuth } from '@/auth/hooks/useAuth'; -import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; -import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { signInUpStepState } from '@/auth/states/signInUpStepState'; -import { tokenPairState } from '@/auth/states/tokenPairState'; import { useSwitchWorkspaceMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; import { buildWorkspaceUrl } from '~/utils/workspace-url.helper'; export const useWorkspaceSwitching = () => { - const setTokenPair = useSetRecoilState(tokenPairState); - const [switchWorkspaceMutation] = useSwitchWorkspaceMutation(); - const { redirectToSSOLoginPage } = useSSO(); const currentWorkspace = useRecoilValue(currentWorkspaceState); - const setAvailableWorkspacesForSSOState = useSetRecoilState( - availableSSOIdentityProvidersState, - ); - const setSignInUpStep = useSetRecoilState(signInUpStepState); - const { signOut } = useAuth(); const switchWorkspace = async (workspaceId: string) => { if (currentWorkspace?.id === workspaceId) return; @@ -36,54 +23,7 @@ export const useWorkspaceSwitching = () => { return (window.location.href = AppPath.Index); } - const url = buildWorkspaceUrl(data.switchWorkspace.subdomain); - - window.location.href = url; - - // window.location.href = `https://${data.switchWorkspace.subdomain}.twenty.work`; - - // const jwt = await generateJWT({ - // variables: { - // workspaceId, - // }, - // }); - // - // if (isDefined(jwt.errors)) { - // throw jwt.errors; - // } - // - // if (!isDefined(jwt.data?.generateJWT)) { - // throw new Error('could not create token'); - // } - // - // if ( - // jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' && - // 'availableSSOIDPs' in jwt.data.generateJWT - // ) { - // if (jwt.data.generateJWT.availableSSOIDPs.length === 1) { - // redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id); - // } - // - // if (jwt.data.generateJWT.availableSSOIDPs.length > 1) { - // await signOut(); - // setAvailableWorkspacesForSSOState( - // jwt.data.generateJWT.availableSSOIDPs, - // ); - // setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); - // } - // - // return; - // } - // - // if ( - // jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' && - // 'authTokens' in jwt.data.generateJWT - // ) { - // const { tokens } = jwt.data.generateJWT.authTokens; - // setTokenPair(tokens); - // await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. - // window.location.href = AppPath.Index; - // } + window.location.href = buildWorkspaceUrl(data.switchWorkspace.subdomain); }; return { switchWorkspace }; From a29527be0b64a331bca2ada3e567122c7b4cc68b Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 7 Nov 2024 18:23:12 +0100 Subject: [PATCH 06/24] feat(server): fix eslint --- .../jobs/workspace-query-runner-job.module.ts | 2 - .../engine/core-modules/auth/auth.resolver.ts | 2 +- .../auth/controllers/sso-auth.controller.ts | 1 - .../user-workspace/user-workspace.service.ts | 5 +- .../workspace/workspace.module.ts | 2 +- .../workspace/workspace.resolver.ts | 2 +- .../get-workspace-subdomain-by-origin.spec.ts | 3 + .../__tests__/workspace-url.utils.spec.ts | 2 +- .../test/utils/createTestingService.utils.ts | 63 ------------------- 9 files changed, 8 insertions(+), 74 deletions(-) delete mode 100644 packages/twenty-server/test/utils/createTestingService.utils.ts diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module.ts index e99954324185..37caf4c1bbf2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/jobs/workspace-query-runner-job.module.ts @@ -1,9 +1,7 @@ -import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { RecordPositionBackfillJob } from 'src/engine/api/graphql/workspace-query-runner/jobs/record-position-backfill.job'; import { RecordPositionBackfillModule } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-module'; -import { AnalyticsModule } from 'src/engine/core-modules/analytics/analytics.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index b7d5b8e2f530..1ce887f83b02 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -38,6 +38,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; +import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-subdomain-by-origin'; import { ChallengeInput } from './dto/challenge.input'; import { ImpersonateInput } from './dto/impersonate.input'; @@ -51,7 +52,6 @@ import { VerifyInput } from './dto/verify.input'; import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { AuthService } from './services/auth.service'; -import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-subdomain-by-origin'; @Resolver() @UseFilters(AuthGraphqlApiExceptionFilter) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index e0c79e3e2f77..c40072a8c7eb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -25,7 +25,6 @@ import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.gua import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { IdentityProviderType, diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 2bd3d4ab359a..6ff8d2b59a36 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -5,10 +5,7 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { Repository } from 'typeorm'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; -import { - AppToken, - AppTokenType, -} from 'src/engine/core-modules/app-token/app-token.entity'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index 0bc987d458a6..310c00ecb99f 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -18,12 +18,12 @@ import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.r import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts'; import { Workspace } from './workspace.entity'; import { WorkspaceService } from './services/workspace.service'; -import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; @Module({ imports: [ diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 7cf762165e68..c4821d42e0f2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -34,8 +34,8 @@ import { WorkspaceExceptionCode, } from 'src/engine/core-modules/workspace/workspace.exception'; import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output'; - import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; + import { Workspace } from './workspace.entity'; import { WorkspaceService } from './services/workspace.service'; diff --git a/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts b/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts index dafc40c96fd5..452f46eec476 100644 --- a/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts +++ b/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts @@ -4,16 +4,19 @@ describe('getWorkspaceSubdomainByOrigin', () => { it('should return subdomain from origin', () => { const origin = 'https://subdomain.example.com'; const result = getWorkspaceSubdomainByOrigin(origin); + expect(result).toBe('subdomain'); }); it('should return undefined if origin is not a subdomain', () => { const origin = 'https://example.com'; const result = getWorkspaceSubdomainByOrigin(origin); + expect(result).toBe(undefined); }); it('should return undefined if subdomain is app', () => { const origin = 'https://app.example.com'; const result = getWorkspaceSubdomainByOrigin(origin); + expect(result).toBe(undefined); }); }); diff --git a/packages/twenty-server/src/utils/__tests__/workspace-url.utils.spec.ts b/packages/twenty-server/src/utils/__tests__/workspace-url.utils.spec.ts index 33f69492eed8..965df6e1fc3f 100644 --- a/packages/twenty-server/src/utils/__tests__/workspace-url.utils.spec.ts +++ b/packages/twenty-server/src/utils/__tests__/workspace-url.utils.spec.ts @@ -1,5 +1,5 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { buildWorkspaceURL } from '../workspace-url.utils'; +import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; describe('buildWorkspaceURL', () => { it('should build workspace URL with subdomain', () => { diff --git a/packages/twenty-server/test/utils/createTestingService.utils.ts b/packages/twenty-server/test/utils/createTestingService.utils.ts deleted file mode 100644 index ba43d6f6aebd..000000000000 --- a/packages/twenty-server/test/utils/createTestingService.utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Provider, Type } from '@nestjs/common'; -import { TestingModule, Test } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; - -function getDependencies(service: Type) { - const dependencies = Reflect.getMetadata('design:paramtypes', service); - // @ts-ignore - console.log('>>>>>>>>>>>>>>', service.context); - if (!dependencies) { - return []; - } - - return dependencies.reduce( - (acc, dep) => { - if (dep.name === 'Repository') { - const name = getRepositoryToken(dep); - console.log( - '>>>>>>>>>>>>>>', - Object.getOwnPropertyNames(dep.prototype), - ); - console.log('>>>>>>>>>>>>>>', dep.metadata); - acc.repositories['frite'] = dep; - } else { - acc.services[dep.name] = dep; - } - return acc; - }, - { - repositories: {}, - services: {}, - }, - ); -} - -export const createTestingService = async >( - service: T, - override?: { - repositories?: Record; - services?: Record; - }, -): Promise => { - const { services, repositories } = getDependencies(service); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - service, - // ...(Object.entries(repositories).map(([name, repository]) => ({ - // provide: repository, - // useValue: override?.repositories?.[name] ?? {}, - // })) as Provider[]), - ...(Object.entries(services).map(([name, service]) => ({ - provide: service, - useValue: override?.services?.[name] ?? {}, - })) as Provider[]), - ], - }).compile(); - - // @ts-ignore - return module.get(service); -}; - -createTestingService(AuthService); From d964f147010eb69851326118afa359133a76aab6 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 8 Nov 2024 18:25:39 +0100 Subject: [PATCH 07/24] feat(subdomain): improve login flow on home page --- packages/twenty-front/.env.example | 4 +- .../src/modules/auth/components/Logo.tsx | 61 ++++--- .../findAvailableSSOIdentityProviders.ts | 13 -- .../queries/findAvailableWorkspacesByEmail.ts | 19 +++ .../sign-in-up/components/SignInUpForm.tsx | 105 ++++++------ .../components/SignInUpGlobalScope.tsx | 158 ++++++++++++++++++ .../SignInUpSSOIdentityProviderSelection.tsx | 66 ++++++++ .../components/SignInUpWorkspaceSelection.tsx | 67 ++++++++ .../useFindAvailableWorkspacedByEmail.ts | 18 ++ .../modules/auth/sign-in-up/hooks/useSSO.ts | 15 -- .../auth/sign-in-up/hooks/useSignInUp.tsx | 46 +---- .../availableIdentityProviderForAuthState.ts | 10 ++ .../states/availableWorkspacesForAuthState.ts | 10 ++ .../auth/states/availableWorkspacesForSSO.ts | 11 -- .../modules/auth/states/signInUpStepState.ts | 2 +- .../src/pages/auth/SSOWorkspaceSelection.tsx | 69 -------- .../twenty-front/src/pages/auth/SignInUp.tsx | 50 +++--- .../src/pages/auth/SignInUpLegacy.tsx | 67 ++++++++ .../auth/__stories__/SignInUp.stories.tsx | 6 +- .../src/utils/workspace-url.helper.ts | 4 +- packages/twenty-front/vite.config.ts | 6 +- .../1730137590546-addSubdomainToWorkspace.ts | 4 +- .../engine/core-modules/auth/auth.module.ts | 2 - .../engine/core-modules/auth/auth.resolver.ts | 31 +++- .../verify-auth.controller.spec.ts | 32 ---- .../controllers/verify-auth.controller.ts | 26 --- .../auth/dto/available-workspaces.output.ts | 45 +++++ .../auth/services/auth.service.ts | 77 +++++++-- .../auth/services/switch-workspace.service.ts | 8 +- .../engine/core-modules/sso/sso.resolver.ts | 14 +- .../user/services/user.service.ts | 21 +++ .../workspace/services/workspace.service.ts | 16 +- .../workspace/workspace.entity.ts | 4 - ...l-hydrate-request-from-token.middleware.ts | 1 + .../get-workspace-subdomain-by-origin.spec.ts | 13 +- .../get-workspace-subdomain-by-origin.ts | 7 +- .../input/button/components/MainButton.tsx | 6 +- 37 files changed, 747 insertions(+), 367 deletions(-) delete mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts create mode 100644 packages/twenty-front/src/modules/auth/graphql/queries/findAvailableWorkspacesByEmail.ts create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail.ts create mode 100644 packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts create mode 100644 packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts delete mode 100644 packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts delete mode 100644 packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx create mode 100644 packages/twenty-front/src/pages/auth/SignInUpLegacy.tsx delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts diff --git a/packages/twenty-front/.env.example b/packages/twenty-front/.env.example index ed478d193ade..31e73f0c462d 100644 --- a/packages/twenty-front/.env.example +++ b/packages/twenty-front/.env.example @@ -3,7 +3,7 @@ GENERATE_SOURCEMAP=false # ———————— Optional ———————— # REACT_APP_PORT=3001 +# REACT_APP_BASE_URL=http://localhost # CHROMATIC_PROJECT_TOKEN= # VITE_DISABLE_TYPESCRIPT_CHECKER=true -# VITE_DISABLE_ESLINT_CHECKER=true -REACT_WEB_APP_BASE_URL=http://localhost:3001 \ No newline at end of file +# VITE_DISABLE_ESLINT_CHECKER=true \ No newline at end of file diff --git a/packages/twenty-front/src/modules/auth/components/Logo.tsx b/packages/twenty-front/src/modules/auth/components/Logo.tsx index 34ddf069cd91..b16d73a3988a 100644 --- a/packages/twenty-front/src/modules/auth/components/Logo.tsx +++ b/packages/twenty-front/src/modules/auth/components/Logo.tsx @@ -1,42 +1,47 @@ +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI'; +import { isDefined } from '~/utils/isDefined'; type LogoProps = { workspaceLogo?: string | null; + size?: string | null; + includeTwentyLogo?: boolean; }; -const StyledContainer = styled.div` - height: 48px; +const StyledContainer = styled.div` + height: ${({ size }) => size}; margin-bottom: ${({ theme }) => theme.spacing(4)}; margin-top: ${({ theme }) => theme.spacing(4)}; position: relative; - width: 48px; + width: ${({ size }) => size}; `; -const StyledTwentyLogo = styled.img` +const StyledTwentyLogo = styled.img` border-radius: ${({ theme }) => theme.border.radius.xs}; - height: 24px; - width: 24px; + height: calc(${({ size }) => size} / 2); + width: calc(${({ size }) => size} / 2); `; -const StyledTwentyLogoContainer = styled.div` +const StyledTwentyLogoContainer = styled.div` align-items: center; background-color: ${({ theme }) => theme.background.primary}; border-radius: ${({ theme }) => theme.border.radius.sm}; - bottom: ${({ theme }) => `-${theme.spacing(3)}`}; + bottom: calc(-12 * ${({ size }) => size} / 48); display: flex; - height: 28px; + height: calc((28 * ${({ size }) => size}) / 48); justify-content: center; position: absolute; - right: ${({ theme }) => `-${theme.spacing(3)}`}; - width: 28px; + right: calc(-12 * ${({ size }) => size} / 48); + width: calc(28 * ${({ size }) => size} / 48); `; type StyledMainLogoProps = { logo?: string | null; + size?: string | null; }; const StyledMainLogo = styled.div` @@ -47,21 +52,37 @@ const StyledMainLogo = styled.div` width: 100%; `; -export const Logo = ({ workspaceLogo }: LogoProps) => { - if (!workspaceLogo) { +export const Logo = (props: LogoProps) => { + const theme = useTheme(); + + const size = props.size ?? theme.spacing(12); + + const includeTwentyLogo = isDefined(props.includeTwentyLogo) + ? props.includeTwentyLogo + : true; + + if (!props.workspaceLogo) { return ( - - + + ); } return ( - - - - - + + + {includeTwentyLogo && ( + + + + )} ); }; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts deleted file mode 100644 index 888cda398c71..000000000000 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/findAvailableSSOIdentityProviders.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* @license Enterprise */ - -import { gql } from '@apollo/client'; - -export const FIND_AVAILABLE_SSO_IDENTITY_PROVIDERS = gql` - mutation FindAvailableSSOIdentityProviders( - $input: FindAvailableSSOIDPInput! - ) { - findAvailableSSOIdentityProviders(input: $input) { - ...AvailableSSOIdentityProvidersFragment - } - } -`; diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/findAvailableWorkspacesByEmail.ts b/packages/twenty-front/src/modules/auth/graphql/queries/findAvailableWorkspacesByEmail.ts new file mode 100644 index 000000000000..14e5b2ef7c90 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/queries/findAvailableWorkspacesByEmail.ts @@ -0,0 +1,19 @@ +import { gql } from '@apollo/client'; + +export const FIND_AVAILABLE_WORKSPACES_BY_EMAIL = gql` + query FindAvailableWorkspacesByEmail($email: String!) { + findAvailableWorkspacesByEmail(email: $email) { + id + displayName + subdomain + logo + sso { + type + id + issuer + name + status + } + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index 228fde192827..d64950287afc 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -70,7 +70,6 @@ export const SignInUpForm = () => { continueWithEmail, continueWithSSO, submitCredentials, - submitSSOEmail, } = useSignInUp(form); if ( @@ -104,13 +103,6 @@ export const SignInUpForm = () => { return; } continueWithCredentials(); - } else if (signInUpStep === SignInUpStep.Password) { - if (!form.formState.isSubmitting) { - setShowErrors(true); - form.handleSubmit(submitCredentials)(); - } - } else if (signInUpStep === SignInUpStep.SSOEmail) { - submitSSOEmail(form.getValues('email')); } } }; @@ -332,54 +324,54 @@ export const SignInUpForm = () => { event.preventDefault(); }} > - {signInUpStep === SignInUpStep.SSOEmail && ( - <> - - ( - - - - )} - /> - - { - setShowErrors(true); - submitSSOEmail(form.getValues('email')); - }} - Icon={() => form.formState.isSubmitting && } - disabled={isSubmitButtonDisabled} - fullWidth - /> - - )} + {/*{signInUpStep === SignInUpStep.SSOEmail && (*/} + {/* <>*/} + {/* */} + {/* (*/} + {/* */} + {/* */} + {/* */} + {/* )}*/} + {/* />*/} + {/* */} + {/* {*/} + {/* setShowErrors(true);*/} + {/* submitSSOEmail(form.getValues('email'));*/} + {/* }}*/} + {/* Icon={() => form.formState.isSubmitting && }*/} + {/* disabled={isSubmitButtonDisabled}*/} + {/* fullWidth*/} + {/* />*/} + {/* */} + {/*)}*/} {signInUpStep === SignInUpStep.Password && ( @@ -387,7 +379,6 @@ export const SignInUpForm = () => { Forgot your password? )} - {signInUpStep === SignInUpStep.Init && } ); }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx new file mode 100644 index 000000000000..e3b955315755 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx @@ -0,0 +1,158 @@ +import styled from '@emotion/styled'; +import { + AnimatedEaseIn, + IconGoogle, + IconMicrosoft, + Loader, + MainButton, +} from 'twenty-ui'; +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { useTheme } from '@emotion/react'; +import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; +import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { motion } from 'framer-motion'; +import { Logo } from '@/auth/components/Logo'; +import { Title } from '@/auth/components/Title'; +import { useFindAvailableWorkspacesByEmail } from '@/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail'; +import { FormEvent, useState } from 'react'; +import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; +import { useSetRecoilState } from 'recoil'; +import { isDefined } from '~/utils/isDefined'; +import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; + +const StyledContentContainer = styled(motion.div)` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledForm = styled.form` + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +`; + +const StyledFullWidthMotionDiv = styled(motion.div)` + width: 100%; +`; + +const StyledInputContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(3)}; +`; + +const validationSchema = z + .object({ + email: z.string().trim().email('Email must be a valid email'), + }) + .required(); + +export const SignInUpGlobalScope = () => { + const theme = useTheme(); + + const { signInWithGoogle } = useSignInWithGoogle(); + const { signInWithMicrosoft } = useSignInWithMicrosoft(); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const { findAvailableWorkspacesByEmail } = + useFindAvailableWorkspacesByEmail(); + const setAvailableWorkspacesForAuthState = useSetRecoilState( + availableWorkspacesForAuthState, + ); + + const [showErrors, setShowErrors] = useState(false); + + const form = useForm>({ + mode: 'onChange', + defaultValues: { + email: '', + }, + resolver: zodResolver(validationSchema), + }); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setShowErrors(true); + const { data, error } = await findAvailableWorkspacesByEmail( + form.getValues('email'), + ); + if (isDefined(data) && data.findAvailableWorkspacesByEmail.length > 1) { + setAvailableWorkspacesForAuthState(data.findAvailableWorkspacesByEmail); + setSignInUpStep(SignInUpStep.WorkspaceSelection); + } + // si 1 workspace sans sso redirige sur workspace avec email en query params pour prefill + // si 1 workspace avec sso et 1 sso login avec le sso + // si plusieurs workspaces redirige sur la liste des workspaces + }; + + return ( + <> + + <> + } + title="Continue with Google" + onClick={signInWithGoogle} + fullWidth + /> + + + <> + } + title="Continue with Microsoft" + onClick={signInWithMicrosoft} + fullWidth + /> + + + + + + ( + + + + )} + /> + + (form.formState.isSubmitting ? : null)} + fullWidth + /> + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx new file mode 100644 index 000000000000..dda0f4e6463d --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx @@ -0,0 +1,66 @@ +/* @license Enterprise */ + +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; +import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; +import { AnimatedEaseIn, MainButton } from 'twenty-ui'; +import { Logo } from '@/auth/components/Logo'; +import { Title } from '@/auth/components/Title'; +import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; + +const StyledContentContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledTitle = styled.h2` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + margin: 0; +`; + +export const SignInUpWorkspaceSelection = () => { + const availableWorkspacesForAuth = useRecoilValue( + availableWorkspacesForAuthState, + ); + + const { redirectToSSOLoginPage } = useSSO(); + + return ( + <> + + + + Welcome to Twenty + + {availableWorkspacesForAuth && + availableWorkspacesForAuth.length !== 0 && + availableWorkspacesForAuth.map((workspace) => ( + <> + + {workspace.displayName ?? DEFAULT_WORKSPACE_NAME} + + + {workspace.sso && + workspace.sso.length !== 0 && + workspace.sso.map((idp) => ( + <> + redirectToSSOLoginPage(idp.id)} + Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)} + fullWidth + /> + + + ))} + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx new file mode 100644 index 000000000000..20ca94b3356b --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx @@ -0,0 +1,67 @@ +/* @license Enterprise */ + +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; +import { H2Title, MainButton } from 'twenty-ui'; +import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; +import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; +import { buildWorkspaceUrl } from '~/utils/workspace-url.helper'; + +const StyledContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledMainButtonContent = styled.div` + display: flex; + width: calc(100% + ${({ theme }) => theme.spacing(4)}); + gap: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledIcon = styled.img<{ src: string }>` + background-image: url(${(props) => props.src}); + height: ${({ theme }) => theme.spacing(4)}; + width: ${({ theme }) => theme.spacing(4)}; + border-radius: 4px; +`; + +const StyledDisplayName = styled.span` + flex-grow: 1; + text-align: center; + margin-right: ${({ theme }) => theme.spacing(8)}; +`; + +export const SignInUpWorkspaceSelection = () => { + const availableWorkspacesForAuth = useRecoilValue( + availableWorkspacesForAuthState, + ); + + const moveToWorkspace = ( + workspace: NonNullable[0], + ) => { + window.location.href = buildWorkspaceUrl(workspace.subdomain); + }; + + return ( + <> + + + {availableWorkspacesForAuth && + availableWorkspacesForAuth.length !== 0 && + availableWorkspacesForAuth.map((workspace) => ( + moveToWorkspace(workspace)} fullWidth> + + + + {workspace.displayName ?? workspace.id} + + + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail.ts new file mode 100644 index 000000000000..7621c010ace5 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail.ts @@ -0,0 +1,18 @@ +import { useFindAvailableWorkspacesByEmailLazyQuery } from '~/generated/graphql'; + +export const useFindAvailableWorkspacesByEmail = () => { + const [findAvailableWorkspacesByEmailQuery] = + useFindAvailableWorkspacesByEmailLazyQuery(); + + const findAvailableWorkspacesByEmail = async (email: string) => { + return await findAvailableWorkspacesByEmailQuery({ + variables: { + email, + }, + }); + }; + + return { + findAvailableWorkspacesByEmail, + }; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts index 86a8b83928f0..f8228e4d1242 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSSO.ts @@ -3,9 +3,7 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { - FindAvailableSsoIdentityProvidersMutationVariables, GetAuthorizationUrlMutationVariables, - useFindAvailableSsoIdentityProvidersMutation, useGetAuthorizationUrlMutation, } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -13,20 +11,8 @@ import { isDefined } from '~/utils/isDefined'; export const useSSO = () => { const { enqueueSnackBar } = useSnackBar(); - const [findAvailableSSOProviderByEmailMutation] = - useFindAvailableSsoIdentityProvidersMutation(); const [getAuthorizationUrlMutation] = useGetAuthorizationUrlMutation(); - const findAvailableSSOProviderByEmail = async ({ - email, - }: FindAvailableSsoIdentityProvidersMutationVariables['input']) => { - return await findAvailableSSOProviderByEmailMutation({ - variables: { - input: { email }, - }, - }); - }; - const getAuthorizationUrlForSSO = async ({ identityProviderId, }: GetAuthorizationUrlMutationVariables['input']) => { @@ -63,6 +49,5 @@ export const useSSO = () => { return { redirectToSSOLoginPage, getAuthorizationUrlForSSO, - findAvailableSSOProviderByEmail, }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index 53994713c93c..4473f7143433 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -12,7 +12,7 @@ import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { isDefined } from '~/utils/isDefined'; import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; -import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; +// import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; import { SignInUpStep, signInUpStepState, @@ -32,10 +32,10 @@ export const useSignInUp = (form: UseFormReturn) => { const isMatchingLocation = useIsMatchingLocation(); - const { redirectToSSOLoginPage, findAvailableSSOProviderByEmail } = useSSO(); - const setAvailableWorkspacesForSSOState = useSetRecoilState( - availableSSOIdentityProvidersState, - ); + const { redirectToSSOLoginPage } = useSSO(); + // const setAvailableWorkspacesForSSOState = useSetRecoilState( + // availableSSOIdentityProvidersState, + // ); const workspaceInviteHash = useParams().workspaceInviteHash; const [searchParams] = useSearchParams(); @@ -107,41 +107,6 @@ export const useSignInUp = (form: UseFormReturn) => { setSignInUpStep(SignInUpStep.SSOEmail); }; - const submitSSOEmail = async (email: string) => { - const result = await findAvailableSSOProviderByEmail({ - email, - }); - - if (isDefined(result.errors)) { - return enqueueSnackBar(result.errors[0].message, { - variant: SnackBarVariant.Error, - }); - } - - if ( - !result.data?.findAvailableSSOIdentityProviders || - result.data?.findAvailableSSOIdentityProviders.length === 0 - ) { - enqueueSnackBar('No workspaces with SSO found', { - variant: SnackBarVariant.Error, - }); - return; - } - // If only one workspace, redirect to SSO - if (result.data?.findAvailableSSOIdentityProviders.length === 1) { - return redirectToSSOLoginPage( - result.data.findAvailableSSOIdentityProviders[0].id, - ); - } - - if (result.data?.findAvailableSSOIdentityProviders.length > 1) { - setAvailableWorkspacesForSSOState( - result.data.findAvailableSSOIdentityProviders, - ); - setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); - } - }; - const submitCredentials: SubmitHandler = useCallback( async (data) => { const token = await readCaptchaToken(); @@ -190,7 +155,6 @@ export const useSignInUp = (form: UseFormReturn) => { continueWithCredentials, continueWithEmail, continueWithSSO, - submitSSOEmail, submitCredentials, }; }; diff --git a/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts b/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts new file mode 100644 index 000000000000..cd5efe1bc17c --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts @@ -0,0 +1,10 @@ +import { createState } from 'twenty-ui'; +import { FindAvailableWorkspacesByEmailQuery } from '~/generated/graphql'; + +export const availableSSOIdentityProvidersForAuthState = createState | null>({ + key: 'availableSSOIdentityProvidersForAuth', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts new file mode 100644 index 000000000000..5604c53421e5 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts @@ -0,0 +1,10 @@ +import { createState } from 'twenty-ui'; +import { FindAvailableWorkspacesByEmailQuery } from '~/generated/graphql'; + +export const availableWorkspacesForAuthState = createState | null>({ + key: 'availableWorkspacesForAuthState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts deleted file mode 100644 index e4c83c26e15e..000000000000 --- a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForSSO.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createState } from 'twenty-ui'; -import { FindAvailableSsoIdentityProvidersMutationResult } from '~/generated/graphql'; - -export const availableSSOIdentityProvidersState = createState< - NonNullable< - FindAvailableSsoIdentityProvidersMutationResult['data'] - >['findAvailableSSOIdentityProviders'] ->({ - key: 'availableSSOIdentityProviders', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts index 71f359fdee2e..c465ec18a575 100644 --- a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts +++ b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts @@ -5,7 +5,7 @@ export enum SignInUpStep { Email = 'email', Password = 'password', SSOEmail = 'SSOEmail', - SSOWorkspaceSelection = 'SSOWorkspaceSelection', + WorkspaceSelection = 'WorkspaceSelection', } export const signInUpStepState = createState({ diff --git a/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx b/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx deleted file mode 100644 index 1767b134262e..000000000000 --- a/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* @license Enterprise */ - -import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; -import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; -import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; -import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; -import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; -import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; -import { MainButton } from 'twenty-ui'; - -const StyledContentContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(8)}; - margin-top: ${({ theme }) => theme.spacing(4)}; -`; - -const StyledTitle = styled.h2` - color: ${({ theme }) => theme.font.color.primary}; - font-size: ${({ theme }) => theme.font.size.md}; - font-weight: ${({ theme }) => theme.font.weight.semiBold}; - margin: 0; -`; - -export const SSOWorkspaceSelection = () => { - const availableSSOIdentityProviders = useRecoilValue( - availableSSOIdentityProvidersState, - ); - - const { redirectToSSOLoginPage } = useSSO(); - - const availableWorkspacesForSSOGroupByWorkspace = - availableSSOIdentityProviders.reduce( - (acc, idp) => { - acc[idp.workspace.id] = [...(acc[idp.workspace.id] ?? []), idp]; - return acc; - }, - {} as Record, - ); - - return ( - <> - - {Object.values(availableWorkspacesForSSOGroupByWorkspace).map( - (idps) => ( - <> - - {idps[0].workspace.displayName ?? DEFAULT_WORKSPACE_NAME} - - - {idps.map((idp) => ( - <> - redirectToSSOLoginPage(idp.id)} - Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)} - fullWidth - /> - - - ))} - - ), - )} - - - - ); -}; diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 851290380804..b1217642fd15 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -1,19 +1,25 @@ import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; -import { Logo } from '@/auth/components/Logo'; -import { Title } from '@/auth/components/Title'; -import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SignInUpStep } from '@/auth/states/signInUpStepState'; -import { IconLockCustom } from '@ui/display/icon/components/IconLock'; -import { AnimatedEaseIn } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; -import { SSOWorkspaceSelection } from './SSOWorkspaceSelection'; import { useWorkspacePublicData } from '@/auth/sign-in-up/hooks/useWorkspacePublicData'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; +import { + isTwentyHomePage, + isTwentyWorkspaceSubdomain, +} from '~/utils/workspace-url.helper'; +import { SignInUpWorkspaceSelection } from '@/auth/sign-in-up/components/SignInUpWorkspaceSelection'; +import { SignInUpGlobalScope } from '@/auth/sign-in-up/components/SignInUpGlobalScope'; +import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; +import { AnimatedEaseIn } from 'twenty-ui'; +import { Logo } from '@/auth/components/Logo'; +import { Title } from '@/auth/components/Title'; +import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; +import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; export const SignInUp = () => { const { form } = useSignInUpForm(); @@ -22,6 +28,7 @@ export const SignInUp = () => { const { signInUpStep, signInUpMode } = useSignInUp(form); const { loading } = useWorkspacePublicData(); + const workspacePublicData = useRecoilValue(workspacePublicDataState); const title = useMemo(() => { @@ -31,8 +38,8 @@ export const SignInUp = () => { ) { return `Welcome to ${workspacePublicData?.displayName ?? 'Twenty'}`; } - if (signInUpStep === SignInUpStep.SSOWorkspaceSelection) { - return 'Choose SSO connection'; + if (signInUpStep === SignInUpStep.WorkspaceSelection) { + return 'Choose a workspace'; } return signInUpMode === SignInUpMode.SignIn ? `Sign in to ${workspacePublicData?.displayName ?? 'Twenty'}` @@ -46,22 +53,21 @@ export const SignInUp = () => { return ( <> - {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( - - ) : ( - - )} + - {!loading && ( - <> - {title} - {signInUpStep === SignInUpStep.SSOWorkspaceSelection ? ( - - ) : ( - - )} - + + {`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`} + + {isTwentyHomePage ? ( + isTwentyHomePage && signInUpStep === SignInUpStep.WorkspaceSelection ? ( + + ) : ( + + ) + ) : ( + )} + ); }; diff --git a/packages/twenty-front/src/pages/auth/SignInUpLegacy.tsx b/packages/twenty-front/src/pages/auth/SignInUpLegacy.tsx new file mode 100644 index 000000000000..f24ce377b87c --- /dev/null +++ b/packages/twenty-front/src/pages/auth/SignInUpLegacy.tsx @@ -0,0 +1,67 @@ +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { Logo } from '@/auth/components/Logo'; +import { Title } from '@/auth/components/Title'; +import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; +import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { IconLockCustom } from '@ui/display/icon/components/IconLock'; +import { AnimatedEaseIn } from 'twenty-ui'; +import { isDefined } from '~/utils/isDefined'; +import { useWorkspacePublicData } from '@/auth/sign-in-up/hooks/useWorkspacePublicData'; +import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; + +export const SignInUpLegacy = () => { + const { form } = useSignInUpForm(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + + const { signInUpStep, signInUpMode } = useSignInUp(form); + + const { loading } = useWorkspacePublicData(); + + const workspacePublicData = useRecoilValue(workspacePublicDataState); + + const title = useMemo(() => { + if ( + signInUpStep === SignInUpStep.Init || + signInUpStep === SignInUpStep.Email + ) { + return `Welcome to ${workspacePublicData?.displayName ?? 'Twenty'}`; + } + if (signInUpStep === SignInUpStep.WorkspaceSelection) { + return 'Choose a workspace'; + } + return signInUpMode === SignInUpMode.SignIn + ? `Sign in to ${workspacePublicData?.displayName ?? 'Twenty'}` + : `Sign up to ${workspacePublicData?.displayName ?? 'Twenty'}`; + }, [signInUpMode, signInUpStep, workspacePublicData]); + + if (isDefined(currentWorkspace)) { + return <>; + } + + return ( + <> + + {signInUpStep === SignInUpStep.WorkspaceSelection ? ( + + ) : ( + + )} + + {/*{!loading && (*/} + {/* <>*/} + {/* {title}*/} + {/* {signInUpStep === SignInUpStep.WorkspaceSelection ? (*/} + {/* */} + {/* ) : (*/} + {/* */} + {/* )}*/} + {/* */} + {/*)}*/} + + ); +}; diff --git a/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx b/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx index 1ce73e337e1e..8810ad98170f 100644 --- a/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx +++ b/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx @@ -11,11 +11,11 @@ import { import { graphqlMocks } from '~/testing/graphqlMocks'; import { AppPath } from '@/types/AppPath'; -import { SignInUp } from '../SignInUp'; +import { SignInUpLegacy } from '../SignInUpLegacy'; const meta: Meta = { title: 'Pages/Auth/SignInUp', - component: SignInUp, + component: SignInUpLegacy, decorators: [PageDecorator], args: { routePath: AppPath.SignInUp }, parameters: { @@ -47,7 +47,7 @@ const meta: Meta = { export default meta; -export type Story = StoryObj; +export type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { diff --git a/packages/twenty-front/src/utils/workspace-url.helper.ts b/packages/twenty-front/src/utils/workspace-url.helper.ts index f4ba438b93bd..54b179212cdd 100644 --- a/packages/twenty-front/src/utils/workspace-url.helper.ts +++ b/packages/twenty-front/src/utils/workspace-url.helper.ts @@ -1,5 +1,5 @@ -export const twentyHostname = process.env.REACT_WEB_APP_BASE_URL - ? new URL(process.env.REACT_WEB_APP_BASE_URL).hostname +export const twentyHostname = process.env.REACT_APP_BASE_URL + ? new URL(process.env.REACT_APP_BASE_URL).hostname : 'twenty.com'; export const isTwentyHosting = diff --git a/packages/twenty-front/vite.config.ts b/packages/twenty-front/vite.config.ts index 095f1cc40934..a062f8f4010a 100644 --- a/packages/twenty-front/vite.config.ts +++ b/packages/twenty-front/vite.config.ts @@ -18,7 +18,7 @@ export default defineConfig(({ command, mode }) => { VITE_BUILD_SOURCEMAP, VITE_DISABLE_TYPESCRIPT_CHECKER, VITE_DISABLE_ESLINT_CHECKER, - REACT_WEB_APP_BASE_URL, + REACT_APP_BASE_URL, REACT_APP_PORT, } = env; @@ -64,7 +64,7 @@ export default defineConfig(({ command, mode }) => { } const { hostname, protocol } = new URL( - REACT_WEB_APP_BASE_URL ?? 'http://localhost', + REACT_APP_BASE_URL ?? 'http://localhost', ); return { @@ -132,7 +132,7 @@ export default defineConfig(({ command, mode }) => { define: { 'process.env': { REACT_APP_SERVER_BASE_URL, - REACT_WEB_APP_BASE_URL, + REACT_APP_BASE_URL, }, }, css: { diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts index 5cc9c76a9c05..e4798ce2b835 100644 --- a/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1730137590546-addSubdomainToWorkspace.ts @@ -9,9 +9,7 @@ export class AddSubdomainToWorkspace1730137590546 await queryRunner.query( `ALTER TABLE "core"."workspace" ADD "subdomain" varchar NULL`, ); - await queryRunner.query( - `UPDATE "core"."workspace" SET "subdomain" = LOWER(REPLACE("id", '-', ''))`, - ); + await queryRunner.query(`UPDATE "core"."workspace" SET "subdomain" = "id"`); await queryRunner.query( `ALTER TABLE "core"."workspace" ALTER COLUMN "subdomain" SET NOT NULL`, ); 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 9bf2c91ecf5f..f6f339e17ada 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 @@ -11,7 +11,6 @@ import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/g import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller'; import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller'; import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; -import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller'; import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service'; @@ -83,7 +82,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; MicrosoftAuthController, GoogleAPIsAuthController, MicrosoftAPIsAuthController, - VerifyAuthController, SSOAuthController, ], providers: [ diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 1ce887f83b02..ec454d316d32 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -39,6 +39,8 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-subdomain-by-origin'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; import { ChallengeInput } from './dto/challenge.input'; import { ImpersonateInput } from './dto/impersonate.input'; @@ -67,6 +69,7 @@ export class AuthResolver { private switchWorkspaceService: SwitchWorkspaceService, private transientTokenService: TransientTokenService, private oauthService: OAuthService, + private environmentService: EnvironmentService, ) {} @UseGuards(CaptchaGuard) @@ -129,7 +132,10 @@ export class AuthResolver { ): Promise { const user = await this.authService.signInUp({ ...signUpInput, - targetWorkspaceSubdomain: getWorkspaceSubdomainByOrigin(origin), + targetWorkspaceSubdomain: getWorkspaceSubdomainByOrigin( + origin, + this.environmentService.get('FRONT_BASE_URL'), + ), fromSSO: false, }); @@ -174,12 +180,24 @@ export class AuthResolver { } @Mutation(() => Verify) - async verify(@Args() verifyInput: VerifyInput): Promise { + async verify( + @Args() verifyInput: VerifyInput, + @OriginHeader() origin: string, + ): Promise { + const workspace = await this.workspaceService.getWorkspaceByOrigin(origin); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + const email = await this.loginTokenService.verifyLoginToken( verifyInput.loginToken, ); - return await this.authService.verify(email); + return await this.authService.verify(email, workspace.id); } @Mutation(() => AuthorizeApp) @@ -275,4 +293,11 @@ export class AuthResolver { args.passwordResetToken, ); } + + @Query(() => [AvailableWorkspaceOutput]) + async findAvailableWorkspacesByEmail( + @Args('email') email: string, + ): Promise { + return this.authService.findAvailableWorkspacesByEmail(email); + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts deleted file mode 100644 index 11dfd40b6d4d..000000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; - -import { VerifyAuthController } from './verify-auth.controller'; - -describe('VerifyAuthController', () => { - let controller: VerifyAuthController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [VerifyAuthController], - providers: [ - { - provide: AuthService, - useValue: {}, - }, - { - provide: LoginTokenService, - useValue: {}, - }, - ], - }).compile(); - - controller = module.get(VerifyAuthController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts deleted file mode 100644 index 9fcfbb0cf91d..000000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Body, Controller, Post, UseFilters } from '@nestjs/common'; - -import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; -import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input'; -import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; -import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; - -@Controller('auth/verify') -@UseFilters(AuthRestApiExceptionFilter) -export class VerifyAuthController { - constructor( - private readonly authService: AuthService, - private readonly loginTokenService: LoginTokenService, - ) {} - - @Post() - async verify(@Body() verifyInput: VerifyInput): Promise { - const email = await this.loginTokenService.verifyLoginToken( - verifyInput.loginToken, - ); - const result = await this.authService.verify(email); - - return result; - } -} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts new file mode 100644 index 000000000000..84dccc6c4e6a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/available-workspaces.output.ts @@ -0,0 +1,45 @@ +/* @license Enterprise */ + +import { Field, ObjectType } from '@nestjs/graphql'; + +import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type'; +import { + IdentityProviderType, + SSOIdentityProviderStatus, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; + +@ObjectType() +class SSOConnection { + @Field(() => IdentityProviderType) + type: SSOConfiguration['type']; + + @Field(() => String) + id: string; + + @Field(() => String) + issuer: string; + + @Field(() => String) + name: string; + + @Field(() => SSOIdentityProviderStatus) + status: SSOConfiguration['status']; +} + +@ObjectType() +export class AvailableWorkspaceOutput { + @Field(() => String) + id: string; + + @Field(() => String, { nullable: true }) + displayName?: string | null; + + @Field(() => String) + subdomain: string; + + @Field(() => String, { nullable: true }) + logo?: string; + + @Field(() => [SSOConnection], { nullable: true }) + sso: SSOConnection[]; +} 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 1ba8a5b67d57..0c5b3f6fc1fe 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 @@ -41,6 +41,8 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; @Injectable() export class AuthService { @@ -48,6 +50,7 @@ export class AuthService { private readonly accessTokenService: AccessTokenService, private readonly refreshTokenService: RefreshTokenService, private readonly userWorkspaceService: UserWorkspaceService, + private readonly userService: UserService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') @@ -175,7 +178,16 @@ export class AuthService { }); } - async verify(email: string): Promise { + private async findOneWithWorkspacesByEmail(email: string) { + return this.userRepository.findOne({ + where: { + email, + }, + relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], + }); + } + + async verify(email: string, workspaceId: string): Promise { if (!email) { throw new AuthException( 'Email is required', @@ -183,12 +195,12 @@ export class AuthService { ); } - const user = await this.userRepository.findOne({ - where: { - email, - }, - relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], - }); + let user = await this.findOneWithWorkspacesByEmail(email); + + if (user && user.defaultWorkspaceId !== workspaceId) { + await this.userService.saveDefaultWorkspace(user, workspaceId); + user = await this.findOneWithWorkspacesByEmail(email); + } if (!user) { throw new AuthException( @@ -197,13 +209,6 @@ export class AuthService { ); } - if (!user.defaultWorkspace) { - throw new AuthException( - 'User has no default workspace', - AuthExceptionCode.INVALID_DATA, - ); - } - // passwordHash is hidden for security reasons user.passwordHash = ''; @@ -466,4 +471,48 @@ export class AuthService { return url.toString(); } + + async findAvailableWorkspacesByEmail(email: string) { + const user = await this.userRepository.findOne({ + where: { + email, + }, + relations: [ + 'workspaces', + 'workspaces.workspace', + 'workspaces.workspace.workspaceSSOIdentityProviders', + ], + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.USER_NOT_FOUND, + ); + } + + return user.workspaces.map((userWorkspace) => ({ + id: userWorkspace.workspaceId, + displayName: userWorkspace.workspace.displayName, + subdomain: userWorkspace.workspace.subdomain, + logo: userWorkspace.workspace.logo, + sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce( + (acc, identityProvider) => + acc.concat( + identityProvider.status === 'Inactive' + ? [] + : [ + { + id: identityProvider.id, + name: identityProvider.name ?? 'Unknown', + issuer: identityProvider.issuer, + type: identityProvider.type, + status: identityProvider.status, + }, + ], + ), + [] as AvailableWorkspaceOutput['sso'], + ), + })); + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts index 68e5ac5405b2..136860c7e806 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts @@ -14,6 +14,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; +import { UserService } from 'src/engine/core-modules/user/services/user.service'; @Injectable() export class SwitchWorkspaceService { @@ -22,7 +23,7 @@ export class SwitchWorkspaceService { private readonly userRepository: Repository, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, - private readonly ssoService: SSOService, + private readonly userService: UserService, private readonly workspaceService: WorkspaceService, private readonly accessTokenService: AccessTokenService, private readonly refreshTokenService: RefreshTokenService, @@ -100,10 +101,7 @@ export class SwitchWorkspaceService { user: User, workspace: Workspace, ): Promise { - await this.userRepository.save({ - id: user.id, - defaultWorkspace: workspace, - }); + await this.userService.saveDefaultWorkspace(user, workspace.id); const token = await this.accessTokenService.generateAccessToken( user.id, diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts index e6e492b5b90f..3b371a13c9a7 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts @@ -39,13 +39,13 @@ export class SSOResolver { ); } - @UseGuards(SSOProviderEnabledGuard) - @Mutation(() => [FindAvailableSSOIDPOutput]) - async findAvailableSSOIdentityProviders( - @Args('input') input: FindAvailableSSOIDPInput, - ): Promise> { - return this.sSOService.findAvailableSSOIdentityProviders(input.email); - } + // @UseGuards(SSOProviderEnabledGuard) + // @Mutation(() => [FindAvailableSSOIDPOutput]) + // async findAvailableSSOIdentityProviders( + // @Args('input') input: FindAvailableSSOIDPInput, + // ): Promise> { + // return this.sSOService.findAvailableSSOIdentityProviders(input.email); + // } @UseGuards(SSOProviderEnabledGuard) @Query(() => [FindAvailableSSOIDPOutput]) diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index a27dc91f9688..99e74e2b715f 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -123,4 +123,25 @@ export class UserService extends TypeOrmQueryService { return user; } + + async saveDefaultWorkspace(user: User, workspaceId: string) { + const exist = await this.userRepository.exists({ + where: { + id: user.id, + workspaces: { + workspaceId, + }, + }, + relations: ['workspaces'], + }); + + if (!exist) { + throw new Error('User does not have access to this workspace'); + } + + return await this.userRepository.save({ + id: user.id, + defaultWorkspaceId: workspaceId, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index a77f220f9377..d109b9496019 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -24,6 +24,7 @@ import { WorkspaceExceptionCode, } from 'src/engine/core-modules/workspace/workspace.exception'; import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-subdomain-by-origin'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { @@ -37,6 +38,7 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly userWorkspaceRepository: Repository, private readonly workspaceManagerService: WorkspaceManagerService, private readonly featureFlagService: FeatureFlagService, + private readonly environmentService: EnvironmentService, private readonly billingSubscriptionService: BillingSubscriptionService, private moduleRef: ModuleRef, ) { @@ -186,8 +188,11 @@ export class WorkspaceService extends TypeOrmQueryService { } async getAuthProvidersByWorkspaceId(workspaceId: string) { - const workspace = await this.workspaceRepository.findOneBy({ - id: workspaceId, + const workspace = await this.workspaceRepository.findOne({ + where: { + id: workspaceId, + }, + relations: ['workspaceSSOIdentityProviders'], }); if (!workspace) { @@ -202,13 +207,16 @@ export class WorkspaceService extends TypeOrmQueryService { magicLink: false, password: workspace.isPasswordAuthEnabled, microsoft: workspace.isMicrosoftAuthEnabled, - sso: workspace.isSSOAuthEnabled, + sso: workspace.workspaceSSOIdentityProviders.length > 0, }; } async getWorkspaceByOrigin(origin: string) { try { - const subdomain = getWorkspaceSubdomainByOrigin(origin); + const subdomain = getWorkspaceSubdomainByOrigin( + origin, + this.environmentService.get('FRONT_BASE_URL'), + ); return this.workspaceRepository.findOneBy({ subdomain }); } catch (e) { 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 3d8d0f867183..ab13422d079f 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 @@ -156,8 +156,4 @@ export class Workspace { @Field() @Column({ default: false }) isMicrosoftAuthEnabled: boolean; - - @Field() - @Column({ default: false }) - isSSOAuthEnabled: boolean; } diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 471d3ca8d81c..5dca0766786c 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -58,6 +58,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware 'GetAuthorizationUrl', 'FindAvailableSSOIdentityProviders', 'GetPublicWorkspaceDataBySubdomain', + 'FindAvailableWorkspacesByEmail', ]; if ( diff --git a/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts b/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts index 452f46eec476..2643e5ecb935 100644 --- a/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts +++ b/packages/twenty-server/src/engine/utils/__tests__/get-workspace-subdomain-by-origin.spec.ts @@ -3,19 +3,22 @@ import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-su describe('getWorkspaceSubdomainByOrigin', () => { it('should return subdomain from origin', () => { const origin = 'https://subdomain.example.com'; - const result = getWorkspaceSubdomainByOrigin(origin); + const result = getWorkspaceSubdomainByOrigin(origin, 'https://example.com'); expect(result).toBe('subdomain'); }); it('should return undefined if origin is not a subdomain', () => { const origin = 'https://example.com'; - const result = getWorkspaceSubdomainByOrigin(origin); + const result = getWorkspaceSubdomainByOrigin(origin, 'https://example.com'); expect(result).toBe(undefined); }); - it('should return undefined if subdomain is app', () => { - const origin = 'https://app.example.com'; - const result = getWorkspaceSubdomainByOrigin(origin); + it('should return undefined if subdomain is front main domain', () => { + const origin = 'https://main.example.com'; + const result = getWorkspaceSubdomainByOrigin( + origin, + 'https://main.example.com', + ); expect(result).toBe(undefined); }); diff --git a/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts b/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts index 32c8d5b3ffea..cfa88e8b9dc6 100644 --- a/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts +++ b/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts @@ -1,4 +1,7 @@ -export const getWorkspaceSubdomainByOrigin = (origin: string) => { +export const getWorkspaceSubdomainByOrigin = ( + origin: string, + frontBaseUrl: string, +) => { const { hostname } = new URL(origin); const hostParts = hostname.split('.'); @@ -7,7 +10,7 @@ export const getWorkspaceSubdomainByOrigin = (origin: string) => { const subdomain = hostParts[0]; - if (subdomain === 'app') return; + if (hostname === new URL(frontBaseUrl).hostname) return; return subdomain; }; diff --git a/packages/twenty-ui/src/input/button/components/MainButton.tsx b/packages/twenty-ui/src/input/button/components/MainButton.tsx index ddfba85f259c..b85d4c13daa6 100644 --- a/packages/twenty-ui/src/input/button/components/MainButton.tsx +++ b/packages/twenty-ui/src/input/button/components/MainButton.tsx @@ -6,11 +6,12 @@ import React from 'react'; export type MainButtonVariant = 'primary' | 'secondary'; type Props = { - title: string; + title?: string; fullWidth?: boolean; width?: number; variant?: MainButtonVariant; soon?: boolean; + children?: React.ReactNode; } & React.ComponentProps<'button'>; const StyledButton = styled.button< @@ -104,6 +105,7 @@ const StyledButton = styled.button< type MainButtonProps = Props & { Icon?: IconComponent; + children?: React.ReactNode; }; export const MainButton = ({ @@ -116,6 +118,7 @@ export const MainButton = ({ onClick, disabled, className, + children, }: MainButtonProps) => { const theme = useTheme(); return ( @@ -125,6 +128,7 @@ export const MainButton = ({ > {Icon && } {title} + {children} ); }; From d80e94054db07093eb6a843b3d67314b307cac01 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 8 Nov 2024 19:08:54 +0100 Subject: [PATCH 08/24] feat(front): improve SettingsOptionCardContent --- .../twenty-front/src/generated/graphql.tsx | 374 ++++++++++-------- .../SettingsSecurityOptionsList.tsx | 30 +- .../src/testing/mock-data/users.ts | 1 - .../commands/database-command.module.ts | 1 - 4 files changed, 212 insertions(+), 194 deletions(-) diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index e9c345cf88ca..472ac79927e3 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -68,6 +68,15 @@ export type AppTokenEdge = { node: AppToken; }; +export type AuthProviders = { + __typename?: 'AuthProviders'; + google: Scalars['Boolean']; + magicLink: Scalars['Boolean']; + microsoft: Scalars['Boolean']; + password: Scalars['Boolean']; + sso: Scalars['Boolean']; +}; + export type AuthToken = { __typename?: 'AuthToken'; expiresAt: Scalars['DateTime']; @@ -90,19 +99,13 @@ export type AuthorizeApp = { redirectUrl: Scalars['String']; }; -export type AvailableAuthMethodsOutput = { - __typename?: 'AvailableAuthMethodsOutput'; - authProviders: AvailableAuthProviders; - id: Scalars['Boolean']; -}; - -export type AvailableAuthProviders = { - __typename?: 'AvailableAuthProviders'; - google: Scalars['Boolean']; - magicLink: Scalars['Boolean']; - microsoft: Scalars['Boolean']; - password: Scalars['Boolean']; - sso: Scalars['Boolean']; +export type AvailableWorkspaceOutput = { + __typename?: 'AvailableWorkspaceOutput'; + displayName?: Maybe; + id: Scalars['String']; + logo?: Maybe; + sso?: Maybe>; + subdomain: Scalars['String']; }; export type Billing = { @@ -224,7 +227,7 @@ export type EditSsoOutput = { issuer: Scalars['String']; name: Scalars['String']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; export type EmailPasswordResetLink = { @@ -316,17 +319,13 @@ export enum FileFolder { WorkspaceLogo = 'WorkspaceLogo' } -export type FindAvailableSsoidpInput = { - email: Scalars['String']; -}; - export type FindAvailableSsoidpOutput = { __typename?: 'FindAvailableSSOIDPOutput'; id: Scalars['String']; issuer: Scalars['String']; name: Scalars['String']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; workspace: WorkspaceNameAndId; }; @@ -336,22 +335,6 @@ export type FullName = { lastName: Scalars['String']; }; -export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth; - -export type GenerateJwtOutputWithAuthTokens = { - __typename?: 'GenerateJWTOutputWithAuthTokens'; - authTokens: AuthTokens; - reason: Scalars['String']; - success: Scalars['Boolean']; -}; - -export type GenerateJwtOutputWithSsoauth = { - __typename?: 'GenerateJWTOutputWithSSOAUTH'; - availableSSOIDPs: Array; - reason: Scalars['String']; - success: Scalars['Boolean']; -}; - export type GetAuthorizationUrlInput = { identityProviderId: Scalars['String']; }; @@ -370,7 +353,7 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; -export enum IdpType { +export enum IdentityProviderType { Oidc = 'OIDC', Saml = 'SAML' } @@ -463,9 +446,7 @@ export type Mutation = { enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; - findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: GenerateJwt; generateTransientToken: TransientToken; getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; @@ -476,6 +457,7 @@ export type Mutation = { sendInvitations: SendInvitationsOutput; signUp: LoginToken; skipSyncEmailOnboardingStep: OnboardingStepSuccess; + switchWorkspace: PublicWorkspaceDataOutput; track: Analytics; updateBillingSubscription: UpdateBillingEntity; updateOneObject: Object; @@ -597,22 +579,12 @@ export type MutationExecuteOneServerlessFunctionArgs = { }; -export type MutationFindAvailableSsoIdentityProvidersArgs = { - input: FindAvailableSsoidpInput; -}; - - export type MutationGenerateApiKeyTokenArgs = { apiKeyId: Scalars['String']; expiresAt: Scalars['String']; }; -export type MutationGenerateJwtArgs = { - workspaceId: Scalars['String']; -}; - - export type MutationGetAuthorizationUrlArgs = { input: GetAuthorizationUrlInput; }; @@ -657,6 +629,11 @@ export type MutationSignUpArgs = { }; +export type MutationSwitchWorkspaceArgs = { + workspaceId: Scalars['String']; +}; + + export type MutationTrackArgs = { action: Scalars['String']; payload: Scalars['JSON']; @@ -784,6 +761,15 @@ export type ProductPricesEntity = { totalNumberOfPrices: Scalars['Int']; }; +export type PublicWorkspaceDataOutput = { + __typename?: 'PublicWorkspaceDataOutput'; + authProviders: AuthProviders; + displayName?: Maybe; + id: Scalars['String']; + logo?: Maybe; + subdomain: Scalars['String']; +}; + export type PublishServerlessFunctionInput = { /** The id of the function. */ id: Scalars['ID']; @@ -797,14 +783,15 @@ export type Query = { clientConfig: ClientConfig; currentUser: User; currentWorkspace: Workspace; + findAvailableWorkspacesByEmail: Array; findManyServerlessFunctions: Array; findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; - getAvailableAuthMethodsByWorkspaceSubdomain: AvailableAuthMethodsOutput; getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; + getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput; getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; @@ -835,6 +822,11 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = { }; +export type QueryFindAvailableWorkspacesByEmailArgs = { + email: Scalars['String']; +}; + + export type QueryFindOneServerlessFunctionArgs = { input: ServerlessFunctionIdInput; }; @@ -850,6 +842,11 @@ export type QueryGetProductPricesArgs = { }; +export type QueryGetPublicWorkspaceDataBySubdomainArgs = { + workspaceId?: InputMaybe; +}; + + export type QueryGetServerlessFunctionSourceCodeArgs = { input: GetServerlessFunctionSourceCodeInput; }; @@ -956,6 +953,15 @@ export type RunWorkflowVersionInput = { workflowVersionId: Scalars['String']; }; +export type SsoConnection = { + __typename?: 'SSOConnection'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdentityProviderType; +}; + export enum SsoIdentityProviderStatus { Active = 'Active', Error = 'Error', @@ -1047,7 +1053,7 @@ export type SetupSsoOutput = { issuer: Scalars['String']; name: Scalars['String']; status: SsoIdentityProviderStatus; - type: IdpType; + type: IdentityProviderType; }; /** Sort Directions */ @@ -1211,8 +1217,12 @@ export type UpdateWorkspaceInput = { displayName?: InputMaybe; domainName?: InputMaybe; inviteHash?: InputMaybe; + isGoogleAuthEnabled?: InputMaybe; + isMicrosoftAuthEnabled?: InputMaybe; + isPasswordAuthEnabled?: InputMaybe; isPublicInviteLinkEnabled?: InputMaybe; logo?: InputMaybe; + subdomain?: InputMaybe; }; export type User = { @@ -1303,6 +1313,9 @@ export type Workspace = { hasValidEntrepriseKey: Scalars['Boolean']; id: Scalars['UUID']; inviteHash?: Maybe; + isGoogleAuthEnabled: Scalars['Boolean']; + isMicrosoftAuthEnabled: Scalars['Boolean']; + isPasswordAuthEnabled: Scalars['Boolean']; isPublicInviteLinkEnabled: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; @@ -1668,13 +1681,6 @@ export type EmailPasswordResetLinkMutationVariables = Exact<{ export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } }; -export type FindAvailableSsoIdentityProvidersMutationVariables = Exact<{ - input: FindAvailableSsoidpInput; -}>; - - -export type FindAvailableSsoIdentityProvidersMutation = { __typename?: 'Mutation', findAvailableSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> }; - export type GenerateApiKeyTokenMutationVariables = Exact<{ apiKeyId: Scalars['String']; expiresAt: Scalars['String']; @@ -1683,13 +1689,6 @@ export type GenerateApiKeyTokenMutationVariables = Exact<{ export type GenerateApiKeyTokenMutation = { __typename?: 'Mutation', generateApiKeyToken: { __typename?: 'ApiKeyToken', token: string } }; -export type GenerateJwtMutationVariables = Exact<{ - workspaceId: Scalars['String']; -}>; - - -export type GenerateJwtMutation = { __typename?: 'Mutation', generateJWT: { __typename?: 'GenerateJWTOutputWithAuthTokens', success: boolean, reason: string, authTokens: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } } | { __typename?: 'GenerateJWTOutputWithSSOAUTH', success: boolean, reason: string, availableSSOIDPs: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> } }; - export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: never; }>; @@ -1707,7 +1706,7 @@ export type ImpersonateMutationVariables = Exact<{ }>; -export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; @@ -1727,6 +1726,13 @@ export type SignUpMutationVariables = Exact<{ export type SignUpMutation = { __typename?: 'Mutation', signUp: { __typename?: 'LoginToken', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; +export type SwitchWorkspaceMutationVariables = Exact<{ + workspaceId: Scalars['String']; +}>; + + +export type SwitchWorkspaceMutation = { __typename?: 'Mutation', switchWorkspace: { __typename?: 'PublicWorkspaceDataOutput', id: string, subdomain: string, authProviders: { __typename?: 'AuthProviders', sso: boolean, google: boolean, magicLink: boolean, password: boolean, microsoft: boolean } } }; + export type UpdatePasswordViaResetTokenMutationVariables = Exact<{ token: Scalars['String']; newPassword: Scalars['String']; @@ -1740,7 +1746,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; @@ -1750,10 +1756,19 @@ export type CheckUserExistsQueryVariables = Exact<{ export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean } }; -export type GetAvailableAuthMethodsByWorkspaceSubdomainQueryVariables = Exact<{ [key: string]: never; }>; +export type FindAvailableWorkspacesByEmailQueryVariables = Exact<{ + email: Scalars['String']; +}>; + + +export type FindAvailableWorkspacesByEmailQuery = { __typename?: 'Query', findAvailableWorkspacesByEmail: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso?: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> | null }> }; + +export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ + workspaceId?: InputMaybe; +}>; -export type GetAvailableAuthMethodsByWorkspaceSubdomainQuery = { __typename?: 'Query', getAvailableAuthMethodsByWorkspaceSubdomain: { __typename?: 'AvailableAuthMethodsOutput', id: boolean, authProviders: { __typename?: 'AvailableAuthProviders', sso: boolean, google: boolean, magicLink: boolean, password: boolean, microsoft: boolean } } }; +export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, authProviders: { __typename?: 'AuthProviders', sso: boolean, google: boolean, magicLink: boolean, password: boolean, microsoft: boolean } } }; export type ValidatePasswordResetTokenQueryVariables = Exact<{ token: Scalars['String']; @@ -1804,14 +1819,14 @@ export type CreateOidcIdentityProviderMutationVariables = Exact<{ }>; -export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; export type CreateSamlIdentityProviderMutationVariables = Exact<{ input: SetupSamlSsoInput; }>; -export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; export type DeleteSsoIdentityProviderMutationVariables = Exact<{ input: DeleteSsoInput; @@ -1825,14 +1840,14 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{ }>; -export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; +export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: string]: never; }>; -export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdpType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; +export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -1849,7 +1864,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -1938,7 +1953,7 @@ export type UpdateWorkspaceMutationVariables = Exact<{ }>; -export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, domainName?: string | null, displayName?: string | null, logo?: string | null, allowImpersonation: boolean } }; +export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, domainName?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean } }; export type UploadWorkspaceLogoMutationVariables = Exact<{ file: Scalars['Upload']; @@ -2100,6 +2115,10 @@ export const UserQueryFragmentFragmentDoc = gql` allowImpersonation activationStatus isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled + subdomain hasValidEntrepriseKey featureFlags { id @@ -2497,39 +2516,6 @@ export function useEmailPasswordResetLinkMutation(baseOptions?: Apollo.MutationH export type EmailPasswordResetLinkMutationHookResult = ReturnType; export type EmailPasswordResetLinkMutationResult = Apollo.MutationResult; export type EmailPasswordResetLinkMutationOptions = Apollo.BaseMutationOptions; -export const FindAvailableSsoIdentityProvidersDocument = gql` - mutation FindAvailableSSOIdentityProviders($input: FindAvailableSSOIDPInput!) { - findAvailableSSOIdentityProviders(input: $input) { - ...AvailableSSOIdentityProvidersFragment - } -} - ${AvailableSsoIdentityProvidersFragmentFragmentDoc}`; -export type FindAvailableSsoIdentityProvidersMutationFn = Apollo.MutationFunction; - -/** - * __useFindAvailableSsoIdentityProvidersMutation__ - * - * To run a mutation, you first call `useFindAvailableSsoIdentityProvidersMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useFindAvailableSsoIdentityProvidersMutation` 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 [findAvailableSsoIdentityProvidersMutation, { data, loading, error }] = useFindAvailableSsoIdentityProvidersMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useFindAvailableSsoIdentityProvidersMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(FindAvailableSsoIdentityProvidersDocument, options); - } -export type FindAvailableSsoIdentityProvidersMutationHookResult = ReturnType; -export type FindAvailableSsoIdentityProvidersMutationResult = Apollo.MutationResult; -export type FindAvailableSsoIdentityProvidersMutationOptions = Apollo.BaseMutationOptions; export const GenerateApiKeyTokenDocument = gql` mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) { generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) { @@ -2564,55 +2550,6 @@ export function useGenerateApiKeyTokenMutation(baseOptions?: Apollo.MutationHook export type GenerateApiKeyTokenMutationHookResult = ReturnType; export type GenerateApiKeyTokenMutationResult = Apollo.MutationResult; export type GenerateApiKeyTokenMutationOptions = Apollo.BaseMutationOptions; -export const GenerateJwtDocument = gql` - mutation GenerateJWT($workspaceId: String!) { - generateJWT(workspaceId: $workspaceId) { - ... on GenerateJWTOutputWithAuthTokens { - success - reason - authTokens { - tokens { - ...AuthTokensFragment - } - } - } - ... on GenerateJWTOutputWithSSOAUTH { - success - reason - availableSSOIDPs { - ...AvailableSSOIdentityProvidersFragment - } - } - } -} - ${AuthTokensFragmentFragmentDoc} -${AvailableSsoIdentityProvidersFragmentFragmentDoc}`; -export type GenerateJwtMutationFn = Apollo.MutationFunction; - -/** - * __useGenerateJwtMutation__ - * - * To run a mutation, you first call `useGenerateJwtMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useGenerateJwtMutation` 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 [generateJwtMutation, { data, loading, error }] = useGenerateJwtMutation({ - * variables: { - * workspaceId: // value for 'workspaceId' - * }, - * }); - */ -export function useGenerateJwtMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(GenerateJwtDocument, options); - } -export type GenerateJwtMutationHookResult = ReturnType; -export type GenerateJwtMutationResult = Apollo.MutationResult; -export type GenerateJwtMutationOptions = Apollo.BaseMutationOptions; export const GenerateTransientTokenDocument = gql` mutation generateTransientToken { generateTransientToken { @@ -2801,6 +2738,47 @@ export function useSignUpMutation(baseOptions?: Apollo.MutationHookOptions; export type SignUpMutationResult = Apollo.MutationResult; export type SignUpMutationOptions = Apollo.BaseMutationOptions; +export const SwitchWorkspaceDocument = gql` + mutation SwitchWorkspace($workspaceId: String!) { + switchWorkspace(workspaceId: $workspaceId) { + id + subdomain + authProviders { + sso + google + magicLink + password + microsoft + } + } +} + `; +export type SwitchWorkspaceMutationFn = Apollo.MutationFunction; + +/** + * __useSwitchWorkspaceMutation__ + * + * To run a mutation, you first call `useSwitchWorkspaceMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSwitchWorkspaceMutation` 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 [switchWorkspaceMutation, { data, loading, error }] = useSwitchWorkspaceMutation({ + * variables: { + * workspaceId: // value for 'workspaceId' + * }, + * }); + */ +export function useSwitchWorkspaceMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SwitchWorkspaceDocument, options); + } +export type SwitchWorkspaceMutationHookResult = ReturnType; +export type SwitchWorkspaceMutationResult = Apollo.MutationResult; +export type SwitchWorkspaceMutationOptions = Apollo.BaseMutationOptions; export const UpdatePasswordViaResetTokenDocument = gql` mutation UpdatePasswordViaResetToken($token: String!, $newPassword: String!) { updatePasswordViaResetToken( @@ -2913,10 +2891,58 @@ export function useCheckUserExistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOp export type CheckUserExistsQueryHookResult = ReturnType; export type CheckUserExistsLazyQueryHookResult = ReturnType; export type CheckUserExistsQueryResult = Apollo.QueryResult; -export const GetAvailableAuthMethodsByWorkspaceSubdomainDocument = gql` - query GetAvailableAuthMethodsByWorkspaceSubdomain { - getAvailableAuthMethodsByWorkspaceSubdomain { +export const FindAvailableWorkspacesByEmailDocument = gql` + query FindAvailableWorkspacesByEmail($email: String!) { + findAvailableWorkspacesByEmail(email: $email) { + id + displayName + subdomain + logo + sso { + type + id + issuer + name + status + } + } +} + `; + +/** + * __useFindAvailableWorkspacesByEmailQuery__ + * + * To run a query within a React component, call `useFindAvailableWorkspacesByEmailQuery` and pass it any options that fit your needs. + * When your component renders, `useFindAvailableWorkspacesByEmailQuery` 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 } = useFindAvailableWorkspacesByEmailQuery({ + * variables: { + * email: // value for 'email' + * }, + * }); + */ +export function useFindAvailableWorkspacesByEmailQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindAvailableWorkspacesByEmailDocument, options); + } +export function useFindAvailableWorkspacesByEmailLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindAvailableWorkspacesByEmailDocument, options); + } +export type FindAvailableWorkspacesByEmailQueryHookResult = ReturnType; +export type FindAvailableWorkspacesByEmailLazyQueryHookResult = ReturnType; +export type FindAvailableWorkspacesByEmailQueryResult = Apollo.QueryResult; +export const GetPublicWorkspaceDataBySubdomainDocument = gql` + query GetPublicWorkspaceDataBySubdomain($workspaceId: String) { + getPublicWorkspaceDataBySubdomain(workspaceId: $workspaceId) { id + logo + displayName + subdomain authProviders { sso google @@ -2929,31 +2955,32 @@ export const GetAvailableAuthMethodsByWorkspaceSubdomainDocument = gql` `; /** - * __useGetAvailableAuthMethodsByWorkspaceSubdomainQuery__ + * __useGetPublicWorkspaceDataBySubdomainQuery__ * - * To run a query within a React component, call `useGetAvailableAuthMethodsByWorkspaceSubdomainQuery` and pass it any options that fit your needs. - * When your component renders, `useGetAvailableAuthMethodsByWorkspaceSubdomainQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useGetPublicWorkspaceDataBySubdomainQuery` and pass it any options that fit your needs. + * When your component renders, `useGetPublicWorkspaceDataBySubdomainQuery` 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 } = useGetAvailableAuthMethodsByWorkspaceSubdomainQuery({ + * const { data, loading, error } = useGetPublicWorkspaceDataBySubdomainQuery({ * variables: { + * workspaceId: // value for 'workspaceId' * }, * }); */ -export function useGetAvailableAuthMethodsByWorkspaceSubdomainQuery(baseOptions?: Apollo.QueryHookOptions) { +export function useGetPublicWorkspaceDataBySubdomainQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(GetAvailableAuthMethodsByWorkspaceSubdomainDocument, options); + return Apollo.useQuery(GetPublicWorkspaceDataBySubdomainDocument, options); } -export function useGetAvailableAuthMethodsByWorkspaceSubdomainLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useGetPublicWorkspaceDataBySubdomainLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(GetAvailableAuthMethodsByWorkspaceSubdomainDocument, options); + return Apollo.useLazyQuery(GetPublicWorkspaceDataBySubdomainDocument, options); } -export type GetAvailableAuthMethodsByWorkspaceSubdomainQueryHookResult = ReturnType; -export type GetAvailableAuthMethodsByWorkspaceSubdomainLazyQueryHookResult = ReturnType; -export type GetAvailableAuthMethodsByWorkspaceSubdomainQueryResult = Apollo.QueryResult; +export type GetPublicWorkspaceDataBySubdomainQueryHookResult = ReturnType; +export type GetPublicWorkspaceDataBySubdomainLazyQueryHookResult = ReturnType; +export type GetPublicWorkspaceDataBySubdomainQueryResult = Apollo.QueryResult; export const ValidatePasswordResetTokenDocument = gql` query ValidatePasswordResetToken($token: String!) { validatePasswordResetToken(passwordResetToken: $token) { @@ -3915,9 +3942,14 @@ export const UpdateWorkspaceDocument = gql` updateWorkspace(data: $input) { id domainName + subdomain displayName logo allowImpersonation + isPublicInviteLinkEnabled + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled } } `; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx index 0cc8315c3c2f..9a1a1b52556d 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx @@ -135,35 +135,23 @@ export const SettingsSecurityOptionsList = () => { Icon={IconGoogle} title="Google" description="Allow logins through Google's single sign-on functionality." - onClick={() => toggleAuthMethod('google')} - > - - + checked={currentWorkspace?.isGoogleAuthEnabled} + onChange={() => toggleAuthMethod('google')} + /> toggleAuthMethod('microsoft')} - > - - + checked={currentWorkspace?.isMicrosoftAuthEnabled} + onChange={() => toggleAuthMethod('microsoft')} + /> toggleAuthMethod('password')} - > - - + checked={currentWorkspace?.isPasswordAuthEnabled} + onChange={() => toggleAuthMethod('password')} + /> Date: Tue, 12 Nov 2024 16:54:18 +0100 Subject: [PATCH 09/24] feat(subdomain): improve signinup --- .../src/modules/auth/hooks/useAuth.ts | 18 ++++--- .../sign-in-up/components/SignInUpForm.tsx | 1 - .../components/SignInUpGlobalScope.tsx | 13 ++++- .../components/SignInUpWorkspaceSelection.tsx | 15 ++---- .../auth/sign-in-up/hooks/useSignInUpForm.ts | 9 +++- .../states/lastAuthenticateWorkspaceState.ts | 4 +- .../hooks/useWorkspaceSwitching.ts | 4 +- .../twenty-front/src/pages/auth/SignInUp.tsx | 50 ++++++++----------- .../src/utils/workspace-url.helper.ts | 35 +++++++++++-- 9 files changed, 91 insertions(+), 58 deletions(-) diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 585cb1b4be5b..b3672fc6ca8d 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -43,7 +43,10 @@ import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTi import { currentUserState } from '../states/currentUserState'; import { tokenPairState } from '../states/tokenPairState'; import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; -import { getWorkspaceSubdomain } from '~/utils/workspace-url.helper'; +import { + getWorkspaceSubdomain, + isTwentyWorkspaceSubdomain, +} from '~/utils/workspace-url.helper'; export const useAuth = () => { const [, setTokenPair] = useRecoilState(tokenPairState); @@ -162,10 +165,12 @@ export const useAuth = () => { const workspace = user.defaultWorkspace ?? null; setCurrentWorkspace(workspace); - setLastAuthenticateWorkspaceState({ - id: workspace?.id, - subdomain: workspace?.subdomain, - }); + if (isDefined(workspace) && isTwentyWorkspaceSubdomain) { + setLastAuthenticateWorkspaceState({ + id: workspace?.id, + subdomain: workspace?.subdomain, + }); + } if (isDefined(verifyResult.data?.verify.user.workspaces)) { const validWorkspaces = verifyResult.data?.verify.user.workspaces @@ -314,8 +319,7 @@ export const useAuth = () => { workspaceInviteHash?: string; }, ) => { - const authServerUrl = REACT_APP_SERVER_BASE_URL; - const url = new URL(`${authServerUrl}${path}`); + const url = new URL(`${REACT_APP_SERVER_BASE_URL}${path}`); if (isDefined(params.workspaceInviteHash)) { url.searchParams.set('inviteHash', params.workspaceInviteHash); } diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index d64950287afc..40f17123eb14 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -1,4 +1,3 @@ -import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx index e3b955315755..cfaca47f8d61 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx @@ -27,6 +27,7 @@ import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; +import { redirectToWorkspace } from '~/utils/workspace-url.helper'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -79,13 +80,22 @@ export const SignInUpGlobalScope = () => { const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setShowErrors(true); + const { data, error } = await findAvailableWorkspacesByEmail( form.getValues('email'), ); if (isDefined(data) && data.findAvailableWorkspacesByEmail.length > 1) { setAvailableWorkspacesForAuthState(data.findAvailableWorkspacesByEmail); - setSignInUpStep(SignInUpStep.WorkspaceSelection); + return setSignInUpStep(SignInUpStep.WorkspaceSelection); + } + + if (isDefined(data) && data.findAvailableWorkspacesByEmail.length === 1) { + return redirectToWorkspace( + data.findAvailableWorkspacesByEmail[0].subdomain, + { email: form.getValues('email') }, + ); } + // si 1 workspace sans sso redirige sur workspace avec email en query params pour prefill // si 1 workspace avec sso et 1 sso login avec le sso // si plusieurs workspaces redirige sur la liste des workspaces @@ -152,7 +162,6 @@ export const SignInUpGlobalScope = () => { /> - ); }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx index 20ca94b3356b..853c1f12d153 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx @@ -1,11 +1,9 @@ -/* @license Enterprise */ - import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; import { H2Title, MainButton } from 'twenty-ui'; import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; -import { buildWorkspaceUrl } from '~/utils/workspace-url.helper'; +import { redirectToWorkspace } from '~/utils/workspace-url.helper'; const StyledContentContainer = styled.div` display: flex; @@ -39,12 +37,6 @@ export const SignInUpWorkspaceSelection = () => { availableWorkspacesForAuthState, ); - const moveToWorkspace = ( - workspace: NonNullable[0], - ) => { - window.location.href = buildWorkspaceUrl(workspace.subdomain); - }; - return ( <> @@ -52,7 +44,10 @@ export const SignInUpWorkspaceSelection = () => { {availableWorkspacesForAuth && availableWorkspacesForAuth.length !== 0 && availableWorkspacesForAuth.map((workspace) => ( - moveToWorkspace(workspace)} fullWidth> + redirectToWorkspace(workspace.subdomain)} + fullWidth + > diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts index 07e2aaf7b174..e0d49000c872 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -3,9 +3,11 @@ import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { useRecoilValue } from 'recoil'; import { z } from 'zod'; +import { useLocation } from 'react-router-dom'; import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; +import { isDefined } from '~/utils/isDefined'; export const validationSchema = z .object({ @@ -20,6 +22,8 @@ export const validationSchema = z export type Form = z.infer; export const useSignInUpForm = () => { + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); const isSignInPrefilled = useRecoilValue(isSignInPrefilledState); const form = useForm({ mode: 'onChange', @@ -30,7 +34,10 @@ export const useSignInUpForm = () => { }); useEffect(() => { - if (isSignInPrefilled === true) { + const email = searchParams.get('email'); + if (isDefined(email)) { + form.setValue('email', email); + } else if (isSignInPrefilled === true) { form.setValue('email', 'tim@apple.dev'); form.setValue('password', 'Applecar2025'); } diff --git a/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts index fb8f880d4bf1..76ad9bc613be 100644 --- a/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts @@ -1,7 +1,7 @@ import { cookieStorageEffect } from '~/utils/recoil-effects'; import { Workspace } from '~/generated/graphql'; import { createState } from 'twenty-ui'; -import { getWorkspaceMaindomain } from '~/utils/workspace-url.helper'; +import { getWorkspaceMainDomain } from '~/utils/workspace-url.helper'; export const lastAuthenticateWorkspaceState = createState { const [switchWorkspaceMutation] = useSwitchWorkspaceMutation(); @@ -23,7 +23,7 @@ export const useWorkspaceSwitching = () => { return (window.location.href = AppPath.Index); } - window.location.href = buildWorkspaceUrl(data.switchWorkspace.subdomain); + redirectToWorkspace(data.switchWorkspace.subdomain); }; return { switchWorkspace }; diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index b1217642fd15..665b534c073a 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -1,16 +1,14 @@ -import { useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SignInUpStep } from '@/auth/states/signInUpStepState'; -import { isDefined } from '~/utils/isDefined'; import { useWorkspacePublicData } from '@/auth/sign-in-up/hooks/useWorkspacePublicData'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { isTwentyHomePage, - isTwentyWorkspaceSubdomain, + redirectToHome, + twentyHomePage, } from '~/utils/workspace-url.helper'; import { SignInUpWorkspaceSelection } from '@/auth/sign-in-up/components/SignInUpWorkspaceSelection'; import { SignInUpGlobalScope } from '@/auth/sign-in-up/components/SignInUpGlobalScope'; @@ -20,38 +18,34 @@ import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; +import { Link } from 'react-router-dom'; +import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; export const SignInUp = () => { + const setLastAuthenticateWorkspaceState = useSetRecoilState( + lastAuthenticateWorkspaceState, + ); const { form } = useSignInUpForm(); - const currentWorkspace = useRecoilValue(currentWorkspaceState); - - const { signInUpStep, signInUpMode } = useSignInUp(form); + const { signInUpStep } = useSignInUp(form); - const { loading } = useWorkspacePublicData(); + useWorkspacePublicData(); const workspacePublicData = useRecoilValue(workspacePublicDataState); - const title = useMemo(() => { - if ( - signInUpStep === SignInUpStep.Init || - signInUpStep === SignInUpStep.Email - ) { - return `Welcome to ${workspacePublicData?.displayName ?? 'Twenty'}`; - } - if (signInUpStep === SignInUpStep.WorkspaceSelection) { - return 'Choose a workspace'; - } - return signInUpMode === SignInUpMode.SignIn - ? `Sign in to ${workspacePublicData?.displayName ?? 'Twenty'}` - : `Sign up to ${workspacePublicData?.displayName ?? 'Twenty'}`; - }, [signInUpMode, signInUpStep, workspacePublicData]); - - if (isDefined(currentWorkspace)) { - return <>; - } + const moveToHome = (event: React.MouseEvent) => { + event.preventDefault(); + setLastAuthenticateWorkspaceState(null); + redirectToHome(); + }; return ( <> + {/* TODO AMOREAUX: Need design for this */} + {!isTwentyHomePage && ( + + Back to home + + )} diff --git a/packages/twenty-front/src/utils/workspace-url.helper.ts b/packages/twenty-front/src/utils/workspace-url.helper.ts index 54b179212cdd..eda43b78f99a 100644 --- a/packages/twenty-front/src/utils/workspace-url.helper.ts +++ b/packages/twenty-front/src/utils/workspace-url.helper.ts @@ -1,16 +1,20 @@ +import { isDefined } from '~/utils/isDefined'; + +// TODO AMOREAUX: Check with charles for the env management in the front export const twentyHostname = process.env.REACT_APP_BASE_URL ? new URL(process.env.REACT_APP_BASE_URL).hostname : 'twenty.com'; +export const twentyHomePage = `app.${twentyHostname}`; + export const isTwentyHosting = window.location.hostname.endsWith(twentyHostname); -export const isTwentyHomePage = - window.location.hostname === `app.${twentyHostname}`; +export const isTwentyHomePage = window.location.hostname === twentyHomePage; export const isTwentyWorkspaceSubdomain = isTwentyHosting && !isTwentyHomePage; -export const getWorkspaceMaindomain = () => { +export const getWorkspaceMainDomain = () => { return isTwentyHosting ? twentyHostname : window.location.hostname; }; @@ -20,12 +24,33 @@ export const getWorkspaceSubdomain = () => { : null; }; -export const buildWorkspaceUrl = (withSubdomain?: string) => { +export const buildWorkspaceUrl = ( + withSubdomain?: string, + searchParams?: Record, +) => { const url = new URL(window.location.href); - if (isTwentyHosting === true && Boolean(withSubdomain)) { + if (isTwentyHosting && Boolean(withSubdomain)) { url.hostname = `${withSubdomain}.${twentyHostname}`; } + if (isDefined(searchParams)) { + Object.entries(searchParams).forEach(([key, value]) => + url.searchParams.set(key, value), + ); + } return url.toString(); }; + +export const redirectToHome = () => { + const url = new URL(window.location.href); + url.hostname = twentyHomePage; + window.location.href = url.toString(); +}; + +export const redirectToWorkspace = ( + subdomain: string, + searchParams?: Record, +) => { + window.location.href = buildWorkspaceUrl(subdomain, searchParams); +}; From 8050c5a2002b1ef65c0a352d1545924f3d2556af Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 12 Nov 2024 17:58:09 +0100 Subject: [PATCH 10/24] feat(subdomain): create WorkspaceProviderEffect --- .../app/components/AppRouterProviders.tsx | 2 + .../hooks/useWorkspacePublicData.ts | 33 --------- .../layout/page/components/DefaultLayout.tsx | 25 ------- .../users/components/UserProviderEffect.tsx | 4 ++ .../components/WorkspaceProviderEffect.tsx | 58 ++++++++++++++++ .../twenty-front/src/pages/auth/SignInUp.tsx | 3 - .../src/pages/auth/SignInUpLegacy.tsx | 67 ------------------- .../auth/__stories__/SignInUp.stories.tsx | 6 +- .../src/utils/workspace-url.helper.ts | 6 +- .../workspace/services/workspace.service.ts | 8 ++- 10 files changed, 77 insertions(+), 135 deletions(-) delete mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspacePublicData.ts create mode 100644 packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx delete mode 100644 packages/twenty-front/src/pages/auth/SignInUpLegacy.tsx diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index 7f64849a27e0..7c986b328eb9 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -17,6 +17,7 @@ import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; import { UserProvider } from '@/users/components/UserProvider'; import { UserProviderEffect } from '@/users/components/UserProviderEffect'; +import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect'; import { StrictMode } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import { getPageTitleFromPath } from '~/utils/title-utils'; @@ -32,6 +33,7 @@ export const AppRouterProviders = () => { + diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspacePublicData.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspacePublicData.ts deleted file mode 100644 index 970f2c898c09..000000000000 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspacePublicData.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; -import { authProvidersState } from '@/client-config/states/authProvidersState'; -import { useSetRecoilState } from 'recoil'; -import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; - -export const useWorkspacePublicData = () => { - const { enqueueSnackBar } = useSnackBar(); - - const setAuthProviders = useSetRecoilState(authProvidersState); - const setWorkspacePublicDataState = useSetRecoilState( - workspacePublicDataState, - ); - - const { loading } = useGetPublicWorkspaceDataBySubdomainQuery({ - onCompleted: (data) => { - const publicWorkspaceDataBySubdomain = - data.getPublicWorkspaceDataBySubdomain; - setAuthProviders(publicWorkspaceDataBySubdomain.authProviders); - setWorkspacePublicDataState(publicWorkspaceDataBySubdomain); - }, - onError: (error) => { - enqueueSnackBar(error.message, { - variant: SnackBarVariant.Error, - }); - }, - }); - - return { - loading, - }; -}; diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 680ee3af8c50..94821fa81b90 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -15,15 +15,6 @@ import styled from '@emotion/styled'; import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; import { Outlet } from 'react-router-dom'; import { useScreenSize } from 'twenty-ui'; -import { - buildWorkspaceUrl, - isTwentyHomePage, - isTwentyHosting, -} from '~/utils/workspace-url.helper'; -import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; -import { useRecoilValue } from 'recoil'; -import { useEffect } from 'react'; -import { isDefined } from '~/utils/isDefined'; const StyledLayout = styled.div` background: ${({ theme }) => theme.background.noisy}; @@ -74,22 +65,6 @@ export const DefaultLayout = () => { const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); - const lastAuthenticateWorkspace = useRecoilValue( - lastAuthenticateWorkspaceState, - ); - - useEffect(() => { - if ( - isTwentyHosting && - isTwentyHomePage && - isDefined(lastAuthenticateWorkspace) - ) { - window.location.href = buildWorkspaceUrl( - lastAuthenticateWorkspace.subdomain, - ); - } - }, [lastAuthenticateWorkspace]); - return ( <> { const [isLoading, setIsLoading] = useState(true); diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx new file mode 100644 index 000000000000..ef2b3f0a09ad --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -0,0 +1,58 @@ +import { useRecoilValue, useSetRecoilState } from 'recoil'; + +import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql'; +import { + getWorkspaceSubdomain, + isTwentyHomePage, + redirectToHome, + redirectToWorkspace, +} from '~/utils/workspace-url.helper'; +import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import { useEffect } from 'react'; +import { isDefined } from '~/utils/isDefined'; +import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; + +export const WorkspaceProviderEffect = () => { + const workspacePublicData = useRecoilValue(workspacePublicDataState); + + const setAuthProviders = useSetRecoilState(authProvidersState); + const setWorkspacePublicDataState = useSetRecoilState( + workspacePublicDataState, + ); + + const setLastAuthenticateWorkspaceState = useSetRecoilState( + lastAuthenticateWorkspaceState, + ); + const lastAuthenticateWorkspace = useRecoilValue( + lastAuthenticateWorkspaceState, + ); + + useGetPublicWorkspaceDataBySubdomainQuery({ + onCompleted: (data) => { + setAuthProviders(data.getPublicWorkspaceDataBySubdomain.authProviders); + setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain); + }, + onError: () => { + setLastAuthenticateWorkspaceState(null); + redirectToHome(); + }, + }); + + useEffect(() => { + if ( + isDefined(workspacePublicData?.subdomain) && + workspacePublicData.subdomain !== getWorkspaceSubdomain() + ) { + redirectToWorkspace(workspacePublicData.subdomain); + } + }, [workspacePublicData]); + + useEffect(() => { + if (isDefined(lastAuthenticateWorkspace) && isTwentyHomePage) { + redirectToWorkspace(lastAuthenticateWorkspace.subdomain); + } + }, [lastAuthenticateWorkspace]); + + return <>; +}; diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 665b534c073a..0d32c8acf9fb 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -3,7 +3,6 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { SignInUpStep } from '@/auth/states/signInUpStepState'; -import { useWorkspacePublicData } from '@/auth/sign-in-up/hooks/useWorkspacePublicData'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { isTwentyHomePage, @@ -28,8 +27,6 @@ export const SignInUp = () => { const { form } = useSignInUpForm(); const { signInUpStep } = useSignInUp(form); - useWorkspacePublicData(); - const workspacePublicData = useRecoilValue(workspacePublicDataState); const moveToHome = (event: React.MouseEvent) => { diff --git a/packages/twenty-front/src/pages/auth/SignInUpLegacy.tsx b/packages/twenty-front/src/pages/auth/SignInUpLegacy.tsx deleted file mode 100644 index f24ce377b87c..000000000000 --- a/packages/twenty-front/src/pages/auth/SignInUpLegacy.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { Logo } from '@/auth/components/Logo'; -import { Title } from '@/auth/components/Title'; -import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; -import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; -import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { SignInUpStep } from '@/auth/states/signInUpStepState'; -import { IconLockCustom } from '@ui/display/icon/components/IconLock'; -import { AnimatedEaseIn } from 'twenty-ui'; -import { isDefined } from '~/utils/isDefined'; -import { useWorkspacePublicData } from '@/auth/sign-in-up/hooks/useWorkspacePublicData'; -import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; - -export const SignInUpLegacy = () => { - const { form } = useSignInUpForm(); - const currentWorkspace = useRecoilValue(currentWorkspaceState); - - const { signInUpStep, signInUpMode } = useSignInUp(form); - - const { loading } = useWorkspacePublicData(); - - const workspacePublicData = useRecoilValue(workspacePublicDataState); - - const title = useMemo(() => { - if ( - signInUpStep === SignInUpStep.Init || - signInUpStep === SignInUpStep.Email - ) { - return `Welcome to ${workspacePublicData?.displayName ?? 'Twenty'}`; - } - if (signInUpStep === SignInUpStep.WorkspaceSelection) { - return 'Choose a workspace'; - } - return signInUpMode === SignInUpMode.SignIn - ? `Sign in to ${workspacePublicData?.displayName ?? 'Twenty'}` - : `Sign up to ${workspacePublicData?.displayName ?? 'Twenty'}`; - }, [signInUpMode, signInUpStep, workspacePublicData]); - - if (isDefined(currentWorkspace)) { - return <>; - } - - return ( - <> - - {signInUpStep === SignInUpStep.WorkspaceSelection ? ( - - ) : ( - - )} - - {/*{!loading && (*/} - {/* <>*/} - {/* {title}*/} - {/* {signInUpStep === SignInUpStep.WorkspaceSelection ? (*/} - {/* */} - {/* ) : (*/} - {/* */} - {/* )}*/} - {/* */} - {/*)}*/} - - ); -}; diff --git a/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx b/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx index 8810ad98170f..1ce73e337e1e 100644 --- a/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx +++ b/packages/twenty-front/src/pages/auth/__stories__/SignInUp.stories.tsx @@ -11,11 +11,11 @@ import { import { graphqlMocks } from '~/testing/graphqlMocks'; import { AppPath } from '@/types/AppPath'; -import { SignInUpLegacy } from '../SignInUpLegacy'; +import { SignInUp } from '../SignInUp'; const meta: Meta = { title: 'Pages/Auth/SignInUp', - component: SignInUpLegacy, + component: SignInUp, decorators: [PageDecorator], args: { routePath: AppPath.SignInUp }, parameters: { @@ -47,7 +47,7 @@ const meta: Meta = { export default meta; -export type Story = StoryObj; +export type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { diff --git a/packages/twenty-front/src/utils/workspace-url.helper.ts b/packages/twenty-front/src/utils/workspace-url.helper.ts index eda43b78f99a..3bb950adf52a 100644 --- a/packages/twenty-front/src/utils/workspace-url.helper.ts +++ b/packages/twenty-front/src/utils/workspace-url.helper.ts @@ -44,8 +44,10 @@ export const buildWorkspaceUrl = ( export const redirectToHome = () => { const url = new URL(window.location.href); - url.hostname = twentyHomePage; - window.location.href = url.toString(); + if (url.hostname !== twentyHomePage) { + url.hostname = twentyHomePage; + window.location.href = url.toString(); + } }; export const redirectToWorkspace = ( diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index d109b9496019..40579dac1c09 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -49,7 +49,7 @@ export class WorkspaceService extends TypeOrmQueryService { } private async generateSubdomain(displayName: string) { - const displayNameWords = displayName.match(/(\w| |\d)+/); + const displayNameWords = displayName.match(/(\w| |\d)+/g); let subdomain = ''; if (displayNameWords) { @@ -107,13 +107,17 @@ export class WorkspaceService extends TypeOrmQueryService { const subdomain = await this.generateSubdomain(data.displayName); + console.log('>>>>>>>>>>>>>>', subdomain); + await this.workspaceRepository.update(user.defaultWorkspaceId, { subdomain, displayName: data.displayName, activationStatus: WorkspaceActivationStatus.ACTIVE, }); - return existingWorkspace; + return await this.workspaceRepository.findOneBy({ + id: user.defaultWorkspaceId, + }); } async softDeleteWorkspace(id: string) { From 7175b6d1853ef83f461a56a9d1a38768c6ddba05 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 12 Nov 2024 20:10:47 +0100 Subject: [PATCH 11/24] feat(subdomain): add env var IS_MULTIWORKSPACE_ENABLED + add link on switch workspace button --- .../twenty-front/src/generated/graphql.tsx | 9 +++-- .../components/SignInUpGlobalScope.tsx | 8 ++-- .../src/modules/auth/states/workspaces.ts | 5 ++- .../MultiWorkspaceDropdownButton.tsx | 38 ++++++++++++------- .../hooks/useWorkspaceSwitching.ts | 7 +++- .../graphql/fragments/userQueryFragment.ts | 1 + .../auth/services/sign-in-up.service.ts | 14 ++++--- .../environment/environment-variables.ts | 5 +++ 8 files changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 472ac79927e3..f39ea23da687 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1706,7 +1706,7 @@ export type ImpersonateMutationVariables = Exact<{ }>; -export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; @@ -1746,7 +1746,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; @@ -1847,7 +1847,7 @@ export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -1864,7 +1864,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null, subdomain: string } | null }> } }; export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -2140,6 +2140,7 @@ export const UserQueryFragmentFragmentDoc = gql` logo displayName domainName + subdomain } } userVars diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx index cfaca47f8d61..1b52e1791f84 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx @@ -20,7 +20,7 @@ import { Title } from '@/auth/components/Title'; import { useFindAvailableWorkspacesByEmail } from '@/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail'; import { FormEvent, useState } from 'react'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { isDefined } from '~/utils/isDefined'; import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; import { @@ -28,6 +28,7 @@ import { signInUpStepState, } from '@/auth/states/signInUpStepState'; import { redirectToWorkspace } from '~/utils/workspace-url.helper'; +import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -57,6 +58,7 @@ const validationSchema = z export const SignInUpGlobalScope = () => { const theme = useTheme(); + const isSignInPrefilled = useRecoilValue(isSignInPrefilledState); const { signInWithGoogle } = useSignInWithGoogle(); const { signInWithMicrosoft } = useSignInWithMicrosoft(); @@ -72,7 +74,7 @@ export const SignInUpGlobalScope = () => { const form = useForm>({ mode: 'onChange', defaultValues: { - email: '', + email: isSignInPrefilled === true ? 'tim@apple.dev' : '', }, resolver: zodResolver(validationSchema), }); @@ -81,7 +83,7 @@ export const SignInUpGlobalScope = () => { event.preventDefault(); setShowErrors(true); - const { data, error } = await findAvailableWorkspacesByEmail( + const { data } = await findAvailableWorkspacesByEmail( form.getValues('email'), ); if (isDefined(data) && data.findAvailableWorkspacesByEmail.length > 1) { diff --git a/packages/twenty-front/src/modules/auth/states/workspaces.ts b/packages/twenty-front/src/modules/auth/states/workspaces.ts index d211351b08d2..da0d270bb39d 100644 --- a/packages/twenty-front/src/modules/auth/states/workspaces.ts +++ b/packages/twenty-front/src/modules/auth/states/workspaces.ts @@ -2,7 +2,10 @@ import { createState } from 'twenty-ui'; import { Workspace } from '~/generated/graphql'; -export type Workspaces = Pick; +export type Workspaces = Pick< + Workspace, + 'id' | 'logo' | 'displayName' | 'subdomain' +>; export const workspacesState = createState({ key: 'workspacesState', diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx index 8efd2b231088..78d5ef8abfaf 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx @@ -15,6 +15,13 @@ import { useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { IconChevronDown, MenuItemSelectAvatar } from 'twenty-ui'; import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI'; +import { buildWorkspaceUrl } from '~/utils/workspace-url.helper'; +import { Link } from 'react-router-dom'; + +const StyledLink = styled(Link)` + text-decoration: none; + width: 100%; +`; const StyledLogo = styled.div<{ logo: string }>` background: url(${({ logo }) => logo}); @@ -114,19 +121,24 @@ export const MultiWorkspaceDropdownButton = ({ dropdownComponents={ {workspaces.map((workspace) => ( - - } - selected={currentWorkspace?.id === workspace.id} - onClick={() => handleChange(workspace.id)} - /> + + + } + selected={currentWorkspace?.id === workspace.id} + onClick={(event: React.MouseEvent) => { + event.preventDefault(); + handleChange(workspace.id); + }} + /> + ))} } diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index 352c8d59af9d..41ed5863add9 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -4,7 +4,10 @@ import { AppPath } from '@/types/AppPath'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useSwitchWorkspaceMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; -import { redirectToWorkspace } from '~/utils/workspace-url.helper'; +import { + redirectToHome, + redirectToWorkspace, +} from '~/utils/workspace-url.helper'; export const useWorkspaceSwitching = () => { const [switchWorkspaceMutation] = useSwitchWorkspaceMutation(); @@ -20,7 +23,7 @@ export const useWorkspaceSwitching = () => { }); if (isDefined(errors) || !isDefined(data?.switchWorkspace.subdomain)) { - return (window.location.href = AppPath.Index); + return redirectToHome(); } redirectToWorkspace(data.switchWorkspace.subdomain); diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 9dad7ee9e3c7..1d5e0201db29 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -57,6 +57,7 @@ export const USER_QUERY_FRAGMENT = gql` logo displayName domainName + subdomain } } userVars diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 2f2775a5accf..5447c77ee2d1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -266,11 +266,15 @@ export class SignInUpService { lastName: string; picture: SignInUpServiceInput['picture']; }) { - if (this.environmentService.get('IS_SIGN_UP_DISABLED')) { - throw new AuthException( - 'Sign up is disabled', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); + if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { + const numberOfWorkspaces = await this.workspaceRepository.count(); + + if (numberOfWorkspaces > 0) { + throw new AuthException( + 'New workspace setup is disabled', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } } const workspaceToCreate = this.workspaceRepository.create({ diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index a50ef428048b..c1f8acb5a024 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -231,6 +231,11 @@ export class EnvironmentVariables { @IsOptional() ENTERPRISE_KEY: string; + @CastToBoolean() + @IsOptional() + @IsBoolean() + IS_MULTIWORKSPACE_ENABLED = true; + // Custom Code Engine @IsEnum(ServerlessDriverType) @IsOptional() From cc06d793d13bbf0f1f2632e13c3cbb6c0814c7f1 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 13 Nov 2024 14:34:09 +0100 Subject: [PATCH 12/24] feat(sso|subdomain): Refactor SSO service URL handling and improve SAML parsing Simplified the retrieval of server URLs by introducing `ServerUrl` utility. Updated callback and issuer URL building in SSO service to use this utility. Enhanced SAML metadata parsing to handle potential null scenarios, and fixed type issues in several frontend components. --- .../utils/parseSAMLMetadataFromXMLFile.ts | 7 +++---- .../utils/sSOIdentityProviderDefaultValues.ts | 10 +++++++++- .../components/MultiWorkspaceDropdownButton.tsx | 2 +- .../auth/controllers/sso-auth.controller.ts | 2 +- .../core-modules/sso/services/sso.service.ts | 5 +++-- .../twenty-server/src/engine/utils/serverUrl.ts | 15 +++++++++++++++ packages/twenty-server/src/main.ts | 5 +++++ .../menu-item/components/MenuItemSelect.tsx | 2 +- .../menu-item/components/MenuItemSelectAvatar.tsx | 2 +- 9 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 packages/twenty-server/src/engine/utils/serverUrl.ts diff --git a/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts b/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts index 2e4fdf294b2d..6ed9924b07ef 100644 --- a/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts +++ b/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts @@ -16,7 +16,6 @@ export const parseSAMLMetadataFromXMLFile = ( try { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlString, 'application/xml'); - if (xmlDoc.getElementsByTagName('parsererror').length > 0) { throw new Error('Error parsing XML'); } @@ -28,10 +27,10 @@ export const parseSAMLMetadataFromXMLFile = ( 'md:IDPSSODescriptor', )?.[0]; const keyDescriptor = xmlDoc.getElementsByTagName('md:KeyDescriptor')[0]; - const keyInfo = keyDescriptor.getElementsByTagName('ds:KeyInfo')[0]; - const x509Data = keyInfo.getElementsByTagName('ds:X509Data')[0]; + const keyInfo = keyDescriptor?.getElementsByTagName('ds:KeyInfo')[0]; + const x509Data = keyInfo?.getElementsByTagName('ds:X509Data')[0]; const x509Certificate = x509Data - .getElementsByTagName('ds:X509Certificate')?.[0] + ?.getElementsByTagName('ds:X509Certificate')?.[0] .textContent?.trim(); const singleSignOnServices = Array.from( diff --git a/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts index ca16bf75ec2b..319ec19b6ab8 100644 --- a/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts +++ b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts @@ -11,7 +11,15 @@ export const sSOIdentityProviderDefaultValues: Record< type: 'SAML', ssoURL: '', name: '', - id: crypto.randomUUID(), + id: + window.location.protocol === 'https:' + ? crypto.randomUUID() + : '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => + ( + +c ^ + (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) + ).toString(16), + ), certificate: '', issuer: '', }), diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx index 78d5ef8abfaf..3df7e8878212 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx @@ -133,7 +133,7 @@ export const MultiWorkspaceDropdownButton = ({ /> } selected={currentWorkspace?.id === workspace.id} - onClick={(event: React.MouseEvent) => { + onClick={(event) => { event.preventDefault(); handleChange(workspace.id); }} diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index c40072a8c7eb..68bf4cfb0c4b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -48,7 +48,7 @@ export class SSOAuthController { @Get('saml/metadata/:identityProviderId') @UseGuards(SSOProviderEnabledGuard) - async generateMetadata(@Req() req: any): Promise { + async generateMetadata(@Req() req: any): Promise { return generateServiceProviderMetadata({ wantAssertionsSigned: false, issuer: this.ssoService.buildIssuerURL({ diff --git a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts index 7b2148d23000..72ebfc7118d5 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts @@ -28,6 +28,7 @@ import { WorkspaceSSOIdentityProvider, } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; +import ServerUrl from 'src/engine/utils/serverUrl'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -189,7 +190,7 @@ export class SSOService { buildCallbackUrl( identityProvider: Pick, ) { - const callbackURL = new URL(this.environmentService.get('SERVER_URL')); + const callbackURL = new URL(ServerUrl.get()); callbackURL.pathname = `/auth/${identityProvider.type.toLowerCase()}/callback`; @@ -199,7 +200,7 @@ export class SSOService { buildIssuerURL( identityProvider: Pick, ) { - return `${this.environmentService.get('SERVER_URL')}/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`; + return `${ServerUrl.get()}/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`; } private isOIDCIdentityProvider( diff --git a/packages/twenty-server/src/engine/utils/serverUrl.ts b/packages/twenty-server/src/engine/utils/serverUrl.ts new file mode 100644 index 000000000000..c3b011c18edd --- /dev/null +++ b/packages/twenty-server/src/engine/utils/serverUrl.ts @@ -0,0 +1,15 @@ +import { INestApplication } from '@nestjs/common'; + +// serverConfig.ts +const ServerUrl = (() => { + let serverUrl = 'http://localhost:3000'; + + return { + get: () => serverUrl, + set: (url: string) => { + serverUrl = url; + }, + }; +})(); + +export default ServerUrl; diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 469845f41103..8efb5d676690 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -17,6 +17,7 @@ import './instrument'; import { settings } from './engine/constants/settings'; import { generateFrontConfig } from './utils/generate-front-config'; +import ServerUrl from 'src/engine/utils/serverUrl'; const bootstrap = async () => { const app = await NestFactory.create(AppModule, { @@ -69,6 +70,10 @@ const bootstrap = async () => { } await app.listen(process.env.PORT ?? 3000); + + const url = new URL(await app.getUrl()); + url.hostname = url.hostname === '[::1]' ? 'localhost' : url.hostname; + ServerUrl.set(url.toString()); }; bootstrap(); diff --git a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelect.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelect.tsx index 3c91c42fbd4a..4147e8fa59bb 100644 --- a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelect.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelect.tsx @@ -42,7 +42,7 @@ type MenuItemSelectProps = { selected: boolean; text: string; className?: string; - onClick?: () => void; + onClick?: (event: React.MouseEvent) => void; disabled?: boolean; hovered?: boolean; hasSubMenu?: boolean; diff --git a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx index 477f6c037dcb..65a87fca09bd 100644 --- a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelectAvatar.tsx @@ -14,7 +14,7 @@ type MenuItemSelectAvatarProps = { selected: boolean; text: string; className?: string; - onClick?: () => void; + onClick?: (event: React.MouseEvent) => void; disabled?: boolean; hovered?: boolean; testId?: string; From 24da8ba4a93a86e0ebdb3049b776c37c67f25212 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 13 Nov 2024 17:56:30 +0100 Subject: [PATCH 13/24] feat(auth): Introduce SSO Identity Provider Selection Replace SignInUpForm with SignInUpWorkspaceScopeForm to handle SSO identity provider selection. Update workspace service and GraphQL queries to support detailed SSO identity provider information. --- .../twenty-front/src/generated/graphql.tsx | 31 +- .../auth/graphql/mutations/switchWorkspace.ts | 8 +- .../getPublicWorkspaceDataBySubdomain.ts | 8 +- .../sign-in-up/components/SignInUpForm.tsx | 383 ------------------ ...lScope.tsx => SignInUpGlobalScopeForm.tsx} | 2 +- .../SignInUpSSOIdentityProviderSelection.tsx | 53 +-- .../components/SignInUpWithGoogle.tsx | 28 ++ .../components/SignInUpWithMicrosoft.tsx | 28 ++ .../components/SignInUpWithPassword.tsx | 236 +++++++++++ .../sign-in-up/components/SignInUpWithSSO.tsx | 41 ++ .../components/SignInUpWorkspaceScopeForm.tsx | 65 +++ .../components/SignInUpWorkspaceSelection.tsx | 2 +- .../auth/sign-in-up/hooks/useSignInUp.tsx | 16 +- .../modules/auth/states/signInUpStepState.ts | 4 +- .../states/authProvidersState.ts | 2 +- ...SettingsSSOIdentitiesProvidersListCard.tsx | 28 +- ...sSSOIdentitiesProvidersListCardWrapper.tsx | 26 +- .../SettingsSecurityOptionsList.tsx | 91 ++--- .../MultiWorkspaceDropdownButton.tsx | 6 +- .../components/WorkspaceProviderEffect.tsx | 18 +- .../twenty-front/src/pages/auth/Invite.tsx | 4 +- .../twenty-front/src/pages/auth/SignInUp.tsx | 38 +- .../auth/controllers/sso-auth.controller.ts | 7 + .../auth/services/switch-workspace.service.ts | 20 - .../core-modules/sso/services/sso.service.ts | 13 +- .../services/workspace-invitation.service.ts | 3 +- .../dtos/public-workspace-data.output.ts | 22 +- .../workspace/services/workspace.service.ts | 8 +- 28 files changed, 616 insertions(+), 575 deletions(-) delete mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx rename packages/twenty-front/src/modules/auth/sign-in-up/components/{SignInUpGlobalScope.tsx => SignInUpGlobalScopeForm.tsx} (99%) create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithPassword.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index f39ea23da687..2f470608a587 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -74,7 +74,7 @@ export type AuthProviders = { magicLink: Scalars['Boolean']; microsoft: Scalars['Boolean']; password: Scalars['Boolean']; - sso: Scalars['Boolean']; + sso: Array; }; export type AuthToken = { @@ -962,6 +962,15 @@ export type SsoConnection = { type: IdentityProviderType; }; +export type SsoIdentityProvider = { + __typename?: 'SSOIdentityProvider'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: Scalars['String']; + type: Scalars['String']; +}; + export enum SsoIdentityProviderStatus { Active = 'Active', Error = 'Error', @@ -1731,7 +1740,7 @@ export type SwitchWorkspaceMutationVariables = Exact<{ }>; -export type SwitchWorkspaceMutation = { __typename?: 'Mutation', switchWorkspace: { __typename?: 'PublicWorkspaceDataOutput', id: string, subdomain: string, authProviders: { __typename?: 'AuthProviders', sso: boolean, google: boolean, magicLink: boolean, password: boolean, microsoft: boolean } } }; +export type SwitchWorkspaceMutation = { __typename?: 'Mutation', switchWorkspace: { __typename?: 'PublicWorkspaceDataOutput', id: string, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: string, status: string, issuer: string }> } } }; export type UpdatePasswordViaResetTokenMutationVariables = Exact<{ token: Scalars['String']; @@ -1768,7 +1777,7 @@ export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ }>; -export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, authProviders: { __typename?: 'AuthProviders', sso: boolean, google: boolean, magicLink: boolean, password: boolean, microsoft: boolean } } }; +export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: string, status: string, issuer: string }> } } }; export type ValidatePasswordResetTokenQueryVariables = Exact<{ token: Scalars['String']; @@ -2745,7 +2754,13 @@ export const SwitchWorkspaceDocument = gql` id subdomain authProviders { - sso + sso { + id + name + type + status + issuer + } google magicLink password @@ -2945,7 +2960,13 @@ export const GetPublicWorkspaceDataBySubdomainDocument = gql` displayName subdomain authProviders { - sso + sso { + id + name + type + status + issuer + } google magicLink password diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts index b999a002cbe5..e4b604ded31c 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/switchWorkspace.ts @@ -6,7 +6,13 @@ export const SWITCH_WORKSPACE = gql` id subdomain authProviders { - sso + sso { + id + name + type + status + issuer + } google magicLink password diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts index 0a97bda57ac2..a93799d7a3ce 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts @@ -8,7 +8,13 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql` displayName subdomain authProviders { - sso + sso { + id + name + type + status + issuer + } google magicLink password diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx deleted file mode 100644 index 40f17123eb14..000000000000 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ /dev/null @@ -1,383 +0,0 @@ -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; -import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; -import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; -import { - useSignInUpForm, - validationSchema, -} from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; -import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; -import { SignInUpStep } from '@/auth/states/signInUpStepState'; -import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; -import { authProvidersState } from '@/client-config/states/authProvidersState'; -import { captchaProviderState } from '@/client-config/states/captchaProviderState'; -import { TextInput } from '@/ui/input/components/TextInput'; -import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; -import { motion } from 'framer-motion'; -import { useMemo, useState } from 'react'; -import { Controller } from 'react-hook-form'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { Key } from 'ts-key-enum'; -import { - ActionLink, - IconGoogle, - IconKey, - IconMicrosoft, - Loader, - MainButton, - StyledText, -} from 'twenty-ui'; -import { isDefined } from '~/utils/isDefined'; - -const StyledContentContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(8)}; - margin-top: ${({ theme }) => theme.spacing(4)}; -`; - -const StyledForm = styled.form` - align-items: center; - display: flex; - flex-direction: column; - width: 100%; -`; - -const StyledFullWidthMotionDiv = styled(motion.div)` - width: 100%; -`; - -const StyledInputContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(3)}; -`; - -export const SignInUpForm = () => { - const captchaProvider = useRecoilValue(captchaProviderState); - const isRequestingCaptchaToken = useRecoilValue( - isRequestingCaptchaTokenState, - ); - const [authProviders] = useRecoilState(authProvidersState); - const [showErrors, setShowErrors] = useState(false); - const { signInWithGoogle } = useSignInWithGoogle(); - const { signInWithMicrosoft } = useSignInWithMicrosoft(); - const { form } = useSignInUpForm(); - const { handleResetPassword } = useHandleResetPassword(); - - const { - signInUpStep, - signInUpMode, - continueWithCredentials, - continueWithEmail, - continueWithSSO, - submitCredentials, - } = useSignInUp(form); - - if ( - signInUpStep === SignInUpStep.Init && - !authProviders.google && - !authProviders.microsoft && - !authProviders.sso - ) { - continueWithEmail(); - } - - const toggleSSOMode = () => { - if (signInUpStep === SignInUpStep.SSOEmail) { - continueWithEmail(); - } else { - continueWithSSO(); - } - }; - - const handleKeyDown = async ( - event: React.KeyboardEvent, - ) => { - if (event.key === Key.Enter) { - event.preventDefault(); - - if (signInUpStep === SignInUpStep.Init) { - continueWithEmail(); - } else if (signInUpStep === SignInUpStep.Email) { - if (isDefined(form?.formState?.errors?.email)) { - setShowErrors(true); - return; - } - continueWithCredentials(); - } - } - }; - - const buttonTitle = useMemo(() => { - if (signInUpStep === SignInUpStep.Init) { - return 'Continue With Email'; - } - - if (signInUpStep === SignInUpStep.Email) { - return 'Continue'; - } - - if (signInUpStep === SignInUpStep.SSOEmail) { - return 'Continue with SSO'; - } - - return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up'; - }, [signInUpMode, signInUpStep]); - - const theme = useTheme(); - - const shouldWaitForCaptchaToken = - signInUpStep !== SignInUpStep.Init && - isDefined(captchaProvider?.provider) && - isRequestingCaptchaToken; - - const isEmailStepSubmitButtonDisabledCondition = - signInUpStep === SignInUpStep.Email && - (!validationSchema.shape.email.safeParse(form.watch('email')).success || - shouldWaitForCaptchaToken); - - // TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders - // We make the isValid check synchronous and update a reactState to make sure this does not happen - const isPasswordStepSubmitButtonDisabledCondition = - signInUpStep === SignInUpStep.Password && - (!form.formState.isValid || - form.formState.isSubmitting || - shouldWaitForCaptchaToken); - - const isSubmitButtonDisabled = - isEmailStepSubmitButtonDisabledCondition || - isPasswordStepSubmitButtonDisabledCondition; - - return ( - <> - - {authProviders.google && ( - <> - } - title="Continue with Google" - onClick={signInWithGoogle} - variant={ - signInUpStep === SignInUpStep.Init ? undefined : 'secondary' - } - fullWidth - /> - - - )} - - {authProviders.microsoft && ( - <> - } - title="Continue with Microsoft" - onClick={signInWithMicrosoft} - variant={ - signInUpStep === SignInUpStep.Init ? undefined : 'secondary' - } - fullWidth - /> - - - )} - {authProviders.sso && ( - <> - } - variant={ - signInUpStep === SignInUpStep.Init ? undefined : 'secondary' - } - title={ - signInUpStep === SignInUpStep.SSOEmail - ? 'Continue with email' - : 'Single sign-on (SSO)' - } - onClick={toggleSSOMode} - fullWidth - /> - - - )} - - {(authProviders.google || - authProviders.microsoft || - authProviders.sso) && } - - {authProviders.password && - (signInUpStep === SignInUpStep.Password || - signInUpStep === SignInUpStep.Email || - signInUpStep === SignInUpStep.Init) && ( - { - event.preventDefault(); - }} - > - {signInUpStep !== SignInUpStep.Init && ( - - ( - - { - onChange(value); - if (signInUpStep === SignInUpStep.Password) { - continueWithEmail(); - } - }} - error={showErrors ? error?.message : undefined} - fullWidth - disableHotkeys - onKeyDown={handleKeyDown} - /> - - )} - /> - - )} - {signInUpStep === SignInUpStep.Password && ( - - ( - - - {signInUpMode === SignInUpMode.SignUp && ( - - )} - - )} - /> - - )} - { - if (signInUpStep === SignInUpStep.Init) { - continueWithEmail(); - return; - } - if (signInUpStep === SignInUpStep.Email) { - if (isDefined(form?.formState?.errors?.email)) { - setShowErrors(true); - return; - } - continueWithCredentials(); - return; - } - setShowErrors(true); - form.handleSubmit(submitCredentials)(); - }} - Icon={() => (form.formState.isSubmitting ? : null)} - disabled={isSubmitButtonDisabled} - fullWidth - /> - - )} - { - event.preventDefault(); - }} - > - {/*{signInUpStep === SignInUpStep.SSOEmail && (*/} - {/* <>*/} - {/* */} - {/* (*/} - {/* */} - {/* */} - {/* */} - {/* )}*/} - {/* />*/} - {/* */} - {/* {*/} - {/* setShowErrors(true);*/} - {/* submitSSOEmail(form.getValues('email'));*/} - {/* }}*/} - {/* Icon={() => form.formState.isSubmitting && }*/} - {/* disabled={isSubmitButtonDisabled}*/} - {/* fullWidth*/} - {/* />*/} - {/* */} - {/*)}*/} - - - {signInUpStep === SignInUpStep.Password && ( - - Forgot your password? - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx similarity index 99% rename from packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx rename to packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index 1b52e1791f84..7528547bfe7c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScope.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -56,7 +56,7 @@ const validationSchema = z }) .required(); -export const SignInUpGlobalScope = () => { +export const SignInUpGlobalScopeForm = () => { const theme = useTheme(); const isSignInPrefilled = useRecoilValue(isSignInPrefilledState); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx index dda0f4e6463d..1e0810ff4867 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx @@ -3,61 +3,36 @@ import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; -import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; -import { AnimatedEaseIn, MainButton } from 'twenty-ui'; -import { Logo } from '@/auth/components/Logo'; -import { Title } from '@/auth/components/Title'; -import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; +import { MainButton } from 'twenty-ui'; + +import { isDefined } from '~/utils/isDefined'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; const StyledContentContainer = styled.div` margin-bottom: ${({ theme }) => theme.spacing(8)}; margin-top: ${({ theme }) => theme.spacing(4)}; `; -const StyledTitle = styled.h2` - color: ${({ theme }) => theme.font.color.primary}; - font-size: ${({ theme }) => theme.font.size.md}; - font-weight: ${({ theme }) => theme.font.weight.semiBold}; - margin: 0; -`; - -export const SignInUpWorkspaceSelection = () => { - const availableWorkspacesForAuth = useRecoilValue( - availableWorkspacesForAuthState, - ); +export const SignInUpSSOIdentityProviderSelection = () => { + const authProviders = useRecoilValue(authProvidersState); const { redirectToSSOLoginPage } = useSSO(); return ( <> - - - - Welcome to Twenty - {availableWorkspacesForAuth && - availableWorkspacesForAuth.length !== 0 && - availableWorkspacesForAuth.map((workspace) => ( + {isDefined(authProviders?.sso) && + authProviders?.sso.map((idp) => ( <> - - {workspace.displayName ?? DEFAULT_WORKSPACE_NAME} - + redirectToSSOLoginPage(idp.id)} + Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)} + fullWidth + /> - {workspace.sso && - workspace.sso.length !== 0 && - workspace.sso.map((idp) => ( - <> - redirectToSSOLoginPage(idp.id)} - Icon={guessSSOIdentityProviderIconByUrl(idp.issuer)} - fullWidth - /> - - - ))} ))} diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx new file mode 100644 index 000000000000..7920b9f7723f --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx @@ -0,0 +1,28 @@ +import { IconGoogle, MainButton } from 'twenty-ui'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { useTheme } from '@emotion/react'; +import { useRecoilValue } from 'recoil'; +import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; + +export const SignInUpWithGoogle = () => { + const theme = useTheme(); + const signInUpStep = useRecoilValue(signInUpStepState); + const { signInWithGoogle } = useSignInWithGoogle(); + + return ( + <> + } + title="Continue with Google" + onClick={signInWithGoogle} + variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} + fullWidth + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx new file mode 100644 index 000000000000..a92ebafea751 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx @@ -0,0 +1,28 @@ +import { IconMicrosoft, MainButton } from 'twenty-ui'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useTheme } from '@emotion/react'; +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; +import { useRecoilValue } from 'recoil'; + +export const SignInUpWithMicrosoft = () => { + const theme = useTheme(); + const signInUpStep = useRecoilValue(signInUpStepState); + const { signInWithMicrosoft } = useSignInWithMicrosoft(); + + return ( + <> + } + title="Continue with Microsoft" + onClick={signInWithMicrosoft} + variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} + fullWidth + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithPassword.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithPassword.tsx new file mode 100644 index 000000000000..0a3d7e328532 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithPassword.tsx @@ -0,0 +1,236 @@ +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { Key } from 'ts-key-enum'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { Loader, MainButton, StyledText } from 'twenty-ui'; +import { isDefined } from '~/utils/isDefined'; +import styled from '@emotion/styled'; +import { Controller } from 'react-hook-form'; +import { motion } from 'framer-motion'; +import { useRecoilValue } from 'recoil'; +import { + useSignInUpForm, + validationSchema, +} from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { captchaProviderState } from '@/client-config/states/captchaProviderState'; +import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; +import { useState, useMemo } from 'react'; +import { useTheme } from '@emotion/react'; + +const StyledFullWidthMotionDiv = styled(motion.div)` + width: 100%; +`; + +const StyledForm = styled.form` + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +`; + +const StyledInputContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(3)}; +`; + +export const SignInUpWithPassword = () => { + const theme = useTheme(); + + const signInUpStep = useRecoilValue(signInUpStepState); + const captchaProvider = useRecoilValue(captchaProviderState); + const isRequestingCaptchaToken = useRecoilValue( + isRequestingCaptchaTokenState, + ); + + const { form } = useSignInUpForm(); + + const { + continueWithEmail, + submitCredentials, + continueWithCredentials, + signInUpMode, + } = useSignInUp(form); + + const [showErrors, setShowErrors] = useState(false); + + const handleKeyDown = async ( + event: React.KeyboardEvent, + ) => { + if (event.key === Key.Enter) { + event.preventDefault(); + + if (signInUpStep === SignInUpStep.Init) { + continueWithEmail(); + } else if (signInUpStep === SignInUpStep.Email) { + if (isDefined(form?.formState?.errors?.email)) { + setShowErrors(true); + return; + } + continueWithCredentials(); + } + } + }; + + const shouldWaitForCaptchaToken = + signInUpStep !== SignInUpStep.Init && + isDefined(captchaProvider?.provider) && + isRequestingCaptchaToken; + + const isEmailStepSubmitButtonDisabledCondition = + signInUpStep === SignInUpStep.Email && + (!validationSchema.shape.email.safeParse(form.watch('email')).success || + shouldWaitForCaptchaToken); + + // TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders + // We make the isValid check synchronous and update a reactState to make sure this does not happen + const isPasswordStepSubmitButtonDisabledCondition = + signInUpStep === SignInUpStep.Password && + (!form.formState.isValid || + form.formState.isSubmitting || + shouldWaitForCaptchaToken); + + const isSubmitButtonDisabled = + isEmailStepSubmitButtonDisabledCondition || + isPasswordStepSubmitButtonDisabledCondition; + + const buttonTitle = useMemo(() => { + if (signInUpStep === SignInUpStep.Init) { + return 'Continue With Email'; + } + + if (signInUpStep === SignInUpStep.Email) { + return 'Continue'; + } + + if (SignInUpMode.SignIn && signInUpStep === SignInUpStep.Password) { + return 'Sign in'; + } + + if (SignInUpMode.SignUp && signInUpStep === SignInUpStep.Password) { + return 'Sign up'; + } + }, [signInUpMode, signInUpStep]); + + return ( + <> + {(signInUpStep === SignInUpStep.Password || + signInUpStep === SignInUpStep.Email || + signInUpStep === SignInUpStep.Init) && ( + { + event.preventDefault(); + }} + > + {signInUpStep !== SignInUpStep.Init && ( + + ( + + { + onChange(value); + if (signInUpStep === SignInUpStep.Password) { + continueWithEmail(); + } + }} + error={showErrors ? error?.message : undefined} + fullWidth + disableHotkeys + onKeyDown={handleKeyDown} + /> + + )} + /> + + )} + {signInUpStep === SignInUpStep.Password && ( + + ( + + + {signInUpMode === SignInUpMode.SignUp && ( + + )} + + )} + /> + + )} + { + if (signInUpStep === SignInUpStep.Init) { + continueWithEmail(); + return; + } + if (signInUpStep === SignInUpStep.Email) { + if (isDefined(form?.formState?.errors?.email)) { + setShowErrors(true); + return; + } + continueWithCredentials(); + return; + } + setShowErrors(true); + form.handleSubmit(submitCredentials)(); + }} + Icon={() => (form.formState.isSubmitting ? : null)} + disabled={isSubmitButtonDisabled} + fullWidth + /> + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx new file mode 100644 index 000000000000..2dee450ce840 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx @@ -0,0 +1,41 @@ +import { IconGoogle, IconLock, MainButton } from 'twenty-ui'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { useTheme } from '@emotion/react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; + +export const SignInUpWithSSO = () => { + const theme = useTheme(); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const authProviders = useRecoilValue(authProvidersState); + + const signInUpStep = useRecoilValue(signInUpStepState); + + const { redirectToSSOLoginPage } = useSSO(); + + const signInWithSSO = () => { + if (authProviders.sso.length === 1) { + return redirectToSSOLoginPage(authProviders.sso[0].id); + } + + setSignInUpStep(SignInUpStep.SSOIdentityProviderSelection); + }; + + return ( + <> + } + title="Single sign-on (SSO)" + onClick={signInWithSSO} + variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} + fullWidth + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx new file mode 100644 index 000000000000..4957d4bf5f98 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx @@ -0,0 +1,65 @@ +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { SignInUpStep } from '@/auth/states/signInUpStepState'; +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import styled from '@emotion/styled'; +import { useCallback } from 'react'; +import { useRecoilState } from 'recoil'; +import { ActionLink } from 'twenty-ui'; +import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle'; +import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft'; +import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO'; +import { SignInUpWithPassword } from '@/auth/sign-in-up/components/SignInUpWithPassword'; + +const StyledContentContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(8)}; + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +export const SignInUpWorkspaceScopeForm = () => { + const [authProviders] = useRecoilState(authProvidersState); + + const { form } = useSignInUpForm(); + const { handleResetPassword } = useHandleResetPassword(); + + const { signInUpStep, continueWithEmail } = useSignInUp(form); + + useCallback(() => { + if ( + signInUpStep === SignInUpStep.Init && + !authProviders.google && + !authProviders.microsoft && + !authProviders.sso + ) { + continueWithEmail(); + } + }, [authProviders, continueWithEmail, signInUpStep]); + + return ( + <> + + {authProviders.google && } + + {authProviders.microsoft && } + + {authProviders.sso && } + + {(authProviders.google || + authProviders.microsoft || + authProviders.sso) && + authProviders.password ? ( + + ) : null} + + {authProviders.password && } + + {signInUpStep === SignInUpStep.Password && ( + + Forgot your password? + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx index 853c1f12d153..ea574c305b14 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx @@ -39,7 +39,7 @@ export const SignInUpWorkspaceSelection = () => { return ( <> - + {availableWorkspacesForAuth && availableWorkspacesForAuth.length !== 0 && diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index 4473f7143433..b57038243982 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -7,12 +7,8 @@ import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useRecoilState, useSetRecoilState } from 'recoil'; +import { useRecoilState } from 'recoil'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; -import { isDefined } from '~/utils/isDefined'; - -import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; -// import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; import { SignInUpStep, signInUpStepState, @@ -32,11 +28,6 @@ export const useSignInUp = (form: UseFormReturn) => { const isMatchingLocation = useIsMatchingLocation(); - const { redirectToSSOLoginPage } = useSSO(); - // const setAvailableWorkspacesForSSOState = useSetRecoilState( - // availableSSOIdentityProvidersState, - // ); - const workspaceInviteHash = useParams().workspaceInviteHash; const [searchParams] = useSearchParams(); const workspacePersonalInviteToken = @@ -103,10 +94,6 @@ export const useSignInUp = (form: UseFormReturn) => { setSignInUpStep, ]); - const continueWithSSO = () => { - setSignInUpStep(SignInUpStep.SSOEmail); - }; - const submitCredentials: SubmitHandler = useCallback( async (data) => { const token = await readCaptchaToken(); @@ -154,7 +141,6 @@ export const useSignInUp = (form: UseFormReturn) => { signInUpMode, continueWithCredentials, continueWithEmail, - continueWithSSO, submitCredentials, }; }; diff --git a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts index c465ec18a575..b3f3c4081871 100644 --- a/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts +++ b/packages/twenty-front/src/modules/auth/states/signInUpStepState.ts @@ -4,8 +4,8 @@ export enum SignInUpStep { Init = 'init', Email = 'email', Password = 'password', - SSOEmail = 'SSOEmail', - WorkspaceSelection = 'WorkspaceSelection', + WorkspaceSelection = 'workspaceSelection', + SSOIdentityProviderSelection = 'SSOIdentityProviderSelection', } export const signInUpStepState = createState({ diff --git a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts index 08d830eb8944..d56572b5a969 100644 --- a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts +++ b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts @@ -9,6 +9,6 @@ export const authProvidersState = createState({ magicLink: false, password: true, microsoft: false, - sso: false, + sso: [], }, }); diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx index e9c72f05c834..cde942bcf3ed 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSSOIdentitiesProvidersListCard.tsx @@ -8,11 +8,14 @@ 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 { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; import isPropValid from '@emotion/is-prop-valid'; import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, useRecoilState } from 'recoil'; import { IconKey } from 'twenty-ui'; +import { useListSsoIdentityProvidersByWorkspaceIdQuery } from '~/generated/graphql'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; const StyledLink = styled(Link, { shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'isDisabled', @@ -22,11 +25,28 @@ const StyledLink = styled(Link, { `; export const SettingsSSOIdentitiesProvidersListCard = () => { + const { enqueueSnackBar } = useSnackBar(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); - const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState); + const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState( + SSOIdentitiesProvidersState, + ); + + const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({ + onCompleted: (data) => { + setSSOIdentitiesProviders( + data?.listSSOIdentityProvidersByWorkspaceId ?? [], + ); + }, + onError: (error: Error) => { + enqueueSnackBar(error.message, { + variant: SnackBarVariant.Error, + }); + }, + }); - return !SSOIdentitiesProviders.length ? ( + return loading || !SSOIdentitiesProviders.length ? ( { - const { enqueueSnackBar } = useSnackBar(); const navigate = useNavigate(); - const [SSOIdentitiesProviders, setSSOIdentitiesProviders] = useRecoilState( - SSOIdentitiesProvidersState, - ); - - const { loading } = useListSsoIdentityProvidersByWorkspaceIdQuery({ - onCompleted: (data) => { - setSSOIdentitiesProviders( - data?.listSSOIdentityProvidersByWorkspaceId ?? [], - ); - }, - onError: (error: Error) => { - enqueueSnackBar(error.message, { - variant: SnackBarVariant.Error, - }); - }, - }); + const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState); return ( { getItemLabel={(SSOIdentityProvider) => `${SSOIdentityProvider.name} - ${SSOIdentityProvider.type}` } - isLoading={loading} RowIconFn={(SSOIdentityProvider) => guessSSOIdentityProviderIconByUrl(SSOIdentityProvider.issuer) } diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx index 9a1a1b52556d..fa1580748321 100644 --- a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx @@ -3,13 +3,10 @@ import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionC 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 { useTheme } from '@emotion/react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { IconLink, - Toggle, Card, - isDefined, IconGoogle, IconMicrosoft, IconPassword, @@ -17,12 +14,8 @@ import { import { useUpdateWorkspaceMutation } from '~/generated/graphql'; import { AuthProviders } from '~/generated-metadata/graphql'; import { capitalize } from '~/utils/string/capitalize'; -import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; - -const StyledToggle = styled(Toggle)` - margin-left: auto; -`; +import { isDefined } from '~/utils/isDefined'; const StyledSettingsSecurityOptionsList = styled.div` display: flex; @@ -32,20 +25,11 @@ const StyledSettingsSecurityOptionsList = styled.div` export const SettingsSecurityOptionsList = () => { const { enqueueSnackBar } = useSnackBar(); - const theme = useTheme(); - - const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState); - const toggleColor = isAdvancedModeEnabled ? theme.color.yellow : undefined; const SSOIdentitiesProviders = useRecoilValue(SSOIdentitiesProvidersState); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( currentWorkspaceState, ); - if (!isDefined(currentWorkspace)) { - throw new Error( - 'The current workspace must be defined to edit its security options.', - ); - } const [updateWorkspace] = useUpdateWorkspaceMutation(); @@ -70,6 +54,7 @@ export const SettingsSecurityOptionsList = () => { } if ( + isDefined(SSOIdentitiesProviders) && SSOIdentitiesProviders.length === 0 && currentWorkspace[key] && Object.entries(currentWorkspace).filter( @@ -130,40 +115,44 @@ export const SettingsSecurityOptionsList = () => { return ( - - toggleAuthMethod('google')} - /> - toggleAuthMethod('microsoft')} - /> - toggleAuthMethod('password')} - /> - - - - handleChange(!currentWorkspace.isPublicInviteLinkEnabled) - } - /> - + {currentWorkspace && ( + <> + + toggleAuthMethod('google')} + /> + toggleAuthMethod('microsoft')} + /> + toggleAuthMethod('password')} + /> + + + + handleChange(!currentWorkspace.isPublicInviteLinkEnabled) + } + /> + + + )} ); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx index 3df7e8878212..294215054418 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx @@ -121,9 +121,11 @@ export const MultiWorkspaceDropdownButton = ({ dropdownComponents={ {workspaces.map((workspace) => ( - + { setAuthProviders(data.getPublicWorkspaceDataBySubdomain.authProviders); setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain); }, - onError: () => { + onError: (error) => { + console.error(error); setLastAuthenticateWorkspaceState(null); redirectToHome(); }, @@ -54,5 +55,20 @@ export const WorkspaceProviderEffect = () => { } }, [lastAuthenticateWorkspace]); + useEffect(() => { + try { + if (isDefined(workspacePublicData?.logo)) { + const link: HTMLLinkElement = + document.querySelector("link[rel*='icon']") || + document.createElement('link'); + link.rel = 'icon'; + link.href = workspacePublicData.logo; + document.getElementsByTagName('head')[0].appendChild(link); + } + } catch (err) { + console.error(err); + } + }, [workspacePublicData]); + return <>; }; diff --git a/packages/twenty-front/src/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx index a657ef942029..719316dae6c3 100644 --- a/packages/twenty-front/src/pages/auth/Invite.tsx +++ b/packages/twenty-front/src/pages/auth/Invite.tsx @@ -1,7 +1,7 @@ import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; -import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; +import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -91,7 +91,7 @@ export const Invite = () => { ) : ( - + )} ); diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 0d32c8acf9fb..35d7e7812092 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -6,19 +6,22 @@ import { SignInUpStep } from '@/auth/states/signInUpStepState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { isTwentyHomePage, + isTwentyWorkspaceSubdomain, redirectToHome, twentyHomePage, } from '~/utils/workspace-url.helper'; import { SignInUpWorkspaceSelection } from '@/auth/sign-in-up/components/SignInUpWorkspaceSelection'; -import { SignInUpGlobalScope } from '@/auth/sign-in-up/components/SignInUpGlobalScope'; +import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; import { AnimatedEaseIn } from 'twenty-ui'; import { Logo } from '@/auth/components/Logo'; import { Title } from '@/auth/components/Title'; -import { SignInUpForm } from '@/auth/sign-in-up/components/SignInUpForm'; +import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm'; import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; import { Link } from 'react-router-dom'; import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; +import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection'; +import { useCallback } from 'react'; export const SignInUp = () => { const setLastAuthenticateWorkspaceState = useSetRecoilState( @@ -35,6 +38,27 @@ export const SignInUp = () => { redirectToHome(); }; + const CurrentFormComponent = useCallback(() => { + if (isTwentyHomePage && signInUpStep === SignInUpStep.WorkspaceSelection) { + return ; + } + + if (isTwentyHomePage) { + return ; + } + + if ( + isTwentyWorkspaceSubdomain && + signInUpStep === SignInUpStep.SSOIdentityProviderSelection + ) { + return ; + } + + if (isTwentyWorkspaceSubdomain) { + return ; + } + }, [signInUpStep]); + return ( <> {/* TODO AMOREAUX: Need design for this */} @@ -49,15 +73,7 @@ export const SignInUp = () => { {`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`} - {isTwentyHomePage ? ( - isTwentyHomePage && signInUpStep === SignInUpStep.WorkspaceSelection ? ( - - ) : ( - - ) - ) : ( - - )} + ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 68bf4cfb0c4b..2e6abdab093c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -116,6 +116,13 @@ export class SSOAuthController { identityProviderId?: string; user: { email: string } & Record; }) { + if (!identityProviderId) { + throw new AuthException( + 'Identity provider ID is required', + AuthExceptionCode.INVALID_DATA, + ); + } + const identityProvider = await this.workspaceSSOIdentityProviderRepository.findOne({ where: { id: identityProviderId }, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts index 136860c7e806..e138a44fa844 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts @@ -9,7 +9,6 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; 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 { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @@ -67,25 +66,6 @@ export class SwitchWorkspaceService { defaultWorkspace: workspace, }); - // if (workspace.workspaceSSOIdentityProviders.length > 0) { - // return { - // useSSOAuth: true, - // workspace, - // availableSSOIdentityProviders: - // await this.sSSOService.listSSOIdentityProvidersByWorkspaceId( - // workspaceId, - // ), - // } as { - // useSSOAuth: true; - // workspace: Workspace; - // availableSSOIdentityProviders: Awaited< - // ReturnType< - // typeof this.sSSOService.listSSOIdentityProvidersByWorkspaceId - // > - // >; - // }; - // } - return { id: workspace.id, subdomain: workspace.subdomain, diff --git a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts index 72ebfc7118d5..23083513c8cc 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts @@ -6,10 +6,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Issuer } from 'openid-client'; import { Repository } from 'typeorm'; -import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; -import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; -import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; @@ -40,9 +36,6 @@ export class SSOService { private readonly workspaceSSOIdentityProviderRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, - private readonly environmentService: EnvironmentService, - @InjectCacheStorage(CacheStorageNamespace.EngineWorkspace) - private readonly cacheStorageService: CacheStorageService, ) {} private async isSSOEnabled(workspaceId: string) { @@ -200,7 +193,11 @@ export class SSOService { buildIssuerURL( identityProvider: Pick, ) { - return `${ServerUrl.get()}/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`; + const authorizationUrl = new URL(ServerUrl.get()); + + authorizationUrl.pathname = `/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`; + + return authorizationUrl.toString(); } private isOIDCIdentityProvider( diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index 4995b00f5d5c..055ce9bfe85d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -29,6 +29,7 @@ import { } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; +import ServerUrl from 'src/engine/utils/serverUrl'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -391,7 +392,7 @@ export class WorkspaceInvitationService { link: link.toString(), workspace: { name: workspace.displayName, logo: workspace.logo }, sender: { email: sender.email, firstName: sender.firstName }, - serverUrl: this.environmentService.get('SERVER_URL'), + serverUrl: ServerUrl.get(), }; const emailTemplate = SendInviteLinkEmail(emailData); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts index de60b94fd24b..833cd6c84357 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/public-workspace-data.output.ts @@ -2,10 +2,28 @@ import { ObjectType, Field } from '@nestjs/graphql'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +@ObjectType() +export class SSOIdentityProvider { + @Field(() => String) + id: string; + + @Field(() => String) + name: string; + + @Field(() => String) + type: string; + + @Field(() => String) + status: string; + + @Field(() => String) + issuer: string; +} + @ObjectType() export class AuthProviders { - @Field(() => Boolean) - sso: boolean; + @Field(() => [SSOIdentityProvider]) + sso: Array; @Field(() => Boolean) google: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 40579dac1c09..27fd3614fd3e 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -211,7 +211,13 @@ export class WorkspaceService extends TypeOrmQueryService { magicLink: false, password: workspace.isPasswordAuthEnabled, microsoft: workspace.isMicrosoftAuthEnabled, - sso: workspace.workspaceSSOIdentityProviders.length > 0, + sso: workspace.workspaceSSOIdentityProviders.map((identityProvider) => ({ + id: identityProvider.id, + name: identityProvider.name, + type: identityProvider.type, + status: identityProvider.status, + issuer: identityProvider.issuer, + })), }; } From 0f9eb2b82888cd133d384b78cecdc60242fc3614 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 14 Nov 2024 18:14:09 +0100 Subject: [PATCH 14/24] refactor(auth): reorganize sign-in/up components Separated sign-in/up password and email input fields into their own components for better modularity and readability. Also unified various states and utility functions under a multi-workspace enablement flag. --- .../twenty-front/src/generated/graphql.tsx | 6 +- .../modules/auth/components/VerifyEffect.tsx | 15 ++ .../components/SignInUpEmailField.tsx | 60 +++++ .../components/SignInUpGlobalScopeForm.tsx | 171 +++++++------ .../components/SignInUpPasswordField.tsx | 67 +++++ .../components/SignInUpWithCredentials.tsx | 147 +++++++++++ .../components/SignInUpWithGoogle.tsx | 2 +- .../components/SignInUpWithMicrosoft.tsx | 2 +- .../components/SignInUpWithPassword.tsx | 236 ------------------ .../sign-in-up/components/SignInUpWithSSO.tsx | 4 +- .../components/SignInUpWorkspaceScopeForm.tsx | 8 +- .../components/SignInUpWorkspaceSelection.tsx | 51 +++- .../auth/sign-in-up/hooks/useSignInUp.tsx | 16 +- .../auth/sign-in-up/hooks/useSignInUpForm.ts | 4 +- .../modules/auth/states/signInUpModeState.ts | 11 + .../components/ClientConfigProviderEffect.tsx | 10 +- .../graphql/queries/getClientConfig.ts | 2 +- .../states/isMultiWorkspaceEnabledState.ts | 6 + .../states/isSignUpDisabledState.ts | 6 - .../components/WorkspaceProviderEffect.tsx | 2 +- .../twenty-front/src/pages/auth/SignInUp.tsx | 5 +- .../src/testing/mock-data/config.ts | 2 +- .../src/utils/workspace-url.helper.ts | 11 +- packages/twenty-server/.env.example | 1 - .../controllers/google-auth.controller.ts | 84 ++++--- .../controllers/microsoft-auth.controller.ts | 78 +++--- .../auth/guards/microsoft-oauth.guard.ts | 7 + .../auth/services/auth.service.ts | 2 +- .../strategies/microsoft.auth.strategy.ts | 3 + .../auth/utils/compute-redirect-error-url.ts | 22 ++ .../client-config/client-config.entity.ts | 8 +- .../client-config/client-config.resolver.ts | 5 +- .../environment/environment-variables.ts | 7 +- .../workspace/services/workspace.service.ts | 2 + .../get-workspace-subdomain-by-origin.ts | 17 +- .../self-hosting/cloud-providers.mdx | 6 +- .../self-hosting/self-hosting-var.mdx | 2 +- 37 files changed, 630 insertions(+), 458 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx delete mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithPassword.tsx create mode 100644 packages/twenty-front/src/modules/auth/states/signInUpModeState.ts create mode 100644 packages/twenty-front/src/modules/client-config/states/isMultiWorkspaceEnabledState.ts delete mode 100644 packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/utils/compute-redirect-error-url.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2f470608a587..84074826fde9 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -167,9 +167,9 @@ export type ClientConfig = { captcha: Captcha; chromeExtensionId?: Maybe; debugMode: Scalars['Boolean']; + isMultiworkspaceEnabled: Scalars['Boolean']; sentry: Sentry; signInPrefilled: Scalars['Boolean']; - signUpDisabled: Scalars['Boolean']; support: Support; }; @@ -1816,7 +1816,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, isMultiworkspaceEnabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -3192,7 +3192,7 @@ export const GetClientConfigDocument = gql` billingFreeTrialDurationInDays } signInPrefilled - signUpDisabled + isMultiworkspaceEnabled debugMode analyticsEnabled support { diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx index a066ddf7ed76..f78495cd1643 100644 --- a/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx @@ -4,10 +4,16 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/auth/hooks/useAuth'; import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { AppPath } from '@/types/AppPath'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { isDefined } from '~/utils/isDefined'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; export const VerifyEffect = () => { + const { enqueueSnackBar } = useSnackBar(); + const [searchParams] = useSearchParams(); const loginToken = searchParams.get('loginToken'); + const errorMessage = searchParams.get('errorMessage'); const isLogged = useIsLogged(); const navigate = useNavigate(); @@ -30,5 +36,14 @@ export const VerifyEffect = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + // TODO AMOREAUX: the error message it display twice. Need help to fix it + if (isDefined(errorMessage)) { + enqueueSnackBar(errorMessage, { + variant: SnackBarVariant.Error, + }); + } + }, []); + return <>; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx new file mode 100644 index 000000000000..a2ca5d1af5c3 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpEmailField.tsx @@ -0,0 +1,60 @@ +import { TextInput } from '@/ui/input/components/TextInput'; +import { Controller, useFormContext } from 'react-hook-form'; +import styled from '@emotion/styled'; +import { motion } from 'framer-motion'; +import { isDefined } from '~/utils/isDefined'; +import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; + +const StyledFullWidthMotionDiv = styled(motion.div)` + width: 100%; +`; + +const StyledInputContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(3)}; +`; + +export const SignInUpEmailField = ({ + showErrors, + onChange: onChangeFromProps, +}: { + showErrors: boolean; + onChange?: (value: string) => void; +}) => { + const form = useFormContext(); + + return ( + + ( + + { + onChange(value); + if (isDefined(onChangeFromProps)) onChangeFromProps(value); + }} + error={showErrors ? error?.message : undefined} + fullWidth + /> + + )} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index 7528547bfe7c..2aac15db2c6c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -1,26 +1,15 @@ import styled from '@emotion/styled'; -import { - AnimatedEaseIn, - IconGoogle, - IconMicrosoft, - Loader, - MainButton, -} from 'twenty-ui'; +import { IconGoogle, IconMicrosoft, Loader, MainButton } from 'twenty-ui'; import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useTheme } from '@emotion/react'; import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; -import { TextInput } from '@/ui/input/components/TextInput'; -import { Controller, useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; +import { FormProvider } from 'react-hook-form'; import { z } from 'zod'; import { motion } from 'framer-motion'; -import { Logo } from '@/auth/components/Logo'; -import { Title } from '@/auth/components/Title'; import { useFindAvailableWorkspacesByEmail } from '@/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail'; -import { FormEvent, useState } from 'react'; -import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useState } from 'react'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { isDefined } from '~/utils/isDefined'; import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; import { @@ -28,7 +17,21 @@ import { signInUpStepState, } from '@/auth/states/signInUpStepState'; import { redirectToWorkspace } from '~/utils/workspace-url.helper'; -import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex'; +import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField'; +import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField'; +import { useAuth } from '@/auth/hooks/useAuth'; +import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken'; +import { + SignInUpMode, + signInUpModeState, +} from '@/auth/states/signInUpModeState'; +import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -42,50 +45,54 @@ const StyledForm = styled.form` width: 100%; `; -const StyledFullWidthMotionDiv = styled(motion.div)` - width: 100%; -`; - -const StyledInputContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(3)}; -`; - -const validationSchema = z +export const validationSchema = z .object({ + exist: z.boolean(), email: z.string().trim().email('Email must be a valid email'), + password: z + .string() + .regex(PASSWORD_REGEX, 'Password must contain at least 8 characters') + .optional(), + captchaToken: z.string().default(''), }) .required(); export const SignInUpGlobalScopeForm = () => { const theme = useTheme(); - const isSignInPrefilled = useRecoilValue(isSignInPrefilledState); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const signInUpStep = useRecoilValue(signInUpStepState); const { signInWithGoogle } = useSignInWithGoogle(); const { signInWithMicrosoft } = useSignInWithMicrosoft(); + const { checkUserExists } = useAuth(); + const { readCaptchaToken } = useReadCaptchaToken(); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); + const { findAvailableWorkspacesByEmail } = useFindAvailableWorkspacesByEmail(); const setAvailableWorkspacesForAuthState = useSetRecoilState( availableWorkspacesForAuthState, ); + const { enqueueSnackBar } = useSnackBar(); + const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken(); const [showErrors, setShowErrors] = useState(false); - const form = useForm>({ - mode: 'onChange', - defaultValues: { - email: isSignInPrefilled === true ? 'tim@apple.dev' : '', - }, - resolver: zodResolver(validationSchema), - }); + const { form } = useSignInUpForm(); - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - setShowErrors(true); + const { submitCredentials } = useSignInUp(form); + const continueToWorkspaceSelection = async () => { const { data } = await findAvailableWorkspacesByEmail( form.getValues('email'), ); + + if (signInUpStep === SignInUpStep.Password) { + await form.handleSubmit(submitCredentials)(); + } + if (isDefined(data) && data.findAvailableWorkspacesByEmail.length > 1) { setAvailableWorkspacesForAuthState(data.findAvailableWorkspacesByEmail); return setSignInUpStep(SignInUpStep.WorkspaceSelection); @@ -103,6 +110,42 @@ export const SignInUpGlobalScopeForm = () => { // si plusieurs workspaces redirige sur la liste des workspaces }; + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (isDefined(form?.formState?.errors?.email)) { + setShowErrors(true); + return; + } + + if (signInUpStep === SignInUpStep.Password) { + await form.handleSubmit(submitCredentials)(); + return continueToWorkspaceSelection(); + } + + const token = await readCaptchaToken(); + checkUserExists.checkUserExistsQuery({ + variables: { + email: form.getValues('email'), + captchaToken: token, + }, + onCompleted: (data) => { + requestFreshCaptchaToken(); + if (data?.checkUserExists.exists) { + continueToWorkspaceSelection(); + } else { + if (!isMultiWorkspaceEnabled) { + return enqueueSnackBar('User not found', { + variant: SnackBarVariant.Error, + }); + } + setSignInUpMode(SignInUpMode.SignUp); + setSignInUpStep(SignInUpStep.Password); + } + }, + }); + }; + return ( <> @@ -125,44 +168,26 @@ export const SignInUpGlobalScopeForm = () => { - - - ( - - - - )} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + + {signInUpStep === SignInUpStep.Password && ( + + )} + + (form.formState.isSubmitting ? : null)} + fullWidth /> - - (form.formState.isSubmitting ? : null)} - fullWidth - /> - + + ); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx new file mode 100644 index 000000000000..abdaa4fc17de --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpPasswordField.tsx @@ -0,0 +1,67 @@ +import { TextInput } from '@/ui/input/components/TextInput'; +import { Controller, useFormContext } from 'react-hook-form'; +import styled from '@emotion/styled'; +import { motion } from 'framer-motion'; +import { StyledText } from 'twenty-ui'; +import { useTheme } from '@emotion/react'; +import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { SignInUpMode } from '@/auth/states/signInUpModeState'; + +const StyledFullWidthMotionDiv = styled(motion.div)` + width: 100%; +`; + +const StyledInputContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(3)}; +`; + +export const SignInUpPasswordField = ({ + showErrors, + signInUpMode, +}: { + showErrors: boolean; + signInUpMode?: SignInUpMode; +}) => { + const theme = useTheme(); + const form = useFormContext(); + + return ( + + ( + + + {signInUpMode === SignInUpMode.SignUp && ( + + )} + + )} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx new file mode 100644 index 000000000000..c373ec0f66cb --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx @@ -0,0 +1,147 @@ +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; +import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; +import { Loader, MainButton } from 'twenty-ui'; +import { isDefined } from '~/utils/isDefined'; +import { SignInUpEmailField } from '@/auth/sign-in-up/components/SignInUpEmailField'; +import { + useSignInUpForm, + validationSchema, +} from '@/auth/sign-in-up/hooks/useSignInUpForm'; +import { useRecoilValue } from 'recoil'; +import styled from '@emotion/styled'; +import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField'; +import { useState, useMemo, useEffect } from 'react'; +import { captchaProviderState } from '@/client-config/states/captchaProviderState'; +import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; +import { FormProvider } from 'react-hook-form'; +import { SignInUpMode } from '@/auth/states/signInUpModeState'; + +const StyledForm = styled.form` + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +`; + +export const SignInUpWithCredentials = () => { + const { form } = useSignInUpForm(); + + const signInUpStep = useRecoilValue(signInUpStepState); + const [showErrors, setShowErrors] = useState(false); + const captchaProvider = useRecoilValue(captchaProviderState); + const isRequestingCaptchaToken = useRecoilValue( + isRequestingCaptchaTokenState, + ); + + const { + signInUpMode, + continueWithEmail, + continueWithCredentials, + submitCredentials, + } = useSignInUp(form); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (isSubmitButtonDisabled) return; + + if (signInUpStep === SignInUpStep.Init) { + continueWithEmail(); + } else if (signInUpStep === SignInUpStep.Email) { + if (isDefined(form?.formState?.errors?.email)) { + setShowErrors(true); + return; + } + continueWithCredentials(); + } else if (signInUpStep === SignInUpStep.Password) { + if (!form.formState.isSubmitting) { + setShowErrors(true); + form.handleSubmit(submitCredentials)(); + } + } + }; + + const buttonTitle = useMemo(() => { + if (signInUpStep === SignInUpStep.Init) { + return 'Continue With Email'; + } + + if (signInUpStep === SignInUpStep.Email) { + return 'Continue'; + } + + if ( + signInUpMode === SignInUpMode.SignIn && + signInUpStep === SignInUpStep.Password + ) { + return 'Sign in'; + } + + if ( + signInUpMode === SignInUpMode.SignUp && + signInUpStep === SignInUpStep.Password + ) { + return 'Sign up'; + } + }, [signInUpMode, signInUpStep]); + + const shouldWaitForCaptchaToken = + signInUpStep !== SignInUpStep.Init && + isDefined(captchaProvider?.provider) && + isRequestingCaptchaToken; + + const isEmailStepSubmitButtonDisabledCondition = + signInUpStep === SignInUpStep.Email && + (!validationSchema.shape.email.safeParse(form.watch('email')).success || + shouldWaitForCaptchaToken); + + // TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders + // We make the isValid check synchronous and update a reactState to make sure this does not happen + const isPasswordStepSubmitButtonDisabledCondition = + signInUpStep === SignInUpStep.Password && + (!form.formState.isValid || + form.formState.isSubmitting || + shouldWaitForCaptchaToken); + + const isSubmitButtonDisabled = + isEmailStepSubmitButtonDisabledCondition || + isPasswordStepSubmitButtonDisabledCondition; + + return ( + <> + {(signInUpStep === SignInUpStep.Password || + signInUpStep === SignInUpStep.Email || + signInUpStep === SignInUpStep.Init) && ( + <> + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + {signInUpStep !== SignInUpStep.Init && ( + + )} + {signInUpStep === SignInUpStep.Password && ( + + )} + (form.formState.isSubmitting ? : null)} + disabled={isSubmitButtonDisabled} + fullWidth + /> + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx index 7920b9f7723f..c7be6d6c8e92 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx @@ -16,7 +16,7 @@ export const SignInUpWithGoogle = () => { return ( <> } + Icon={() => } title="Continue with Google" onClick={signInWithGoogle} variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx index a92ebafea751..608c659db116 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx @@ -16,7 +16,7 @@ export const SignInUpWithMicrosoft = () => { return ( <> } + Icon={() => } title="Continue with Microsoft" onClick={signInWithMicrosoft} variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithPassword.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithPassword.tsx deleted file mode 100644 index 0a3d7e328532..000000000000 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithPassword.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { - SignInUpStep, - signInUpStepState, -} from '@/auth/states/signInUpStepState'; -import { Key } from 'ts-key-enum'; -import { TextInput } from '@/ui/input/components/TextInput'; -import { SignInUpMode, useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; -import { Loader, MainButton, StyledText } from 'twenty-ui'; -import { isDefined } from '~/utils/isDefined'; -import styled from '@emotion/styled'; -import { Controller } from 'react-hook-form'; -import { motion } from 'framer-motion'; -import { useRecoilValue } from 'recoil'; -import { - useSignInUpForm, - validationSchema, -} from '@/auth/sign-in-up/hooks/useSignInUpForm'; -import { captchaProviderState } from '@/client-config/states/captchaProviderState'; -import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; -import { useState, useMemo } from 'react'; -import { useTheme } from '@emotion/react'; - -const StyledFullWidthMotionDiv = styled(motion.div)` - width: 100%; -`; - -const StyledForm = styled.form` - align-items: center; - display: flex; - flex-direction: column; - width: 100%; -`; - -const StyledInputContainer = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(3)}; -`; - -export const SignInUpWithPassword = () => { - const theme = useTheme(); - - const signInUpStep = useRecoilValue(signInUpStepState); - const captchaProvider = useRecoilValue(captchaProviderState); - const isRequestingCaptchaToken = useRecoilValue( - isRequestingCaptchaTokenState, - ); - - const { form } = useSignInUpForm(); - - const { - continueWithEmail, - submitCredentials, - continueWithCredentials, - signInUpMode, - } = useSignInUp(form); - - const [showErrors, setShowErrors] = useState(false); - - const handleKeyDown = async ( - event: React.KeyboardEvent, - ) => { - if (event.key === Key.Enter) { - event.preventDefault(); - - if (signInUpStep === SignInUpStep.Init) { - continueWithEmail(); - } else if (signInUpStep === SignInUpStep.Email) { - if (isDefined(form?.formState?.errors?.email)) { - setShowErrors(true); - return; - } - continueWithCredentials(); - } - } - }; - - const shouldWaitForCaptchaToken = - signInUpStep !== SignInUpStep.Init && - isDefined(captchaProvider?.provider) && - isRequestingCaptchaToken; - - const isEmailStepSubmitButtonDisabledCondition = - signInUpStep === SignInUpStep.Email && - (!validationSchema.shape.email.safeParse(form.watch('email')).success || - shouldWaitForCaptchaToken); - - // TODO: isValid is actually a proxy function. If it is not rendered the first time, react might not trigger re-renders - // We make the isValid check synchronous and update a reactState to make sure this does not happen - const isPasswordStepSubmitButtonDisabledCondition = - signInUpStep === SignInUpStep.Password && - (!form.formState.isValid || - form.formState.isSubmitting || - shouldWaitForCaptchaToken); - - const isSubmitButtonDisabled = - isEmailStepSubmitButtonDisabledCondition || - isPasswordStepSubmitButtonDisabledCondition; - - const buttonTitle = useMemo(() => { - if (signInUpStep === SignInUpStep.Init) { - return 'Continue With Email'; - } - - if (signInUpStep === SignInUpStep.Email) { - return 'Continue'; - } - - if (SignInUpMode.SignIn && signInUpStep === SignInUpStep.Password) { - return 'Sign in'; - } - - if (SignInUpMode.SignUp && signInUpStep === SignInUpStep.Password) { - return 'Sign up'; - } - }, [signInUpMode, signInUpStep]); - - return ( - <> - {(signInUpStep === SignInUpStep.Password || - signInUpStep === SignInUpStep.Email || - signInUpStep === SignInUpStep.Init) && ( - { - event.preventDefault(); - }} - > - {signInUpStep !== SignInUpStep.Init && ( - - ( - - { - onChange(value); - if (signInUpStep === SignInUpStep.Password) { - continueWithEmail(); - } - }} - error={showErrors ? error?.message : undefined} - fullWidth - disableHotkeys - onKeyDown={handleKeyDown} - /> - - )} - /> - - )} - {signInUpStep === SignInUpStep.Password && ( - - ( - - - {signInUpMode === SignInUpMode.SignUp && ( - - )} - - )} - /> - - )} - { - if (signInUpStep === SignInUpStep.Init) { - continueWithEmail(); - return; - } - if (signInUpStep === SignInUpStep.Email) { - if (isDefined(form?.formState?.errors?.email)) { - setShowErrors(true); - return; - } - continueWithCredentials(); - return; - } - setShowErrors(true); - form.handleSubmit(submitCredentials)(); - }} - Icon={() => (form.formState.isSubmitting ? : null)} - disabled={isSubmitButtonDisabled} - fullWidth - /> - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx index 2dee450ce840..485e55b52708 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx @@ -1,4 +1,4 @@ -import { IconGoogle, IconLock, MainButton } from 'twenty-ui'; +import { IconLock, MainButton } from 'twenty-ui'; import { SignInUpStep, signInUpStepState, @@ -29,7 +29,7 @@ export const SignInUpWithSSO = () => { return ( <> } + Icon={() => } title="Single sign-on (SSO)" onClick={signInWithSSO} variant={signInUpStep === SignInUpStep.Init ? undefined : 'secondary'} diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx index 4957d4bf5f98..0dee7ec79dea 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx @@ -11,7 +11,7 @@ import { ActionLink } from 'twenty-ui'; import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle'; import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft'; import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO'; -import { SignInUpWithPassword } from '@/auth/sign-in-up/components/SignInUpWithPassword'; +import { SignInUpWithCredentials } from '@/auth/sign-in-up/components/SignInUpWithCredentials'; const StyledContentContainer = styled.div` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -44,16 +44,16 @@ export const SignInUpWorkspaceScopeForm = () => { {authProviders.microsoft && } - {authProviders.sso && } + {authProviders.sso.length > 0 && } {(authProviders.google || authProviders.microsoft || - authProviders.sso) && + authProviders.sso.length > 0) && authProviders.password ? ( ) : null} - {authProviders.password && } + {authProviders.password && } {signInUpStep === SignInUpStep.Password && ( diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx index ea574c305b14..322b5e95a262 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx @@ -4,6 +4,8 @@ import { H2Title, MainButton } from 'twenty-ui'; import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; import { redirectToWorkspace } from '~/utils/workspace-url.helper'; +import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; const StyledContentContainer = styled.div` display: flex; @@ -36,26 +38,51 @@ export const SignInUpWorkspaceSelection = () => { const availableWorkspacesForAuth = useRecoilValue( availableWorkspacesForAuthState, ); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + + const createNewWorkspace = () => { + console.log('>>>>>>>>>>>>>> createNewWorkspace'); + }; return ( <> - {availableWorkspacesForAuth && - availableWorkspacesForAuth.length !== 0 && - availableWorkspacesForAuth.map((workspace) => ( - redirectToWorkspace(workspace.subdomain)} - fullWidth - > + availableWorkspacesForAuth.length !== 0 && ( + <> + + {availableWorkspacesForAuth.map((workspace) => ( + redirectToWorkspace(workspace.subdomain)} + fullWidth + > + + + + {workspace.displayName ?? workspace.id} + + + + ))} + + )} + {isMultiWorkspaceEnabled && ( + <> + {availableWorkspacesForAuth && + availableWorkspacesForAuth.length > 0 && ( + + )} + createNewWorkspace()} fullWidth> - - - {workspace.displayName ?? workspace.id} - + + Create workspace - ))} + + )} ); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index b57038243982..99a371a3c39f 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -15,16 +15,16 @@ import { } from '@/auth/states/signInUpStepState'; import { AppPath } from '@/types/AppPath'; import { useAuth } from '../../hooks/useAuth'; - -export enum SignInUpMode { - SignIn = 'sign-in', - SignUp = 'sign-up', -} +import { + SignInUpMode, + signInUpModeState, +} from '@/auth/states/signInUpModeState'; export const useSignInUp = (form: UseFormReturn) => { const { enqueueSnackBar } = useSnackBar(); const [signInUpStep, setSignInUpStep] = useRecoilState(signInUpStepState); + const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); const isMatchingLocation = useIsMatchingLocation(); @@ -35,12 +35,6 @@ export const useSignInUp = (form: UseFormReturn) => { const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite)); - const [signInUpMode, setSignInUpMode] = useState(() => { - return isMatchingLocation(AppPath.SignInUp) - ? SignInUpMode.SignIn - : SignInUpMode.SignUp; - }); - const { signInWithCredentials, signUpWithCredentials, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts index e0d49000c872..8e1ba8929c69 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -29,6 +29,8 @@ export const useSignInUpForm = () => { mode: 'onChange', defaultValues: { exist: false, + email: '', + password: '', }, resolver: zodResolver(validationSchema), }); @@ -38,7 +40,7 @@ export const useSignInUpForm = () => { if (isDefined(email)) { form.setValue('email', email); } else if (isSignInPrefilled === true) { - form.setValue('email', 'tim@apple.dev'); + form.setValue('email', 'moreaux.antoine@gmail.com'); form.setValue('password', 'Applecar2025'); } }, [form, isSignInPrefilled]); diff --git a/packages/twenty-front/src/modules/auth/states/signInUpModeState.ts b/packages/twenty-front/src/modules/auth/states/signInUpModeState.ts new file mode 100644 index 000000000000..65d4cc9c1aec --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/signInUpModeState.ts @@ -0,0 +1,11 @@ +import { createState } from 'twenty-ui'; + +export enum SignInUpMode { + SignIn = 'sign-in', + SignUp = 'sign-up', +} + +export const signInUpModeState = createState({ + key: 'signInUpModeState', + defaultValue: SignInUpMode.SignIn, +}); diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx index 8a0365d4113c..b27b4b7cff0e 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -6,7 +6,7 @@ import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabl import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; -import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { sentryConfigState } from '@/client-config/states/sentryConfigState'; import { supportChatState } from '@/client-config/states/supportChatState'; import { useEffect } from 'react'; @@ -19,7 +19,9 @@ export const ClientConfigProviderEffect = () => { const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState); const setIsSignInPrefilled = useSetRecoilState(isSignInPrefilledState); - const setIsSignUpDisabled = useSetRecoilState(isSignUpDisabledState); + const setIsMultiworkspaceEnabled = useSetRecoilState( + isMultiWorkspaceEnabledState, + ); const setBilling = useSetRecoilState(billingState); const setSupportChat = useSetRecoilState(supportChatState); @@ -46,7 +48,7 @@ export const ClientConfigProviderEffect = () => { setIsDebugMode(data?.clientConfig.debugMode); setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled); setIsSignInPrefilled(data?.clientConfig.signInPrefilled); - setIsSignUpDisabled(data?.clientConfig.signUpDisabled); + setIsMultiworkspaceEnabled(data?.clientConfig.isMultiworkspaceEnabled); setBilling(data?.clientConfig.billing); setSupportChat(data?.clientConfig.support); @@ -69,7 +71,7 @@ export const ClientConfigProviderEffect = () => { data, setIsDebugMode, setIsSignInPrefilled, - setIsSignUpDisabled, + setIsMultiworkspaceEnabled, setSupportChat, setBilling, setSentryConfig, diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index aa5da6bc8dc6..857f16948dd2 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -9,7 +9,7 @@ export const GET_CLIENT_CONFIG = gql` billingFreeTrialDurationInDays } signInPrefilled - signUpDisabled + isMultiworkspaceEnabled debugMode analyticsEnabled support { diff --git a/packages/twenty-front/src/modules/client-config/states/isMultiWorkspaceEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isMultiWorkspaceEnabledState.ts new file mode 100644 index 000000000000..3d01ca3e52ec --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/isMultiWorkspaceEnabledState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isMultiWorkspaceEnabledState = createState({ + key: 'isMultiworkspaceEnabled', + defaultValue: true, +}); diff --git a/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts b/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts deleted file mode 100644 index a82b1a821072..000000000000 --- a/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createState } from 'twenty-ui'; - -export const isSignUpDisabledState = createState({ - key: 'isSignUpDisabledState', - defaultValue: false, -}); diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx index 73644144a517..bb527fa5cde8 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -50,7 +50,7 @@ export const WorkspaceProviderEffect = () => { }, [workspacePublicData]); useEffect(() => { - if (isDefined(lastAuthenticateWorkspace) && isTwentyHomePage) { + if (isDefined(lastAuthenticateWorkspace?.subdomain) && isTwentyHomePage) { redirectToWorkspace(lastAuthenticateWorkspace.subdomain); } }, [lastAuthenticateWorkspace]); diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 35d7e7812092..3ba54a5c89bf 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -8,7 +8,7 @@ import { isTwentyHomePage, isTwentyWorkspaceSubdomain, redirectToHome, - twentyHomePage, + twentyHomePageUrl, } from '~/utils/workspace-url.helper'; import { SignInUpWorkspaceSelection } from '@/auth/sign-in-up/components/SignInUpWorkspaceSelection'; import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm'; @@ -27,6 +27,7 @@ export const SignInUp = () => { const setLastAuthenticateWorkspaceState = useSetRecoilState( lastAuthenticateWorkspaceState, ); + const { form } = useSignInUpForm(); const { signInUpStep } = useSignInUp(form); @@ -63,7 +64,7 @@ export const SignInUp = () => { <> {/* TODO AMOREAUX: Need design for this */} {!isTwentyHomePage && ( - + Back to home )} diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 6e8ade28b55f..e702cd067eee 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -3,7 +3,7 @@ import { CaptchaDriverType } from '~/generated/graphql'; export const mockedClientConfig: ClientConfig = { signInPrefilled: true, - signUpDisabled: false, + isMultiworkspaceEnabled: true, chromeExtensionId: 'MOCKED_EXTENSION_ID', debugMode: false, analyticsEnabled: true, diff --git a/packages/twenty-front/src/utils/workspace-url.helper.ts b/packages/twenty-front/src/utils/workspace-url.helper.ts index 3bb950adf52a..c8328f237f27 100644 --- a/packages/twenty-front/src/utils/workspace-url.helper.ts +++ b/packages/twenty-front/src/utils/workspace-url.helper.ts @@ -5,12 +5,15 @@ export const twentyHostname = process.env.REACT_APP_BASE_URL ? new URL(process.env.REACT_APP_BASE_URL).hostname : 'twenty.com'; -export const twentyHomePage = `app.${twentyHostname}`; +export const twentyHomePageHostname = `app.${twentyHostname}`; + +export const twentyHomePageUrl = `${window.location.protocol}//${twentyHomePageHostname}`; export const isTwentyHosting = window.location.hostname.endsWith(twentyHostname); -export const isTwentyHomePage = window.location.hostname === twentyHomePage; +export const isTwentyHomePage = + window.location.hostname === twentyHomePageHostname; export const isTwentyWorkspaceSubdomain = isTwentyHosting && !isTwentyHomePage; @@ -44,8 +47,8 @@ export const buildWorkspaceUrl = ( export const redirectToHome = () => { const url = new URL(window.location.href); - if (url.hostname !== twentyHomePage) { - url.hostname = twentyHomePage; + if (url.hostname !== twentyHomePageHostname) { + url.hostname = twentyHomePageHostname; window.location.href = url.toString(); } }; diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 6054688dc782..6591de240530 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -23,7 +23,6 @@ ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access # IS_BILLING_ENABLED=false # BILLING_PLAN_REQUIRED_LINK=https://twenty.com/stripe-redirection # AUTH_PASSWORD_ENABLED=false -# IS_SIGN_UP_DISABLED=false # AUTH_MICROSOFT_ENABLED=false # AUTH_MICROSOFT_CLIENT_ID=replace_me_with_azure_client_id # AUTH_MICROSOFT_TENANT_ID=replace_me_with_azure_tenant_id diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index 896e89a3d1ca..e8831a6510b0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -20,6 +20,9 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { computeRedirectErrorUrl } from 'src/engine/core-modules/auth/utils/compute-redirect-error-url'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) @@ -27,6 +30,7 @@ export class GoogleAuthController { constructor( private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, + private readonly environmentService: EnvironmentService, ) {} @Get() @@ -40,43 +44,55 @@ export class GoogleAuthController { @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard) @UseFilters(AuthOAuthExceptionFilter) async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) { - const { - firstName, - lastName, - email, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - targetWorkspaceSubdomain, - } = req.user; + try { + const { + firstName, + lastName, + email, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + targetWorkspaceSubdomain, + } = req.user; - const user = await this.authService.signInUp({ - email, - firstName, - lastName, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - targetWorkspaceSubdomain, - fromSSO: true, - }); + const user = await this.authService.signInUp({ + email, + firstName, + lastName, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + targetWorkspaceSubdomain, + fromSSO: true, + }); - if (!user.defaultWorkspace.isGoogleAuthEnabled) { - throw new AuthException( - 'Google auth is not enabled for this workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } + if (!user.defaultWorkspace.isGoogleAuthEnabled) { + throw new AuthException( + 'Google auth is not enabled for this workspace', + AuthExceptionCode.OAUTH_ACCESS_DENIED, + ); + } - const loginToken = await this.loginTokenService.generateLoginToken( - user.email, - ); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); - return res.redirect( - await this.authService.computeRedirectURI( - loginToken.token, - user.defaultWorkspace.subdomain, - ), - ); + return res.redirect( + await this.authService.computeRedirectURI( + loginToken.token, + user.defaultWorkspace.subdomain, + ), + ); + } catch (err) { + if (err instanceof AuthException) { + return res.redirect( + computeRedirectErrorUrl({ + frontBaseUrl: this.environmentService.get('FRONT_BASE_URL'), + errorMessage: err.message, + }), + ); + } + throw err; + } } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 26fea2832c60..4aa26399f74f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -19,6 +19,8 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { computeRedirectErrorUrl } from 'src/engine/core-modules/auth/utils/compute-redirect-error-url'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) @@ -26,6 +28,7 @@ export class MicrosoftAuthController { constructor( private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, + private readonly environmentService: EnvironmentService, ) {} @Get() @@ -41,41 +44,52 @@ export class MicrosoftAuthController { @Req() req: MicrosoftRequest, @Res() res: Response, ) { - const { - firstName, - lastName, - email, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - } = req.user; + try { + const { + firstName, + lastName, + email, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + targetWorkspaceSubdomain, + } = req.user; - const user = await this.authService.signInUp({ - email, - firstName, - lastName, - picture, - workspaceInviteHash, - workspacePersonalInviteToken, - fromSSO: true, - }); + const user = await this.authService.signInUp({ + email, + firstName, + lastName, + picture, + workspaceInviteHash, + workspacePersonalInviteToken, + targetWorkspaceSubdomain, + fromSSO: true, + }); - if (!user.defaultWorkspace.isMicrosoftAuthEnabled) { - throw new AuthException( - 'Microsoft auth is not enabled for this workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } + if (!user.defaultWorkspace.isMicrosoftAuthEnabled) { + throw new AuthException( + 'Microsoft auth is not enabled for this workspace', + AuthExceptionCode.OAUTH_ACCESS_DENIED, + ); + } - const loginToken = await this.loginTokenService.generateLoginToken( - user.email, - ); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); - return res.redirect( - await this.authService.computeRedirectURI( - loginToken.token, - user.defaultWorkspace.subdomain, - ), - ); + return res.redirect( + await this.authService.computeRedirectURI( + loginToken.token, + user.defaultWorkspace.subdomain, + ), + ); + } catch (err) { + return res.redirect( + computeRedirectErrorUrl({ + frontBaseUrl: this.environmentService.get('FRONT_BASE_URL'), + errorMessage: err.message, + }), + ); + } } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts index dd67b676832e..049f147898da 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/microsoft-oauth.guard.ts @@ -26,6 +26,13 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') { workspacePersonalInviteToken; } + if ( + request.query.workspaceSubdomain && + typeof request.query.workspaceSubdomain === 'string' + ) { + request.params.workspaceSubdomain = request.query.workspaceSubdomain; + } + return (await super.canActivate(context)) as boolean; } } 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 0c5b3f6fc1fe..9682a7b047f2 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 @@ -459,7 +459,7 @@ export class AuthService { return workspace; } - async computeRedirectURI(loginToken: string, subdomain: string) { + async computeRedirectURI(loginToken: string, subdomain) { const url = buildWorkspaceURL( this.environmentService.get('FRONT_BASE_URL'), { subdomain }, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts index babcf1540b71..39077fb6d06c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/microsoft.auth.strategy.ts @@ -21,6 +21,7 @@ export type MicrosoftRequest = Omit< picture: string | null; workspaceInviteHash?: string; workspacePersonalInviteToken?: string; + targetWorkspaceSubdomain?: string; }; }; @@ -41,6 +42,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { ...options, state: JSON.stringify({ workspaceInviteHash: req.params.workspaceInviteHash, + workspaceSubdomain: req.params.workspaceSubdomain, ...(req.params.workspacePersonalInviteToken ? { workspacePersonalInviteToken: @@ -83,6 +85,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, workspacePersonalInviteToken: state.workspacePersonalInviteToken, + targetWorkspaceSubdomain: state.workspaceSubdomain, }; done(null, user); diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/compute-redirect-error-url.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/compute-redirect-error-url.ts new file mode 100644 index 000000000000..cebfe2b70e63 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/compute-redirect-error-url.ts @@ -0,0 +1,22 @@ +import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; + +export function computeRedirectErrorUrl({ + errorMessage, + frontBaseUrl, + subdomain, +}: { + errorMessage: string; + frontBaseUrl: string; + subdomain?: string; +}) { + const url = buildWorkspaceURL( + frontBaseUrl, + { subdomain: subdomain ?? 'app' }, + { + withPathname: '/verify', + withSearchParams: { errorMessage }, + }, + ); + + return url.toString(); +} diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index f56c8d52354a..7bb5ddefdfba 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -2,12 +2,6 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces'; -@ObjectType() -class Telemetry { - @Field(() => Boolean) - enabled: boolean; -} - @ObjectType() class Billing { @Field(() => Boolean) @@ -65,7 +59,7 @@ export class ClientConfig { signInPrefilled: boolean; @Field(() => Boolean) - signUpDisabled: boolean; + isMultiworkspaceEnabled: boolean; @Field(() => Boolean) debugMode: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts index bf065f6c4a25..18e192efa5fa 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.resolver.ts @@ -18,8 +18,11 @@ export class ClientConfigResolver { 'BILLING_FREE_TRIAL_DURATION_IN_DAYS', ), }, + signInPrefilled: this.environmentService.get('SIGN_IN_PREFILLED'), - signUpDisabled: this.environmentService.get('IS_SIGN_UP_DISABLED'), + isMultiworkspaceEnabled: this.environmentService.get( + 'IS_MULTIWORKSPACE_ENABLED', + ), debugMode: this.environmentService.get('DEBUG_MODE'), support: { supportDriver: this.environmentService.get('SUPPORT_DRIVER'), diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index c1f8acb5a024..fe702ed055e8 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -234,7 +234,7 @@ export class EnvironmentVariables { @CastToBoolean() @IsOptional() @IsBoolean() - IS_MULTIWORKSPACE_ENABLED = true; + IS_MULTIWORKSPACE_ENABLED = false; // Custom Code Engine @IsEnum(ServerlessDriverType) @@ -372,11 +372,6 @@ export class EnvironmentVariables { @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0) WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 60; - @CastToBoolean() - @IsOptional() - @IsBoolean() - IS_SIGN_UP_DISABLED = false; - @IsEnum(CaptchaDriverType) @IsOptional() CAPTCHA_DRIVER?: CaptchaDriverType; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 27fd3614fd3e..23bd119884e4 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -228,6 +228,8 @@ export class WorkspaceService extends TypeOrmQueryService { this.environmentService.get('FRONT_BASE_URL'), ); + if (!subdomain) return; + return this.workspaceRepository.findOneBy({ subdomain }); } catch (e) { throw new WorkspaceException( diff --git a/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts b/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts index cfa88e8b9dc6..24d355b07688 100644 --- a/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts +++ b/packages/twenty-server/src/engine/utils/get-workspace-subdomain-by-origin.ts @@ -2,15 +2,14 @@ export const getWorkspaceSubdomainByOrigin = ( origin: string, frontBaseUrl: string, ) => { - const { hostname } = new URL(origin); + const { hostname: originHostname } = new URL(origin); + const { hostname: frontBaseHostname } = new URL(frontBaseUrl); - const hostParts = hostname.split('.'); + if ( + originHostname === frontBaseHostname || + originHostname === `app.${frontBaseHostname}` + ) + return; - if (hostParts.length <= 2) return; - - const subdomain = hostParts[0]; - - if (hostname === new URL(frontBaseUrl).hostname) return; - - return subdomain; + return originHostname.replace(`.${frontBaseHostname}`, ''); }; diff --git a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx index 8c40c0b3137d..11b65c64ffcc 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx @@ -58,7 +58,7 @@ This uses the prebuilt images found on [docker hub](https://hub.docker.com/r/twe - Is set in respective tf-files - See docs [Setup Environment Variables](https://twenty.com/developers/section/self-hosting/self-hosting-var) for usage -- After deployment you could can set `IS_SIGN_UP_DISABLED=true` (and run `terraform plan/apply` again) to disable new workspaces from being created +- After deployment you could can set `IS_MULTIWORKSPACE_ENABLED=false` (and run `terraform plan/apply` again) to disable new workspaces from being created #### Security and networking @@ -312,8 +312,8 @@ resource "azurerm_container_app" "twenty_server" { # Environment variables env { - name = "IS_SIGN_UP_DISABLED" - value = false + name = "IS_MULTIWORKSPACE_ENABLED" + value = true } env { name = "SIGN_IN_PREFILLED" diff --git a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx index a4657acf598d..9660d50d7d51 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx @@ -77,7 +77,7 @@ yarn command:prod cron:calendar:calendar-event-list-fetch ['AUTH_MICROSOFT_CALLBACK_URL', 'http://[YourDomain]/auth/microsoft/redirect', 'Microsoft auth callback'], ['AUTH_GOOGLE_APIS_CALLBACK_URL', 'http://[YourDomain]/auth/microsoft-apis/get-access-token', 'Microsoft APIs auth callback'], ['FRONT_AUTH_CALLBACK_URL', 'http://localhost:3001/verify ', 'Callback used for Login page'], - ['IS_SIGN_UP_DISABLED', 'false', 'Disable sign-up'], + ['IS_MULTIWORKSPACE_ENABLED', 'true', 'Allow sign-up with workspace creation'], ['PASSWORD_RESET_TOKEN_EXPIRES_IN', '5m', 'Password reset token expiration time'], ]}> From f63f48882a9869c0a7d67db4ef30af8f02b39e01 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Thu, 14 Nov 2024 18:18:38 +0100 Subject: [PATCH 15/24] feat(auth): Consolidate HorizontalSeparator imports Consolidate all HorizontalSeparator imports to 'twenty-ui' instead of local paths. This change simplifies module dependencies and ensures consistency across the sign-in and sign-up components. --- .../components/SignInUpGlobalScopeForm.tsx | 13 ++++++++++--- .../SignInUpSSOIdentityProviderSelection.tsx | 3 +-- .../components/SignInUpWithCredentials.tsx | 2 +- .../sign-in-up/components/SignInUpWithGoogle.tsx | 3 +-- .../sign-in-up/components/SignInUpWithMicrosoft.tsx | 3 +-- .../auth/sign-in-up/components/SignInUpWithSSO.tsx | 3 +-- .../components/SignInUpWorkspaceScopeForm.tsx | 3 +-- .../components/SignInUpWorkspaceSelection.tsx | 3 +-- .../auth/sign-in-up/hooks/useSignInUpForm.ts | 2 +- 9 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index 2aac15db2c6c..b035e158d941 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -1,6 +1,11 @@ import styled from '@emotion/styled'; -import { IconGoogle, IconMicrosoft, Loader, MainButton } from 'twenty-ui'; -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; +import { + IconGoogle, + IconMicrosoft, + Loader, + MainButton, + HorizontalSeparator, +} from 'twenty-ui'; import { useTheme } from '@emotion/react'; import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; @@ -180,7 +185,9 @@ export const SignInUpGlobalScopeForm = () => { )} (form.formState.isSubmitting ? : null)} diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx index 1e0810ff4867..10422b829347 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection.tsx @@ -1,11 +1,10 @@ /* @license Enterprise */ -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; -import { MainButton } from 'twenty-ui'; +import { MainButton, HorizontalSeparator } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; import { authProvidersState } from '@/client-config/states/authProvidersState'; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx index c373ec0f66cb..c535294c4db1 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithCredentials.tsx @@ -13,7 +13,7 @@ import { import { useRecoilValue } from 'recoil'; import styled from '@emotion/styled'; import { SignInUpPasswordField } from '@/auth/sign-in-up/components/SignInUpPasswordField'; -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo } from 'react'; import { captchaProviderState } from '@/client-config/states/captchaProviderState'; import { isRequestingCaptchaTokenState } from '@/captcha/states/isRequestingCaptchaTokenState'; import { FormProvider } from 'react-hook-form'; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx index c7be6d6c8e92..ad98752504b7 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithGoogle.tsx @@ -1,9 +1,8 @@ -import { IconGoogle, MainButton } from 'twenty-ui'; +import { IconGoogle, MainButton, HorizontalSeparator } from 'twenty-ui'; import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useTheme } from '@emotion/react'; import { useRecoilValue } from 'recoil'; import { useSignInWithGoogle } from '@/auth/sign-in-up/hooks/useSignInWithGoogle'; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx index 608c659db116..652336b0d644 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithMicrosoft.tsx @@ -1,10 +1,9 @@ -import { IconMicrosoft, MainButton } from 'twenty-ui'; +import { IconMicrosoft, MainButton, HorizontalSeparator } from 'twenty-ui'; import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; import { useTheme } from '@emotion/react'; -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMicrosoft'; import { useRecoilValue } from 'recoil'; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx index 485e55b52708..2314a6414bae 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWithSSO.tsx @@ -1,9 +1,8 @@ -import { IconLock, MainButton } from 'twenty-ui'; +import { IconLock, MainButton, HorizontalSeparator } from 'twenty-ui'; import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useTheme } from '@emotion/react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx index 0dee7ec79dea..e156c56c626b 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceScopeForm.tsx @@ -1,4 +1,3 @@ -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { useHandleResetPassword } from '@/auth/sign-in-up/hooks/useHandleResetPassword'; import { useSignInUp } from '@/auth/sign-in-up/hooks/useSignInUp'; import { useSignInUpForm } from '@/auth/sign-in-up/hooks/useSignInUpForm'; @@ -7,7 +6,7 @@ import { authProvidersState } from '@/client-config/states/authProvidersState'; import styled from '@emotion/styled'; import { useCallback } from 'react'; import { useRecoilState } from 'recoil'; -import { ActionLink } from 'twenty-ui'; +import { ActionLink, HorizontalSeparator } from 'twenty-ui'; import { SignInUpWithGoogle } from '@/auth/sign-in-up/components/SignInUpWithGoogle'; import { SignInUpWithMicrosoft } from '@/auth/sign-in-up/components/SignInUpWithMicrosoft'; import { SignInUpWithSSO } from '@/auth/sign-in-up/components/SignInUpWithSSO'; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx index 322b5e95a262..e4e5ec057878 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx @@ -1,10 +1,9 @@ import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; -import { H2Title, MainButton } from 'twenty-ui'; +import { H2Title, MainButton, HorizontalSeparator } from 'twenty-ui'; import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; import { redirectToWorkspace } from '~/utils/workspace-url.helper'; -import { HorizontalSeparator } from '@/auth/sign-in-up/components/HorizontalSeparator'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; const StyledContentContainer = styled.div` diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts index 8e1ba8929c69..621446e2fee6 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -40,7 +40,7 @@ export const useSignInUpForm = () => { if (isDefined(email)) { form.setValue('email', email); } else if (isSignInPrefilled === true) { - form.setValue('email', 'moreaux.antoine@gmail.com'); + form.setValue('email', 'tim@apple.dev'); form.setValue('password', 'Applecar2025'); } }, [form, isSignInPrefilled]); From 04c0901826e8bee16b2d356922c1e15f00c54055 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 15 Nov 2024 14:52:35 +0100 Subject: [PATCH 16/24] refactor: Remove findAvailableWorkspacesByEmail query Refactor codebase to streamline user workspace handling by removing the findAvailableWorkspacesByEmail query. Updated related resolvers and components to integrate workspace retrieval logic directly within checkUserExists. Simplified GraphQL and client-side state management by consolidating workspace data flow. --- .../twenty-front/src/generated/graphql.tsx | 85 +++++++------------ .../auth/graphql/queries/checkUserExists.ts | 21 ++++- .../queries/findAvailableWorkspacesByEmail.ts | 19 ----- .../components/SignInUpGlobalScopeForm.tsx | 55 ++++++------ .../components/SignInUpWorkspaceSelection.tsx | 19 ----- .../states/availableWorkspacesForAuthState.ts | 9 +- .../engine/core-modules/auth/auth.resolver.ts | 18 +++- .../auth/dto/user-exists.entity.ts | 28 +++++- .../core-modules/sso/services/sso.service.ts | 38 --------- .../engine/core-modules/sso/sso.resolver.ts | 8 -- ...l-hydrate-request-from-token.middleware.ts | 1 - packages/twenty-ui/src/display/index.ts | 2 +- 12 files changed, 122 insertions(+), 181 deletions(-) delete mode 100644 packages/twenty-front/src/modules/auth/graphql/queries/findAvailableWorkspacesByEmail.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 84074826fde9..db7ab8be164d 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -778,7 +778,7 @@ export type PublishServerlessFunctionInput = { export type Query = { __typename?: 'Query'; billingPortalSession: SessionEntity; - checkUserExists: UserExists; + checkUserExists: UserExistsOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; currentUser: User; @@ -1269,14 +1269,23 @@ export type UserEdge = { export type UserExists = { __typename?: 'UserExists'; + availableWorkspaces?: Maybe>; exists: Scalars['Boolean']; }; +export type UserExistsOutput = UserExists | UserNotExists; + export type UserMappingOptionsUser = { __typename?: 'UserMappingOptionsUser'; user?: Maybe; }; +export type UserNotExists = { + __typename?: 'UserNotExists'; + availableWorkspaces?: Maybe>; + exists: Scalars['Boolean']; +}; + export type UserWorkspace = { __typename?: 'UserWorkspace'; createdAt: Scalars['DateTime']; @@ -1763,14 +1772,7 @@ export type CheckUserExistsQueryVariables = Exact<{ }>; -export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean } }; - -export type FindAvailableWorkspacesByEmailQueryVariables = Exact<{ - email: Scalars['String']; -}>; - - -export type FindAvailableWorkspacesByEmailQuery = { __typename?: 'Query', findAvailableWorkspacesByEmail: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso?: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> | null }> }; +export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean, availableWorkspaces?: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso?: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> | null }> | null } | { __typename?: 'UserNotExists', exists: boolean } }; export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ workspaceId?: InputMaybe; @@ -2874,7 +2876,25 @@ export type VerifyMutationOptions = Apollo.BaseMutationOptions; export type CheckUserExistsLazyQueryHookResult = ReturnType; export type CheckUserExistsQueryResult = Apollo.QueryResult; -export const FindAvailableWorkspacesByEmailDocument = gql` - query FindAvailableWorkspacesByEmail($email: String!) { - findAvailableWorkspacesByEmail(email: $email) { - id - displayName - subdomain - logo - sso { - type - id - issuer - name - status - } - } -} - `; - -/** - * __useFindAvailableWorkspacesByEmailQuery__ - * - * To run a query within a React component, call `useFindAvailableWorkspacesByEmailQuery` and pass it any options that fit your needs. - * When your component renders, `useFindAvailableWorkspacesByEmailQuery` 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 } = useFindAvailableWorkspacesByEmailQuery({ - * variables: { - * email: // value for 'email' - * }, - * }); - */ -export function useFindAvailableWorkspacesByEmailQuery(baseOptions: Apollo.QueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(FindAvailableWorkspacesByEmailDocument, options); - } -export function useFindAvailableWorkspacesByEmailLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(FindAvailableWorkspacesByEmailDocument, options); - } -export type FindAvailableWorkspacesByEmailQueryHookResult = ReturnType; -export type FindAvailableWorkspacesByEmailLazyQueryHookResult = ReturnType; -export type FindAvailableWorkspacesByEmailQueryResult = Apollo.QueryResult; export const GetPublicWorkspaceDataBySubdomainDocument = gql` query GetPublicWorkspaceDataBySubdomain($workspaceId: String) { getPublicWorkspaceDataBySubdomain(workspaceId: $workspaceId) { diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts index ddf5505bba86..0a3c9b0aca4e 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/checkUserExists.ts @@ -3,7 +3,26 @@ import { gql } from '@apollo/client'; export const CHECK_USER_EXISTS = gql` query CheckUserExists($email: String!, $captchaToken: String) { checkUserExists(email: $email, captchaToken: $captchaToken) { - exists + __typename + ... on UserExists { + exists + availableWorkspaces { + id + displayName + subdomain + logo + sso { + type + id + issuer + name + status + } + } + } + ... on UserNotExists { + exists + } } } `; diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/findAvailableWorkspacesByEmail.ts b/packages/twenty-front/src/modules/auth/graphql/queries/findAvailableWorkspacesByEmail.ts deleted file mode 100644 index 14e5b2ef7c90..000000000000 --- a/packages/twenty-front/src/modules/auth/graphql/queries/findAvailableWorkspacesByEmail.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { gql } from '@apollo/client'; - -export const FIND_AVAILABLE_WORKSPACES_BY_EMAIL = gql` - query FindAvailableWorkspacesByEmail($email: String!) { - findAvailableWorkspacesByEmail(email: $email) { - id - displayName - subdomain - logo - sso { - type - id - issuer - name - status - } - } - } -`; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index b035e158d941..b6470610b22a 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -12,7 +12,6 @@ import { useSignInWithMicrosoft } from '@/auth/sign-in-up/hooks/useSignInWithMic import { FormProvider } from 'react-hook-form'; import { z } from 'zod'; import { motion } from 'framer-motion'; -import { useFindAvailableWorkspacesByEmail } from '@/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail'; import { useState } from 'react'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { isDefined } from '~/utils/isDefined'; @@ -37,6 +36,7 @@ import { signInUpModeState, } from '@/auth/states/signInUpModeState'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; +import { UserExists } from '~/generated/graphql'; const StyledContentContainer = styled(motion.div)` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -75,9 +75,7 @@ export const SignInUpGlobalScopeForm = () => { const setSignInUpStep = useSetRecoilState(signInUpStepState); const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); - const { findAvailableWorkspacesByEmail } = - useFindAvailableWorkspacesByEmail(); - const setAvailableWorkspacesForAuthState = useSetRecoilState( + const setAvailableWorkspacesForAuth = useSetRecoilState( availableWorkspacesForAuthState, ); const { enqueueSnackBar } = useSnackBar(); @@ -89,25 +87,17 @@ export const SignInUpGlobalScopeForm = () => { const { submitCredentials } = useSignInUp(form); - const continueToWorkspaceSelection = async () => { - const { data } = await findAvailableWorkspacesByEmail( - form.getValues('email'), - ); - - if (signInUpStep === SignInUpStep.Password) { - await form.handleSubmit(submitCredentials)(); - } - - if (isDefined(data) && data.findAvailableWorkspacesByEmail.length > 1) { - setAvailableWorkspacesForAuthState(data.findAvailableWorkspacesByEmail); + const continueToWorkspaceSelection = async ( + availableWorkspaces: UserExists['availableWorkspaces'], + ) => { + if (isDefined(availableWorkspaces) && availableWorkspaces.length > 1) { return setSignInUpStep(SignInUpStep.WorkspaceSelection); } - if (isDefined(data) && data.findAvailableWorkspacesByEmail.length === 1) { - return redirectToWorkspace( - data.findAvailableWorkspacesByEmail[0].subdomain, - { email: form.getValues('email') }, - ); + if (isDefined(availableWorkspaces) && availableWorkspaces.length === 1) { + return redirectToWorkspace(availableWorkspaces[0].subdomain, { + email: form.getValues('email'), + }); } // si 1 workspace sans sso redirige sur workspace avec email en query params pour prefill @@ -115,29 +105,36 @@ export const SignInUpGlobalScopeForm = () => { // si plusieurs workspaces redirige sur la liste des workspaces }; - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - + const handleSubmit = async () => { if (isDefined(form?.formState?.errors?.email)) { setShowErrors(true); return; } if (signInUpStep === SignInUpStep.Password) { - await form.handleSubmit(submitCredentials)(); - return continueToWorkspaceSelection(); + await submitCredentials(form.getValues()); + // TODO AMOREAUX: Continue to workspace creation + return; } const token = await readCaptchaToken(); - checkUserExists.checkUserExistsQuery({ + await checkUserExists.checkUserExistsQuery({ variables: { email: form.getValues('email'), captchaToken: token, }, onCompleted: (data) => { requestFreshCaptchaToken(); - if (data?.checkUserExists.exists) { - continueToWorkspaceSelection(); + if ( + data?.checkUserExists.exists && + data.checkUserExists.__typename === 'UserExists' + ) { + setAvailableWorkspacesForAuth( + data?.checkUserExists.availableWorkspaces, + ); + continueToWorkspaceSelection( + data?.checkUserExists.availableWorkspaces, + ); } else { if (!isMultiWorkspaceEnabled) { return enqueueSnackBar('User not found', { @@ -175,7 +172,7 @@ export const SignInUpGlobalScopeForm = () => { {/* eslint-disable-next-line react/jsx-props-no-spreading */} - + {signInUpStep === SignInUpStep.Password && ( { const availableWorkspacesForAuth = useRecoilValue( availableWorkspacesForAuthState, ); - const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); - - const createNewWorkspace = () => { - console.log('>>>>>>>>>>>>>> createNewWorkspace'); - }; return ( <> @@ -68,20 +63,6 @@ export const SignInUpWorkspaceSelection = () => { ))} )} - {isMultiWorkspaceEnabled && ( - <> - {availableWorkspacesForAuth && - availableWorkspacesForAuth.length > 0 && ( - - )} - createNewWorkspace()} fullWidth> - - - Create workspace - - - - )} ); diff --git a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts index 5604c53421e5..97d124f1ac23 100644 --- a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts +++ b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts @@ -1,10 +1,9 @@ import { createState } from 'twenty-ui'; -import { FindAvailableWorkspacesByEmailQuery } from '~/generated/graphql'; +import { UserExists } from '~/generated/graphql'; -export const availableWorkspacesForAuthState = createState | null>({ +export const availableWorkspacesForAuthState = createState< + UserExists['availableWorkspaces'] +>({ key: 'availableWorkspacesForAuthState', defaultValue: null, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index ec454d316d32..caaaf97cbefb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -47,7 +47,7 @@ import { ImpersonateInput } from './dto/impersonate.input'; import { LoginToken } from './dto/login-token.entity'; import { SignUpInput } from './dto/sign-up.input'; import { ApiKeyToken, AuthTokens } from './dto/token.entity'; -import { UserExists } from './dto/user-exists.entity'; +import { UserExistsOutput } from './dto/user-exists.entity'; import { CheckUserExistsInput } from './dto/user-exists.input'; import { Verify } from './dto/verify.entity'; import { VerifyInput } from './dto/verify.input'; @@ -73,15 +73,25 @@ export class AuthResolver { ) {} @UseGuards(CaptchaGuard) - @Query(() => UserExists) + @Query(() => UserExistsOutput) async checkUserExists( @Args() checkUserExistsInput: CheckUserExistsInput, - ): Promise { + ): Promise { const { exists } = await this.authService.checkUserExists( checkUserExistsInput.email, ); - return { exists }; + return { + exists, + ...(exists + ? { + availableWorkspaces: + await this.authService.findAvailableWorkspacesByEmail( + checkUserExistsInput.email, + ), + } + : {}), + }; } @Query(() => WorkspaceInviteHashValid) diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts index b4b70d0af839..b615476ac32a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/user-exists.entity.ts @@ -1,7 +1,33 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType, createUnionType } from '@nestjs/graphql'; + +import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; @ObjectType() export class UserExists { @Field(() => Boolean) exists: boolean; + + @Field(() => [AvailableWorkspaceOutput], { nullable: true }) + availableWorkspaces?: Array; +} + +@ObjectType() +class UserNotExists { + @Field(() => Boolean) + exists: boolean; + + @Field(() => [AvailableWorkspaceOutput], { nullable: true }) + availableWorkspaces?: Array; } + +export const UserExistsOutput = createUnionType({ + name: 'UserExistsOutput', + types: () => [UserExists, UserNotExists] as const, + resolveType(value) { + if (value.exists === true) { + return UserExists; + } + + return UserNotExists; + }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts index 23083513c8cc..80bc37071a00 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts @@ -133,44 +133,6 @@ export class SSOService { }; } - async findAvailableSSOIdentityProviders(email: string) { - const user = await this.userRepository.findOne({ - where: { email }, - relations: [ - 'workspaces', - 'workspaces.workspace', - 'workspaces.workspace.workspaceSSOIdentityProviders', - ], - }); - - if (!user) { - throw new SSOException('User not found', SSOExceptionCode.USER_NOT_FOUND); - } - - return user.workspaces.flatMap((userWorkspace) => - ( - userWorkspace.workspace - .workspaceSSOIdentityProviders as Array - ).reduce((acc, identityProvider) => { - if (identityProvider.status === 'Inactive') return acc; - - acc.push({ - id: identityProvider.id, - name: identityProvider.name ?? 'Unknown', - issuer: identityProvider.issuer, - type: identityProvider.type, - status: identityProvider.status, - workspace: { - id: userWorkspace.workspaceId, - displayName: userWorkspace.workspace.displayName, - }, - }); - - return acc; - }, [] as Array), - ); - } - async findSSOIdentityProviderById(identityProviderId?: string) { // if identityProviderId is not provide, typeorm return a random idp instead of undefined if (!identityProviderId) return undefined; diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts index 3b371a13c9a7..1f96744dedfe 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts @@ -39,14 +39,6 @@ export class SSOResolver { ); } - // @UseGuards(SSOProviderEnabledGuard) - // @Mutation(() => [FindAvailableSSOIDPOutput]) - // async findAvailableSSOIdentityProviders( - // @Args('input') input: FindAvailableSSOIDPInput, - // ): Promise> { - // return this.sSOService.findAvailableSSOIdentityProviders(input.email); - // } - @UseGuards(SSOProviderEnabledGuard) @Query(() => [FindAvailableSSOIDPOutput]) async listSSOIdentityProvidersByWorkspaceId( diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 5dca0766786c..01ec0af42227 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -56,7 +56,6 @@ export class GraphQLHydrateRequestFromTokenMiddleware 'IntrospectionQuery', 'ExchangeAuthorizationCode', 'GetAuthorizationUrl', - 'FindAvailableSSOIdentityProviders', 'GetPublicWorkspaceDataBySubdomain', 'FindAvailableWorkspacesByEmail', ]; diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index 45144332b2bf..e8f248ba7a31 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -49,8 +49,8 @@ export * from './icon/types/IconComponent'; export * from './info/components/Info'; export * from './status/components/Status'; export * from './tag/components/Tag'; -export * from './text/components/SeparatorLineText'; export * from './text/components/HorizontalSeparator'; +export * from './text/components/SeparatorLineText'; export * from './tooltip/AppTooltip'; export * from './tooltip/OverflowingTextWithTooltip'; export * from './typography/components/H1Title'; From 2817e201690000d642af110112f6ed21b5171f8c Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 18 Nov 2024 12:36:13 +0100 Subject: [PATCH 17/24] chore: remove unused hook and enhance token handling Removed the useFindAvailableWorkspacesByEmail hook as it was no longer in use. Improved the workspace token handling by adding support for workspace-specific tokens and streamlined subdomain generation for workspaces based on user email or display name. --- packages/twenty-front/.env.example | 7 +- .../src/generated-metadata/graphql.ts | 2 +- .../sign-in-up/components/SignInUpForm.tsx | 0 .../components/SignInUpGlobalScopeForm.tsx | 1 - .../useFindAvailableWorkspacedByEmail.ts | 18 --- .../auth/sign-in-up/hooks/useSignInUp.tsx | 28 ++--- .../auth/sign-in-up/hooks/useSignInUpForm.ts | 2 +- .../availableIdentityProviderForAuthState.ts | 9 +- .../states/lastAuthenticateWorkspaceState.ts | 1 + .../src/modules/auth/states/tokenPairState.ts | 18 ++- .../src/pages/auth/SSOWorkspaceSelection.tsx | 0 .../twenty-front/src/pages/auth/SignInUp.tsx | 33 ++---- .../src/pages/onboarding/CreateWorkspace.tsx | 8 +- packages/twenty-front/vite.config.ts | 19 +++- packages/twenty-server/.env.example | 5 + .../core-query-builder.factory.ts | 3 +- .../metadata/rest-api-metadata.service.ts | 8 +- .../engine/core-modules/auth/auth.module.ts | 2 + .../engine/core-modules/auth/auth.resolver.ts | 15 ++- .../google-apis-auth.controller.ts | 2 - .../controllers/google-auth.controller.ts | 2 +- .../microsoft-apis-auth.controller.ts | 25 ++++- .../controllers/microsoft-auth.controller.ts | 1 + .../controllers/sign-up-auth.controller.ts | 68 +++++++++++ .../auth/controllers/sso-auth.controller.ts | 5 +- .../auth/services/oauth.service.ts | 1 + .../services/access-token.service.spec.ts | 4 +- .../token/services/access-token.service.ts | 22 ++-- .../token/services/login-token.service.ts | 18 ++- .../environment/environment-variables.ts | 11 +- .../core-modules/open-api/open-api.service.ts | 2 +- .../workspace/services/workspace.service.ts | 106 ++++++++++++++++-- .../src/engine/guards/jwt-auth.guard.ts | 3 +- ...l-hydrate-request-from-token.middleware.ts | 3 +- .../src/engine/utils/serverUrl.ts | 13 ++- packages/twenty-server/src/main.ts | 35 +++++- .../twenty-server/src/utils/is-work-email.ts | 18 ++- 37 files changed, 390 insertions(+), 128 deletions(-) delete mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx delete mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail.ts delete mode 100644 packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/auth/controllers/sign-up-auth.controller.ts diff --git a/packages/twenty-front/.env.example b/packages/twenty-front/.env.example index 31e73f0c462d..abdf4e3b56ac 100644 --- a/packages/twenty-front/.env.example +++ b/packages/twenty-front/.env.example @@ -6,4 +6,9 @@ GENERATE_SOURCEMAP=false # REACT_APP_BASE_URL=http://localhost # CHROMATIC_PROJECT_TOKEN= # VITE_DISABLE_TYPESCRIPT_CHECKER=true -# VITE_DISABLE_ESLINT_CHECKER=true \ No newline at end of file +# VITE_DISABLE_ESLINT_CHECKER=true + +###### --------------> !!! FOR CHARLES AND FELIX !!! we can create a gist in twenty if you want <--------------------------- +###### To configure a local certificate you can follow these step https://gist.github.com/AMoreaux/635ca9c38924d42a4d914dabe4376f72 +# SSL_KEY_PATH="~/certs/your-cert.key" +# SSL_CERT_PATH="~/certs/your-cert.crt" \ No newline at end of file diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index b26b39ddc122..1a40a8d4446b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -168,7 +168,7 @@ export type ClientConfig = { debugMode: Scalars['Boolean']['output']; sentry: Sentry; signInPrefilled: Scalars['Boolean']['output']; - signUpDisabled: Scalars['Boolean']['output']; + isMultiworkspaceEnabled: Scalars['Boolean']['output']; support: Support; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index b6470610b22a..27ccae358b0e 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -113,7 +113,6 @@ export const SignInUpGlobalScopeForm = () => { if (signInUpStep === SignInUpStep.Password) { await submitCredentials(form.getValues()); - // TODO AMOREAUX: Continue to workspace creation return; } diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail.ts deleted file mode 100644 index 7621c010ace5..000000000000 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useFindAvailableWorkspacedByEmail.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useFindAvailableWorkspacesByEmailLazyQuery } from '~/generated/graphql'; - -export const useFindAvailableWorkspacesByEmail = () => { - const [findAvailableWorkspacesByEmailQuery] = - useFindAvailableWorkspacesByEmailLazyQuery(); - - const findAvailableWorkspacesByEmail = async (email: string) => { - return await findAvailableWorkspacesByEmailQuery({ - variables: { - email, - }, - }); - }; - - return { - findAvailableWorkspacesByEmail, - }; -}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index 99a371a3c39f..82cdd4088389 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -96,19 +96,21 @@ export const useSignInUp = (form: UseFormReturn) => { throw new Error('Email and password are required'); } - signInUpMode === SignInUpMode.SignIn && !isInviteMode - ? await signInWithCredentials( - data.email.toLowerCase().trim(), - data.password, - token, - ) - : await signUpWithCredentials( - data.email.toLowerCase().trim(), - data.password, - workspaceInviteHash, - workspacePersonalInviteToken, - token, - ); + if (signInUpMode === SignInUpMode.SignIn && !isInviteMode) { + await signInWithCredentials( + data.email.toLowerCase().trim(), + data.password, + token, + ); + } else { + await signUpWithCredentials( + data.email.toLowerCase().trim(), + data.password, + workspaceInviteHash, + workspacePersonalInviteToken, + token, + ); + } } catch (err: any) { enqueueSnackBar(err?.message, { variant: SnackBarVariant.Error, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts index 621446e2fee6..1194594b9de6 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -26,7 +26,7 @@ export const useSignInUpForm = () => { const searchParams = new URLSearchParams(location.search); const isSignInPrefilled = useRecoilValue(isSignInPrefilledState); const form = useForm({ - mode: 'onChange', + mode: 'onSubmit', defaultValues: { exist: false, email: '', diff --git a/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts b/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts index cd5efe1bc17c..d3100a826b3a 100644 --- a/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts +++ b/packages/twenty-front/src/modules/auth/states/availableIdentityProviderForAuthState.ts @@ -1,10 +1,9 @@ import { createState } from 'twenty-ui'; -import { FindAvailableWorkspacesByEmailQuery } from '~/generated/graphql'; +import { UserExists } from '~/generated/graphql'; -export const availableSSOIdentityProvidersForAuthState = createState | null>({ +export const availableSSOIdentityProvidersForAuthState = createState< + NonNullable[0]['sso'] +>({ key: 'availableSSOIdentityProvidersForAuth', defaultValue: [], }); diff --git a/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts index 76ad9bc613be..21ad1edec71a 100644 --- a/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/lastAuthenticateWorkspaceState.ts @@ -12,6 +12,7 @@ export const lastAuthenticateWorkspaceState = createState({ key: 'tokenPairState', defaultValue: null, effects: [ - cookieStorageEffect('tokenPair', undefined, { - validateInitFn: (payload: AuthTokenPair) => - Boolean(payload['accessToken']), - }), + cookieStorageEffect( + `${getWorkspaceSubdomain() ?? 'twentyRoot'}TokenPair`, + { + Domain: `.${twentyHostname}`, + }, + { + validateInitFn: (payload: AuthTokenPair) => + Boolean(payload['accessToken']), + }, + ), ], }); diff --git a/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx b/packages/twenty-front/src/pages/auth/SSOWorkspaceSelection.tsx deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 3ba54a5c89bf..f49b88c0731d 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -21,7 +21,6 @@ import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/consta import { Link } from 'react-router-dom'; import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/SignInUpSSOIdentityProviderSelection'; -import { useCallback } from 'react'; export const SignInUp = () => { const setLastAuthenticateWorkspaceState = useSetRecoilState( @@ -39,27 +38,6 @@ export const SignInUp = () => { redirectToHome(); }; - const CurrentFormComponent = useCallback(() => { - if (isTwentyHomePage && signInUpStep === SignInUpStep.WorkspaceSelection) { - return ; - } - - if (isTwentyHomePage) { - return ; - } - - if ( - isTwentyWorkspaceSubdomain && - signInUpStep === SignInUpStep.SSOIdentityProviderSelection - ) { - return ; - } - - if (isTwentyWorkspaceSubdomain) { - return ; - } - }, [signInUpStep]); - return ( <> {/* TODO AMOREAUX: Need design for this */} @@ -74,7 +52,16 @@ export const SignInUp = () => { {`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`} - + {isTwentyHomePage && signInUpStep === SignInUpStep.WorkspaceSelection ? ( + + ) : isTwentyHomePage ? ( + + ) : isTwentyWorkspaceSubdomain && + signInUpStep === SignInUpStep.SSOIdentityProviderSelection ? ( + + ) : isTwentyWorkspaceSubdomain ? ( + + ) : null} ); diff --git a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx index a0c861683f9b..63b0ce188bc0 100644 --- a/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx +++ b/packages/twenty-front/src/pages/onboarding/CreateWorkspace.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; import { zodResolver } from '@hookform/resolvers/zod'; import { useCallback } from 'react'; import { Controller, SubmitHandler, useForm } from 'react-hook-form'; -import { useSetRecoilState } from 'recoil'; +import { useSetRecoilState, useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; import { H2Title, Loader, MainButton } from 'twenty-ui'; import { z } from 'zod'; @@ -22,6 +22,8 @@ import { useActivateWorkspaceMutation, } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { tokenPairState } from '@/auth/states/tokenPairState'; const StyledContentContainer = styled.div` width: 100%; @@ -47,6 +49,7 @@ type Form = z.infer; export const CreateWorkspace = () => { const { enqueueSnackBar } = useSnackBar(); const onboardingStatus = useOnboardingStatus(); + const tokenPair = useRecoilValue(tokenPairState); const [activateWorkspace] = useActivateWorkspaceMutation(); const apolloMetadataClient = useApolloMetadataClient(); @@ -75,6 +78,7 @@ export const CreateWorkspace = () => { }, }, }); + setIsCurrentUserLoaded(false); await apolloMetadataClient?.refetchQueries({ @@ -84,6 +88,8 @@ export const CreateWorkspace = () => { if (isDefined(result.errors)) { throw result.errors ?? new Error('Unknown error'); } + + window.location.href = `${REACT_APP_SERVER_BASE_URL}/auth/redirect?accessToken=${tokenPair?.accessToken.token}`; } catch (error: any) { enqueueSnackBar(error?.message, { variant: SnackBarVariant.Error, diff --git a/packages/twenty-front/vite.config.ts b/packages/twenty-front/vite.config.ts index a062f8f4010a..aa7dcaadf77a 100644 --- a/packages/twenty-front/vite.config.ts +++ b/packages/twenty-front/vite.config.ts @@ -3,6 +3,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import react from '@vitejs/plugin-react-swc'; import wyw from '@wyw-in-js/vite'; import path from 'path'; +import fs from 'fs'; import { defineConfig, loadEnv, searchForWorkspaceRoot } from 'vite'; import checker from 'vite-plugin-checker'; import svgr from 'vite-plugin-svgr'; @@ -63,9 +64,13 @@ export default defineConfig(({ command, mode }) => { }; } - const { hostname, protocol } = new URL( - REACT_APP_BASE_URL ?? 'http://localhost', - ); + const { hostname, protocol } = new URL(REACT_APP_BASE_URL ?? `localhost`); + + if (protocol === 'https:' && (!env.SSL_KEY_PATH || !env.SSL_CERT_PATH)) { + throw new Error( + 'to use https SSL_KEY_PATH and SSL_CERT_PATH must be both defined', + ); + } return { root: __dirname, @@ -75,6 +80,14 @@ export default defineConfig(({ command, mode }) => { port: port, host: hostname, protocol: protocol.slice(0, -1) as 'http' | 'https', + ...(protocol === 'https:' + ? { + https: { + key: fs.readFileSync(env.SSL_KEY_PATH), + cert: fs.readFileSync(env.SSL_CERT_PATH), + }, + } + : {}), fs: { allow: [ searchForWorkspaceRoot(process.cwd()), diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 6591de240530..5794a009e826 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -74,3 +74,8 @@ ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access # PG_SSL_ALLOW_SELF_SIGNED=true # SESSION_STORE_SECRET=replace_me_with_a_random_string_session # ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key +###### --------------> !!! FOR CHARLES AND FELIX !!! we can create a gist in twenty if you want <--------------------------- +###### To configure a local certificate you can follow these step https://gist.github.com/AMoreaux/635ca9c38924d42a4d914dabe4376f72 +# SSL_KEY_PATH="~/certs/your-cert.key" +# SSL_CERT_PATH="~/certs/your-cert.crt" +# SERVER_BASE_URL=localhost \ No newline at end of file diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts index 5f0d63966b1c..5088a36857f7 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts @@ -50,7 +50,8 @@ export class CoreQueryBuilderFactory { objectMetadataItems: ObjectMetadataEntity[]; objectMetadataItem: ObjectMetadataEntity; }> { - const { workspace } = await this.accessTokenService.validateToken(request); + const { workspace } = + await this.accessTokenService.validateTokenByRequest(request); const objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts index c53f82783889..cad1df914039 100644 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts +++ b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts @@ -18,7 +18,7 @@ export class RestApiMetadataService { ) {} async get(request: Request) { - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); const data = await this.metadataQueryBuilderFactory.get(request); return await this.restApiService.call( @@ -29,7 +29,7 @@ export class RestApiMetadataService { } async create(request: Request) { - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); const data = await this.metadataQueryBuilderFactory.create(request); return await this.restApiService.call( @@ -40,7 +40,7 @@ export class RestApiMetadataService { } async update(request: Request) { - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); const data = await this.metadataQueryBuilderFactory.update(request); return await this.restApiService.call( @@ -51,7 +51,7 @@ export class RestApiMetadataService { } async delete(request: Request) { - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); const data = await this.metadataQueryBuilderFactory.delete(request); return await this.restApiService.call( 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 f6f339e17ada..df8b789f6381 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 @@ -41,6 +41,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; +import { SignUpAuthController } from 'src/engine/core-modules/auth/controllers/sign-up-auth.controller'; import { AuthResolver } from './auth.resolver'; @@ -83,6 +84,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; GoogleAPIsAuthController, MicrosoftAPIsAuthController, SSOAuthController, + SignUpAuthController, ], providers: [ SignInUpService, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index caaaf97cbefb..3c274fb3e5d2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -129,6 +129,7 @@ export class AuthResolver { const user = await this.authService.challenge(challengeInput, workspace); const loginToken = await this.loginTokenService.generateLoginToken( user.email, + workspace.id, ); return { loginToken }; @@ -151,6 +152,7 @@ export class AuthResolver { const loginToken = await this.loginTokenService.generateLoginToken( user.email, + user.defaultWorkspaceId, ); return { loginToken }; @@ -196,18 +198,19 @@ export class AuthResolver { ): Promise { const workspace = await this.workspaceService.getWorkspaceByOrigin(origin); - if (!workspace) { + const { sub: email, workspaceId: workspaceIdFromLoginToken } = + await this.loginTokenService.verifyLoginToken(verifyInput.loginToken); + + const workspaceId = workspaceIdFromLoginToken ?? workspace?.id; + + if (!workspaceId) { throw new AuthException( 'Workspace not found', AuthExceptionCode.WORKSPACE_NOT_FOUND, ); } - const email = await this.loginTokenService.verifyLoginToken( - verifyInput.loginToken, - ); - - return await this.authService.verify(email, workspace.id); + return await this.authService.verify(email, workspaceId); } @Mutation(() => AuthorizeApp) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index c2771f784752..b2a09db3c4f5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -23,7 +23,6 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; -import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -34,7 +33,6 @@ export class GoogleAPIsAuthController { private readonly googleAPIsService: GoogleAPIsService, private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, - private readonly workspaceService: WorkspaceService, private readonly onboardingService: OnboardingService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index e8831a6510b0..519a81a474fd 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -20,7 +20,6 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { computeRedirectErrorUrl } from 'src/engine/core-modules/auth/utils/compute-redirect-error-url'; @@ -75,6 +74,7 @@ export class GoogleAuthController { const loginToken = await this.loginTokenService.generateLoginToken( user.email, + user.defaultWorkspace.id, ); return res.redirect( diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts index d0aec9e129bb..ced79b1dd73c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts @@ -6,8 +6,10 @@ import { UseFilters, UseGuards, } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { Response } from 'express'; +import { Repository } from 'typeorm'; import { AuthException, @@ -21,6 +23,8 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microsoft-api-request.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; +import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Controller('auth/microsoft-apis') @UseFilters(AuthRestApiExceptionFilter) @@ -30,6 +34,8 @@ export class MicrosoftAPIsAuthController { private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, private readonly onboardingService: OnboardingService, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, ) {} @Get() @@ -96,10 +102,23 @@ export class MicrosoftAPIsAuthController { }); } + const workspace = await this.workspaceRepository.findOneBy({ + id: workspaceId, + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + return res.redirect( - `${this.environmentService.get('FRONT_BASE_URL')}${ - redirectLocation || '/settings/accounts' - }`, + buildWorkspaceURL( + this.environmentService.get('FRONT_BASE_URL'), + { workspace }, + { withPathname: redirectLocation || '/settings/accounts' }, + ).toString(), ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 4aa26399f74f..a12954d95427 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -75,6 +75,7 @@ export class MicrosoftAuthController { const loginToken = await this.loginTokenService.generateLoginToken( user.email, + user.defaultWorkspace.id, ); return res.redirect( diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sign-up-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sign-up-auth.controller.ts new file mode 100644 index 000000000000..2b9231284510 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sign-up-auth.controller.ts @@ -0,0 +1,68 @@ +import { Controller, Get, Req, Res, UseFilters } from '@nestjs/common'; + +import { Request, Response } from 'express'; + +import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; +import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { isDefined } from 'src/utils/is-defined'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import ServerUrl from 'src/engine/utils/serverUrl'; + +@Controller('auth/redirect') +@UseFilters(AuthRestApiExceptionFilter) +export class SignUpAuthController { + constructor( + private readonly accessTokenService: AccessTokenService, + private readonly authService: AuthService, + private readonly environmentService: EnvironmentService, + ) {} + + @Get() + async redirect(@Req() request: Request, @Res() res: Response) { + if ( + !isDefined(request?.query?.accessToken) || + typeof request.query.accessToken !== 'string' + ) { + throw new AuthException( + 'Invalid access token', + AuthExceptionCode.OAUTH_ACCESS_DENIED, + ); + } + + const { user, workspace } = await this.accessTokenService.validateToken( + request.query.accessToken, + ); + + if (!isDefined(user)) { + throw new AuthException( + 'Invalid access token', + AuthExceptionCode.OAUTH_ACCESS_DENIED, + ); + } + + const { tokens } = await this.authService.verify(user.email, workspace.id); + + const redirectUrl = buildWorkspaceURL( + this.environmentService.get('FRONT_BASE_URL'), + { subdomain: workspace.subdomain }, + ); + + res.cookie( + `${workspace.subdomain ?? 'twentyRoot'}TokenPair`, + JSON.stringify(tokens), + { + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 1 week + domain: `.${redirectUrl.hostname}`, + secure: new URL(ServerUrl.get()).protocol === 'https:', + }, + ); + + return res.status(200).redirect(redirectUrl.toString()); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 2e6abdab093c..d3c397797a7f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -164,6 +164,9 @@ export class SSOAuthController { ); } - return this.loginTokenService.generateLoginToken(user.email); + return this.loginTokenService.generateLoginToken( + user.email, + identityProvider.workspaceId, + ); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts index 83f7b0ff7bc7..a1064877b51b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts @@ -144,6 +144,7 @@ export class OAuthService { ); const loginToken = await this.loginTokenService.generateLoginToken( user.email, + user.defaultWorkspaceId, ); return { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index 92e987d25fa4..941fbef4c912 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -166,7 +166,7 @@ describe('AccessTokenService', () => { .spyOn(service['jwtStrategy'], 'validate') .mockReturnValue(mockAuthContext as any); - const result = await service.validateToken(mockRequest); + const result = await service.validateTokenByRequest(mockRequest); expect(result).toEqual(mockAuthContext); expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( @@ -184,7 +184,7 @@ describe('AccessTokenService', () => { headers: {}, } as Request; - await expect(service.validateToken(mockRequest)).rejects.toThrow( + await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow( AuthException, ); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index 1443ee7a6bad..5497a2c36e82 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -112,7 +112,18 @@ export class AccessTokenService { }; } - async validateToken(request: Request): Promise { + async validateToken(token: string): Promise { + await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); + + const decoded = await this.jwtWrapperService.decode(token); + + const { user, apiKey, workspace, workspaceMemberId } = + await this.jwtStrategy.validate(decoded as JwtPayload); + + return { user, apiKey, workspace, workspaceMemberId }; + } + + async validateTokenByRequest(request: Request): Promise { const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); if (!token) { @@ -122,13 +133,6 @@ export class AccessTokenService { ); } - await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); - - const decoded = await this.jwtWrapperService.decode(token); - - const { user, apiKey, workspace, workspaceMemberId } = - await this.jwtStrategy.validate(decoded as JwtPayload); - - return { user, apiKey, workspace, workspaceMemberId }; + return this.validateToken(token); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts index 24c96b4e42c5..4968efffdfb3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -18,8 +18,15 @@ export class LoginTokenService { private readonly environmentService: EnvironmentService, ) {} - async generateLoginToken(email: string): Promise { - const secret = this.jwtWrapperService.generateAppSecret('LOGIN'); + async generateLoginToken( + email: string, + workspaceId: string, + ): Promise { + const secret = this.jwtWrapperService.generateAppSecret( + 'LOGIN', + workspaceId, + ); + const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); if (!expiresIn) { @@ -32,6 +39,7 @@ export class LoginTokenService { const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); const jwtPayload = { sub: email, + workspaceId, }; return { @@ -43,11 +51,13 @@ export class LoginTokenService { }; } - async verifyLoginToken(loginToken: string): Promise { + async verifyLoginToken( + loginToken: string, + ): Promise<{ sub: string; workspaceId?: string }> { await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); return this.jwtWrapperService.decode(loginToken, { json: true, - }).sub; + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index fe702ed055e8..663aba1fbb53 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -132,7 +132,7 @@ export class EnvironmentVariables { // Server URL @IsUrl({ require_tld: false }) @IsOptional() - SERVER_URL: string; + SERVER_URL = 'localhost'; @IsString() APP_SECRET: string; @@ -463,6 +463,15 @@ export class EnvironmentVariables { // milliseconds @CastToPositiveNumber() SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000; + + // SSL + @IsString() + @ValidateIf((env) => env.SERVER_URL.startsWith('https')) + SSL_KEY_PATH: string; + + @IsString() + @ValidateIf((env) => env.SERVER_URL.startsWith('https')) + SSL_CERT_PATH: string; } export const validate = ( diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 7a2828d0f364..61d75608be19 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -59,7 +59,7 @@ export class OpenApiService { try { const { workspace } = - await this.accessTokenService.validateToken(request); + await this.accessTokenService.validateTokenByRequest(request); objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 23bd119884e4..649f158b6b60 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -25,6 +25,7 @@ import { } from 'src/engine/core-modules/workspace/workspace.exception'; import { getWorkspaceSubdomainByOrigin } from 'src/engine/utils/get-workspace-subdomain-by-origin'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { getDomainNameByEmail, isWorkEmail } from 'src/utils/is-work-email'; // eslint-disable-next-line @nx/workspace-inject-workspace-repository export class WorkspaceService extends TypeOrmQueryService { @@ -48,19 +49,109 @@ export class WorkspaceService extends TypeOrmQueryService { }); } - private async generateSubdomain(displayName: string) { + private generateRandomSubdomain(): string { + const prefixes = [ + 'cool', + 'smart', + 'fast', + 'bright', + 'shiny', + 'happy', + 'funny', + 'clever', + 'brave', + 'kind', + 'gentle', + 'quick', + 'sharp', + 'calm', + 'silent', + 'lucky', + 'fierce', + 'swift', + 'mighty', + 'noble', + 'bold', + 'wise', + 'eager', + 'joyful', + 'glad', + 'zany', + 'witty', + 'bouncy', + 'graceful', + 'colorful', + ]; + const suffixes = [ + 'raccoon', + 'panda', + 'whale', + 'tiger', + 'dolphin', + 'eagle', + 'penguin', + 'owl', + 'fox', + 'wolf', + 'lion', + 'bear', + 'hawk', + 'shark', + 'sparrow', + 'moose', + 'lynx', + 'falcon', + 'rabbit', + 'hedgehog', + 'monkey', + 'horse', + 'koala', + 'kangaroo', + 'elephant', + 'giraffe', + 'panther', + 'crocodile', + 'seal', + 'octopus', + ]; + + const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)]; + const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)]; + + return `${randomPrefix}-${randomSuffix}`; + } + + private getSubdomainNameByEmail(email: string) { + if (isWorkEmail(email)) { + return getDomainNameByEmail(email); + } + } + + private getSubdomainNameByDisplayName(displayName: string) { const displayNameWords = displayName.match(/(\w| |\d)+/g); - let subdomain = ''; if (displayNameWords) { - subdomain = displayNameWords.join('-').replace(/ /g, '').toLowerCase(); + return displayNameWords.join('-').replace(/ /g, '').toLowerCase(); } + } + + private async generateSubdomain({ + email, + displayName, + }: { + email: string; + displayName: string; + }) { + const subdomain = + this.getSubdomainNameByEmail(email) ?? + this.getSubdomainNameByDisplayName(displayName) ?? + this.generateRandomSubdomain(); const existingWorkspaceCount = await this.workspaceRepository.countBy({ subdomain, }); - return `${subdomain}${existingWorkspaceCount > 0 ? Math.random().toString(36).substring(2, 10) : ''}`; + return `${subdomain}${existingWorkspaceCount <= 1 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`; } async activateWorkspace(user: User, data: ActivateWorkspaceInput) { @@ -105,9 +196,10 @@ export class WorkspaceService extends TypeOrmQueryService { user, ); - const subdomain = await this.generateSubdomain(data.displayName); - - console.log('>>>>>>>>>>>>>>', subdomain); + const subdomain = await this.generateSubdomain({ + email: user.email, + displayName: data.displayName, + }); await this.workspaceRepository.update(user.defaultWorkspaceId, { subdomain, diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index cd173f03c55a..fb7edf644ba4 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -14,7 +14,8 @@ export class JwtAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); try { - const data = await this.accessTokenService.validateToken(request); + const data = + await this.accessTokenService.validateTokenByRequest(request); const metadataVersion = await this.workspaceStorageCacheService.getMetadataVersion( data.workspace.id, diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 01ec0af42227..f1eb58a07e21 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -18,7 +18,7 @@ class GraphqlTokenValidationProxy { async validateToken(req: Request) { try { - return await this.accessTokenService.validateToken(req); + return await this.accessTokenService.validateTokenByRequest(req); } catch (error) { const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter(); @@ -57,7 +57,6 @@ export class GraphQLHydrateRequestFromTokenMiddleware 'ExchangeAuthorizationCode', 'GetAuthorizationUrl', 'GetPublicWorkspaceDataBySubdomain', - 'FindAvailableWorkspacesByEmail', ]; if ( diff --git a/packages/twenty-server/src/engine/utils/serverUrl.ts b/packages/twenty-server/src/engine/utils/serverUrl.ts index c3b011c18edd..bde6824494f0 100644 --- a/packages/twenty-server/src/engine/utils/serverUrl.ts +++ b/packages/twenty-server/src/engine/utils/serverUrl.ts @@ -1,11 +1,14 @@ -import { INestApplication } from '@nestjs/common'; - -// serverConfig.ts const ServerUrl = (() => { - let serverUrl = 'http://localhost:3000'; + let serverUrl = ''; return { - get: () => serverUrl, + get: () => { + if (serverUrl === '') { + throw new Error('ServerUrl is not initialized. Call set() first.'); + } + + return serverUrl; + }, set: (url: string) => { serverUrl = url; }, diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 8efb5d676690..062ad5cec472 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -2,6 +2,8 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; +import fs from 'fs'; + import session from 'express-session'; import bytes from 'bytes'; import { useContainer } from 'class-validator'; @@ -17,7 +19,7 @@ import './instrument'; import { settings } from './engine/constants/settings'; import { generateFrontConfig } from './utils/generate-front-config'; -import ServerUrl from 'src/engine/utils/serverUrl'; +import ServerUrl from './engine/utils/serverUrl'; const bootstrap = async () => { const app = await NestFactory.create(AppModule, { @@ -25,10 +27,36 @@ const bootstrap = async () => { bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true', rawBody: true, snapshot: process.env.DEBUG_MODE === 'true', + ...(process.env.SERVER_URL && + process.env.SERVER_URL.startsWith('https') && + process.env.SSL_KEY_PATH && + process.env.SSL_CERT_PATH + ? { + httpsOptions: { + key: fs.readFileSync(process.env.SSL_KEY_PATH), + cert: fs.readFileSync(process.env.SSL_CERT_PATH), + }, + } + : {}), }); const logger = app.get(LoggerService); const environmentService = app.get(EnvironmentService); + const serverUrl = new URL( + `${environmentService.get('SERVER_URL')}:${environmentService.get('PORT')}`, + ); + + if ( + serverUrl.protocol === 'https:' && + (!environmentService.get('SSL_KEY_PATH') || + !environmentService.get('SSL_CERT_PATH')) + ) + throw new Error( + 'SSL_KEY_PATH and SSL_CERT_PATH must be defined if https is used', + ); + + // set httpsOptions here + // TODO: Double check this as it's not working for now, it's going to be heplful for durable trees in twenty "orm" // // Apply context id strategy for durable trees // ContextIdFactory.apply(new AggregateByWorkspaceContextIdStrategy()); @@ -69,10 +97,13 @@ const bootstrap = async () => { app.use(session(getSessionStorageOptions(environmentService))); } - await app.listen(process.env.PORT ?? 3000); + await app.listen(serverUrl.port, serverUrl.hostname); const url = new URL(await app.getUrl()); + + // prevent ipv6 issue for redirectUri builder url.hostname = url.hostname === '[::1]' ? 'localhost' : url.hostname; + ServerUrl.set(url.toString()); }; diff --git a/packages/twenty-server/src/utils/is-work-email.ts b/packages/twenty-server/src/utils/is-work-email.ts index c8ebcf358844..19346f1c85fd 100644 --- a/packages/twenty-server/src/utils/is-work-email.ts +++ b/packages/twenty-server/src/utils/is-work-email.ts @@ -1,21 +1,29 @@ import { emailProvidersSet } from 'src/utils/email-providers'; -export const isWorkEmail = (email: string) => { +export const getDomainNameByEmail = (email: string) => { if (!email) { - return false; + throw new Error('Email is required'); } const fields = email.split('@'); if (fields.length !== 2) { - return false; + throw new Error('Invalid email format'); } const domain = fields[1]; if (!domain) { - return false; + throw new Error('Invalid email format'); } - return !emailProvidersSet.has(domain); + return domain; +}; + +export const isWorkEmail = (email: string) => { + try { + return !emailProvidersSet.has(getDomainNameByEmail(email)); + } catch (err) { + return false; + } }; From 9f3ac474c4f1c96b98eb8660cb7e15724313b9ed Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 18 Nov 2024 13:26:29 +0100 Subject: [PATCH 18/24] refactor(auth): remove workspace ID from login token generation Simplify the login token generation process by eliminating the workspace ID parameter. Adjust relevant services and controllers to accommodate this change, reducing parameter dependency and enhancing code clarity. --- .../engine/core-modules/auth/auth.resolver.ts | 18 ++++-------------- .../auth/controllers/google-auth.controller.ts | 1 - .../controllers/microsoft-auth.controller.ts | 1 - .../auth/controllers/sso-auth.controller.ts | 5 +---- .../core-modules/auth/services/auth.service.ts | 4 ++-- .../auth/services/oauth.service.ts | 1 - .../auth/token/services/login-token.service.ts | 15 +++------------ 7 files changed, 10 insertions(+), 35 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 3c274fb3e5d2..8662ccb8a455 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -129,7 +129,6 @@ export class AuthResolver { const user = await this.authService.challenge(challengeInput, workspace); const loginToken = await this.loginTokenService.generateLoginToken( user.email, - workspace.id, ); return { loginToken }; @@ -152,7 +151,6 @@ export class AuthResolver { const loginToken = await this.loginTokenService.generateLoginToken( user.email, - user.defaultWorkspaceId, ); return { loginToken }; @@ -198,19 +196,11 @@ export class AuthResolver { ): Promise { const workspace = await this.workspaceService.getWorkspaceByOrigin(origin); - const { sub: email, workspaceId: workspaceIdFromLoginToken } = - await this.loginTokenService.verifyLoginToken(verifyInput.loginToken); - - const workspaceId = workspaceIdFromLoginToken ?? workspace?.id; - - if (!workspaceId) { - throw new AuthException( - 'Workspace not found', - AuthExceptionCode.WORKSPACE_NOT_FOUND, - ); - } + const { sub: email } = await this.loginTokenService.verifyLoginToken( + verifyInput.loginToken, + ); - return await this.authService.verify(email, workspaceId); + return await this.authService.verify(email, workspace?.id); } @Mutation(() => AuthorizeApp) diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index 519a81a474fd..6682d66c301b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -74,7 +74,6 @@ export class GoogleAuthController { const loginToken = await this.loginTokenService.generateLoginToken( user.email, - user.defaultWorkspace.id, ); return res.redirect( diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index a12954d95427..4aa26399f74f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -75,7 +75,6 @@ export class MicrosoftAuthController { const loginToken = await this.loginTokenService.generateLoginToken( user.email, - user.defaultWorkspace.id, ); return res.redirect( diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index d3c397797a7f..2e6abdab093c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -164,9 +164,6 @@ export class SSOAuthController { ); } - return this.loginTokenService.generateLoginToken( - user.email, - identityProvider.workspaceId, - ); + return this.loginTokenService.generateLoginToken(user.email); } } 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 9682a7b047f2..74e88370b0f3 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 @@ -187,7 +187,7 @@ export class AuthService { }); } - async verify(email: string, workspaceId: string): Promise { + async verify(email: string, workspaceId?: string): Promise { if (!email) { throw new AuthException( 'Email is required', @@ -197,7 +197,7 @@ export class AuthService { let user = await this.findOneWithWorkspacesByEmail(email); - if (user && user.defaultWorkspaceId !== workspaceId) { + if (user && workspaceId && user.defaultWorkspaceId !== workspaceId) { await this.userService.saveDefaultWorkspace(user, workspaceId); user = await this.findOneWithWorkspacesByEmail(email); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts index a1064877b51b..83f7b0ff7bc7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts @@ -144,7 +144,6 @@ export class OAuthService { ); const loginToken = await this.loginTokenService.generateLoginToken( user.email, - user.defaultWorkspaceId, ); return { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts index 4968efffdfb3..b45b6c34676c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -18,14 +18,8 @@ export class LoginTokenService { private readonly environmentService: EnvironmentService, ) {} - async generateLoginToken( - email: string, - workspaceId: string, - ): Promise { - const secret = this.jwtWrapperService.generateAppSecret( - 'LOGIN', - workspaceId, - ); + async generateLoginToken(email: string): Promise { + const secret = this.jwtWrapperService.generateAppSecret('LOGIN'); const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); @@ -39,7 +33,6 @@ export class LoginTokenService { const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); const jwtPayload = { sub: email, - workspaceId, }; return { @@ -51,9 +44,7 @@ export class LoginTokenService { }; } - async verifyLoginToken( - loginToken: string, - ): Promise<{ sub: string; workspaceId?: string }> { + async verifyLoginToken(loginToken: string): Promise<{ sub: string }> { await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); return this.jwtWrapperService.decode(loginToken, { From 29fec527509303b1bbe443716eec10f0adf97ff5 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 18 Nov 2024 14:01:43 +0100 Subject: [PATCH 19/24] refactor: update security item display in navigation drawer Renamed StyledDeveloperSection to StyledContainer for reuse. Adjusted the security item rendering to conditionally include the advanced mode icon when enabled. Added comments regarding design considerations for this change. --- .../SettingsNavigationDrawerItems.tsx | 24 ++++++++++++------- .../components/NavigationDrawerSection.tsx | 3 +++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx index 36efe4c8f738..7d002160647b 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx @@ -56,7 +56,7 @@ const StyledIconContainer = styled.div` height: 75%; `; -const StyledDeveloperSection = styled.div` +const StyledContainer = styled.div` display: flex; width: 100%; gap: ${({ theme }) => theme.spacing(1)}; @@ -187,12 +187,20 @@ export const SettingsNavigationDrawerItems = () => { Icon={IconCode} /> )} - + {isAdvancedModeEnabled && ( + + + + + + + )} + {isAdvancedModeEnabled && ( { exit="exit" variants={motionAnimationVariants} > - + @@ -221,7 +229,7 @@ export const SettingsNavigationDrawerItems = () => { /> )} - + )} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx index afc7fe803744..775e830009b5 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx @@ -7,6 +7,9 @@ const StyledSection = styled.div` width: 100%; margin-bottom: ${({ theme }) => theme.spacing(3)}; flex-shrink: 1; + // I need to remove this line to display the advanced mode icon for the security item in SettingsNavigationDrawerItems + // But it's necessary for this issue: https://github.com/twentyhq/twenty/issues/7733 + // Need help to define a good design for this case overflow: hidden; `; From ca721f70c82d25d79e7330d14dc46f15620fd431 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 18 Nov 2024 15:45:39 +0100 Subject: [PATCH 20/24] refactor(auth): remove workspace selection feature Remove the workspace selection component and its associated state management logic. Updated the authentication flow to redirect to the first available workspace directly. --- .../components/SignInUpGlobalScopeForm.tsx | 45 +++++------- .../components/SignInUpWorkspaceSelection.tsx | 69 ------------------- .../states/availableWorkspacesForAuthState.ts | 9 --- .../twenty-front/src/pages/auth/SignInUp.tsx | 5 +- packages/twenty-server/src/main.ts | 2 + 5 files changed, 19 insertions(+), 111 deletions(-) delete mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx delete mode 100644 packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx index 27ccae358b0e..7fc378cd8a7a 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpGlobalScopeForm.tsx @@ -15,7 +15,6 @@ import { motion } from 'framer-motion'; import { useState } from 'react'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { isDefined } from '~/utils/isDefined'; -import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; import { SignInUpStep, signInUpStepState, @@ -75,9 +74,6 @@ export const SignInUpGlobalScopeForm = () => { const setSignInUpStep = useSetRecoilState(signInUpStepState); const [signInUpMode, setSignInUpMode] = useRecoilState(signInUpModeState); - const setAvailableWorkspacesForAuth = useSetRecoilState( - availableWorkspacesForAuthState, - ); const { enqueueSnackBar } = useSnackBar(); const { requestFreshCaptchaToken } = useRequestFreshCaptchaToken(); @@ -87,24 +83,6 @@ export const SignInUpGlobalScopeForm = () => { const { submitCredentials } = useSignInUp(form); - const continueToWorkspaceSelection = async ( - availableWorkspaces: UserExists['availableWorkspaces'], - ) => { - if (isDefined(availableWorkspaces) && availableWorkspaces.length > 1) { - return setSignInUpStep(SignInUpStep.WorkspaceSelection); - } - - if (isDefined(availableWorkspaces) && availableWorkspaces.length === 1) { - return redirectToWorkspace(availableWorkspaces[0].subdomain, { - email: form.getValues('email'), - }); - } - - // si 1 workspace sans sso redirige sur workspace avec email en query params pour prefill - // si 1 workspace avec sso et 1 sso login avec le sso - // si plusieurs workspaces redirige sur la liste des workspaces - }; - const handleSubmit = async () => { if (isDefined(form?.formState?.errors?.email)) { setShowErrors(true); @@ -128,13 +106,22 @@ export const SignInUpGlobalScopeForm = () => { data?.checkUserExists.exists && data.checkUserExists.__typename === 'UserExists' ) { - setAvailableWorkspacesForAuth( - data?.checkUserExists.availableWorkspaces, - ); - continueToWorkspaceSelection( - data?.checkUserExists.availableWorkspaces, - ); - } else { + if ( + isDefined(data?.checkUserExists.availableWorkspaces) && + data.checkUserExists.availableWorkspaces.length >= 1 + ) { + return redirectToWorkspace( + data?.checkUserExists.availableWorkspaces[0].subdomain, + { + email: form.getValues('email'), + }, + ); + } + } + if ( + data?.checkUserExists.exists && + data.checkUserExists.__typename === 'UserNotExists' + ) { if (!isMultiWorkspaceEnabled) { return enqueueSnackBar('User not found', { variant: SnackBarVariant.Error, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx deleted file mode 100644 index 911f45857575..000000000000 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpWorkspaceSelection.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; -import { H2Title, MainButton, HorizontalSeparator } from 'twenty-ui'; -import { availableWorkspacesForAuthState } from '@/auth/states/availableWorkspacesForAuthState'; -import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; -import { redirectToWorkspace } from '~/utils/workspace-url.helper'; -import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; - -const StyledContentContainer = styled.div` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.spacing(4)}; - margin-bottom: ${({ theme }) => theme.spacing(8)}; - margin-top: ${({ theme }) => theme.spacing(4)}; -`; - -const StyledMainButtonContent = styled.div` - display: flex; - width: calc(100% + ${({ theme }) => theme.spacing(4)}); - gap: ${({ theme }) => theme.spacing(4)}; -`; - -const StyledIcon = styled.img<{ src: string }>` - background-image: url(${(props) => props.src}); - height: ${({ theme }) => theme.spacing(4)}; - width: ${({ theme }) => theme.spacing(4)}; - border-radius: 4px; -`; - -const StyledDisplayName = styled.span` - flex-grow: 1; - text-align: center; - margin-right: ${({ theme }) => theme.spacing(8)}; -`; - -export const SignInUpWorkspaceSelection = () => { - const availableWorkspacesForAuth = useRecoilValue( - availableWorkspacesForAuthState, - ); - - return ( - <> - - {availableWorkspacesForAuth && - availableWorkspacesForAuth.length !== 0 && ( - <> - - {availableWorkspacesForAuth.map((workspace) => ( - redirectToWorkspace(workspace.subdomain)} - fullWidth - > - - - - {workspace.displayName ?? workspace.id} - - - - ))} - - )} - - - ); -}; diff --git a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts b/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts deleted file mode 100644 index 97d124f1ac23..000000000000 --- a/packages/twenty-front/src/modules/auth/states/availableWorkspacesForAuthState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createState } from 'twenty-ui'; -import { UserExists } from '~/generated/graphql'; - -export const availableWorkspacesForAuthState = createState< - UserExists['availableWorkspaces'] ->({ - key: 'availableWorkspacesForAuthState', - defaultValue: null, -}); diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index f49b88c0731d..689d330efc2c 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -10,7 +10,6 @@ import { redirectToHome, twentyHomePageUrl, } from '~/utils/workspace-url.helper'; -import { SignInUpWorkspaceSelection } from '@/auth/sign-in-up/components/SignInUpWorkspaceSelection'; import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGlobalScopeForm'; import { FooterNote } from '@/auth/sign-in-up/components/FooterNote'; import { AnimatedEaseIn } from 'twenty-ui'; @@ -52,9 +51,7 @@ export const SignInUp = () => { {`Welcome to ${workspacePublicData?.displayName ?? DEFAULT_WORKSPACE_NAME}`} - {isTwentyHomePage && signInUpStep === SignInUpStep.WorkspaceSelection ? ( - - ) : isTwentyHomePage ? ( + {isTwentyHomePage ? ( ) : isTwentyWorkspaceSubdomain && signInUpStep === SignInUpStep.SSOIdentityProviderSelection ? ( diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 062ad5cec472..22dd7385d8f7 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -105,6 +105,8 @@ const bootstrap = async () => { url.hostname = url.hostname === '[::1]' ? 'localhost' : url.hostname; ServerUrl.set(url.toString()); + + logger.log(`Application is running on: ${url.toString()}`, 'Server Info'); }; bootstrap(); From 8ade0e3f74c9ce82ce60aba713904f0bc029bed6 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 18 Nov 2024 19:31:40 +0100 Subject: [PATCH 21/24] feat(workspace): add support for multiworkspace subdomains Updated various controllers and services to include WorkspaceService for multiworkspace subdomain conditional logic. Adjusted URL construction logic to handle single and multiworkspace modes seamlessly, ensuring proper redirects and configurations based on the environment settings. --- .../twenty-front/src/generated/graphql.tsx | 17 ++++-------- .../getPublicWorkspaceDataBySubdomain.ts | 4 +-- .../components/NavigationDrawerHeader.tsx | 5 +++- .../components/WorkspaceProviderEffect.tsx | 10 ++++++- .../twenty-front/src/pages/auth/Invite.tsx | 4 ++- packages/twenty-front/vite.config.ts | 15 ++++++----- .../database/typeorm-seeds/core/workspaces.ts | 4 +++ .../google-apis-auth.controller.ts | 4 ++- .../controllers/google-auth.controller.ts | 5 ++++ .../microsoft-apis-auth.controller.ts | 4 ++- .../controllers/microsoft-auth.controller.ts | 5 ++++ .../controllers/sign-up-auth.controller.ts | 6 ++++- .../auth/controllers/sso-auth.controller.ts | 2 ++ .../auth/services/auth.service.ts | 6 +++-- .../auth/utils/compute-redirect-error-url.ts | 14 ++++------ .../environment/environment-variables.ts | 2 +- .../user-workspace/user-workspace.service.ts | 2 +- .../services/workspace-invitation.service.ts | 10 ++++--- .../workspace/services/workspace.service.ts | 26 ++++++++++++++++++- .../workspace/workspace.resolver.ts | 9 ++----- packages/twenty-server/src/main.ts | 6 ++++- .../src/utils/workspace-url.utils.ts | 21 ++------------- 22 files changed, 110 insertions(+), 71 deletions(-) diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index db7ab8be164d..6b8033f7d914 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -842,11 +842,6 @@ export type QueryGetProductPricesArgs = { }; -export type QueryGetPublicWorkspaceDataBySubdomainArgs = { - workspaceId?: InputMaybe; -}; - - export type QueryGetServerlessFunctionSourceCodeArgs = { input: GetServerlessFunctionSourceCodeInput; }; @@ -1772,11 +1767,9 @@ export type CheckUserExistsQueryVariables = Exact<{ }>; -export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename?: 'UserExists', exists: boolean, availableWorkspaces?: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso?: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> | null }> | null } | { __typename?: 'UserNotExists', exists: boolean } }; +export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, availableWorkspaces?: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso?: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> | null }> | null } | { __typename: 'UserNotExists', exists: boolean } }; -export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ - workspaceId?: InputMaybe; -}>; +export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>; export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: string, status: string, issuer: string }> } } }; @@ -2876,6 +2869,7 @@ export type VerifyMutationOptions = Apollo.BaseMutationOptions; export type CheckUserExistsQueryResult = Apollo.QueryResult; export const GetPublicWorkspaceDataBySubdomainDocument = gql` - query GetPublicWorkspaceDataBySubdomain($workspaceId: String) { - getPublicWorkspaceDataBySubdomain(workspaceId: $workspaceId) { + query GetPublicWorkspaceDataBySubdomain { + getPublicWorkspaceDataBySubdomain { id logo displayName @@ -2963,7 +2957,6 @@ export const GetPublicWorkspaceDataBySubdomainDocument = gql` * @example * const { data, loading, error } = useGetPublicWorkspaceDataBySubdomainQuery({ * variables: { - * workspaceId: // value for 'workspaceId' * }, * }); */ diff --git a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts index a93799d7a3ce..b91ccf5fdcec 100644 --- a/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts +++ b/packages/twenty-front/src/modules/auth/graphql/queries/getPublicWorkspaceDataBySubdomain.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client'; export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql` - query GetPublicWorkspaceDataBySubdomain($workspaceId: String) { - getPublicWorkspaceDataBySubdomain(workspaceId: $workspaceId) { + query GetPublicWorkspaceDataBySubdomain { + getPublicWorkspaceDataBySubdomain { id logo displayName diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx index 1f700f59d776..7701a1c5bd1c 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx @@ -13,6 +13,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; const StyledContainer = styled.div` align-items: center; @@ -62,7 +63,9 @@ export const NavigationDrawerHeader = ({ }: NavigationDrawerHeaderProps) => { const isMobile = useIsMobile(); const workspaces = useRecoilValue(workspacesState); - const isMultiWorkspace = workspaces !== null && workspaces.length > 1; + const isMultiworkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + const isMultiWorkspace = + workspaces !== null && workspaces.length > 1 && isMultiworkspaceEnabled; const isNavigationDrawerExpanded = useRecoilValue( isNavigationDrawerExpandedState, ); diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx index bb527fa5cde8..ad0647da1398 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -12,6 +12,7 @@ import { authProvidersState } from '@/client-config/states/authProvidersState'; import { useEffect } from 'react'; import { isDefined } from '~/utils/isDefined'; import { lastAuthenticateWorkspaceState } from '@/auth/states/lastAuthenticateWorkspaceState'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; export const WorkspaceProviderEffect = () => { const workspacePublicData = useRecoilValue(workspacePublicDataState); @@ -28,6 +29,8 @@ export const WorkspaceProviderEffect = () => { lastAuthenticateWorkspaceState, ); + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + useGetPublicWorkspaceDataBySubdomainQuery({ onCompleted: (data) => { setAuthProviders(data.getPublicWorkspaceDataBySubdomain.authProviders); @@ -42,6 +45,7 @@ export const WorkspaceProviderEffect = () => { useEffect(() => { if ( + isMultiWorkspaceEnabled && isDefined(workspacePublicData?.subdomain) && workspacePublicData.subdomain !== getWorkspaceSubdomain() ) { @@ -50,7 +54,11 @@ export const WorkspaceProviderEffect = () => { }, [workspacePublicData]); useEffect(() => { - if (isDefined(lastAuthenticateWorkspace?.subdomain) && isTwentyHomePage) { + if ( + isMultiWorkspaceEnabled && + isDefined(lastAuthenticateWorkspace?.subdomain) && + isTwentyHomePage + ) { redirectToWorkspace(lastAuthenticateWorkspace.subdomain); } }, [lastAuthenticateWorkspace]); diff --git a/packages/twenty-front/src/pages/auth/Invite.tsx b/packages/twenty-front/src/pages/auth/Invite.tsx index 719316dae6c3..b7309bf9016c 100644 --- a/packages/twenty-front/src/pages/auth/Invite.tsx +++ b/packages/twenty-front/src/pages/auth/Invite.tsx @@ -16,6 +16,7 @@ import { useAddUserToWorkspaceMutation, } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; +import { currentUserState } from '@/auth/states/currentUserState'; const StyledContentContainer = styled.div` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -28,6 +29,7 @@ export const Invite = () => { const { form } = useSignInUpForm(); const currentWorkspace = useRecoilValue(currentWorkspaceState); + const currentUser = useRecoilValue(currentUserState); const [addUserToWorkspace] = useAddUserToWorkspaceMutation(); const [addUserToWorkspaceByInviteToken] = useAddUserToWorkspaceByInviteTokenMutation(); @@ -77,7 +79,7 @@ export const Invite = () => { {title} - {isDefined(workspaceFromInviteHash) ? ( + {isDefined(workspaceFromInviteHash) && isDefined(currentUser) ? ( <> { }; } - const { hostname, protocol } = new URL(REACT_APP_BASE_URL ?? `localhost`); + const baseUrl = new URL(REACT_APP_BASE_URL ?? `http://localhost:${port}`); - if (protocol === 'https:' && (!env.SSL_KEY_PATH || !env.SSL_CERT_PATH)) { + if ( + baseUrl.protocol === 'https:' && + (!env.SSL_KEY_PATH || !env.SSL_CERT_PATH) + ) { throw new Error( 'to use https SSL_KEY_PATH and SSL_CERT_PATH must be both defined', ); @@ -78,9 +81,9 @@ export default defineConfig(({ command, mode }) => { server: { port: port, - host: hostname, - protocol: protocol.slice(0, -1) as 'http' | 'https', - ...(protocol === 'https:' + host: baseUrl.hostname, + protocol: baseUrl.protocol.slice(0, -1) as 'http' | 'https', + ...(baseUrl.protocol === 'https:' ? { https: { key: fs.readFileSync(env.SSL_KEY_PATH), @@ -149,7 +152,7 @@ export default defineConfig(({ command, mode }) => { define: { 'process.env': { REACT_APP_SERVER_BASE_URL, - REACT_APP_BASE_URL, + REACT_APP_BASE_URL: baseUrl.toString(), }, }, css: { diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/workspaces.ts b/packages/twenty-server/src/database/typeorm-seeds/core/workspaces.ts index bfd5d34365ea..676f0c48493a 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/workspaces.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/workspaces.ts @@ -23,6 +23,7 @@ export const seedWorkspaces = async ( | 'domainName' | 'inviteHash' | 'logo' + | 'subdomain' | 'activationStatus' >; } = { @@ -30,6 +31,7 @@ export const seedWorkspaces = async ( id: workspaceId, displayName: 'Apple', domainName: 'apple.dev', + subdomain: 'apple', inviteHash: 'apple.dev-invite-hash', logo: 'https://twentyhq.github.io/placeholder-images/workspaces/apple-logo.png', activationStatus: WorkspaceActivationStatus.ACTIVE, @@ -38,6 +40,7 @@ export const seedWorkspaces = async ( id: workspaceId, displayName: 'Twenty', domainName: 'twenty.dev', + subdomain: 'twenty', inviteHash: 'twenty.dev-invite-hash', logo: 'https://twentyhq.github.io/placeholder-images/workspaces/twenty-logo.png', activationStatus: WorkspaceActivationStatus.ACTIVE, @@ -51,6 +54,7 @@ export const seedWorkspaces = async ( 'id', 'displayName', 'domainName', + 'subdomain', 'inviteHash', 'logo', 'activationStatus', diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index b2a09db3c4f5..ea38ce8c9c59 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -25,6 +25,7 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @Controller('auth/google-apis') @UseFilters(AuthRestApiExceptionFilter) @@ -34,6 +35,7 @@ export class GoogleAPIsAuthController { private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, private readonly onboardingService: OnboardingService, + private readonly workspaceService: WorkspaceService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, ) {} @@ -116,7 +118,7 @@ export class GoogleAPIsAuthController { return res.redirect( buildWorkspaceURL( this.environmentService.get('FRONT_BASE_URL'), - { workspace }, + this.workspaceService.getSubdomainIfMultiworkspaceEnabled(workspace), { withPathname: redirectLocation || '/settings/accounts' }, ).toString(), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index 6682d66c301b..8748481d0dd3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -22,6 +22,7 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { computeRedirectErrorUrl } from 'src/engine/core-modules/auth/utils/compute-redirect-error-url'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) @@ -29,6 +30,7 @@ export class GoogleAuthController { constructor( private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, + private readonly workspaceService: WorkspaceService, private readonly environmentService: EnvironmentService, ) {} @@ -87,6 +89,9 @@ export class GoogleAuthController { return res.redirect( computeRedirectErrorUrl({ frontBaseUrl: this.environmentService.get('FRONT_BASE_URL'), + subdomain: this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') + ? 'app' + : null, errorMessage: err.message, }), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts index ced79b1dd73c..2c88434b0388 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts @@ -25,6 +25,7 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { buildWorkspaceURL } from 'src/utils/workspace-url.utils'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @Controller('auth/microsoft-apis') @UseFilters(AuthRestApiExceptionFilter) @@ -33,6 +34,7 @@ export class MicrosoftAPIsAuthController { private readonly microsoftAPIsService: MicrosoftAPIsService, private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, + private readonly workspaceService: WorkspaceService, private readonly onboardingService: OnboardingService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -116,7 +118,7 @@ export class MicrosoftAPIsAuthController { return res.redirect( buildWorkspaceURL( this.environmentService.get('FRONT_BASE_URL'), - { workspace }, + this.workspaceService.getSubdomainIfMultiworkspaceEnabled(workspace), { withPathname: redirectLocation || '/settings/accounts' }, ).toString(), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 4aa26399f74f..8d766b7a9153 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -21,6 +21,7 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { computeRedirectErrorUrl } from 'src/engine/core-modules/auth/utils/compute-redirect-error-url'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) @@ -28,6 +29,7 @@ export class MicrosoftAuthController { constructor( private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, + private readonly workspaceService: WorkspaceService, private readonly environmentService: EnvironmentService, ) {} @@ -87,6 +89,9 @@ export class MicrosoftAuthController { return res.redirect( computeRedirectErrorUrl({ frontBaseUrl: this.environmentService.get('FRONT_BASE_URL'), + subdomain: this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') + ? 'app' + : null, errorMessage: err.message, }), ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sign-up-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sign-up-auth.controller.ts index 2b9231284510..f2d6869245d5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sign-up-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sign-up-auth.controller.ts @@ -13,6 +13,7 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm import { isDefined } from 'src/utils/is-defined'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import ServerUrl from 'src/engine/utils/serverUrl'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @Controller('auth/redirect') @UseFilters(AuthRestApiExceptionFilter) @@ -20,6 +21,7 @@ export class SignUpAuthController { constructor( private readonly accessTokenService: AccessTokenService, private readonly authService: AuthService, + private readonly workspaceService: WorkspaceService, private readonly environmentService: EnvironmentService, ) {} @@ -50,7 +52,9 @@ export class SignUpAuthController { const redirectUrl = buildWorkspaceURL( this.environmentService.get('FRONT_BASE_URL'), - { subdomain: workspace.subdomain }, + this.workspaceService.getSubdomainIfMultiworkspaceEnabled( + user.defaultWorkspace, + ), ); res.cookie( diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 2e6abdab093c..d31c04b57a73 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -32,6 +32,7 @@ import { } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @Controller('auth') @UseFilters(AuthRestApiExceptionFilter) @@ -41,6 +42,7 @@ export class SSOAuthController { private readonly authService: AuthService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly userWorkspaceService: UserWorkspaceService, + private readonly workspaceService: WorkspaceService, private readonly ssoService: SSOService, @InjectRepository(WorkspaceSSOIdentityProvider, 'core') private readonly workspaceSSOIdentityProviderRepository: Repository, 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 74e88370b0f3..a4cb6dfb1c18 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 @@ -43,6 +43,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; @Injectable() export class AuthService { @@ -50,6 +51,7 @@ export class AuthService { private readonly accessTokenService: AccessTokenService, private readonly refreshTokenService: RefreshTokenService, private readonly userWorkspaceService: UserWorkspaceService, + private readonly workspaceService: WorkspaceService, private readonly userService: UserService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly signInUpService: SignInUpService, @@ -459,10 +461,10 @@ export class AuthService { return workspace; } - async computeRedirectURI(loginToken: string, subdomain) { + async computeRedirectURI(loginToken: string, subdomain: string) { const url = buildWorkspaceURL( this.environmentService.get('FRONT_BASE_URL'), - { subdomain }, + this.workspaceService.getSubdomainIfMultiworkspaceEnabled({ subdomain }), { withPathname: '/verify', withSearchParams: { loginToken }, diff --git a/packages/twenty-server/src/engine/core-modules/auth/utils/compute-redirect-error-url.ts b/packages/twenty-server/src/engine/core-modules/auth/utils/compute-redirect-error-url.ts index cebfe2b70e63..dfa137849252 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/utils/compute-redirect-error-url.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/utils/compute-redirect-error-url.ts @@ -7,16 +7,12 @@ export function computeRedirectErrorUrl({ }: { errorMessage: string; frontBaseUrl: string; - subdomain?: string; + subdomain: string | null; }) { - const url = buildWorkspaceURL( - frontBaseUrl, - { subdomain: subdomain ?? 'app' }, - { - withPathname: '/verify', - withSearchParams: { errorMessage }, - }, - ); + const url = buildWorkspaceURL(frontBaseUrl, subdomain, { + withPathname: '/verify', + withSearchParams: { errorMessage }, + }); return url.toString(); } diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index 663aba1fbb53..5ef322a8e9e4 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -132,7 +132,7 @@ export class EnvironmentVariables { // Server URL @IsUrl({ require_tld: false }) @IsOptional() - SERVER_URL = 'localhost'; + SERVER_URL = 'http://localhost'; @IsString() APP_SECRET: string; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 6ff8d2b59a36..98c49e95a4a5 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -132,7 +132,7 @@ export class UserWorkspaceService extends TypeOrmQueryService { return await this.addUserToWorkspace(user, appToken.workspace); } - public async getUserCount(workspaceId): Promise { + public async getUserCount(workspaceId: string): Promise { return await this.userWorkspaceRepository.countBy({ workspaceId, }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index 055ce9bfe85d..f44da7f53634 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, forwardRef } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import crypto from 'crypto'; @@ -39,10 +39,10 @@ export class WorkspaceInvitationService { private readonly appTokenRepository: Repository, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, - private readonly environmentService: EnvironmentService, - private readonly emailService: EmailService, @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, + private readonly environmentService: EnvironmentService, + private readonly emailService: EmailService, private readonly onboardingService: OnboardingService, ) {} @@ -377,7 +377,9 @@ export class WorkspaceInvitationService { const link = buildWorkspaceURL( this.environmentService.get('FRONT_BASE_URL'), - { workspace }, + this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') + ? workspace.subdomain + : null, ); for (const invitation of invitationsPr) { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 649f158b6b60..f8bfcc4e4973 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, Logger } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { InjectRepository } from '@nestjs/typeorm'; @@ -313,8 +313,32 @@ export class WorkspaceService extends TypeOrmQueryService { }; } + getSubdomainIfMultiworkspaceEnabled(workspace: Pick) { + if (this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { + return workspace.subdomain; + } + + return null; + } + async getWorkspaceByOrigin(origin: string) { try { + if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { + const workspaces = await this.workspaceRepository.find({ + order: { + createdAt: 'DESC', + }, + }); + + if (workspaces.length > 1) { + Logger.warn( + `In single-workspace mode, there should be only one workspace. Today there are ${workspaces.length} workspaces`, + ); + } + + return workspaces[0]; + } + const subdomain = getWorkspaceSubdomainByOrigin( origin, this.environmentService.get('FRONT_BASE_URL'), diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index c4821d42e0f2..2dcaaa5ffb1d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -154,13 +154,8 @@ export class WorkspaceResolver { } @Query(() => PublicWorkspaceDataOutput) - async getPublicWorkspaceDataBySubdomain( - @OriginHeader() origin: string, - @Args('workspaceId', { nullable: true }) workspaceId?: string, - ) { - const workspace = workspaceId - ? await this.workspaceService.findById(workspaceId) - : await this.workspaceService.getWorkspaceByOrigin(origin); + async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) { + const workspace = await this.workspaceService.getWorkspaceByOrigin(origin); if (!workspace) { return new WorkspaceException( diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 22dd7385d8f7..3ea244a20259 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -43,9 +43,13 @@ const bootstrap = async () => { const environmentService = app.get(EnvironmentService); const serverUrl = new URL( - `${environmentService.get('SERVER_URL')}:${environmentService.get('PORT')}`, + environmentService.get('SERVER_URL').startsWith('http') + ? environmentService.get('SERVER_URL') + : `http://${environmentService.get('SERVER_URL')}`, ); + serverUrl.port = environmentService.get('PORT').toString(); + if ( serverUrl.protocol === 'https:' && (!environmentService.get('SSL_KEY_PATH') || diff --git a/packages/twenty-server/src/utils/workspace-url.utils.ts b/packages/twenty-server/src/utils/workspace-url.utils.ts index 2af4717eb871..f018d3a9b89f 100644 --- a/packages/twenty-server/src/utils/workspace-url.utils.ts +++ b/packages/twenty-server/src/utils/workspace-url.utils.ts @@ -1,12 +1,6 @@ -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; - export const buildWorkspaceURL = ( baseUrl: string, - params: - | { - workspace: Workspace; - } - | { subdomain: string }, + subdomain: string | null, { withPathname, withSearchParams, @@ -15,20 +9,9 @@ export const buildWorkspaceURL = ( withSearchParams?: Record; } = {}, ) => { - const subdomain = - 'subdomain' in params - ? params.subdomain - : 'workspace' in params - ? params.workspace.subdomain - : null; - - if (!subdomain) { - throw new Error('Subdomain not found'); - } - const url = new URL(baseUrl); - url.hostname = `${subdomain}.${url.hostname}`; + url.hostname = subdomain ? `${subdomain}.${url.hostname}` : url.hostname; if (withPathname) { url.pathname = withPathname; From 0dea459676d9856122484df8cbc10fcf222acd5b Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 18 Nov 2024 19:52:31 +0100 Subject: [PATCH 22/24] fix(auth, workspace): correct workspace length check and subdomain handling Add a TODO comment for workspace logger issue where message becomes undefined. Adjust target workspace subdomain initialization based on multi-workspace environment setting. --- .../src/engine/core-modules/auth/auth.resolver.ts | 12 ++++++++---- .../workspace/services/workspace.service.ts | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 8662ccb8a455..15b25de1aaeb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -142,10 +142,14 @@ export class AuthResolver { ): Promise { const user = await this.authService.signInUp({ ...signUpInput, - targetWorkspaceSubdomain: getWorkspaceSubdomainByOrigin( - origin, - this.environmentService.get('FRONT_BASE_URL'), - ), + targetWorkspaceSubdomain: this.environmentService.get( + 'IS_MULTIWORKSPACE_ENABLED', + ) + ? getWorkspaceSubdomainByOrigin( + origin, + this.environmentService.get('FRONT_BASE_URL'), + ) + : undefined, fromSSO: false, }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index f8bfcc4e4973..1fa37c6e5b54 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -331,6 +331,7 @@ export class WorkspaceService extends TypeOrmQueryService { }); if (workspaces.length > 1) { + // TODO AMOREAUX: this logger is trigger twice and the second time the message is undefined for an unknown reason Logger.warn( `In single-workspace mode, there should be only one workspace. Today there are ${workspaces.length} workspaces`, ); From 90ec8f8023cf2a5541a30c038e473ad89cde93eb Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Mon, 18 Nov 2024 19:56:18 +0100 Subject: [PATCH 23/24] feat(settings): conditionally render domain section Add conditional rendering to the domain section based on the multi-workspace feature state. This ensures the section is only displayed if the multi-workspace feature is enabled. --- .../src/pages/settings/SettingsWorkspace.tsx | 98 ++++++++++--------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx index 69abbe97b78f..18e7eaf60f00 100644 --- a/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx +++ b/packages/twenty-front/src/pages/settings/SettingsWorkspace.tsx @@ -1,5 +1,6 @@ import { GithubVersionLink, H2Title, Section, IconWorld } from 'twenty-ui'; import { Link } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; import styled from '@emotion/styled'; @@ -13,53 +14,60 @@ import { SettingsPath } from '@/types/SettingsPath'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import packageJson from '../../../package.json'; import { SettingsCard } from '@/settings/components/SettingsCard'; +import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; const StyledLink = styled(Link)` text-decoration: none; `; -export const SettingsWorkspace = () => ( - - -
- - -
-
- - -
-
- - - } /> - -
-
- } - description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time." - /> -
-
- -
-
- -
-
-
-); +export const SettingsWorkspace = () => { + const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); + return ( + + +
+ + +
+
+ + +
+ {isMultiWorkspaceEnabled && ( +
+ + + } /> + +
+ )} + +
+ } + description="Grant Twenty support temporary access to your workspace so we can troubleshoot problems or recover content on your behalf. You can revoke access at any time." + /> +
+
+ +
+
+ +
+
+
+ ); +}; From d1047ec46a9cdd568df7839ea897c8d242e0b03b Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 19 Nov 2024 16:30:21 +0100 Subject: [PATCH 24/24] refactor(twenty-ui): Revert change on MenuItemSelect and Select --- packages/twenty-ui/src/input/components/Toggle.tsx | 3 --- .../src/navigation/menu-item/components/MenuItemSelect.tsx | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/twenty-ui/src/input/components/Toggle.tsx b/packages/twenty-ui/src/input/components/Toggle.tsx index aa3c5dd3a58b..c9f95385d2c5 100644 --- a/packages/twenty-ui/src/input/components/Toggle.tsx +++ b/packages/twenty-ui/src/input/components/Toggle.tsx @@ -74,9 +74,6 @@ export const Toggle = ({ type="checkbox" checked={value} disabled={disabled} - onClick={(event) => { - event.stopPropagation(); - }} onChange={(event) => { onChange?.(event.target.checked); }} diff --git a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelect.tsx b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelect.tsx index 4147e8fa59bb..3c91c42fbd4a 100644 --- a/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelect.tsx +++ b/packages/twenty-ui/src/navigation/menu-item/components/MenuItemSelect.tsx @@ -42,7 +42,7 @@ type MenuItemSelectProps = { selected: boolean; text: string; className?: string; - onClick?: (event: React.MouseEvent) => void; + onClick?: () => void; disabled?: boolean; hovered?: boolean; hasSubMenu?: boolean;