Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6ba5088
feat(card): wip - migrate CardHome to Controller
Brunonascdev Apr 7, 2026
d245a79
feat(card): add unit tests
Brunonascdev Apr 8, 2026
c8c0880
feat(card): inversed spending limit progress bar
Brunonascdev Apr 8, 2026
84b21aa
merge with main
Brunonascdev Apr 8, 2026
cf32d13
feat(card): inversed spending limit progress bar
Brunonascdev Apr 8, 2026
d913b45
fix(card): lint issues
Brunonascdev Apr 8, 2026
148c70e
chore(card): remove card controller card home from state logs
Brunonascdev Apr 8, 2026
fe4cc69
feat(card): adapt alerts
Brunonascdev Apr 8, 2026
2582100
feat(card): fix CardAuthentication location selector
Brunonascdev Apr 9, 2026
e0e0161
test(card): increase coverage
Brunonascdev Apr 9, 2026
e59d71d
test(card): use mockReturnValue and remove snapshot
Brunonascdev Apr 9, 2026
e82274f
fix(card): not memoized set on CardHome
Brunonascdev Apr 9, 2026
7c5499a
Merge branch 'main' of github.com:MetaMask/metamask-mobile into feat/…
Brunonascdev Apr 9, 2026
c4c15eb
feat(card): fix metal card available for international users
Brunonascdev Apr 9, 2026
17a8879
refactor(card): remove specific Galileo provider from push provisioning
Brunonascdev Apr 10, 2026
b209a62
Merge branch 'main' of github.com:MetaMask/metamask-mobile into feat/…
Brunonascdev Apr 10, 2026
b213346
feat(card): unauthenticated state
Brunonascdev Apr 10, 2026
6ac6fb0
fix(card): spending limit not working
Brunonascdev Apr 10, 2026
aa4c674
lint(card): fix lint issues on controller tests
Brunonascdev Apr 10, 2026
a7fce72
test(card): remove CardHome snapshot
Brunonascdev Apr 10, 2026
53e3ded
merge with main
Brunonascdev Apr 10, 2026
1902c54
feat(card): fix wrong balance and refactor card home footer
Brunonascdev Apr 10, 2026
1c0813c
feat(card): general refactors and SOC updates, refactor capabilities
Brunonascdev Apr 10, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const mockReset = jest.fn();
const mockDispatch = jest.fn();
const mockAddListener = jest.fn(() => jest.fn());

let mockRouteParams: Record<string, unknown> = {};

jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
Expand All @@ -39,6 +41,7 @@ jest.mock('@react-navigation/native', () => ({
dispatch: mockDispatch,
addListener: mockAddListener,
}),
useRoute: () => ({ params: mockRouteParams }),
}));

jest.mock('../../hooks/useCardAuth');
Expand Down Expand Up @@ -126,6 +129,8 @@ jest.mock('../../../../../../locales/i18n', () => ({
'card.card_otp_authentication.didnt_receive_code':
"Didn't receive the code?",
'card.card_otp_authentication.resend_verification': 'Resend',
'card.card_authentication.auth_prompt_info':
'Log in to your card account to access this feature.',
};
if (key === 'card.card_otp_authentication.description_with_phone_number') {
return `We sent a code to ${params?.maskedPhoneNumber}`;
Expand Down Expand Up @@ -169,6 +174,7 @@ jest.useFakeTimers({ advanceTimers: true });
describe('CardAuthentication Component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRouteParams = {};

mockInitiateMutateAsync.mockResolvedValue(undefined);
mockSubmitMutateAsync.mockResolvedValue({ done: true });
Expand Down Expand Up @@ -198,6 +204,17 @@ describe('CardAuthentication Component', () => {
screen.getByTestId(CardAuthenticationSelectors.VERIFY_ACCOUNT_BUTTON),
).toBeOnTheScreen();
});

it('renders signup button and password toggle', () => {
render();

expect(
screen.getByTestId(CardAuthenticationSelectors.SIGNUP_BUTTON),
).toBeOnTheScreen();
expect(
screen.getByTestId('password-visibility-toggle'),
).toBeOnTheScreen();
});
});

describe('Login Step - Location Selection', () => {
Expand Down Expand Up @@ -368,16 +385,32 @@ describe('CardAuthentication Component', () => {
});
});

it('calls setUserLocation when pressing US location button', () => {
it('does not call setUserLocation on flag press, defers to login', async () => {
render();
const usBox = screen.getByTestId('us-location-box');
const Engine = jest.requireMock('../../../../../core/Engine').default;
const EngineModule = jest.requireMock(
'../../../../../core/Engine',
).default;

fireEvent.press(usBox);

expect(
Engine.context.CardController.setUserLocation,
).toHaveBeenCalledWith('us');
EngineModule.context.CardController.setUserLocation,
).not.toHaveBeenCalled();

const emailInput = screen.getByTestId('email-field');
const passwordInput = screen.getByTestId('password-field');
const loginButton = screen.getByTestId(
CardAuthenticationSelectors.VERIFY_ACCOUNT_BUTTON,
);

fireEvent.changeText(emailInput, 'test@example.com');
fireEvent.changeText(passwordInput, 'password123');
fireEvent.press(loginButton);

await waitFor(() => {
expect(mockInitiateMutateAsync).toHaveBeenCalledWith('us');
});
});

it('navigates to card home on successful login', async () => {
Expand Down Expand Up @@ -814,4 +847,35 @@ describe('CardAuthentication Component', () => {
expect(screen.queryByTestId('otp-code-field')).not.toBeOnTheScreen();
});
});

describe('Auth Prompt Info Banner', () => {
it('shows info banner when showAuthPrompt param is true', () => {
mockRouteParams = { showAuthPrompt: true };
render();

expect(screen.getByTestId('card-message-box')).toBeOnTheScreen();
expect(
screen.getByText('Log in to your card account to access this feature.'),
).toBeOnTheScreen();
});

it('does not show info banner when showAuthPrompt param is absent', () => {
render();

expect(screen.queryByTestId('card-message-box')).not.toBeOnTheScreen();
});

it('does not show info banner on OTP step even with showAuthPrompt param', () => {
mockRouteParams = { showAuthPrompt: true };
mockUseCardAuth.mockReturnValue(
makeDefaultHookReturn({
currentStep: { type: 'otp', destination: '+1555****90' },
}),
);

render();

expect(screen.queryByTestId('card-message-box')).not.toBeOnTheScreen();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useNavigation } from '@react-navigation/native';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Platform, TouchableOpacity, TextInputProps } from 'react-native';
import {
Expand All @@ -20,13 +20,14 @@ import { useCardAuth } from '../../hooks/useCardAuth';
import { CardAuthenticationSelectors } from './CardAuthentication.testIds';
import Routes from '../../../../../constants/navigation/Routes';
import { strings } from '../../../../../../locales/i18n';
import CardMessageBox from '../../components/CardMessageBox/CardMessageBox';
import Logger from '../../../../../util/Logger';
import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
import { useDispatch, useSelector } from 'react-redux';
import { setOnboardingId } from '../../../../../core/redux/slices/card';
import { selectCardUserLocation } from '../../../../../selectors/cardController';
import Engine from '../../../../../core/Engine';
import { CardMessageBoxType, type CardLocation } from '../../types';
import { CardActions, CardScreens } from '../../util/metrics';
import OnboardingStep from '../../components/Onboarding/OnboardingStep';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
Expand All @@ -38,14 +39,25 @@ const autoComplete = Platform.select<TextInputProps['autoComplete']>({
default: 'one-time-code',
});

// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type CardAuthenticationParams = {
CardAuthentication: { showAuthPrompt?: boolean } | undefined;
};

const CardAuthentication = () => {
const tw = useTailwind();
const { trackEvent, createEventBuilder } = useAnalytics();
const navigation = useNavigation();
const route =
useRoute<RouteProp<CardAuthenticationParams, 'CardAuthentication'>>();
const showAuthPrompt = route.params?.showAuthPrompt ?? false;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const location = useSelector(selectCardUserLocation);
const persistedLocation = useSelector(selectCardUserLocation);
const [selectedLocation, setSelectedLocation] = useState<CardLocation>(
persistedLocation ?? 'international',
);
const [confirmCode, setConfirmCode] = useState('');
const [latestValueSubmitted, setLatestValueSubmitted] = useState<
string | null
Expand Down Expand Up @@ -167,7 +179,7 @@ const CardAuthentication = () => {

try {
if (!isOtpStep) {
await initiate.mutateAsync(location ?? 'international');
await initiate.mutateAsync(selectedLocation);
}
const result = await submit.mutateAsync({
type: 'email_password',
Expand Down Expand Up @@ -210,7 +222,7 @@ const CardAuthentication = () => {
initiate,
submit,
isOtpStep,
location,
selectedLocation,
password,
navigation,
dispatch,
Expand Down Expand Up @@ -271,7 +283,7 @@ const CardAuthentication = () => {
: strings(
'card.card_otp_authentication.description_without_phone_number',
)
: '',
: undefined,
[maskedPhoneNumber, isOtpStep],
);

Expand Down Expand Up @@ -347,13 +359,14 @@ const CardAuthentication = () => {
</>
) : (
<>
{showAuthPrompt && (
<CardMessageBox messageType={CardMessageBoxType.AuthPrompt} />
)}
<Box twClassName="flex-row justify-between gap-2">
<TouchableOpacity
onPress={() =>
Engine.context.CardController.setUserLocation('international')
}
onPress={() => setSelectedLocation('international')}
style={tw.style(
`flex flex-col items-center justify-center flex-1 bg-background-muted rounded-lg ${location === 'international' ? 'border border-text-default' : ''}`,
`flex flex-col items-center justify-center flex-1 bg-background-muted rounded-lg ${selectedLocation === 'international' ? 'border border-text-default' : ''}`,
)}
>
<Box
Expand All @@ -370,11 +383,9 @@ const CardAuthentication = () => {
</Box>
</TouchableOpacity>
<TouchableOpacity
onPress={() =>
Engine.context.CardController.setUserLocation('us')
}
onPress={() => setSelectedLocation('us')}
style={tw.style(
`flex flex-col items-center justify-center flex-1 bg-background-muted rounded-lg ${location === 'us' ? 'border border-text-default' : ''}`,
`flex flex-col items-center justify-center flex-1 bg-background-muted rounded-lg ${selectedLocation === 'us' ? 'border border-text-default' : ''}`,
)}
>
<Box
Expand Down Expand Up @@ -455,8 +466,9 @@ const CardAuthentication = () => {
password,
performLogin,
resendCooldown,
showAuthPrompt,
tw,
location,
selectedLocation,
],
);

Expand Down
Loading
Loading