From 39082cf78755881699767a0846e0fd28283c51cc Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 17 Jul 2026 15:33:42 +0200 Subject: [PATCH] Only show the install-apps onboarding step to workspace creators (#22990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New users joining an existing workspace through an invite link were routed through the "Install your first apps" onboarding step, which is meant for workspace creation only. #22347 set `ONBOARDING_INSTALL_APPS_PENDING` inside `activateOnboardingForUser`, which is shared by both sign-up paths. Gate it per path like the connect-account step: `true` on `signUpOnNewWorkspace`, `false` on `signInUpOnExistingWorkspace`. Verified locally: invited users now go straight from create-profile into the workspace, and workspace creators still get the install-apps step. Covered by a unit test on the invite path and integration tests asserting the onboarding status per sign-up path (invite → `PROFILE_CREATION`; workspace creation → `SYNC_EMAIL` then `APPS_INSTALLATION`). --- .../auth/services/sign-in-up.service.spec.ts | 59 +++++++++++++--- .../auth/services/sign-in-up.service.ts | 22 +++--- ...pace-onboarding-status.integration-spec.ts | 40 +++++++++++ ...er-settings-onboarding.integration-spec.ts | 69 +++++-------------- ...and-workspace-creation.integration-spec.ts | 30 ++++++++ .../utils/get-onboarding-status.util.ts | 51 ++++++++++++++ .../skip-sync-email-onboarding-step.util.ts | 50 ++++++++++++++ 7 files changed, 251 insertions(+), 70 deletions(-) create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/sign-up/sign-up-in-workspace-onboarding-status.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/get-onboarding-status.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/skip-sync-email-onboarding-step.util.ts 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 6f6019632f..8f7fa9dccf 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -1,3 +1,5 @@ +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; + import { AuthException, AuthExceptionCode, @@ -57,6 +59,20 @@ const createSignInUpServiceForTests = () => { release: jest.fn(), }; + const mockUserWorkspaceService = { + create: jest.fn(), + checkUserWorkspaceExists: jest.fn(), + addUserToWorkspaceIfUserNotInWorkspace: jest.fn(), + }; + + const mockOnboardingService = { + setOnboardingConnectAccountPending: jest.fn(), + setOnboardingCreateProfilePending: jest.fn(), + setOnboardingInstallAppsPending: jest.fn(), + setOnboardingInviteTeamPending: jest.fn(), + createOnboardingStatusForWorkspaceMember: jest.fn(), + }; + const service = new SignInUpService( mockUserRepository as any, mockWorkspaceRepository as any, @@ -64,15 +80,8 @@ const createSignInUpServiceForTests = () => { validatePersonalInvitation: jest.fn(), invalidateWorkspaceInvitation: jest.fn(), } as any, - { - create: jest.fn(), - checkUserWorkspaceExists: jest.fn(), - } as any, - { - setOnboardingCreateProfilePending: jest.fn(), - setOnboardingInviteTeamPending: jest.fn(), - createOnboardingStatusForWorkspaceMember: jest.fn(), - } as any, + mockUserWorkspaceService as any, + mockOnboardingService as any, { emitCustomBatchEvent: jest.fn(), } as any, @@ -121,6 +130,7 @@ const createSignInUpServiceForTests = () => { mockUserRepository, mockWorkspaceRepository, mockConfigurationValues, + mockOnboardingService, }; }; @@ -309,3 +319,34 @@ describe('SignInUpService workspace-creation policy', () => { }); }); }); + +describe('SignInUpService onboarding steps', () => { + it('does not flag the install-apps step for a new user joining an existing workspace', async () => { + const { service, mockOnboardingService } = createSignInUpServiceForTests(); + + await service.signInUpOnExistingWorkspace({ + workspace: { + id: 'existing-workspace-id', + activationStatus: WorkspaceActivationStatus.ACTIVE, + } as any, + userData: { + type: 'newUserWithPicture', + newUserWithPicture: { + email: 'invited.user@acme.dev', + firstName: 'Invited', + lastName: 'User', + }, + }, + }); + + expect( + mockOnboardingService.setOnboardingCreateProfilePending, + ).toHaveBeenCalledWith(expect.objectContaining({ value: true }), undefined); + expect( + mockOnboardingService.setOnboardingInstallAppsPending, + ).not.toHaveBeenCalled(); + expect( + mockOnboardingService.setOnboardingConnectAccountPending, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index bb1af8024f..cba90cfcb4 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 @@ -332,6 +332,7 @@ export class SignInUpService { user, workspace: params.workspace, shouldShowConnectAccountStep: false, + shouldShowInstallAppsStep: false, }); await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace( @@ -364,10 +365,12 @@ export class SignInUpService { user, workspace, shouldShowConnectAccountStep, + shouldShowInstallAppsStep, }: { user: Pick; workspace: WorkspaceEntity; shouldShowConnectAccountStep: boolean; + shouldShowInstallAppsStep: boolean; }, queryRunner?: QueryRunner, ) { @@ -391,14 +394,16 @@ export class SignInUpService { queryRunner, ); - await this.onboardingService.setOnboardingInstallAppsPending( - { - userId: user.id, - workspaceId: workspace.id, - value: true, - }, - queryRunner, - ); + if (shouldShowInstallAppsStep) { + await this.onboardingService.setOnboardingInstallAppsPending( + { + userId: user.id, + workspaceId: workspace.id, + value: true, + }, + queryRunner, + ); + } } private async saveNewUser( @@ -670,6 +675,7 @@ export class SignInUpService { user, workspace, shouldShowConnectAccountStep: true, + shouldShowInstallAppsStep: true, }, queryRunner, ); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/sign-up-in-workspace-onboarding-status.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/sign-up-in-workspace-onboarding-status.integration-spec.ts new file mode 100644 index 0000000000..9aff364277 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/sign-up-in-workspace-onboarding-status.integration-spec.ts @@ -0,0 +1,40 @@ +import { randomUUID } from 'crypto'; + +import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; +import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util'; +import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util'; +import { isDefined } from 'twenty-shared/utils'; + +import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; + +describe('Onboarding status when signing up in an existing workspace (integration)', () => { + let createdUserAccessToken: string | undefined; + + afterEach(async () => { + if (isDefined(createdUserAccessToken)) { + await deleteUser({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + createdUserAccessToken = undefined; + } + }); + + it('should only require profile creation for a new user joining through an invite', async () => { + createdUserAccessToken = await signUpInWorkspaceAndGetAccessToken( + `invited-onboarding-status-${randomUUID()}@example.com`, + ); + + const { + data: { currentUser }, + } = await getOnboardingStatus({ + accessToken: createdUserAccessToken, + expectToFail: false, + }); + + expect(currentUser.onboardingStatus).toBe( + OnboardingStatus.PROFILE_CREATION, + ); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts index b4954e1905..d4a0101af7 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/update-workspace-member-settings-onboarding.integration-spec.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import gql from 'graphql-tag'; import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; +import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; @@ -76,49 +77,17 @@ describe('updateWorkspaceMemberSettings and profile onboarding', () => { newUserAccessToken = await signUpInWorkspaceAndGetAccessToken(uniqueEmail); - const triggerInstallAppsOnboardingStepMutation = gql` - mutation TriggerInstallAppsOnboardingStep( - $universalIdentifiers: [String!]! - ) { - triggerInstallAppsOnboardingStep( - universalIdentifiers: $universalIdentifiers - ) { - success - } - } - `; + const { + data: { currentUser: currentUserBeforeNameUpdate }, + } = await getOnboardingStatus({ + accessToken: newUserAccessToken, + expectToFail: false, + }); - await makeMetadataAPIRequest( - { - query: triggerInstallAppsOnboardingStepMutation, - variables: { universalIdentifiers: [] }, - }, - newUserAccessToken, + expect(currentUserBeforeNameUpdate.onboardingStatus).toBe( + OnboardingStatus.PROFILE_CREATION, ); - const currentUserWithOnboardingQuery = gql` - query CurrentUserWithOnboarding { - currentUser { - id - onboardingStatus - } - } - `; - - const beforeNameUpdateResponse = await makeMetadataAPIRequest( - { - query: currentUserWithOnboardingQuery, - variables: {}, - }, - newUserAccessToken, - ); - - expect(beforeNameUpdateResponse.status).toBe(200); - expect(beforeNameUpdateResponse.body.errors).toBeUndefined(); - expect( - beforeNameUpdateResponse.body.data.currentUser.onboardingStatus, - ).toBe(OnboardingStatus.PROFILE_CREATION); - const workspaceMemberQuery = gql` query WorkspaceMemberForProfileOnboarding( $workspaceMemberFilter: WorkspaceMemberFilterInput! @@ -183,20 +152,14 @@ describe('updateWorkspaceMemberSettings and profile onboarding', () => { true, ); - const afterNameUpdateResponse = await makeMetadataAPIRequest( - { - query: currentUserWithOnboardingQuery, - variables: {}, - }, - newUserAccessToken, - ); + const { + data: { currentUser: currentUserAfterNameUpdate }, + } = await getOnboardingStatus({ + accessToken: newUserAccessToken, + expectToFail: false, + }); - expect(afterNameUpdateResponse.status).toBe(200); - expect(afterNameUpdateResponse.body.errors).toBeUndefined(); - expect( - afterNameUpdateResponse.body.data.currentUser.onboardingStatus, - ).not.toBe(OnboardingStatus.PROFILE_CREATION); - expect(afterNameUpdateResponse.body.data.currentUser.onboardingStatus).toBe( + expect(currentUserAfterNameUpdate.onboardingStatus).toBe( OnboardingStatus.COMPLETED, ); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts index 22b7547afd..4f602820db 100644 --- a/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/workspace/successful-user-and-workspace-creation.integration-spec.ts @@ -5,7 +5,9 @@ import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util'; import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; +import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util'; import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util'; +import { skipSyncEmailOnboardingStep } from 'test/integration/graphql/utils/skip-sync-email-onboarding-step.util'; import { signUp } from 'test/integration/graphql/utils/sign-up.util'; import { createOneLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/create-logic-function.util'; import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; @@ -13,6 +15,7 @@ import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; describe('Successful user and workspace creation', () => { @@ -79,6 +82,33 @@ describe('Successful user and workspace creation', () => { WorkspaceActivationStatus.ACTIVE, ); + const { + data: { currentUser: currentUserAfterActivation }, + } = await getOnboardingStatus({ + accessToken: newWorkspaceAccessToken, + expectToFail: false, + }); + + expect(currentUserAfterActivation.onboardingStatus).toBe( + OnboardingStatus.SYNC_EMAIL, + ); + + await skipSyncEmailOnboardingStep({ + accessToken: newWorkspaceAccessToken, + expectToFail: false, + }); + + const { + data: { currentUser: currentUserAfterSyncEmailSkip }, + } = await getOnboardingStatus({ + accessToken: newWorkspaceAccessToken, + expectToFail: false, + }); + + expect(currentUserAfterSyncEmailSkip.onboardingStatus).toBe( + OnboardingStatus.APPS_INSTALLATION, + ); + const { data: { currentUser }, } = await getCurrentUser({ diff --git a/packages/twenty-server/test/integration/graphql/utils/get-onboarding-status.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-onboarding-status.util.ts new file mode 100644 index 0000000000..cb3a6d31dd --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/get-onboarding-status.util.ts @@ -0,0 +1,51 @@ +import gql from 'graphql-tag'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum'; + +type GetOnboardingStatusUtilArgs = { + accessToken: string; + expectToFail?: boolean; +}; + +export const getOnboardingStatus = async ({ + accessToken, + expectToFail, +}: GetOnboardingStatusUtilArgs): CommonResponseBody<{ + currentUser: { onboardingStatus: OnboardingStatus | null }; +}> => { + const query = gql` + query CurrentUserOnboardingStatus { + currentUser { + onboardingStatus + } + } + `; + + const response = await makeMetadataAPIRequest( + { + query, + variables: {}, + }, + accessToken, + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Get onboarding status should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Get onboarding status has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/skip-sync-email-onboarding-step.util.ts b/packages/twenty-server/test/integration/graphql/utils/skip-sync-email-onboarding-step.util.ts new file mode 100644 index 0000000000..ca786babdc --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/skip-sync-email-onboarding-step.util.ts @@ -0,0 +1,50 @@ +import gql from 'graphql-tag'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +type SkipSyncEmailOnboardingStepUtilArgs = { + accessToken: string; + expectToFail?: boolean; +}; + +export const skipSyncEmailOnboardingStep = async ({ + accessToken, + expectToFail, +}: SkipSyncEmailOnboardingStepUtilArgs): CommonResponseBody<{ + skipSyncEmailOnboardingStep: { success: boolean }; +}> => { + const mutation = gql` + mutation SkipSyncEmailOnboardingStep { + skipSyncEmailOnboardingStep { + success + } + } + `; + + const response = await makeMetadataAPIRequest( + { + query: mutation, + variables: {}, + }, + accessToken, + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'Skip sync email onboarding step should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Skip sync email onboarding step has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +};