Only show the install-apps onboarding step to workspace creators (#22990)

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`).
This commit is contained in:
Charles Bochet
2026-07-17 15:33:42 +02:00
committed by GitHub
parent 3303bdd258
commit 39082cf787
7 changed files with 251 additions and 70 deletions
@@ -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();
});
});
@@ -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<UserEntity, 'id' | 'firstName' | 'lastName'>;
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,
);
@@ -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,
);
});
});
@@ -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,
);
});
@@ -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({
@@ -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 };
};
@@ -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 };
};