Add a one-click deeplink that signs the user in and creates a Submit workspace - #98887
Add a one-click deeplink that signs the user in and creates a Submit workspace#98887abzokhattab wants to merge 13 commits into
Conversation
Marketing emails need a plain URL that drops the recipient into a ready-to-use Submit workspace. Today that outcome is only reachable by picking the intent in the onboarding UI, so it can't be expressed as a link. Support an `intent` param on the onboarding route and act on it once the authenticated screens mount, reusing the existing auto-create hook so the existing-workspace and restricted-policy-creation guards keep repeat clicks idempotent.
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
Creating the Submit workspace out-of-band left users stranded: the onboarding navigator mounts straight from the deeplink URL and picks its entry step from the account's domain, so it settled on the work email step before the handler ran, and startOnboardingFlow cannot re-route a navigator that is already in the root state. The workspace ended up orphaned behind a modal the user could not get past. Let the deeplink pick the onboarding entry step instead, so the existing EMPLOYER path creates the workspace and nothing competes for navigation. Users who already finished onboarding never enter that flow, so for them the workspace is still created directly.
…rding The link is going out to existing users, so dropping them into guided setup asks questions they have already answered. Skip the onboarding UI entirely and create the workspace outright, leaving them where the "Submit to my employer" flow normally lands: Spend with the #admins room in the side panel. Onboarding is suppressed for the session in both places that can start it, the router and the navigation guard, so nothing pulls the user into the flow while the workspace is being created.
The link is scoped to existing users with an intent already set, so anyone who still has guided setup ahead of them is left to the normal onboarding flow, which already offers the Submit outcome. That removes the need to suppress onboarding at all, so the router, the navigation guard and the shared workspace-creation hook go back to their original behaviour. Also corrects a stale comment describing the post-creation destination as Categories; the shared helper navigates to Spend with #admins in the side panel.
Use US spelling for "recognize" and drop the percent-encoded exitTo examples, which cspell reads as the unknown words "Fintent" and "Dsubmit".
|
@MelvinBot @codex review the PR, find bugs or regressions |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8b21e7156b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Review:
|
Custom-scheme links like new-expensify://onboarding?intent=submit put the route where a host would sit, so stripping the origin discarded the route and the intent was lost. Links opened while the app was already running were missed too, since the latched URL is stale by then and only secure links were recorded.
|
Thanks both — three findings between you, two of which were real native bugs. Fixed in 3215c16. Fixed: custom-scheme URLs lost the route (Codex P1 #1 / Melvin #1)Confirmed and reachable, not just latent: The origin is now stripped differently per scheme — host dropped for Fixed: warm-app links were never seen (Codex P1 #2)Also correct. Extended that same condition to onboarding-intent links rather than adding a parallel mechanism — it is the precedent already in place for exactly this problem. Not changing: the migration NVP write (Melvin #2)Correct that Leaving it. Making it conditional means duplicating Unit tests, lint, typecheck and the React Compiler check are green. Native still needs a manual pass, which is where all three of these live. |
getRouteFromLink already strips whichever linking-config prefix matched, which covers the app schemes and the dev server's port, and it is what DeepLinkHandler uses to read report deeplinks.
|
@MelvinBot @codex review the PR, find bugs or regressions |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7fad41a199
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
cspell reads the percent-encoded literals as the unknown words "Fintent" and "Dsubmit", and encoding explicitly shows what the link actually carries.
|
🤖 I reviewed the diff and traced the deeplink → workspace-creation path end to end. One real bug (native only) plus two minor notes. The core web flow looks correct. 🐞 Warm-opened native deeplinks lose the intent — the warm-path recording writes to the wrong "initial URL"The last commit ("Fix native deeplink paths…") added recording of the intent URL when a link is opened while the app is already running:
if (hasSecureLinkKey(state.url) || getOnboardingIntentFromUrl(state.url)) {
onInitialUrl(state.url as Route);
}But The handler that actually reads the intent reads a different value — the
const {initialURL} = useInitialURLState();
const [urlAtMount] = useState(getCurrentUrl);
return getOnboardingIntentFromUrl(urlAtMount) ?? getOnboardingIntentFromUrl(initialURL);That context
The Web is unaffected ( Fix directionFor the warm path to reach the hook, the intent URL needs to land in the value the hook reads. Either have Minor notes
Looks correct ✅
I did not spin up a device test since the only open concern is the native/HybridApp warm path, which the web build can't exercise. |
The initial URL outlives a sign-out, so remounting on a second account read the same intent again and created a Submit workspace for that account.
Good catch, this one is real. Fixed in f1887b4.
The guard is now module scope, so it survives the remount and the intent applies at most once per app process. I went with that over clearing Worth noting the same replay characteristic already exists for the secure-link signal in |
Keeps the diff to files this feature actually changes.
|
@MelvinBot resolve open threads if u tackled them and resolve the conflicts |
|
✅ Resolved the 3 open codex threads — each was verified as addressed in the current code (
|
…mit-onboarding-deeplink # Conflicts: # src/libs/Navigation/AppNavigator/AuthScreens.tsx
This is the shape the emailed link actually arrives in on mobile, where OldDot hands over a path rather than a full URL.
|
The PR is ready @hungvu193 @iwiznia @carlosmiceli |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a6e5615680
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (hasAppliedIntent || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { | ||
| return; |
There was a problem hiding this comment.
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 👍 / 👎.
| // The deeplink delivers the same outcome as the Submit plan welcome modal, so keep that modal from opening too. | ||
| setSubmitMigrationModalShown(); |
There was a problem hiding this comment.
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 👍 / 👎.
|
@MelvinBot review |
Code reviewSolid, well-scoped PR — the parsing logic is thoroughly unit-tested and the comments explain the "why" throughout. Below are a few things worth a second look before merge. Nothing here is a hard blocker; the guard edge case (#1) is the one most worth deciding on consciously. Potential issues1. 2. 3. No test around the wiring/guard. Minor
Strengths
|
Explanation of Change
There's no way to link directly to "start with Submit" from an email today: creating a Submit workspace requires a signed-in user to pick the intent in the onboarding UI (or click "Get started" in Old Dot pricing), neither of which is reproducible with a plain URL.
This PR adds an
intentparam to the onboarding route so a single link produces "click link → sign in → Submit workspace created".ROUTES.ONBOARDING_ROOT.getRoute()now takes an optional intent and buildsonboarding?intent=submit, with the accepted values in the newCONST.ONBOARDING_INTENTS. Those are short, stable aliases rather than the internal onboarding choice strings (newDotEmployer), since they get embedded in emails we can't redeploy.The intent survives the logged-out → logged-in transition by riding along in the deeplink itself, the same mechanism
exitTo=workspace/newalready relies on.getOnboardingIntentFromUrlreads it either from a direct link (/onboarding?intent=submit) or from theexitToof a transition or magic link, encoded or not.Who the link acts on. Per the Slack discussion on the issue, this is going out to existing users, so it only acts on recipients who have completed guided setup. For them
ApplySubmitOnboardingIntentcreates the workspace throughuseAutoCreateSubmitWorkspace— the same hook the onboarding purpose page and the Submit plan welcome modal already use — and skipsCompleteGuidedSetup, so their recorded onboarding choice is left alone. Anyone who still has onboarding ahead of them is left to it untouched, since that flow already offers the Submit outcome. That fallback is why nothing here suppresses or reroutes onboarding, and idempotency comes free with the hook, which already skips creation when there's an editable group workspace, an onboarding policy ID, or restricted policy creation.Where they land is not encoded in the link. The hook finishes by calling
navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue— today Spend > Expenses with the side panel open on #admins — so changing that destination later is a one-line change and every link already sitting in an inbox keeps working.On the route name:
onboarding?intent=submitnames a flow most recipients never see, but the param genuinely is an onboarding intent (it resolves to the sameCONST.ONBOARDING_CHOICES.EMPLOYERthe purpose step would set), and for recipients who haven't onboarded it's literally where they land. Happy to rename if you'd prefer something else.Open questions for the reviewer
submit2026as a beta to respect or graduate. In the current codebasesubmit2026isCONST.POLICY.TYPE.SUBMIT— a policy type, not an entry inCONST.BETAS— and Submit workspace creation is gated by business logic (isRestrictedPolicyCreation, existing editable workspaces), not a beta check. So this PR adds no beta gate. Please confirm that matches the intended rollout state.getCurrentUrl()returns''there), anduseOnboardingDeeplinkIntentreadsinitialURLto cover it, but it needs testing on a HybridApp build.Fixed Issues
$ #98644
PROPOSAL:
Tests
On iOS, force-close the app before opening a link so it arrives as a cold launch; the intent is applied at most once per app process.
https://dev.new.expensify.com:8082/v/<accountID>/<validateCode>?exitTo=onboarding%3Fintent%3Dsubmitxcrun simctl openurl booted "new-expensify://v/<accountID>/<validateCode>?exitTo=onboarding%3Fintent%3Dsubmit"in terminalhttps://dev.new.expensify.com:8082/onboarding?intent=submitxcrun simctl openurl booted "new-expensify://onboarding?intent=submit"in terminalmain— no step is skipped and no workspace is created behind it.Offline tests
Creating the workspace is an optimistic Onyx write, so offline behaviour matches the existing onboarding purpose flow: the workspace appears immediately with a pending state and the request is queued.
QA Steps
Same as the Tests section above.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
Screen.Recording.2026-08-23.at.16.33.07.mov
Screen.Recording.2026-08-23.at.16.34.35.mov
Screen.Recording.2026-08-23.at.16.35.42.mov
iOS: mWeb Safari
Screen.Recording.2026-08-23.at.16.36.36.mov
Screen.Recording.2026-08-23.at.16.38.04.mov
MacOS: Chrome / Safari
1.mov
2.mov
3.mov
4.mov