Skip to content

Commit 05850d9

Browse files
runway-github[bot]Brunonascdevalescodeklejeune
authored
chore(runway): cherry-pick feat(card): cp-7.64.0 create card-kyc-notification deep link handler (#25703)
- feat(card): cp-7.64.0 create card-kyc-notification deep link handler (#25607) <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR implements a deeplink handler for Card KYC push notifications, allowing users to be routed to the appropriate screen based on their KYC verification status when tapping on a notification about their verification result. **Motivation**: When users complete their KYC verification process, they receive push notifications about the result. Previously, there was no handler to deep link users directly to the relevant screen based on their verification status. **Solution**: - Added a new `card-kyc-notification` deeplink action that checks the user's KYC verification state and navigates accordingly - Handles two scenarios: 1. **Onboarding flow** (user has onboardingId): Routes to KYCFailed, Complete (→ PersonalDetails), or KYCPending 2. **Authenticated flow** (user is already logged in): Routes to KYCFailed, Complete (→ CardHome via SpendingLimit), or CardHome - Updated the `Complete` screen to accept a `nextDestination` route param for proper navigation after KYC approval - Added logic to suppress the "keep going" modal when users are deeplinked directly to the Complete screen - Added `logout` method to CardSDK for proper session cleanup - Refactored `useCardProviderAuthentication` to get location from Redux selector instead of function parameters ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Added deeplink handler for Card KYC push notifications to route users to appropriate screens based on verification status ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card KYC Notification Deeplink Handler Scenario: User taps KYC approved notification during onboarding Given user is in the Card onboarding flow with a pending KYC verification And user has an onboardingId stored in Redux When user taps on a push notification with the card-kyc-notification deeplink And the KYC status is VERIFIED Then user is navigated to the Complete screen And tapping Continue navigates to PersonalDetails Scenario: User taps KYC approved notification when authenticated Given user is authenticated with the Card provider And user has completed KYC verification When user taps on a push notification with the card-kyc-notification deeplink And the KYC status is VERIFIED Then user is navigated to the Complete screen And tapping Continue navigates to SpendingLimit screen Scenario: User taps KYC rejected notification Given user has submitted KYC verification When user taps on a push notification with the card-kyc-notification deeplink And the KYC status is REJECTED Then user is navigated to the KYCFailed screen Scenario: User taps notification while KYC is still pending Given user has submitted KYC verification When user taps on a push notification with the card-kyc-notification deeplink And the KYC status is PENDING Then user is navigated to the KYCPending screen (onboarding) or CardHome (authenticated) ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [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. ## **Pre-merge reviewer checklist** - [ ] 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** > Adds a new deeplink entry point and changes navigation/auth state handling, which could misroute users or regress card onboarding/auth flows if state/params aren’t set as expected. > > **Overview** > Adds a new `card-kyc-notification` universal link action that inspects Card feature flags plus the user’s onboarding/auth state, fetches KYC verification status (via `CardSDK`), and deep-navigates to the appropriate Card screen (e.g., `KYC_FAILED`, `KYC_PENDING`, or `COMPLETE` with a `nextDestination` param). > > Refactors Card authentication to source `location` from Redux (`selectUserCardLocation`) rather than passing it through login/OTP APIs, and updates UI to persist location selection in state. > > Improves session cleanup by introducing `CardSDK.logout()` (server logout + always-clear local token) and wiring `logoutFromProvider()` to call it while still clearing Redux state on failures; `CardHome` now shows a toast when an authentication error forces logout/redirect. Also tweaks onboarding navigation (skip “keep going” modal when deeplinked to `COMPLETE`), adjusts close-button behavior to `reset` to Card Home, and updates related copy/SSN helper text and tests. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d043390. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Alejandro Machado <alejandro@macha.do> Co-authored-by: Kevin Le Jeune <kevin.le-jeune@consensys.net> [826b8b5](826b8b5) Co-authored-by: Bruno Nascimento <brunonascimentodev@gmail.com> Co-authored-by: Alejandro Machado <alejandro@macha.do> Co-authored-by: Kevin Le Jeune <kevin.le-jeune@consensys.net>
1 parent 21c4ad3 commit 05850d9

21 files changed

Lines changed: 1747 additions & 98 deletions

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { renderScreen } from '../../../../../util/test/renderWithProvider';
33
import CardAuthentication from './CardAuthentication';
44
import Routes from '../../../../../constants/navigation/Routes';
55
import { CardAuthenticationSelectors } from './CardAuthentication.testIds';
6-
import { CardLocation } from '../../types';
76
import { backgroundState } from '../../../../../util/test/initial-root-state';
87

98
// Mock whenEngineReady to prevent async polling after test teardown
@@ -282,7 +281,7 @@ describe('CardAuthentication Component', () => {
282281
});
283282

284283
describe('Login Step - Login Functionality', () => {
285-
it('calls login with correct parameters for international location', async () => {
284+
it('calls login with correct parameters', async () => {
286285
render();
287286
const emailInput = screen.getByTestId('email-field');
288287
const passwordInput = screen.getByTestId('password-field');
@@ -296,14 +295,13 @@ describe('CardAuthentication Component', () => {
296295

297296
await waitFor(() => {
298297
expect(mockLogin).toHaveBeenCalledWith({
299-
location: 'international',
300298
email: 'test@example.com',
301299
password: 'password123',
302300
});
303301
});
304302
});
305303

306-
it('calls login with US location when selected', async () => {
304+
it('calls login after selecting US location', async () => {
307305
render();
308306
const usBox = screen.getByTestId('us-location-box');
309307
const emailInput = screen.getByTestId('email-field');
@@ -319,7 +317,6 @@ describe('CardAuthentication Component', () => {
319317

320318
await waitFor(() => {
321319
expect(mockLogin).toHaveBeenCalledWith({
322-
location: 'us' as CardLocation,
323320
email: 'test@example.com',
324321
password: 'password123',
325322
});
@@ -383,7 +380,6 @@ describe('CardAuthentication Component', () => {
383380

384381
await waitFor(() => {
385382
expect(mockLogin).toHaveBeenCalledWith({
386-
location: 'international',
387383
email: 'test@example.com',
388384
password: 'password123',
389385
});

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

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ import { useTheme } from '../../../../../util/theme';
2424
import useCardProviderAuthentication from '../../hooks/useCardProviderAuthentication';
2525
import { CardAuthenticationSelectors } from './CardAuthentication.testIds';
2626
import Routes from '../../../../../constants/navigation/Routes';
27-
import { CardLocation } from '../../types';
2827
import { strings } from '../../../../../../locales/i18n';
2928
import Logger from '../../../../../util/Logger';
3029
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
31-
import { useDispatch } from 'react-redux';
30+
import { useDispatch, useSelector } from 'react-redux';
3231
import {
32+
selectUserCardLocation,
3333
setOnboardingId,
3434
setUserCardLocation,
3535
} from '../../../../../core/redux/slices/card';
@@ -53,7 +53,7 @@ const CardAuthentication = () => {
5353
const [password, setPassword] = useState('');
5454
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
5555
const [loading, setLoading] = useState(false);
56-
const [location, setLocation] = useState<CardLocation>('international');
56+
const location = useSelector(selectUserCardLocation);
5757
const [otpData, setOtpData] = useState<{
5858
userId: string;
5959
maskedPhoneNumber?: string;
@@ -116,7 +116,6 @@ const CardAuthentication = () => {
116116
try {
117117
await sendOtpLogin({
118118
userId: otpData.userId,
119-
location,
120119
});
121120
// Reset countdown when OTP is sent
122121
setResendCooldown(60);
@@ -127,7 +126,7 @@ const CardAuthentication = () => {
127126

128127
sendOtp();
129128
}
130-
}, [step, otpData?.userId, sendOtpLogin, location]);
129+
}, [step, otpData?.userId, sendOtpLogin]);
131130

132131
// Cooldown timer effect
133132
useEffect(() => {
@@ -173,7 +172,6 @@ const CardAuthentication = () => {
173172
try {
174173
setLoading(true);
175174
const loginResponse = await login({
176-
location,
177175
email,
178176
password,
179177
...(otpCode ? { otpCode } : {}),
@@ -190,7 +188,6 @@ const CardAuthentication = () => {
190188
}
191189

192190
if (loginResponse?.phase) {
193-
dispatch(setUserCardLocation(location));
194191
dispatch(setOnboardingId(loginResponse.userId));
195192
navigation.reset({
196193
index: 0,
@@ -217,7 +214,6 @@ const CardAuthentication = () => {
217214
},
218215
[
219216
email,
220-
location,
221217
login,
222218
password,
223219
step,
@@ -253,13 +249,12 @@ const CardAuthentication = () => {
253249
try {
254250
await sendOtpLogin({
255251
userId: otpData.userId,
256-
location,
257252
});
258253
setResendCooldown(60);
259254
} catch (err) {
260255
Logger.log('CardAuthentication::Resend OTP failed', err);
261256
}
262-
}, [resendCooldown, otpData?.userId, sendOtpLogin, location, otpLoading]);
257+
}, [resendCooldown, otpData?.userId, sendOtpLogin, otpLoading]);
263258

264259
const handleBackToLogin = useCallback(() => {
265260
setStep('login');
@@ -367,7 +362,7 @@ const CardAuthentication = () => {
367362
<>
368363
<Box twClassName="flex-row justify-between gap-2">
369364
<TouchableOpacity
370-
onPress={() => setLocation('international')}
365+
onPress={() => dispatch(setUserCardLocation('international'))}
371366
style={tw.style(
372367
`flex flex-col items-center justify-center flex-1 bg-background-muted rounded-lg ${location === 'international' ? 'border border-text-default' : ''}`,
373368
)}
@@ -386,7 +381,7 @@ const CardAuthentication = () => {
386381
</Box>
387382
</TouchableOpacity>
388383
<TouchableOpacity
389-
onPress={() => setLocation('us')}
384+
onPress={() => dispatch(setUserCardLocation('us'))}
390385
style={tw.style(
391386
`flex flex-col items-center justify-center flex-1 bg-background-muted rounded-lg ${location === 'us' ? 'border border-text-default' : ''}`,
392387
)}
@@ -465,14 +460,15 @@ const CardAuthentication = () => {
465460
handlePasswordChange,
466461
handleResendOtp,
467462
isPasswordVisible,
468-
location,
469463
otpError,
470464
otpLoading,
471465
password,
472466
performLogin,
473467
resendCooldown,
474468
step,
475469
tw,
470+
dispatch,
471+
location,
476472
],
477473
);
478474
const actions = useMemo(

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2529,6 +2529,43 @@ describe('CardHome Component', () => {
25292529
]);
25302530
});
25312531
});
2532+
2533+
it('completes full auth error cleanup flow including toast display', async () => {
2534+
// Given: authenticated user with authentication error
2535+
setupMockSelectors({ isAuthenticated: true });
2536+
mockIsAuthenticationError.mockReturnValue(true);
2537+
mockRemoveCardBaanxToken.mockResolvedValue(undefined);
2538+
setupLoadCardDataMock({
2539+
error: 'Token expired',
2540+
isAuthenticated: true,
2541+
});
2542+
2543+
// When: component renders with authentication error
2544+
render();
2545+
2546+
// Then: should complete full cleanup flow:
2547+
// 1. Remove token
2548+
await waitFor(() => {
2549+
expect(mockRemoveCardBaanxToken).toHaveBeenCalled();
2550+
});
2551+
2552+
// 2. Dispatch Redux actions
2553+
await waitFor(() => {
2554+
expect(mockDispatch).toHaveBeenCalledWith(
2555+
expect.objectContaining({ type: 'card/resetAuthenticatedData' }),
2556+
);
2557+
expect(mockDispatch).toHaveBeenCalledWith(
2558+
expect.objectContaining({ type: 'card/clearAllCache' }),
2559+
);
2560+
});
2561+
2562+
// 3. Navigate to authentication screen (this happens after toast is shown)
2563+
await waitFor(() => {
2564+
expect(StackActions.replace).toHaveBeenCalledWith(
2565+
Routes.CARD.AUTHENTICATION,
2566+
);
2567+
});
2568+
});
25322569
});
25332570

25342571
describe('KYC Status Verification', () => {

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,14 @@ const CardHome = () => {
763763
dispatch(resetAuthenticatedData());
764764
dispatch(clearAllCache());
765765

766+
toastRef?.current?.showToast({
767+
variant: ToastVariants.Icon,
768+
labelOptions: [
769+
{ label: strings('card.card_home.authentication_error') },
770+
],
771+
hasNoTimeout: false,
772+
iconName: IconName.Warning,
773+
});
766774
navigation.dispatch(StackActions.replace(Routes.CARD.AUTHENTICATION));
767775
} catch (error) {
768776
if (!isComponentUnmountedRef.current) {
@@ -776,7 +784,7 @@ const CardHome = () => {
776784
};
777785

778786
handleAuthenticationError();
779-
}, [cardError, dispatch, isAuthenticated, navigation]);
787+
}, [cardError, dispatch, isAuthenticated, navigation, toastRef]);
780788

781789
useEffect(() => {
782790
if (isSDKLoading) {

app/components/UI/Card/components/Onboarding/Complete.test.tsx

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import React from 'react';
22
import { render, fireEvent, waitFor } from '@testing-library/react-native';
3-
import { StackActions, useNavigation } from '@react-navigation/native';
3+
import {
4+
StackActions,
5+
useNavigation,
6+
useRoute,
7+
} from '@react-navigation/native';
48
import { useDispatch } from 'react-redux';
59
import Complete from './Complete';
610
import Routes from '../../../../../constants/navigation/Routes';
@@ -14,6 +18,7 @@ const mockStackReplace = jest.fn((routeName: string) => ({
1418

1519
jest.mock('@react-navigation/native', () => ({
1620
useNavigation: jest.fn(),
21+
useRoute: jest.fn(),
1722
StackActions: {
1823
replace: jest.fn((routeName: string) => ({
1924
type: 'REPLACE',
@@ -217,6 +222,11 @@ describe('Complete Component', () => {
217222
dispatch: mockNavigationDispatch,
218223
});
219224

225+
// Default: no route params
226+
(useRoute as jest.Mock).mockReturnValue({
227+
params: {},
228+
});
229+
220230
(useDispatch as jest.Mock).mockReturnValue(mockDispatch);
221231

222232
const { useMetrics } = jest.requireMock('../../../../hooks/useMetrics');
@@ -517,4 +527,120 @@ describe('Complete Component', () => {
517527
});
518528
});
519529
});
530+
531+
describe('Deep Link Navigation (nextDestination param)', () => {
532+
it('navigates to PersonalDetails when nextDestination is personal_details', async () => {
533+
(useRoute as jest.Mock).mockReturnValue({
534+
params: { nextDestination: 'personal_details' },
535+
});
536+
537+
const { getByTestId } = render(<Complete />);
538+
const button = getByTestId('complete-confirm-button');
539+
fireEvent.press(button);
540+
541+
await waitFor(() => {
542+
expect(mockStackReplace).toHaveBeenCalledWith(
543+
Routes.CARD.ONBOARDING.PERSONAL_DETAILS,
544+
);
545+
expect(mockNavigationDispatch).toHaveBeenCalledWith(
546+
expect.objectContaining({
547+
routeName: Routes.CARD.ONBOARDING.PERSONAL_DETAILS,
548+
}),
549+
);
550+
});
551+
});
552+
553+
it('does not reset onboarding state when navigating to PersonalDetails', async () => {
554+
(useRoute as jest.Mock).mockReturnValue({
555+
params: { nextDestination: 'personal_details' },
556+
});
557+
558+
const { resetOnboardingState } = jest.requireMock(
559+
'../../../../../core/redux/slices/card',
560+
);
561+
562+
const { getByTestId } = render(<Complete />);
563+
fireEvent.press(getByTestId('complete-confirm-button'));
564+
565+
await waitFor(() => {
566+
expect(resetOnboardingState).not.toHaveBeenCalled();
567+
});
568+
});
569+
570+
it('navigates to SpendingLimit when nextDestination is card_home', async () => {
571+
(useRoute as jest.Mock).mockReturnValue({
572+
params: { nextDestination: 'card_home' },
573+
});
574+
575+
const { getByTestId } = render(<Complete />);
576+
const button = getByTestId('complete-confirm-button');
577+
fireEvent.press(button);
578+
579+
await waitFor(() => {
580+
expect(mockStackReplace).toHaveBeenCalledWith(
581+
Routes.CARD.SPENDING_LIMIT,
582+
{ flow: 'onboarding' },
583+
);
584+
expect(mockNavigationDispatch).toHaveBeenCalledWith(
585+
expect.objectContaining({ routeName: Routes.CARD.SPENDING_LIMIT }),
586+
);
587+
});
588+
});
589+
590+
it('resets onboarding state when nextDestination is card_home', async () => {
591+
(useRoute as jest.Mock).mockReturnValue({
592+
params: { nextDestination: 'card_home' },
593+
});
594+
595+
const { resetOnboardingState } = jest.requireMock(
596+
'../../../../../core/redux/slices/card',
597+
);
598+
599+
const { getByTestId } = render(<Complete />);
600+
fireEvent.press(getByTestId('complete-confirm-button'));
601+
602+
await waitFor(() => {
603+
expect(mockDispatch).toHaveBeenCalledWith(resetOnboardingState());
604+
});
605+
});
606+
607+
it('does not check token when nextDestination is provided', async () => {
608+
(useRoute as jest.Mock).mockReturnValue({
609+
params: { nextDestination: 'card_home' },
610+
});
611+
612+
const { getCardBaanxToken } = jest.requireMock(
613+
'../../util/cardTokenVault',
614+
);
615+
616+
const { getByTestId } = render(<Complete />);
617+
fireEvent.press(getByTestId('complete-confirm-button'));
618+
619+
await waitFor(() => {
620+
expect(getCardBaanxToken).not.toHaveBeenCalled();
621+
});
622+
});
623+
624+
it('falls back to default behavior when nextDestination is undefined', async () => {
625+
(useRoute as jest.Mock).mockReturnValue({
626+
params: {},
627+
});
628+
629+
const { getCardBaanxToken } = jest.requireMock(
630+
'../../util/cardTokenVault',
631+
);
632+
getCardBaanxToken.mockResolvedValue({
633+
success: true,
634+
tokenData: { accessToken: 'mock-token' },
635+
});
636+
637+
const { getByTestId } = render(<Complete />);
638+
fireEvent.press(getByTestId('complete-confirm-button'));
639+
640+
await waitFor(() => {
641+
expect(getCardBaanxToken).toHaveBeenCalled();
642+
expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME);
643+
});
644+
});
645+
});
520646
});

0 commit comments

Comments
 (0)