fix(onboarding): show the connect step after workspace creation (#21701)

## Problem

Creating a new workspace as an existing user (e.g. someone who already
has other workspaces) skips the **Connect account / Sync emails** step
entirely — the connect modal never appears.

## Root cause (regression from #21640)

`#21640` moved the `CONNECTED_ACCOUNTS` permission gate onto the
`WORKSPACE_ACTIVATION → SYNC_EMAIL` transition in
`getNextOnboardingStatus`:

```ts
// before #21640: WORKSPACE_ACTIVATION → PROFILE_CREATION (no permission read)
// after  #21640:
if (WORKSPACE_ACTIVATION) {
  return isAccountSyncEnabled ? SYNC_EMAIL : PROFILE_CREATION;
}
```

That transition fires from `CreateWorkspace.tsx` immediately after
`activateWorkspace()` + `loadCurrentUser()`. But
`setNextOnboardingStatus` is a memoized callback that captured
`isAccountSyncEnabled` at render time — *before* activation, when the
brand-new workspace has no roles/permissions yet, so
`currentUserWorkspace.permissionFlags` is empty and the flag reads
`false`. `loadCurrentUser()` refreshes the atoms, but the executing
callback still holds the stale `false`. So it optimistically routes to
`PROFILE_CREATION`, skipping `SYNC_EMAIL` and overriding the backend
status (which correctly says `SYNC_EMAIL`). For an existing user the
profile step is also a no-op (name already set), so they sail straight
into the app.

Before #21640 the same permission gate lived on the `PROFILE_CREATION →
SYNC_EMAIL` transition, which fires from the profile step — long after
activation, when permissions are loaded — so it never misfired.

## Fix

The backend (`OnboardingService.getOnboardingStatus`) decides the
connect step purely from `ONBOARDING_CONNECT_ACCOUNT_PENDING` and never
consults the permission. Drop the permission gate from the frontend
transition so the two agree — `WORKSPACE_ACTIVATION` always advances to
`SYNC_EMAIL`. The `WORKSPACE_ACTIVATION` branch only ever runs for
workspace creators (who are admins with the permission), so the gate was
only ever reachable via the stale read.

Removes the now-unused `isAccountSyncEnabled` / `usePermissionFlagMap`
plumbing and the obsolete "skip SyncEmail when account sync is disabled"
unit test.

## Test plan

- [ ] Create a new workspace as an existing user (with other workspaces)
→ the Connect account / Sync emails step now appears
- [ ] Fresh signup → onboarding still flows `Workspace activation → Sync
emails → Create profile → Invite team`
- [x] `useSetNextOnboardingStatus` unit tests updated (the "after
workspace activation → SYNC_EMAIL" case is retained and now
unconditional)
- [x] `npx nx typecheck twenty-front` — clean for changed files (only
the pre-existing, unrelated `idb-keyval` module-resolution errors remain
in this environment)
- [x] `npx nx lint:diff-with-main twenty-front` — changed files clean

> Note: the unit test file couldn't be executed in my sandbox because
`idb-keyval` (a declared dependency, pulled in transitively via
`jotaiStore`) isn't installed here; it runs normally in CI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY

---
_Generated by [Claude
Code](https://claude.ai/code/session_019MyY3bfAEij4AwSXMCLtWY)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21701?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-16 23:37:51 +02:00
committed by GitHub
parent 35c2a24afb
commit 74b56ba66c
2 changed files with 4 additions and 37 deletions
@@ -16,7 +16,6 @@ import {
import { import {
OnboardingStatus, OnboardingStatus,
PermissionFlagType,
SubscriptionStatus, SubscriptionStatus,
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
import { import {
@@ -31,7 +30,6 @@ const renderHooks = (
onboardingStatus: OnboardingStatus, onboardingStatus: OnboardingStatus,
withCurrentBillingSubscription: boolean, withCurrentBillingSubscription: boolean,
withOneWorkspaceMember = true, withOneWorkspaceMember = true,
permissionFlags = mockedUserData.currentUserWorkspace.permissionFlags,
) => { ) => {
const { result } = renderHook( const { result } = renderHook(
() => { () => {
@@ -55,10 +53,7 @@ const renderHooks = (
); );
act(() => { act(() => {
result.current.setCurrentUser({ ...mockedUserData, onboardingStatus }); result.current.setCurrentUser({ ...mockedUserData, onboardingStatus });
result.current.setCurrentUserWorkspace({ result.current.setCurrentUserWorkspace(mockedUserData.currentUserWorkspace);
...mockedUserData.currentUserWorkspace,
permissionFlags,
});
result.current.setCurrentWorkspace({ result.current.setCurrentWorkspace({
...mockCurrentWorkspace, ...mockCurrentWorkspace,
currentBillingSubscription: withCurrentBillingSubscription currentBillingSubscription: withCurrentBillingSubscription
@@ -92,16 +87,6 @@ describe('useSetNextOnboardingStatus', () => {
expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL); expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL);
}); });
it('should skip SyncEmail when account sync is disabled', () => {
const nextOnboardingStatus = renderHooks(
OnboardingStatus.WORKSPACE_ACTIVATION,
false,
true,
[PermissionFlagType.WORKSPACE_MEMBERS],
);
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION);
});
it('should create profile after syncing emails', () => { it('should create profile after syncing emails', () => {
const nextOnboardingStatus = renderHooks( const nextOnboardingStatus = renderHooks(
OnboardingStatus.SYNC_EMAIL, OnboardingStatus.SYNC_EMAIL,
@@ -9,33 +9,25 @@ import {
currentWorkspaceState, currentWorkspaceState,
} from '@/auth/states/currentWorkspaceState'; } from '@/auth/states/currentWorkspaceState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import { OnboardingStatus } from '~/generated-metadata/graphql';
OnboardingStatus,
PermissionFlagType,
} from '~/generated-metadata/graphql';
import { useStore } from 'jotai'; import { useStore } from 'jotai';
type GetNextOnboardingStatusArgs = { type GetNextOnboardingStatusArgs = {
currentUser: CurrentUser | null; currentUser: CurrentUser | null;
currentWorkspace: CurrentWorkspace | null; currentWorkspace: CurrentWorkspace | null;
calendarBookingPageId: string | null; calendarBookingPageId: string | null;
isAccountSyncEnabled: boolean;
}; };
const getNextOnboardingStatus = ({ const getNextOnboardingStatus = ({
currentUser, currentUser,
currentWorkspace, currentWorkspace,
calendarBookingPageId, calendarBookingPageId,
isAccountSyncEnabled,
}: GetNextOnboardingStatusArgs) => { }: GetNextOnboardingStatusArgs) => {
if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) { if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) {
return isAccountSyncEnabled return OnboardingStatus.SYNC_EMAIL;
? OnboardingStatus.SYNC_EMAIL
: OnboardingStatus.PROFILE_CREATION;
} }
if (currentUser?.onboardingStatus === OnboardingStatus.SYNC_EMAIL) { if (currentUser?.onboardingStatus === OnboardingStatus.SYNC_EMAIL) {
@@ -63,16 +55,12 @@ export const useSetNextOnboardingStatus = () => {
const currentUser = useAtomStateValue(currentUserState); const currentUser = useAtomStateValue(currentUserState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState); const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState); const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
const permissionMap = usePermissionFlagMap();
const isAccountSyncEnabled =
permissionMap[PermissionFlagType.CONNECTED_ACCOUNTS];
return useCallback(() => { return useCallback(() => {
const nextOnboardingStatus = getNextOnboardingStatus({ const nextOnboardingStatus = getNextOnboardingStatus({
currentUser, currentUser,
currentWorkspace, currentWorkspace,
calendarBookingPageId, calendarBookingPageId,
isAccountSyncEnabled,
}); });
store.set(currentUserState.atom, (current) => { store.set(currentUserState.atom, (current) => {
if (isDefined(current)) { if (isDefined(current)) {
@@ -83,11 +71,5 @@ export const useSetNextOnboardingStatus = () => {
} }
return current; return current;
}); });
}, [ }, [currentUser, currentWorkspace, calendarBookingPageId, store]);
currentUser,
currentWorkspace,
calendarBookingPageId,
isAccountSyncEnabled,
store,
]);
}; };