From 240e185323152effc3ef42ed36617c67b3b5280d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Mon, 20 Jul 2026 17:00:39 +0200
Subject: [PATCH] Show connect emails step to invited users without granting
credits (#23058)
Invited users never saw the connect-emails onboarding step, so they
could not connect their inbox while onboarding. Now they do, but only
the first user (the workspace creator) earns the import-contacts reward
for it.
The credit is gated on the workspace having a single member, reusing the
same "first user" signal the frontend already uses to gate the
invite-team step. The connect-account step is still claimed for everyone
so invited users' onboarding advances normally.
The frontend hides the "free credits" tag and the header counter bump
for invited users, so we do not promise credits they will not receive.
---
.../src/pages/onboarding/SyncEmails.tsx | 11 +++-
.../__stories__/SyncEmails.stories.tsx | 31 ++++++++++-
.../auth/services/sign-in-up.service.spec.ts | 4 +-
.../auth/services/sign-in-up.service.ts | 2 +-
.../onboarding/onboarding.module.ts | 3 +-
.../onboarding/onboarding.service.spec.ts | 53 ++++++++++++++++++-
.../onboarding/onboarding.service.ts | 27 +++++++++-
...pace-onboarding-status.integration-spec.ts | 6 +--
...er-settings-onboarding.integration-spec.ts | 6 +++
9 files changed, 128 insertions(+), 15 deletions(-)
diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx
index 8423062362..591cae3d08 100644
--- a/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx
+++ b/packages/twenty-front/src/pages/onboarding/SyncEmails.tsx
@@ -1,3 +1,4 @@
+import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
@@ -47,11 +48,17 @@ export const SyncEmails = () => {
clientConfigApiStatusState,
).isLoadedOnce;
const onboardingConfig = useAtomStateValue(onboardingConfigState);
+ const currentWorkspace = useAtomStateValue(currentWorkspaceState);
+
+ const isFirstWorkspaceUser = currentWorkspace?.workspaceMembersCount === 1;
+ const creditsReward = isFirstWorkspaceUser
+ ? onboardingConfig?.importContactsCreditsReward
+ : undefined;
const connectWithProvider = async (provider: ConnectedAccountProvider) => {
setOnboardingFreeCredits((current) => ({
...current,
- importContacts: onboardingConfig?.importContactsCreditsReward ?? 0,
+ importContacts: creditsReward ?? 0,
}));
try {
@@ -94,7 +101,7 @@ export const SyncEmails = () => {
return (
connectWithProvider(ConnectedAccountProvider.GOOGLE)
diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx
index 72c8336b95..f236ece16b 100644
--- a/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx
+++ b/packages/twenty-front/src/pages/onboarding/__stories__/SyncEmails.stories.tsx
@@ -1,9 +1,11 @@
import { getOperationName } from '~/utils/getOperationName';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { HttpResponse, graphql } from 'msw';
-import { within } from 'storybook/test';
+import { expect, within } from 'storybook/test';
import { AppPath } from 'twenty-shared/types';
+import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
+import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { OnboardingStatus } from '~/generated-metadata/graphql';
import { GET_CURRENT_USER } from '~/modules/users/graphql/queries/getCurrentUser';
import { SyncEmails } from '~/pages/onboarding/SyncEmails';
@@ -12,7 +14,17 @@ import {
type PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
-import { mockedOnboardingUserData } from '~/testing/mock-data/users';
+import {
+ mockCurrentWorkspace,
+ mockedOnboardingUserData,
+} from '~/testing/mock-data/users';
+
+const setWorkspaceMembersCount = (workspaceMembersCount: number) => {
+ jotaiStore.set(currentWorkspaceState.atom, {
+ ...mockCurrentWorkspace,
+ workspaceMembersCount,
+ });
+};
const meta: Meta = {
title: 'Pages/Onboarding/SyncEmails',
@@ -42,8 +54,23 @@ export default meta;
export type Story = StoryObj;
export const Default: Story = {
+ beforeEach: () => {
+ setWorkspaceMembersCount(1);
+ },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
await canvas.findByText('Import your contacts');
+ await canvas.findByText('Earn +2');
+ },
+};
+
+export const InvitedUser: Story = {
+ beforeEach: () => {
+ setWorkspaceMembersCount(2);
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement.ownerDocument.body);
+ await canvas.findByText('Import your contacts');
+ expect(canvas.queryByText('Earn +2')).not.toBeInTheDocument();
},
};
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 7dc7b9051b..cc87a76543 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
@@ -327,7 +327,7 @@ 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 () => {
+ it('flags the connect-account step but not the install-apps step for a new user joining an existing workspace', async () => {
const { service, mockOnboardingService } = createSignInUpServiceForTests();
await service.signInUpOnExistingWorkspace({
@@ -353,7 +353,7 @@ describe('SignInUpService onboarding steps', () => {
).not.toHaveBeenCalled();
expect(
mockOnboardingService.setOnboardingConnectAccountPending,
- ).not.toHaveBeenCalled();
+ ).toHaveBeenCalledWith(expect.objectContaining({ value: true }), undefined);
});
it('flags the install-apps step for a user creating a new workspace', async () => {
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 cba90cfcb4..d312c2f8e5 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
@@ -331,7 +331,7 @@ export class SignInUpService {
await this.activateOnboardingForUser({
user,
workspace: params.workspace,
- shouldShowConnectAccountStep: false,
+ shouldShowConnectAccountStep: true,
shouldShowInstallAppsStep: false,
});
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts
index 3c90ecba66..56a94e9ced 100644
--- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.module.ts
@@ -5,6 +5,7 @@ import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { OnboardingResolver } from 'src/engine/core-modules/onboarding/onboarding.resolver';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
+import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { OnboardingInviteSuggestionsModule } from 'src/modules/onboarding-invite-suggestions/onboarding-invite-suggestions.module';
@@ -13,7 +14,7 @@ import { OnboardingInviteSuggestionsModule } from 'src/modules/onboarding-invite
BillingModule,
UserVarsModule,
OnboardingInviteSuggestionsModule,
- TypeOrmModule.forFeature([WorkspaceEntity]),
+ TypeOrmModule.forFeature([WorkspaceEntity, UserWorkspaceEntity]),
],
exports: [OnboardingService],
providers: [OnboardingService, OnboardingResolver],
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
index 7a1178d52a..128d5542ab 100644
--- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
@@ -16,6 +16,7 @@ import {
} from 'src/engine/core-modules/onboarding/onboarding.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
+import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
describe('OnboardingService', () => {
@@ -24,6 +25,7 @@ describe('OnboardingService', () => {
let billingCreditService: BillingCreditService;
let twentyConfigService: TwentyConfigService;
let messageQueueService: MessageQueueService;
+ let userWorkspaceRepository: Repository;
const userId = 'user-id';
const workspaceId = 'workspace-id';
@@ -62,6 +64,12 @@ describe('OnboardingService', () => {
provide: getRepositoryToken(WorkspaceEntity),
useClass: Repository,
},
+ {
+ provide: getRepositoryToken(UserWorkspaceEntity),
+ useValue: {
+ countBy: jest.fn(),
+ },
+ },
{
provide: getQueueToken(MessageQueue.workspaceQueue),
useValue: {
@@ -79,6 +87,9 @@ describe('OnboardingService', () => {
messageQueueService = module.get(
getQueueToken(MessageQueue.workspaceQueue),
);
+ userWorkspaceRepository = module.get>(
+ getRepositoryToken(UserWorkspaceEntity),
+ );
});
afterEach(() => {
@@ -86,8 +97,9 @@ describe('OnboardingService', () => {
});
describe('completeOnboardingConnectAccountStep', () => {
- it('should credit the import-contacts reward when the step was claimed', async () => {
+ it('should credit the import-contacts reward when the step was claimed by the first workspace user', async () => {
jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
+ jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(1);
jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000);
await service.completeOnboardingConnectAccountStep({
@@ -106,6 +118,25 @@ describe('OnboardingService', () => {
});
});
+ it('should claim the step but not credit when the workspace has more than one member', async () => {
+ jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
+ jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(2);
+
+ await service.completeOnboardingConnectAccountStep({
+ userId,
+ workspaceId,
+ });
+
+ expect(userVarsService.delete).toHaveBeenCalledWith({
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING,
+ });
+ expect(
+ billingCreditService.creditWorkspaceBalance,
+ ).not.toHaveBeenCalled();
+ });
+
it('should not credit anything when the step was already consumed', async () => {
jest.spyOn(userVarsService, 'delete').mockResolvedValue(0);
@@ -124,6 +155,7 @@ describe('OnboardingService', () => {
.spyOn(userVarsService, 'delete')
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(0);
+ jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(1);
jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000);
await Promise.all([
@@ -138,6 +170,7 @@ describe('OnboardingService', () => {
it('should not throw when crediting fails', async () => {
jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
+ jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(1);
jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000);
jest
.spyOn(billingCreditService, 'creditWorkspaceBalance')
@@ -150,6 +183,24 @@ describe('OnboardingService', () => {
}),
).resolves.not.toThrow();
});
+
+ it('should not throw when the workspace member count is unavailable', async () => {
+ jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
+ jest
+ .spyOn(userWorkspaceRepository, 'countBy')
+ .mockRejectedValue(new Error('database failure'));
+
+ await expect(
+ service.completeOnboardingConnectAccountStep({
+ userId,
+ workspaceId,
+ }),
+ ).resolves.not.toThrow();
+
+ expect(
+ billingCreditService.creditWorkspaceBalance,
+ ).not.toHaveBeenCalled();
+ });
});
describe('triggerInstallAppsOnboardingStep', () => {
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
index 81d984025a..35d77bad87 100644
--- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
@@ -19,6 +19,7 @@ import {
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
+import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
export enum OnboardingStepKeys {
@@ -46,6 +47,8 @@ export class OnboardingService {
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository,
+ @InjectRepository(UserWorkspaceEntity)
+ private readonly userWorkspaceRepository: Repository,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@@ -190,7 +193,19 @@ export class OnboardingService {
return;
}
- await this.creditImportContactsReward({ workspaceId });
+ await this.creditImportContactsRewardForFirstWorkspaceUser({ workspaceId });
+ }
+
+ private async isFirstWorkspaceUser({
+ workspaceId,
+ }: {
+ workspaceId: string;
+ }): Promise {
+ const workspaceUserCount = await this.userWorkspaceRepository.countBy({
+ workspaceId,
+ });
+
+ return workspaceUserCount === 1;
}
private async claimOnboardingConnectAccountStep({
@@ -209,12 +224,20 @@ export class OnboardingService {
return isDefined(affectedRows) && affectedRows > 0;
}
- private async creditImportContactsReward({
+ private async creditImportContactsRewardForFirstWorkspaceUser({
workspaceId,
}: {
workspaceId: string;
}) {
try {
+ const isFirstWorkspaceUser = await this.isFirstWorkspaceUser({
+ workspaceId,
+ });
+
+ if (!isFirstWorkspaceUser) {
+ return;
+ }
+
await this.billingCreditService.creditWorkspaceBalance({
workspaceId,
amountMicro: this.twentyConfigService.get(
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
index 9aff364277..9c047c35a0 100644
--- 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
@@ -21,7 +21,7 @@ describe('Onboarding status when signing up in an existing workspace (integratio
}
});
- it('should only require profile creation for a new user joining through an invite', async () => {
+ it('should start with the connect-account step for a new user joining through an invite', async () => {
createdUserAccessToken = await signUpInWorkspaceAndGetAccessToken(
`invited-onboarding-status-${randomUUID()}@example.com`,
);
@@ -33,8 +33,6 @@ describe('Onboarding status when signing up in an existing workspace (integratio
expectToFail: false,
});
- expect(currentUser.onboardingStatus).toBe(
- OnboardingStatus.PROFILE_CREATION,
- );
+ expect(currentUser.onboardingStatus).toBe(OnboardingStatus.SYNC_EMAIL);
});
});
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 d4a0101af7..9d095d552e 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
@@ -5,6 +5,7 @@ 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 { skipSyncEmailOnboardingStep } from 'test/integration/graphql/utils/skip-sync-email-onboarding-step.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
@@ -77,6 +78,11 @@ describe('updateWorkspaceMemberSettings and profile onboarding', () => {
newUserAccessToken = await signUpInWorkspaceAndGetAccessToken(uniqueEmail);
+ await skipSyncEmailOnboardingStep({
+ accessToken: newUserAccessToken,
+ expectToFail: false,
+ });
+
const {
data: { currentUser: currentUserBeforeNameUpdate },
} = await getOnboardingStatus({