Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ const createExpenseOnboardingChoices = {
SUBMIT: backendOnboardingChoices.SUBMIT,
} as const;

// Values accepted by the `intent` param on the onboarding deeplink. Kept separate from the internal onboarding
// choice strings because they are embedded in emails we can't redeploy.
const onboardingIntents = {
SUBMIT: 'submit',
} as const;

const signupQualifiers = {
INDIVIDUAL: 'individual',
VSB: 'vsb',
Expand Down Expand Up @@ -6689,6 +6695,7 @@ const CONST = {
EXPENSIFY_ICON_NAME: 'Expensify',

ONBOARDING_CHOICES: {...onboardingChoices},
ONBOARDING_INTENTS: {...onboardingIntents},
SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices},
CREATE_EXPENSE_ONBOARDING_CHOICES: {...createExpenseOnboardingChoices},
ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers},
Expand Down Expand Up @@ -9614,6 +9621,9 @@ type IOUActionParams = ValueOf<typeof CONST.IOU.ACTION_PARAMS>;
type SubscriptionType = ValueOf<typeof CONST.SUBSCRIPTION.TYPE>;
type CancellationType = ValueOf<typeof CONST.CANCELLATION_TYPE>;

/** Valid values for the `intent` param on the onboarding deeplink */
type OnboardingIntent = ValueOf<typeof CONST.ONBOARDING_INTENTS>;

/** Valid `page` values for the Enable Payments flow */
type EnablePaymentsPageType = ValueOf<typeof CONST.ENABLE_PAYMENTS.PAGE_NAMES>;

Expand All @@ -9635,6 +9645,7 @@ export type {
CancellationType,
OnboardingInvite,
OnboardingAccounting,
OnboardingIntent,
IOUActionParams,
EnablePaymentsPageType,
EnablePaymentsSubPageType,
Expand Down
9 changes: 5 additions & 4 deletions src/DeepLinkHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import useOnyx from './hooks/useOnyx';
import {openReportFromDeepLink} from './libs/actions/Link';
import * as Report from './libs/actions/Report';
import {hasAuthToken, isAnonymousUser} from './libs/actions/Session';
import getOnboardingIntentFromUrl from './libs/getOnboardingIntentFromUrl';
import Log from './libs/Log';
import {getReportIDFromLink} from './libs/ReportUtils';
import {endSpan} from './libs/telemetry/activeSpans';
Expand Down Expand Up @@ -142,10 +143,10 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {
Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url});
}
const isCurrentlyAuthenticated = hasAuthToken();
// A Submit-via-PDF secure access link can arrive while the app is already running (warm), where
// getInitialURL() is empty. Record it so onboarding suppression has a session-sticky signal, the same
// way the cold path does via onInitialUrl above. Scoped to secure links so other deep links are unaffected.
if (hasSecureLinkKey(state.url)) {
// A Submit-via-PDF secure access link, or an onboarding deeplink carrying an intent, can arrive while the
// app is already running (warm), where getInitialURL() is empty. Record it so the handlers reading the
// initial URL have a session-sticky signal, the same way the cold path does via onInitialUrl above.
if (hasSecureLinkKey(state.url) || getOnboardingIntentFromUrl(state.url)) {
onInitialUrl(state.url as Route);
}
openReportFromDeepLink(
Expand Down
4 changes: 2 additions & 2 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {UpperCaseCharacters} from 'type-fest/source/internal';

import type {SearchFilterKey, SearchQueryString, UserFriendlyKey} from './components/Search/types';
import type CONST from './CONST';
import type {EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType} from './CONST';
import type {EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType, OnboardingIntent} from './CONST';
import type {ReplacementReason} from './libs/actions/Card';
import type {RootNavigatorParamList} from './libs/Navigation/types';
import type {Screen} from './SCREENS';
Expand Down Expand Up @@ -3964,7 +3964,7 @@ const ROUTES = {
ONBOARDING_ROOT: {
route: 'onboarding',

getRoute: () => 'onboarding' as const,
getRoute: (intent?: OnboardingIntent) => (intent ? (`onboarding?intent=${intent}` as const) : ('onboarding' as const)),
},
ONBOARDING_PERSONAL_DETAILS: {
route: 'onboarding/personal-details',
Expand Down
23 changes: 23 additions & 0 deletions src/hooks/useOnboardingDeeplinkIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {useInitialURLState} from '@components/InitialURLContextProvider';

import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl';
import getCurrentUrl from '@libs/Navigation/currentUrl';

import type {OnboardingIntent} from '@src/CONST';

import {useState} from 'react';

/**
* Resolves the onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit`.
*
* The URL is latched at mount because the app rewrites it as soon as it navigates. The initial URL covers native,
* where the browser URL is empty, and links opened while the app is already running.
*/
function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined {
const {initialURL} = useInitialURLState();
const [urlAtMount] = useState(getCurrentUrl);

return getOnboardingIntentFromUrl(urlAtMount) ?? getOnboardingIntentFromUrl(initialURL);
Comment thread
MelvinBot marked this conversation as resolved.
Comment thread
MelvinBot marked this conversation as resolved.
}

export default useOnboardingDeeplinkIntent;
2 changes: 2 additions & 0 deletions src/libs/Navigation/AppNavigator/AuthScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import OnboardingModalNavigator from './Navigators/OnboardingModalNavigator';
import SubmitPlanWelcomeModalNavigator from './Navigators/SubmitPlanWelcomeModalNavigator';
import TestToolsModalNavigator from './Navigators/TestToolsModalNavigator';
import {loadRightModalNavigator, loadSearchRouterPage} from './searchRouterLazyLoaders';
import SubmitIntentDeeplinkHandler from './SubmitIntentDeeplinkHandler';
import TestDriveDemoNavigator from './TestDriveDemoNavigator';
import ThreeDSAuthHandler from './ThreeDSAuthHandler';
import useModalCardStyleInterpolator from './useModalCardStyleInterpolator';
Expand Down Expand Up @@ -164,6 +165,7 @@ function AuthScreens() {
<>
<AuthScreensInitHandler />
<SearchRouterWarmup />
<SubmitIntentDeeplinkHandler />
<ThreeDSAuthHandler />
<UserStatusHandler />
<SupportalPermissionDeniedModal />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useOnyx from '@hooks/useOnyx';

import {setSubmitMigrationModalShown} from '@userActions/User';

import ONYXKEYS from '@src/ONYXKEYS';

import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
import {isSupportalSessionSelector} from '@selectors/Session';
import {useEffect} from 'react';

// Module scope rather than a ref so it survives this component remounting. The deeplink is read from the initial URL,
// which the provider above the navigator keeps for the life of the process, so signing out and into another account
// remounts this component with the same intent still readable and would create a workspace for that second account.
let hasAppliedIntent = false;

/**
* Creates the Submit workspace requested by an `intent=submit` onboarding deeplink.
*
* The link only goes to existing users, so it acts solely on recipients who have finished guided setup. Anyone who
* still has onboarding ahead of them is left to it, since that flow already offers the Submit outcome.
*/
function ApplySubmitOnboardingIntent() {
const {firstName, lastName} = useCurrentUserPersonalDetails();
const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace();

// Waiting on HAS_LOADED_APP keeps the eligibility checks inside useAutoCreateSubmitWorkspace from running
// against a half-populated store, where they would miss an existing workspace and create a duplicate.
const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP);
const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector});
const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector});

useEffect(() => {
if (hasAppliedIntent || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) {
return;
Comment on lines +35 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consume ignored intents during supportal sessions

When a warm onboarding link is opened during a supportal session, this condition returns without setting hasAppliedIntent, while DeepLinkHandler has already stored the URL in the session-sticky initial-URL context. When supportal access ends and isSupportalSession changes to false, the same effect runs again and creates a Submit workspace for the restored account. The new module flag does not cover this path because the supportal branch bypasses it; consume the intent when rejecting it rather than deferring it until the session changes.

Useful? React with 👍 / 👎.

}
hasAppliedIntent = true;

if (!isOnboardingCompleted) {
return;
}

// The deeplink delivers the same outcome as the Submit plan welcome modal, so keep that modal from opening too.
setSubmitMigrationModalShown();
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress the welcome guard before loaded-state evaluation

For recipients who also satisfy SubmitPlanWelcomeModalGuard's employer-intent eligibility, the guard's HAS_LOADED_APP subscription queues its proactive navigation in a microtask, whereas this suppression only runs later in a React passive effect. The guard can therefore mount the Submit welcome modal before this call marks it shown; the subsequent workspace navigation dismisses it, but users see an unintended modal flash during the one-click flow. The deeplink needs to suppress or bypass the guard before its app-loaded evaluation runs.

Useful? React with 👍 / 👎.


// `false` skips CompleteGuidedSetup, which is already done. The hook navigates to the user's existing Submit
// workspace rather than creating a second one, which is what makes repeat clicks idempotent.
autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false);
}, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]);

return null;
}

export default ApplySubmitOnboardingIntent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import useOnboardingDeeplinkIntent from '@hooks/useOnboardingDeeplinkIntent';

import CONST from '@src/CONST';

import React from 'react';

import ApplySubmitOnboardingIntent from './ApplySubmitOnboardingIntent';

/**
* Recognizes the `intent=submit` onboarding deeplink and hands off to the component that acts on it.
*/
function SubmitIntentDeeplinkHandler() {
const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent();

if (onboardingDeeplinkIntent !== CONST.ONBOARDING_INTENTS.SUBMIT) {
return null;
}

return <ApplySubmitOnboardingIntent />;
}

export default SubmitIntentDeeplinkHandler;
38 changes: 38 additions & 0 deletions src/libs/getOnboardingIntentFromUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Reads the `intent` param of the onboarding deeplink (e.g. `onboarding?intent=submit`).
*
* It arrives either directly, or nested in the `exitTo` of a transition or magic link. Nesting it in `exitTo` is what
* carries the intent across the logged-out to logged-in transition, since the deeplink outlives the sign-in itself.
*/
import type {OnboardingIntent} from '@src/CONST';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';

import {getRouteFromLink} from './ReportUtils';
import {getSearchParamFromPath} from './Url';

const ONBOARDING_INTENT_VALUES = new Set<string>(Object.values(CONST.ONBOARDING_INTENTS));

function isOnboardingIntent(value: string | null): value is OnboardingIntent {
return !!value && ONBOARDING_INTENT_VALUES.has(value);
}

function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingIntent | undefined {
if (!url) {
return undefined;
}

// getRouteFromLink strips whichever linking-config prefix matched, so web URLs, the desktop `app://-/` origin and
// the native scheme all reduce to the same route. It leaves the leading slash on in-app paths.
const pathWithQuery = getRouteFromLink(url).replace(/^\/+/, '');
const onboardingPathWithQuery = pathWithQuery.startsWith(ROUTES.ONBOARDING_ROOT.route) ? pathWithQuery : getSearchParamFromPath(pathWithQuery, 'exitTo');

if (!onboardingPathWithQuery?.startsWith(ROUTES.ONBOARDING_ROOT.route)) {
return undefined;
}

const intent = getSearchParamFromPath(onboardingPathWithQuery, 'intent');
return isOnboardingIntent(intent) ? intent : undefined;
}

export default getOnboardingIntentFromUrl;
66 changes: 66 additions & 0 deletions tests/unit/getOnboardingIntentFromUrlTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl';

import CONST from '@src/CONST';

describe('getOnboardingIntentFromUrl', () => {
const encodedOnboardingRoute = encodeURIComponent('onboarding?intent=submit');

it('reads the intent from a direct onboarding link', () => {
expect(getOnboardingIntentFromUrl('https://new.expensify.com/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from a custom scheme link, where the route sits where a host would', () => {
expect(getOnboardingIntentFromUrl('new-expensify://onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from a custom scheme link with a placeholder host', () => {
expect(getOnboardingIntentFromUrl('app://-/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from the exitTo of a custom scheme magic link', () => {
expect(getOnboardingIntentFromUrl(`new-expensify://v/12345/678910?exitTo=${encodedOnboardingRoute}`)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from a link served on a port, as the dev server does', () => {
expect(getOnboardingIntentFromUrl('https://dev.new.expensify.com:8082/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from an in-app path without an origin', () => {
expect(getOnboardingIntentFromUrl('/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

// HybridApp hands NewDot the deeplink as a bare path rather than a full URL, which is the shape the emailed
// magic link arrives in on mobile.
it('reads the intent from the exitTo of a magic link passed as a bare path', () => {
expect(getOnboardingIntentFromUrl(`v/12345/678910?exitTo=${encodedOnboardingRoute}`)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from the exitTo of an OldDot transition link', () => {
const url = `https://new.expensify.com/transition?email=${encodeURIComponent('me@example.com')}&shortLivedAuthToken=abc123&exitTo=${encodedOnboardingRoute}`;

expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from the exitTo of a magic link', () => {
const url = `https://new.expensify.com/v/12345/678910?exitTo=${encodedOnboardingRoute}`;

expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it('reads the intent from an unencoded exitTo, which OldDot mobile does not encode', () => {
const url = 'https://new.expensify.com/transition?shortLivedAuthToken=abc123&exitTo=onboarding?intent=submit';

expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT);
});

it.each([
['no url', undefined],
['an empty url', ''],
['an onboarding link without an intent', 'https://new.expensify.com/onboarding'],
['an unknown intent value', 'https://new.expensify.com/onboarding?intent=notARealIntent'],
['an intent on a non-onboarding route', 'https://new.expensify.com/settings/profile?intent=submit'],
['an intent on a non-onboarding exitTo', `https://new.expensify.com/transition?exitTo=${encodeURIComponent('workspace/new?intent=submit')}`],
])('returns undefined for %s', (_description, url) => {
expect(getOnboardingIntentFromUrl(url)).toBeUndefined();
});
});
Loading