From 622a794e236b70a378f6e7536fd17f30f9304bfb Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Tue, 19 May 2026 17:39:20 -0700 Subject: [PATCH 01/19] Skip the onboarding "add your work email" screen for validated accounts --- src/libs/Navigation/guards/OnboardingGuard.ts | 1 + src/libs/actions/Welcome/OnboardingFlow.ts | 6 ++++- .../BaseOnboardingWorkEmail.tsx | 24 ++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/guards/OnboardingGuard.ts b/src/libs/Navigation/guards/OnboardingGuard.ts index 2318b6eea998..1783195aed48 100644 --- a/src/libs/Navigation/guards/OnboardingGuard.ts +++ b/src/libs/Navigation/guards/OnboardingGuard.ts @@ -110,6 +110,7 @@ function getOnboardingRoute(): Route { currentOnboardingPurposeSelected: onboardingPurposeSelected, onboardingInitialPath, onboardingValues: onboarding, + isAccountValidated: !!account?.validated, }) as Route; } diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 67c20470020a..38c0b77074ca 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -29,6 +29,7 @@ type GetOnboardingInitialPathParamsType = { currentOnboardingCompanySize: OnyxEntry; onboardingInitialPath: OnyxEntry; onboardingValues: OnyxEntry; + isAccountValidated?: boolean; }; type OnboardingTaskLinks = Partial<{ @@ -107,6 +108,7 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI currentOnboardingCompanySize, onboardingInitialPath = '', onboardingValues, + isAccountValidated, } = getOnboardingInitialPathParams; const state = getStateFromPath(onboardingInitialPath, linkingConfig.config); const currentOnboardingValues = onboardingValuesParam ?? onboardingValues; @@ -126,7 +128,9 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI if (isIndividual) { Onyx.set(ONYXKEYS.ONBOARDING_CUSTOM_CHOICES, [CONST.ONBOARDING_CHOICES.EMPLOYER, CONST.ONBOARDING_CHOICES.TRACK_BUSINESS, CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]); } - if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted) { + // A validated account has no reason to be on the onboarding "add work email" screen. + // The backend command also refuses validated callers, so showing the screen would just lead to an error. + if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated) { return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 836e0c98d987..1b3b90a7bd76 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -50,6 +50,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai const illustrations = useMemoizedLazyIllustrations(['EnvelopeReceipt', 'Gears', 'Profile']); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [session] = useOnyx(ONYXKEYS.SESSION); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [formValue] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM); const workEmail = formValue?.[INPUT_IDS.ONBOARDING_WORK_EMAIL]; const [onboardingErrorMessage] = useOnyx(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY); @@ -69,6 +70,27 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai }, []); useEffect(() => { + // A validated account should never see this screen — the backend command also refuses validated callers. + // This catches the case where the user lands here without first calling AddWorkEmail (e.g. signed up, + // closed the tab on this screen, signed back in via magic code which validated the account, and onboarding + // resumed on this route). Force-replace so the back button can't return here. + if (account?.validated) { + if (isVsb) { + Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(), {forceReplace: true}); + return; + } + if (isSmb) { + Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(), {forceReplace: true}); + return; + } + if (!onboardingValues?.isMergeAccountStepSkipped) { + Navigation.navigate(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(), {forceReplace: true}); + return; + } + Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); + return; + } + if (onboardingValues?.shouldValidate === undefined && onboardingValues?.isMergeAccountStepCompleted === undefined) { return; } @@ -96,7 +118,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - }, [onboardingValues?.shouldValidate, isVsb, isSmb, isFocused, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.isMergeAccountStepSkipped]); + }, [account?.validated, onboardingValues?.shouldValidate, isVsb, isSmb, isFocused, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.isMergeAccountStepSkipped]); const submitWorkEmail = useCallback((values: FormOnyxValues) => { AddWorkEmail(values[INPUT_IDS.ONBOARDING_WORK_EMAIL].trim()); From 6035759d76493108f7232fc1fcc7c1f4e60b490f Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Tue, 19 May 2026 17:39:21 -0700 Subject: [PATCH 02/19] Add unit test for validated-account skip in getOnboardingInitialPath --- tests/unit/OnboardingFlowTest.ts | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index e6fba9282507..c09105d7ecf6 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -87,5 +87,50 @@ describe('OnboardingFlow', () => { const path = getOnboardingInitialPath(params); expect(path).toBe('/onboarding/employees'); }); + + it('should skip the work email step when the account is already validated', () => { + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + hasAccessiblePolicies: true, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: false, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: false, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.EMPLOYER, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/', + onboardingValues: undefined, + isAccountValidated: true, + }; + const path = getOnboardingInitialPath(params); + expect(path).not.toBe('/onboarding/work-email'); + expect(path).toBe('/onboarding/employees'); + }); + + it('should still route to the work email step when the account is not validated', () => { + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + hasAccessiblePolicies: true, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: false, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: false, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.EMPLOYER, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/', + onboardingValues: undefined, + isAccountValidated: false, + }; + const path = getOnboardingInitialPath(params); + expect(path).toBe('/onboarding/work-email'); + }); }); }); From 494a5349209be7afd43237545490aed96e31826b Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 11:06:35 -0700 Subject: [PATCH 03/19] Hide work-email merge-validation screen from public-domain users --- .../BaseOnboardingWorkEmail.tsx | 5 +++-- .../BaseOnboardingWorkEmailValidation.tsx | 20 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 1b3b90a7bd76..8f583b14024e 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -96,7 +96,8 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai } setOnboardingErrorMessage(null); - if (onboardingValues?.shouldValidate) { + // Skip the merge-validation step for public-domain accounts; the screen's "someone from your domain created a workspace" wording only makes sense for private domains. + if (onboardingValues?.shouldValidate && !account?.isFromPublicDomain) { Navigation.navigate(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.getRoute()); return; } @@ -118,7 +119,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - }, [account?.validated, onboardingValues?.shouldValidate, isVsb, isSmb, isFocused, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.isMergeAccountStepSkipped]); + }, [account?.validated, account?.isFromPublicDomain, onboardingValues?.shouldValidate, isVsb, isSmb, isFocused, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.isMergeAccountStepSkipped]); const submitWorkEmail = useCallback((values: FormOnyxValues) => { AddWorkEmail(values[INPUT_IDS.ONBOARDING_WORK_EMAIL].trim()); diff --git a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx index ebfbc3929f19..97077427b697 100644 --- a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx +++ b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx @@ -42,6 +42,24 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.WORK_EMAIL_VALIDATION); useEffect(() => { + // Public-domain accounts have no work account to merge with; the screen's wording assumes a private domain workspace. + if (account?.isFromPublicDomain) { + if (isVsb) { + Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(), {forceReplace: true}); + return; + } + if (isSmb) { + Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(), {forceReplace: true}); + return; + } + if (!onboardingValues?.isMergeAccountStepSkipped) { + Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(), {forceReplace: true}); + return; + } + Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); + return; + } + if (onboardingValues?.isMergeAccountStepCompleted === undefined) { return; } @@ -68,7 +86,7 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - }, [onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.shouldRedirectToClassicAfterMerge, onboardingValues?.isMergeAccountStepSkipped, isVsb, isSmb, isFocused]); + }, [account?.isFromPublicDomain, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.shouldRedirectToClassicAfterMerge, onboardingValues?.isMergeAccountStepSkipped, isVsb, isSmb, isFocused]); const sendValidateCode = () => { if (!credentials?.login) { From f8e24815027b481abe1fc203bd6f4e94490000b1 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 11:15:45 -0700 Subject: [PATCH 04/19] Apply prettier formatting --- .../OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx | 11 ++++++++++- .../BaseOnboardingWorkEmailValidation.tsx | 10 +++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 8f583b14024e..b091bb014596 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -119,7 +119,16 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - }, [account?.validated, account?.isFromPublicDomain, onboardingValues?.shouldValidate, isVsb, isSmb, isFocused, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.isMergeAccountStepSkipped]); + }, [ + account?.validated, + account?.isFromPublicDomain, + onboardingValues?.shouldValidate, + isVsb, + isSmb, + isFocused, + onboardingValues?.isMergeAccountStepCompleted, + onboardingValues?.isMergeAccountStepSkipped, + ]); const submitWorkEmail = useCallback((values: FormOnyxValues) => { AddWorkEmail(values[INPUT_IDS.ONBOARDING_WORK_EMAIL].trim()); diff --git a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx index 97077427b697..962f2e1cc8ad 100644 --- a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx +++ b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx @@ -86,7 +86,15 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - }, [account?.isFromPublicDomain, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.shouldRedirectToClassicAfterMerge, onboardingValues?.isMergeAccountStepSkipped, isVsb, isSmb, isFocused]); + }, [ + account?.isFromPublicDomain, + onboardingValues?.isMergeAccountStepCompleted, + onboardingValues?.shouldRedirectToClassicAfterMerge, + onboardingValues?.isMergeAccountStepSkipped, + isVsb, + isSmb, + isFocused, + ]); const sendValidateCode = () => { if (!credentials?.login) { From 1b6cd6acc228b43feaf270ae1d3e7f8cdbfe10e0 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 12:13:21 -0700 Subject: [PATCH 05/19] Block validation-screen URL from resuming onboarding for public-domain users --- src/libs/actions/Welcome/OnboardingFlow.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 38c0b77074ca..b20f7dc8e48b 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -134,6 +134,18 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } + // Public-domain users should never resume onboarding on the work-email merge-validation screen. + // Its wording ("Someone from has already created a workspace") makes no sense for gmail-style accounts. + if (isUserFromPublicDomain && onboardingInitialPath.includes(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.route)) { + if (isVsb) { + return `/${ROUTES.ONBOARDING_ACCOUNTING.route}`; + } + if (isSmb) { + return `/${ROUTES.ONBOARDING_EMPLOYEES.route}`; + } + return `/${ROUTES.ONBOARDING_PURPOSE.route}`; + } + if (!isUserFromPublicDomain && hasAccessiblePolicies) { if (onboardingInitialPath) { return onboardingInitialPath; From 4a3a62683b24f2f10a40f1873608500b4348b23e Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 12:18:29 -0700 Subject: [PATCH 06/19] Render null on validation screen for public-domain accounts --- .../BaseOnboardingWorkEmailValidation.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx index 962f2e1cc8ad..138e8de23754 100644 --- a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx +++ b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx @@ -108,6 +108,11 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi MergeIntoAccountAndLogin(workEmail, validateCode, session?.accountID); }; + // Public-domain accounts have no work account to merge with; render nothing while the redirect above takes effect. + if (account?.isFromPublicDomain) { + return null; + } + return ( Date: Wed, 20 May 2026 13:19:50 -0700 Subject: [PATCH 07/19] Hide PrivateDomain merge screen from public-domain users The "People you may know are already here!" screen lives at /onboarding/private-domain and assumes the signup email is on a private domain. Validated public-domain users were being routed to it from BaseOnboardingWorkEmail and getting stuck (the screen's redirect only fires after a magic-code submission). Gate routing to it on isFromPublicDomain at the work-email redirect, the initial-path resolver (catches reload), and the screen itself (defense in depth). --- src/hooks/useOnboardingFlow.ts | 2 ++ src/libs/actions/Welcome/OnboardingFlow.ts | 7 ++--- .../BaseOnboardingPrivateDomain.tsx | 27 +++++++++++++++++-- .../BaseOnboardingWorkEmail.tsx | 3 ++- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index 84bb1a4d6257..e63c23428586 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -102,6 +102,7 @@ function useOnboardingFlowRouter() { currentOnboardingPurposeSelected: onboardingPurposeSelected, onboardingInitialPath, onboardingValues, + isAccountValidated: !!account?.validated, }); }); } @@ -125,6 +126,7 @@ function useOnboardingFlowRouter() { onboardingValues, account?.isFromPublicDomain, account?.hasAccessibleDomainPolicies, + account?.validated, onboardingCompanySize, onboardingPurposeSelected, onboardingInitialPath, diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index b20f7dc8e48b..dbc76e81aa21 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -134,9 +134,10 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } - // Public-domain users should never resume onboarding on the work-email merge-validation screen. - // Its wording ("Someone from has already created a workspace") makes no sense for gmail-style accounts. - if (isUserFromPublicDomain && onboardingInitialPath.includes(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.route)) { + // Public-domain users should never resume onboarding on the work-email merge-validation screen + // ("Someone from has already created a workspace") or the private-domain screen + // ("People you may know are already here!") — both screens assume the signup email is on a private domain. + if (isUserFromPublicDomain && (onboardingInitialPath.includes(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.route) || onboardingInitialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route))) { if (isVsb) { return `/${ROUTES.ONBOARDING_ACCOUNTING.route}`; } diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index 40bf0bdeba79..e8c1aafb8eef 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -29,6 +29,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard const {translate} = useLocalize(); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [session] = useOnyx(ONYXKEYS.SESSION); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); const {isBetaEnabled} = usePermissions(); const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); @@ -66,13 +67,30 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard }, [route.params?.backTo, onboardingValues]); useEffect(() => { + if (account?.isFromPublicDomain) { + return; + } if (isValidated) { return; } sendValidateCode(); - }, [sendValidateCode, isValidated]); + }, [sendValidateCode, isValidated, account?.isFromPublicDomain]); useEffect(() => { + // Public-domain accounts shouldn't be on this screen — the "people you may know" copy assumes a private domain. + if (account?.isFromPublicDomain) { + if (isVsb) { + Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); + return; + } + if (isSmb) { + Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); + return; + } + Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); + return; + } + if (!isValidated) { return; } @@ -95,7 +113,12 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); } - }, [isValidated, joinablePoliciesLength, getAccessiblePoliciesAction?.loading, isVsb, isSmb]); + }, [isValidated, joinablePoliciesLength, getAccessiblePoliciesAction?.loading, isVsb, isSmb, account?.isFromPublicDomain]); + + // Render nothing for public-domain accounts; the redirect useEffect above will navigate away. + if (account?.isFromPublicDomain) { + return null; + } return ( Date: Wed, 20 May 2026 13:48:01 -0700 Subject: [PATCH 08/19] Shorten comments per review feedback --- src/libs/actions/Welcome/OnboardingFlow.ts | 5 +---- src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index dbc76e81aa21..2682ee4aec2a 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -129,14 +129,11 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI Onyx.set(ONYXKEYS.ONBOARDING_CUSTOM_CHOICES, [CONST.ONBOARDING_CHOICES.EMPLOYER, CONST.ONBOARDING_CHOICES.TRACK_BUSINESS, CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]); } // A validated account has no reason to be on the onboarding "add work email" screen. - // The backend command also refuses validated callers, so showing the screen would just lead to an error. if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated) { return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } - // Public-domain users should never resume onboarding on the work-email merge-validation screen - // ("Someone from has already created a workspace") or the private-domain screen - // ("People you may know are already here!") — both screens assume the signup email is on a private domain. + // Skip the merge-validation and private-domain screens for public-domain accounts. if (isUserFromPublicDomain && (onboardingInitialPath.includes(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.route) || onboardingInitialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route))) { if (isVsb) { return `/${ROUTES.ONBOARDING_ACCOUNTING.route}`; diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 3e85e3489cbf..404f34a8cbf6 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -70,10 +70,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai }, []); useEffect(() => { - // A validated account should never see this screen — the backend command also refuses validated callers. - // This catches the case where the user lands here without first calling AddWorkEmail (e.g. signed up, - // closed the tab on this screen, signed back in via magic code which validated the account, and onboarding - // resumed on this route). Force-replace so the back button can't return here. + // A validated account has no reason to be on the onboarding "add work email" screen. if (account?.validated) { if (isVsb) { Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(), {forceReplace: true}); @@ -97,7 +94,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai } setOnboardingErrorMessage(null); - // Skip the merge-validation step for public-domain accounts; the screen's "someone from your domain created a workspace" wording only makes sense for private domains. + // Skip the merge-validation step for public-domain accounts. if (onboardingValues?.shouldValidate && !account?.isFromPublicDomain) { Navigation.navigate(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.getRoute()); return; From 4e6bb42c92caba07d316f5617d2d03b8566a68e5 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 13:49:09 -0700 Subject: [PATCH 09/19] Extract VSB/SMB/PURPOSE redirect into a shared callback --- .../BaseOnboardingPrivateDomain.tsx | 50 +++++++------------ .../BaseOnboardingWorkEmail.tsx | 31 ++++-------- 2 files changed, 29 insertions(+), 52 deletions(-) diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index e8c1aafb8eef..0e0496cc82ed 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -66,6 +66,21 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard Navigation.goBack(routeToNavigate); }, [route.params?.backTo, onboardingValues]); + const navigateToNextOnboardingStep = useCallback( + (backTo: string | undefined, options?: {forceReplace?: boolean}) => { + if (isVsb) { + Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(backTo), options); + return; + } + if (isSmb) { + Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(backTo), options); + return; + } + Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(backTo), options); + }, + [isVsb, isSmb], + ); + useEffect(() => { if (account?.isFromPublicDomain) { return; @@ -79,15 +94,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard useEffect(() => { // Public-domain accounts shouldn't be on this screen — the "people you may know" copy assumes a private domain. if (account?.isFromPublicDomain) { - if (isVsb) { - Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); - return; - } - if (isSmb) { - Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); - return; - } - Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); + navigateToNextOnboardingStep(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); return; } @@ -103,17 +110,9 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard // When validation succeeded but there are no joinable workspaces and the API call has completed, // navigate to the next onboarding step (same as the skip button behavior). if (getAccessiblePoliciesAction?.loading === false) { - if (isVsb) { - Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); - return; - } - if (isSmb) { - Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); - return; - } - Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); + navigateToNextOnboardingStep(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); } - }, [isValidated, joinablePoliciesLength, getAccessiblePoliciesAction?.loading, isVsb, isSmb, account?.isFromPublicDomain]); + }, [isValidated, joinablePoliciesLength, getAccessiblePoliciesAction?.loading, account?.isFromPublicDomain, navigateToNextOnboardingStep]); // Render nothing for public-domain accounts; the redirect useEffect above will navigate away. if (account?.isFromPublicDomain) { @@ -161,18 +160,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard validateError={getAccessiblePoliciesAction?.errors} hasMagicCodeBeenSent={hasMagicCodeBeenSent} shouldShowSkipButton - handleSkipButtonPress={() => { - if (isVsb) { - Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(route.params?.backTo)); - return; - } - - if (isSmb) { - Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(route.params?.backTo)); - return; - } - Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(route.params?.backTo)); - }} + handleSkipButtonPress={() => navigateToNextOnboardingStep(route.params?.backTo)} buttonStyles={[styles.flex2, styles.justifyContentEnd]} isLoading={getAccessiblePoliciesAction?.loading} /> diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 404f34a8cbf6..a77344151040 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -70,8 +70,9 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai }, []); useEffect(() => { - // A validated account has no reason to be on the onboarding "add work email" screen. - if (account?.validated) { + // PRIVATE_DOMAIN ("People you may know are already here") only makes sense for users on a private domain. + const shouldShowPrivateDomainStep = !onboardingValues?.isMergeAccountStepSkipped && !account?.isFromPublicDomain; + const navigateToNextStep = () => { if (isVsb) { Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(), {forceReplace: true}); return; @@ -80,12 +81,16 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(), {forceReplace: true}); return; } - // PRIVATE_DOMAIN ("People you may know are already here") only makes sense for users on a private domain. - if (!onboardingValues?.isMergeAccountStepSkipped && !account?.isFromPublicDomain) { + if (shouldShowPrivateDomainStep) { Navigation.navigate(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(), {forceReplace: true}); return; } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); + }; + + // A validated account has no reason to be on the onboarding "add work email" screen. + if (account?.validated) { + navigateToNextStep(); return; } @@ -99,24 +104,8 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai Navigation.navigate(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.getRoute()); return; } - // Once we verify that shouldValidate is false, we need to force replace the screen - // so that we don't navigate back on back button press - if (isVsb) { - Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(), {forceReplace: true}); - return; - } - - if (isSmb) { - Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(), {forceReplace: true}); - return; - } - - if (!onboardingValues?.isMergeAccountStepSkipped) { - Navigation.navigate(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(), {forceReplace: true}); - return; - } - Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); + navigateToNextStep(); }, [ account?.validated, account?.isFromPublicDomain, From e09837d466865cd4b0d2e9a631bb50145ededad4 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 14:04:39 -0700 Subject: [PATCH 10/19] Set validated:false in work-email-validation test setup signInWithTestUser sets validated:true by default, which now short-circuits the useEffect via the validated branch and prevents the navigation under test. The AddWorkEmail flow is only valid for unvalidated callers. --- tests/ui/WorkEmailOnboarding.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ui/WorkEmailOnboarding.tsx b/tests/ui/WorkEmailOnboarding.tsx index 7493f8e877fa..6feefd0a4068 100644 --- a/tests/ui/WorkEmailOnboarding.tsx +++ b/tests/ui/WorkEmailOnboarding.tsx @@ -325,6 +325,8 @@ describe('OnboardingWorkEmail Page', () => { await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { hasCompletedGuidedSetupFlow: false, }); + // AddWorkEmail is gated on an unvalidated caller; signInWithTestUser sets validated:true by default. + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); }); const {unmount} = renderOnboardingWorkEmailPage(SCREENS.ONBOARDING.WORK_EMAIL, {backTo: ''}); From 3cc8e4a445407dd74ec397be2d1b2fabf1abc1e2 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 14:47:29 -0700 Subject: [PATCH 11/19] Narrow public-domain guards to only the PrivateDomain screen The WorkEmailValidation screen is the merge entry-point for users adding a work email tied to an existing account; gating it on isFromPublicDomain broke the happy path where a public-domain user merges into a private-domain account. --- src/libs/actions/Welcome/OnboardingFlow.ts | 4 ++-- .../BaseOnboardingWorkEmail.tsx | 3 +-- .../BaseOnboardingWorkEmailValidation.tsx | 24 ------------------- 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 2682ee4aec2a..f2f72fc7ac6b 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -133,8 +133,8 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } - // Skip the merge-validation and private-domain screens for public-domain accounts. - if (isUserFromPublicDomain && (onboardingInitialPath.includes(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.route) || onboardingInitialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route))) { + // PRIVATE_DOMAIN ("People you may know are already here") only makes sense for users on a private domain. + if (isUserFromPublicDomain && onboardingInitialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route)) { if (isVsb) { return `/${ROUTES.ONBOARDING_ACCOUNTING.route}`; } diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index a77344151040..3355e1effec3 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -99,8 +99,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai } setOnboardingErrorMessage(null); - // Skip the merge-validation step for public-domain accounts. - if (onboardingValues?.shouldValidate && !account?.isFromPublicDomain) { + if (onboardingValues?.shouldValidate) { Navigation.navigate(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.getRoute()); return; } diff --git a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx index 138e8de23754..a0a3cc94b6da 100644 --- a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx +++ b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx @@ -42,24 +42,6 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.WORK_EMAIL_VALIDATION); useEffect(() => { - // Public-domain accounts have no work account to merge with; the screen's wording assumes a private domain workspace. - if (account?.isFromPublicDomain) { - if (isVsb) { - Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(), {forceReplace: true}); - return; - } - if (isSmb) { - Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(), {forceReplace: true}); - return; - } - if (!onboardingValues?.isMergeAccountStepSkipped) { - Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(), {forceReplace: true}); - return; - } - Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - return; - } - if (onboardingValues?.isMergeAccountStepCompleted === undefined) { return; } @@ -87,7 +69,6 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); }, [ - account?.isFromPublicDomain, onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.shouldRedirectToClassicAfterMerge, onboardingValues?.isMergeAccountStepSkipped, @@ -108,11 +89,6 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi MergeIntoAccountAndLogin(workEmail, validateCode, session?.accountID); }; - // Public-domain accounts have no work account to merge with; render nothing while the redirect above takes effect. - if (account?.isFromPublicDomain) { - return null; - } - return ( Date: Wed, 20 May 2026 14:47:34 -0700 Subject: [PATCH 12/19] Test public-domain user reaches work email validation screen --- tests/ui/WorkEmailOnboarding.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/ui/WorkEmailOnboarding.tsx b/tests/ui/WorkEmailOnboarding.tsx index 6feefd0a4068..0dd2747ae96d 100644 --- a/tests/ui/WorkEmailOnboarding.tsx +++ b/tests/ui/WorkEmailOnboarding.tsx @@ -377,6 +377,33 @@ describe('OnboardingWorkEmail Page', () => { unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should still navigate to Onboarding work email validation page when caller is on a public domain', async () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + // Public-domain users with a merge target must still reach WORK_EMAIL_VALIDATION so MergeIntoAccountAndLogIn can run. + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + }); + + const {unmount} = renderOnboardingWorkEmailPage(SCREENS.ONBOARDING.WORK_EMAIL, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + AddWorkEmailShouldValidate(); + + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_WORK_EMAIL_VALIDATION.getRoute()); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); }); describe('OnboardingWorkEmailValidation Page', () => { From ecc1973a46b94daf34f190dbe10df29f8fec75f2 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 14:50:09 -0700 Subject: [PATCH 13/19] Test PrivateDomain redirects public-domain users by qualifier --- tests/ui/WorkEmailOnboarding.tsx | 114 +++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/ui/WorkEmailOnboarding.tsx b/tests/ui/WorkEmailOnboarding.tsx index 0dd2747ae96d..79b65395153d 100644 --- a/tests/ui/WorkEmailOnboarding.tsx +++ b/tests/ui/WorkEmailOnboarding.tsx @@ -16,6 +16,7 @@ import HttpUtils from '@libs/HttpUtils'; import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@navigation/types'; +import OnboardingPrivateDomain from '@pages/OnboardingPrivateDomain'; import OnboardingWorkEmail from '@pages/OnboardingWorkEmail'; import OnboardingWorkEmailValidation from '@pages/OnboardingWorkEmailValidation'; import CONST from '@src/CONST'; @@ -92,6 +93,28 @@ const renderOnboardingWorkEmailValidationPage = ( ); }; +const renderOnboardingPrivateDomainPage = ( + initialRouteName: typeof SCREENS.ONBOARDING.PRIVATE_DOMAIN, + initialParams: OnboardingModalNavigatorParamList[typeof SCREENS.ONBOARDING.PRIVATE_DOMAIN], +) => { + return render( + + + + + + + + + , + {wrapper: HTMLProviderWrapper}, + ); +}; + const navigate = jest.spyOn(Navigation, 'navigate'); function MergeIntoAccountAndLoginBlockMerge() { @@ -660,3 +683,94 @@ describe('OnboardingWorkEmailValidation Page', () => { await waitForBatchedUpdatesWithAct(); }); }); + +describe('OnboardingPrivateDomain Page', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue({ + isSmallScreenWidth: false, + shouldUseNarrowLayout: false, + } as ResponsiveLayoutResult); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + + jest.clearAllMocks(); + }); + + it('should redirect a public-domain user away to the purpose step', async () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, {isFromPublicDomain: true}); + }); + + const {unmount} = renderOnboardingPrivateDomainPage(SCREENS.ONBOARDING.PRIVATE_DOMAIN, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PURPOSE.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should redirect a public-domain SMB user away to the employees step', async () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, {isFromPublicDomain: true}); + }); + + const {unmount} = renderOnboardingPrivateDomainPage(SCREENS.ONBOARDING.PRIVATE_DOMAIN, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_EMPLOYEES.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should redirect a public-domain VSB user away to the accounting step', async () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, {isFromPublicDomain: true}); + }); + + const {unmount} = renderOnboardingPrivateDomainPage(SCREENS.ONBOARDING.PRIVATE_DOMAIN, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_ACCOUNTING.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute()), {forceReplace: true}); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); +}); From 305d737e6f1a57e9becbf72d9c29f5a54d9d8a47 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 14:50:59 -0700 Subject: [PATCH 14/19] Test getOnboardingInitialPath URL guard narrowing --- tests/unit/OnboardingFlowTest.ts | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index c09105d7ecf6..6e1be19a9516 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -132,5 +132,49 @@ describe('OnboardingFlow', () => { const path = getOnboardingInitialPath(params); expect(path).toBe('/onboarding/work-email'); }); + + it('should skip a private-domain URL for a public-domain validated user', () => { + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + hasAccessiblePolicies: true, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: false, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: true, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/onboarding/private-domain', + onboardingValues: undefined, + isAccountValidated: true, + }; + const path = getOnboardingInitialPath(params); + expect(path).toBe('/onboarding/purpose'); + }); + + it('should not redirect away from a work-email-validation URL for a public-domain user', () => { + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + hasAccessiblePolicies: true, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: true, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: true, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/onboarding/work-email/validation', + onboardingValues: undefined, + isAccountValidated: true, + }; + const path = getOnboardingInitialPath(params); + expect(path).not.toBe('/onboarding/purpose'); + }); }); }); From c3ee09096a335ef0bd49241f4fa90816d006ecc2 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 15:59:25 -0700 Subject: [PATCH 15/19] Gate PRIVATE_DOMAIN skip on validated, not just public-domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isFromPublicDomain doesn't flip after AddWorkEmail swaps the primary, so the previous gate skipped PRIVATE_DOMAIN for the staging happy path (unvalidated gmail user adds a non-existing work email). Only validated public-domain users — the original bug case — should bypass the screen. --- .../BaseOnboardingPrivateDomain.tsx | 16 +++++++++------- .../BaseOnboardingWorkEmail.tsx | 11 +++++------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index 0e0496cc82ed..19fd5c75dc21 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -81,19 +81,22 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard [isVsb, isSmb], ); + // Only validated public-domain users are blocked from this screen — for them the "people on YOUR domain" copy would reference gmail.com. + // An unvalidated public-domain user who just submitted a work email may land here before isFromPublicDomain updates; that's the staging happy path. + const shouldBlockPublicDomain = !!account?.validated && !!account?.isFromPublicDomain; + useEffect(() => { - if (account?.isFromPublicDomain) { + if (shouldBlockPublicDomain) { return; } if (isValidated) { return; } sendValidateCode(); - }, [sendValidateCode, isValidated, account?.isFromPublicDomain]); + }, [sendValidateCode, isValidated, shouldBlockPublicDomain]); useEffect(() => { - // Public-domain accounts shouldn't be on this screen — the "people you may know" copy assumes a private domain. - if (account?.isFromPublicDomain) { + if (shouldBlockPublicDomain) { navigateToNextOnboardingStep(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); return; } @@ -112,10 +115,9 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard if (getAccessiblePoliciesAction?.loading === false) { navigateToNextOnboardingStep(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(), {forceReplace: true}); } - }, [isValidated, joinablePoliciesLength, getAccessiblePoliciesAction?.loading, account?.isFromPublicDomain, navigateToNextOnboardingStep]); + }, [isValidated, joinablePoliciesLength, getAccessiblePoliciesAction?.loading, shouldBlockPublicDomain, navigateToNextOnboardingStep]); - // Render nothing for public-domain accounts; the redirect useEffect above will navigate away. - if (account?.isFromPublicDomain) { + if (shouldBlockPublicDomain) { return null; } diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 3355e1effec3..9aa70bf3b50d 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -70,9 +70,7 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai }, []); useEffect(() => { - // PRIVATE_DOMAIN ("People you may know are already here") only makes sense for users on a private domain. - const shouldShowPrivateDomainStep = !onboardingValues?.isMergeAccountStepSkipped && !account?.isFromPublicDomain; - const navigateToNextStep = () => { + const navigateToNextStep = (shouldSkipPrivateDomain = false) => { if (isVsb) { Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(), {forceReplace: true}); return; @@ -81,16 +79,17 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai Navigation.navigate(ROUTES.ONBOARDING_EMPLOYEES.getRoute(), {forceReplace: true}); return; } - if (shouldShowPrivateDomainStep) { + if (!shouldSkipPrivateDomain && !onboardingValues?.isMergeAccountStepSkipped) { Navigation.navigate(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(), {forceReplace: true}); return; } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); }; - // A validated account has no reason to be on the onboarding "add work email" screen. + // A validated account has no reason to be on the onboarding "add work email" screen. For a public-domain primary the + // PRIVATE_DOMAIN screen would reference gmail.com (etc.) so skip it. if (account?.validated) { - navigateToNextStep(); + navigateToNextStep(account?.isFromPublicDomain); return; } From 13268e4d5735cd3bf1e6834c9772320e82f0a568 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 20 May 2026 15:59:32 -0700 Subject: [PATCH 16/19] Test staging behavior preserved for unvalidated public-domain users --- tests/ui/WorkEmailOnboarding.tsx | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/ui/WorkEmailOnboarding.tsx b/tests/ui/WorkEmailOnboarding.tsx index 79b65395153d..e05e28a47fc0 100644 --- a/tests/ui/WorkEmailOnboarding.tsx +++ b/tests/ui/WorkEmailOnboarding.tsx @@ -427,6 +427,56 @@ describe('OnboardingWorkEmail Page', () => { unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should navigate to Onboarding private domain when an unvalidated public-domain user adds a work email with no existing account', async () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + // Match staging: an unvalidated public-domain user submitting a non-merge work email still sees PRIVATE_DOMAIN after the primary swap. + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false, isFromPublicDomain: true}); + }); + + const {unmount} = renderOnboardingWorkEmailPage(SCREENS.ONBOARDING.WORK_EMAIL, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + AddWorkEmailShouldValidateFailure(); + + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PRIVATE_DOMAIN.getRoute(), {forceReplace: true}); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should skip Onboarding private domain for a validated public-domain user', async () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + // Validated public-domain user (original bug case): PRIVATE_DOMAIN would say "people on gmail.com" — skip to PURPOSE. + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: true, isFromPublicDomain: true}); + }); + + const {unmount} = renderOnboardingWorkEmailPage(SCREENS.ONBOARDING.WORK_EMAIL, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); }); describe('OnboardingWorkEmailValidation Page', () => { From 1b2393b917b1a69de41ed5c71a34c28397ea2e31 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Thu, 21 May 2026 10:55:31 -0700 Subject: [PATCH 17/19] Run prettier on BaseOnboardingWorkEmailValidation --- .../BaseOnboardingWorkEmailValidation.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx index a0a3cc94b6da..ebfbc3929f19 100644 --- a/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx +++ b/src/pages/OnboardingWorkEmailValidation/BaseOnboardingWorkEmailValidation.tsx @@ -68,14 +68,7 @@ function BaseOnboardingWorkEmailValidation({shouldUseNativeStyles}: BaseOnboardi } Navigation.navigate(ROUTES.ONBOARDING_PURPOSE.getRoute(), {forceReplace: true}); - }, [ - onboardingValues?.isMergeAccountStepCompleted, - onboardingValues?.shouldRedirectToClassicAfterMerge, - onboardingValues?.isMergeAccountStepSkipped, - isVsb, - isSmb, - isFocused, - ]); + }, [onboardingValues?.isMergeAccountStepCompleted, onboardingValues?.shouldRedirectToClassicAfterMerge, onboardingValues?.isMergeAccountStepSkipped, isVsb, isSmb, isFocused]); const sendValidateCode = () => { if (!credentials?.login) { From b45ad2a50620085e96d3d2c2afafd9ea287e1e95 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Fri, 22 May 2026 09:18:58 -0700 Subject: [PATCH 18/19] Gate private-domain URL guard on isAccountValidated --- src/libs/actions/Welcome/OnboardingFlow.ts | 5 +++-- tests/unit/OnboardingFlowTest.ts | 24 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index f2f72fc7ac6b..bb4d383021c5 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -133,8 +133,9 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; } - // PRIVATE_DOMAIN ("People you may know are already here") only makes sense for users on a private domain. - if (isUserFromPublicDomain && onboardingInitialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route)) { + // PRIVATE_DOMAIN ("People you may know are already here") only makes sense for users on a private domain. Only redirect + // validated accounts; unvalidated users mid-AddWorkEmail can legitimately land here while isFromPublicDomain is stale. + if (isUserFromPublicDomain && isAccountValidated && onboardingInitialPath.includes(ROUTES.ONBOARDING_PRIVATE_DOMAIN.route)) { if (isVsb) { return `/${ROUTES.ONBOARDING_ACCOUNTING.route}`; } diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index 6e1be19a9516..18be0fc5a84b 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -155,6 +155,30 @@ describe('OnboardingFlow', () => { expect(path).toBe('/onboarding/purpose'); }); + it('should not redirect away from a private-domain URL for a public-domain unvalidated user', () => { + // Mirrors the BaseOnboardingPrivateDomain screen-level guard: an unvalidated public-domain user who just + // submitted a work email may land here while isFromPublicDomain is stale. They must keep the private-domain step. + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + hasAccessiblePolicies: true, + onboardingValuesParam: { + hasCompletedGuidedSetupFlow: false, + shouldRedirectToClassicAfterMerge: false, + shouldValidate: false, + isMergingAccountBlocked: false, + isMergeAccountStepCompleted: true, + signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.INDIVIDUAL, + }, + currentOnboardingPurposeSelected: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, + currentOnboardingCompanySize: CONST.ONBOARDING_COMPANY_SIZE.SMALL, + onboardingInitialPath: '/onboarding/private-domain', + onboardingValues: undefined, + isAccountValidated: false, + }; + const path = getOnboardingInitialPath(params); + expect(path).not.toBe('/onboarding/purpose'); + }); + it('should not redirect away from a work-email-validation URL for a public-domain user', () => { const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: true, From 3c6c84e51864f212e508ab0d0120cd4dc6e38ddd Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Wed, 27 May 2026 09:16:17 -0700 Subject: [PATCH 19/19] Narrow useOnyx ACCOUNT subscription to fields actually read --- .../BaseOnboardingPrivateDomain.tsx | 7 ++++++- src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index 19fd5c75dc21..003b25d0dad5 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -29,7 +29,12 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard const {translate} = useLocalize(); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [session] = useOnyx(ONYXKEYS.SESSION); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, { + selector: (acc) => ({ + validated: acc?.validated, + isFromPublicDomain: acc?.isFromPublicDomain, + }), + }); const {isBetaEnabled} = usePermissions(); const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); diff --git a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx index 9aa70bf3b50d..455ec5872e39 100644 --- a/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx +++ b/src/pages/OnboardingWorkEmail/BaseOnboardingWorkEmail.tsx @@ -50,7 +50,12 @@ function BaseOnboardingWorkEmail({shouldUseNativeStyles}: BaseOnboardingWorkEmai const illustrations = useMemoizedLazyIllustrations(['EnvelopeReceipt', 'Gears', 'Profile']); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [session] = useOnyx(ONYXKEYS.SESSION); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, { + selector: (acc) => ({ + validated: acc?.validated, + isFromPublicDomain: acc?.isFromPublicDomain, + }), + }); const [formValue] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM); const workEmail = formValue?.[INPUT_IDS.ONBOARDING_WORK_EMAIL]; const [onboardingErrorMessage] = useOnyx(ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY);