Skip to content

Commit ef84315

Browse files
authored
feat(card): handle unauthenticated case on money account linkage (#30227)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** This branch improves the **Money Account → Card linkage** entry flow when the user is **not** authenticated with the Card backend. **Previous behaviour:** Link-card CTAs from Money Account effectively sent unauthenticated users to Card home without completing auth or resuming linkage. **New behaviour:** 1. **Authenticated** — Unchanged: if requirements are met and the account is not already delegated, open the linkage bottom sheet (still requires `moneyAccountCardToken` when authenticated). 2. **Not authenticated, cardholder** — Set a Redux `pendingMoneyAccountCardLink` flag, navigate into Card (`CARD.ROOT` → `CARD.HOME` → `CARD.AUTHENTICATION`) with `showAuthPrompt: true` and a `postAuthRedirect` payload (origin for future multi-entrypoint use). After successful login, **`NavigationService.navigation.goBack()`** pops the pushed `Card.ROOT` so the user returns to the tab they came from (e.g. Money) **without** leaving `CardAuthentication` on the stack or cross-navigating with a flicker. A `useEffect` in [`useMoneyAccountCardLinkage`](app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx) resumes: waits for `cardHomeDataStatus` to reach `success` or `error` before clearing the pending flag when `moneyAccountCardToken` is still missing (avoids clearing too early while card home data loads post-login); if delegated already, clears pending; if token is present, opens the linkage sheet and clears pending. 3. **Not authenticated, not a cardholder** — Navigates to Card onboarding root with `moneyAccountLinkIntent: true` (Spending-limit lock for Money as spending source remains for a follow-up branch). **What changed (high level):** 1. **Redux** — [`app/core/redux/slices/card/index.ts`](app/core/redux/slices/card/index.ts): `pendingMoneyAccountCardLink`, `setPendingMoneyAccountCardLink`, `selectPendingMoneyAccountCardLink`. 2. **Hook** — [`useMoneyAccountCardLinkage.tsx`](app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx): `startLinkFlow(origin)`, nested navigation for auth vs onboarding, resume effect with `selectCardHomeDataStatus` gating. 3. **Money UI** — [`MoneyHomeView.tsx`](app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx): link CTA calls `startLinkFlow` with root-level origin `{ screen: Routes.MONEY.ROOT, params: { screen: Routes.MONEY.HOME } }`. 4. **Card auth** — [`CardAuthentication.tsx`](app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx): optional route params `postAuthRedirect` / `showAuthPrompt`; on successful login when `postAuthRedirect` is set, `goBack()` on the root `NavigationService` instead of resetting to Card home or navigating to Money by name. ### Why - Cardholders who start linkage from Money need **auth first**, then **the same bottom sheet** once delegation data is ready, without losing context or leaving a stale auth screen on the stack when returning to tabs. - **`moneyAccountCardToken`** is unavailable until post-auth card data loads; gating on **`cardHomeDataStatus`** avoids dropping the pending flag during that window. ### What changed (scoped paths) | Area | Files / behaviour | | ---- | ----------------- | | **Redux (card slice)** | [`app/core/redux/slices/card/index.ts`](app/core/redux/slices/card/index.ts), [`app/core/redux/slices/card/index.test.ts`](app/core/redux/slices/card/index.test.ts) | | **Linkage hook** | [`app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx`](app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx), [`.test.tsx`](app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx) | | **Card login** | [`app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx`](app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx), [`.test.tsx`](app/components/UI/Card/Views/CardAuthentication/CardAuthentication.test.tsx) | | **Money home** | [`app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx`](app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx), [`.test.tsx`](app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx) | ### Out of scope (intentional) - Onboarding branch: locking Money Account as spending source on Spending Limit (`moneyAccountLinkIntent` wiring beyond navigation is deferred). ## **Changelog** CHANGELOG entry: Improved Money Account link-to-Card flow for unauthenticated cardholders (auth screen, return to origin tab without stale stack, resume linkage sheet after card data loads); added pending linkage Redux flag and onboarding navigation intent for non-cardholders. ## **Related issues** Fixes: <!-- Add ticket ID(s), e.g. Fixes: MUSD-xxx or #12345 --> ## **Manual testing steps** ```gherkin Feature: Money Account link card when not authenticated (cardholder) Background: Given Money Account is enabled and requirements for card linkage are met And the user is a Card cardholder but not authenticated with the Card backend Scenario: link card from Money home When the user taps the link card CTA from Money Account home Then they are taken to Card authentication with the auth prompt as configured When the user completes login successfully Then they return to Money Account (tab under the pushed Card stack is revealed) And Card authentication is not left on the stack when re-opening the Card tab When card home / delegation data has finished loading Then the linkage bottom sheet opens if the Money account is not already delegated and a card token is available Scenario: already delegated after login When the user completes login and data shows the Money account is already delegated Then the pending linkage flow clears without showing the sheet Scenario: authenticated user Given the user is already authenticated with Card When they tap link card from Money home and are not already delegated Then the linkage bottom sheet opens as before (no redirect to auth) ``` ## **Screenshots/Recordings** ### **Before** <!-- Unauthenticated: redirect to Card home only; no resume sheet; possible stack flicker. --> ### **After** <!-- Auth → back to Money tab → linkage sheet after data load; clean Card tab stack. --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Modifies cross-stack navigation and linkage orchestration using a new Redux pending flag; main risk is regressions in navigation stack behavior or incorrectly resuming/clearing the pending linkage state after login. > > **Overview** > Improves the Money Account → Card linkage entry flow for unauthenticated users by introducing a `pendingMoneyAccountCardLink` Redux flag and a new `startLinkFlow(origin)` API in `useMoneyAccountCardLinkage` that routes users to Card auth/onboarding and resumes opening the Link Card sheet after authentication. > > Updates `CardAuthentication` to accept an optional `postAuthRedirect` param and, on successful login, pop `Card.ROOT` via `NavigationService.navigation.goBack()` instead of resetting the inner Card stack, preserving the originating tab’s navigation state. > > Refactors `MoneyHomeView` link-card CTAs to call `startLinkFlow` (passing the Money home origin) and expands unit tests to cover the new branching and resume behavior, including token-resolution and data-loading edge cases. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 34bad9c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 7274b18 commit ef84315

8 files changed

Lines changed: 602 additions & 43 deletions

File tree

app/components/UI/Card/Views/CardAuthentication/CardAuthentication.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ jest.mock('../../../../../core/Engine', () => ({
2424
},
2525
}));
2626

27+
const mockNavigationServiceNavigate = jest.fn();
28+
const mockNavigationServiceGoBack = jest.fn();
29+
jest.mock('../../../../../core/NavigationService', () => ({
30+
__esModule: true,
31+
default: {
32+
get navigation() {
33+
return {
34+
navigate: mockNavigationServiceNavigate,
35+
goBack: mockNavigationServiceGoBack,
36+
};
37+
},
38+
},
39+
}));
40+
2741
const mockNavigate = jest.fn();
2842
const mockGoBack = jest.fn();
2943
const mockReset = jest.fn();
@@ -442,6 +456,35 @@ describe('CardAuthentication Component', () => {
442456
});
443457
});
444458

459+
it('pops Card.ROOT off the root navigator on successful login when postAuthRedirect is set (no inner Card-stack reset, no cross-stack navigate)', async () => {
460+
mockRouteParams = {
461+
postAuthRedirect: {
462+
screen: Routes.MONEY.ROOT,
463+
params: { screen: Routes.MONEY.HOME },
464+
},
465+
};
466+
mockSubmitMutateAsync.mockResolvedValue({ done: true });
467+
render();
468+
const emailInput = screen.getByTestId('email-field');
469+
const passwordInput = screen.getByTestId('password-field');
470+
const loginButton = screen.getByTestId(
471+
CardAuthenticationSelectors.VERIFY_ACCOUNT_BUTTON,
472+
);
473+
474+
fireEvent.changeText(emailInput, 'test@example.com');
475+
fireEvent.changeText(passwordInput, 'password123');
476+
fireEvent.press(loginButton);
477+
478+
await waitFor(() => {
479+
expect(mockNavigationServiceGoBack).toHaveBeenCalledTimes(1);
480+
});
481+
// The origin (e.g. the Money tab) lives below Card.ROOT in the outer
482+
// navigator — popping reveals it without touching its own state or
483+
// doing a cross-stack navigate.
484+
expect(mockNavigationServiceNavigate).not.toHaveBeenCalled();
485+
expect(mockReset).not.toHaveBeenCalled();
486+
});
487+
445488
it('does not navigate when login error exists', () => {
446489
mockUseCardAuth.mockReturnValue(
447490
makeDefaultHookReturn({

app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { selectCardUserLocation } from '../../../../../selectors/cardController'
3030
import { CardMessageBoxType, type CardLocation } from '../../types';
3131
import { CardActions, CardScreens } from '../../util/metrics';
3232
import OnboardingStep from '../../components/Onboarding/OnboardingStep';
33+
import NavigationService from '../../../../../core/NavigationService';
3334
import { useTailwind } from '@metamask/design-system-twrnc-preset';
3435
import { countryCodeToFlag } from '../../util/countryCodeToFlag';
3536

@@ -41,7 +42,12 @@ const autoComplete = Platform.select<TextInputProps['autoComplete']>({
4142

4243
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
4344
type CardAuthenticationParams = {
44-
CardAuthentication: { showAuthPrompt?: boolean } | undefined;
45+
CardAuthentication:
46+
| {
47+
showAuthPrompt?: boolean;
48+
postAuthRedirect?: { screen: string; params?: object };
49+
}
50+
| undefined;
4551
};
4652

4753
const CardAuthentication = () => {
@@ -51,6 +57,7 @@ const CardAuthentication = () => {
5157
const route =
5258
useRoute<RouteProp<CardAuthenticationParams, 'CardAuthentication'>>();
5359
const showAuthPrompt = route.params?.showAuthPrompt ?? false;
60+
const postAuthRedirect = route.params?.postAuthRedirect;
5461
const [email, setEmail] = useState('');
5562
const [password, setPassword] = useState('');
5663
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
@@ -207,6 +214,11 @@ const CardAuthentication = () => {
207214
return;
208215
}
209216

217+
if (postAuthRedirect) {
218+
NavigationService.navigation?.goBack();
219+
return;
220+
}
221+
210222
// Successful login — navigate to home
211223
navigation.reset({
212224
index: 0,
@@ -228,6 +240,7 @@ const CardAuthentication = () => {
228240
dispatch,
229241
trackEvent,
230242
createEventBuilder,
243+
postAuthRedirect,
231244
],
232245
);
233246

0 commit comments

Comments
 (0)