Skip to content

Commit ab07f46

Browse files
authored
feat(card): cp-7.68.0 Add View PIN option (#26646)
<!-- 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** Adds a "View PIN" option to the Card Home manage card section, allowing users to securely view their card PIN through a PCI-compliant image-based display. **Why**: Users need to retrieve their card PIN (e.g. for ATM use or in-store transactions). The PIN is never transmitted as plain text — it is rendered as an image via a time-limited, single-use secure token from the `POST /v1/card/pin/token` endpoint, ensuring PCI compliance. **What changed**: - **New SDK method** (`CardSDK.generateCardPinToken`): Calls `POST /v1/card/pin/token` with optional `customCss` for theming the PIN image. Mirrors the existing `generateCardDetailsToken` pattern with proper error handling. - **React Query integration**: New `cardQueries.pin` key factory and `pinTokenMutationFn` following the established React Query patterns from the codebase. - **`useCardPinToken` hook**: Wraps `useMutation` for PIN token generation. Automatically applies dark/light theme-aware `customCss` (background and text colors) so the PIN image matches the app appearance. - **`ViewPinBottomSheet` component**: Displays the PIN image in a bottom sheet with a skeleton loader and `CardScreenshotDeterrent` enabled to prevent screenshots of sensitive data. - **`CardHome` integration**: New `ManageCardListItem` for "View PIN" with biometric authentication gating (matching the "View Card Details" flow). Falls back to password bottom sheet with a PIN-specific description when biometrics are not configured. Visible for US users (all card types) and international users with non-virtual cards. - **Analytics**: Added `VIEW_PIN_BUTTON` action to `CardActions` enum, tracked via `CARD_BUTTON_CLICKED` event. - **Navigation**: Registered `CardViewPinModal` route and added the `ViewPinBottomSheet` screen to `CardModalsRoutes`. - **Tests**: Added tests across 5 files — SDK method tests, query layer tests, hook tests, bottom sheet snapshot/render tests, and 11 new CardHome integration tests covering visibility conditions, biometric auth flow, password fallback, and loading guards. ## **Changelog** CHANGELOG entry: Added "View PIN" option to the Card Home screen, allowing users to securely view their card PIN via biometric or password authentication. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: View card PIN Scenario: View PIN button is visible for eligible users Given the user is authenticated with an active card And the user is a US user OR has a non-virtual (metal) card When the user navigates to the Card Home screen Then a "View PIN" option is displayed in the manage card section Scenario: View PIN button is hidden for international virtual card users Given the user is an international user with a virtual card When the user navigates to the Card Home screen Then the "View PIN" option is NOT displayed Scenario: View PIN with biometric authentication Given the user has biometric authentication configured When the user taps "View PIN" Then a biometric prompt is displayed And upon successful authentication, the card PIN is shown as an image in a bottom sheet And the PIN image matches the current theme (light/dark background) Scenario: View PIN with password fallback Given the user does NOT have biometric authentication configured When the user taps "View PIN" Then a password bottom sheet appears with the message "Enter your wallet password to view your card PIN." And upon entering the correct password, the card PIN is shown in a bottom sheet Scenario: View PIN biometric cancellation Given the user has biometric authentication configured When the user taps "View PIN" and cancels the biometric prompt Then no PIN is displayed and the user returns to Card Home Scenario: View PIN error handling Given the user taps "View PIN" and authentication succeeds When the PIN token request fails Then an error toast is shown with "Failed to load PIN. Please try again." Scenario: Screenshot prevention Given the card PIN bottom sheet is displayed When the user attempts to take a screenshot Then the screenshot deterrent is active and prevents capture of the PIN ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots or recordings of the View PIN flow --> ### **Before** <!-- Card Home without View PIN option --> ### **After** <!-- Card Home with View PIN option + View PIN bottom sheet --> ## **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 authenticated flow to fetch and display a sensitive card PIN image via a new SDK endpoint and modal UI, with biometric/password gating and error handling. Risk is mainly around auth/error-state handling and the new network call/token lifecycle. > > **Overview** > Adds a new **“View PIN”** manage-card action on `CardHome`, shown only for eligible users (authenticated, has a card, not loading; US users or non-virtual cards), gated by `reauthenticate()` with a password-bottom-sheet fallback when biometrics aren’t configured and guarded against concurrent loads. > > Introduces PIN-token generation plumbing: `CardSDK.generateCardPinToken` calling `POST /v1/card/pin/token`, React Query `cardQueries.pin` + `useCardPinToken` (theme-aware `customCss`), plus a new `ViewPinBottomSheet` modal route (`Routes.CARD.MODALS.VIEW_PIN`) that renders the PIN image with a skeleton loader and `CardScreenshotDeterrent` enabled. > > Updates analytics (`CardActions.VIEW_PIN_BUTTON`), test IDs, and English strings, and adds comprehensive tests for the SDK/query/hook, the new bottom sheet (snapshot/render), and CardHome visibility/auth/error flows. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 514ae0c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent bee9243 commit ab07f46

20 files changed

Lines changed: 1798 additions & 1 deletion

File tree

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

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import {
9999
} from '../../../../../core/redux/slices/card';
100100
import { useIsSwapEnabledForPriorityToken } from '../../hooks/useIsSwapEnabledForPriorityToken';
101101
import useCardDetailsToken from '../../hooks/useCardDetailsToken';
102+
import useCardPinToken from '../../hooks/useCardPinToken';
102103

103104
const mockNavigate = jest.fn();
104105
const mockGoBack = jest.fn();
@@ -274,6 +275,26 @@ jest.mock('../../hooks/useCardDetailsToken', () => ({
274275
})),
275276
}));
276277

278+
const mockGeneratePinToken = jest.fn();
279+
const mockResetPinToken = jest.fn();
280+
jest.mock('../../hooks/useCardPinToken', () => ({
281+
__esModule: true,
282+
default: jest.fn(() => ({
283+
generatePinToken: mockGeneratePinToken,
284+
isLoading: false,
285+
error: null,
286+
imageUrl: null,
287+
reset: mockResetPinToken,
288+
})),
289+
}));
290+
291+
jest.mock('../../components/ViewPinBottomSheet', () => ({
292+
createViewPinBottomSheetNavigationDetails: jest.fn((params) => [
293+
'CardModals',
294+
{ screen: 'CardViewPinModal', params },
295+
]),
296+
}));
297+
277298
// Mock useAuthentication for biometric verification
278299
const mockReauthenticate = jest.fn();
279300
jest.mock('../../../../../core/Authentication/hooks/useAuthentication', () =>
@@ -503,6 +524,13 @@ jest.mock('../../../../../../locales/i18n', () => ({
503524
'Authentication required to resume spending on your card.',
504525
'card.password_bottomsheet.description_unfreeze':
505526
'Enter your wallet password to unfreeze your card.',
527+
'card.card_home.manage_card_options.view_pin': 'View PIN',
528+
'card.card_home.manage_card_options.view_pin_description':
529+
'View your card PIN securely',
530+
'card.card_home.manage_card_options.view_pin_error':
531+
'Failed to load PIN. Please try again.',
532+
'card.password_bottomsheet.description_view_pin':
533+
'Enter your wallet password to view your card PIN.',
506534
};
507535
return strings[key] || key;
508536
},
@@ -3938,6 +3966,308 @@ describe('CardHome Component', () => {
39383966
});
39393967
});
39403968
});
3969+
3970+
describe('View PIN Button', () => {
3971+
beforeEach(() => {
3972+
mockGeneratePinToken.mockClear();
3973+
mockResetPinToken.mockClear();
3974+
mockReauthenticate.mockClear();
3975+
mockReauthenticate.mockResolvedValue(undefined);
3976+
});
3977+
3978+
it('does not show view pin button when user is not authenticated', () => {
3979+
// Given: User is not authenticated
3980+
setupMockSelectors({ isAuthenticated: false });
3981+
setupLoadCardDataMock({
3982+
isAuthenticated: false,
3983+
isBaanxLoginEnabled: true,
3984+
cardDetails: { type: CardType.VIRTUAL },
3985+
isLoading: false,
3986+
});
3987+
3988+
// When: component renders
3989+
render();
3990+
3991+
// Then: view pin button is not shown
3992+
expect(
3993+
screen.queryByTestId(CardHomeSelectors.VIEW_PIN_BUTTON),
3994+
).toBeNull();
3995+
});
3996+
3997+
it('does not show view pin button when user has no card', () => {
3998+
// Given: Authenticated user without card
3999+
setupMockSelectors({ isAuthenticated: true });
4000+
setupLoadCardDataMock({
4001+
isAuthenticated: true,
4002+
isBaanxLoginEnabled: true,
4003+
cardDetails: null,
4004+
warning: CardStateWarning.NoCard,
4005+
isLoading: false,
4006+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4007+
});
4008+
4009+
// When: component renders
4010+
render();
4011+
4012+
// Then: view pin button is not shown
4013+
expect(
4014+
screen.queryByTestId(CardHomeSelectors.VIEW_PIN_BUTTON),
4015+
).toBeNull();
4016+
});
4017+
4018+
it('does not show view pin button while loading', () => {
4019+
// Given: Loading state
4020+
setupMockSelectors({ isAuthenticated: true });
4021+
setupLoadCardDataMock({
4022+
isAuthenticated: true,
4023+
isBaanxLoginEnabled: true,
4024+
cardDetails: { type: CardType.VIRTUAL },
4025+
isLoading: true,
4026+
});
4027+
4028+
// When: component renders
4029+
render();
4030+
4031+
// Then: view pin button is not shown
4032+
expect(
4033+
screen.queryByTestId(CardHomeSelectors.VIEW_PIN_BUTTON),
4034+
).toBeNull();
4035+
});
4036+
4037+
it('does not show view pin button for international virtual card', () => {
4038+
// Given: International user with virtual card
4039+
setupMockSelectors({
4040+
isAuthenticated: true,
4041+
userLocation: 'international',
4042+
});
4043+
setupLoadCardDataMock({
4044+
isAuthenticated: true,
4045+
isBaanxLoginEnabled: true,
4046+
cardDetails: { type: CardType.VIRTUAL },
4047+
isLoading: false,
4048+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4049+
});
4050+
4051+
// When: component renders
4052+
render();
4053+
4054+
// Then: view pin button is not shown
4055+
expect(
4056+
screen.queryByTestId(CardHomeSelectors.VIEW_PIN_BUTTON),
4057+
).toBeNull();
4058+
});
4059+
4060+
it('shows view pin button for US user with virtual card', () => {
4061+
// Given: US user with virtual card
4062+
setupMockSelectors({ isAuthenticated: true, userLocation: 'us' });
4063+
setupLoadCardDataMock({
4064+
isAuthenticated: true,
4065+
isBaanxLoginEnabled: true,
4066+
cardDetails: { type: CardType.VIRTUAL },
4067+
isLoading: false,
4068+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4069+
});
4070+
4071+
// When: component renders
4072+
render();
4073+
4074+
// Then: view pin button is shown
4075+
expect(
4076+
screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON),
4077+
).toBeOnTheScreen();
4078+
});
4079+
4080+
it('shows view pin button for international user with metal card', () => {
4081+
// Given: International user with metal card
4082+
setupMockSelectors({
4083+
isAuthenticated: true,
4084+
userLocation: 'international',
4085+
});
4086+
setupLoadCardDataMock({
4087+
isAuthenticated: true,
4088+
isBaanxLoginEnabled: true,
4089+
cardDetails: { type: CardType.METAL },
4090+
isLoading: false,
4091+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4092+
});
4093+
4094+
// When: component renders
4095+
render();
4096+
4097+
// Then: view pin button is shown (non-virtual card)
4098+
expect(
4099+
screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON),
4100+
).toBeOnTheScreen();
4101+
});
4102+
4103+
it('calls generatePinToken and navigates to ViewPinBottomSheet after biometric auth', async () => {
4104+
// Given: Authenticated US user with card and biometric auth succeeds
4105+
setupMockSelectors({ isAuthenticated: true, userLocation: 'us' });
4106+
setupLoadCardDataMock({
4107+
isAuthenticated: true,
4108+
isBaanxLoginEnabled: true,
4109+
cardDetails: { type: CardType.VIRTUAL },
4110+
isLoading: false,
4111+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4112+
});
4113+
4114+
mockReauthenticate.mockResolvedValueOnce(undefined);
4115+
mockGeneratePinToken.mockResolvedValueOnce({
4116+
token: 'pin-token-123',
4117+
imageUrl: 'https://cards.baanx.com/pin-image?token=pin-token-123',
4118+
});
4119+
4120+
// When: component renders and button is pressed
4121+
render();
4122+
const button = screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON);
4123+
fireEvent.press(button);
4124+
4125+
// Then: reauthenticate is called first, then generatePinToken, then navigation
4126+
await waitFor(() => {
4127+
expect(mockReauthenticate).toHaveBeenCalled();
4128+
});
4129+
await waitFor(() => {
4130+
expect(mockGeneratePinToken).toHaveBeenCalled();
4131+
});
4132+
await waitFor(() => {
4133+
expect(mockNavigate).toHaveBeenCalledWith('CardModals', {
4134+
screen: 'CardViewPinModal',
4135+
params: {
4136+
imageUrl: 'https://cards.baanx.com/pin-image?token=pin-token-123',
4137+
},
4138+
});
4139+
});
4140+
});
4141+
4142+
it('resets pin token after successful navigation', async () => {
4143+
// Given: Authenticated US user with card
4144+
setupMockSelectors({ isAuthenticated: true, userLocation: 'us' });
4145+
setupLoadCardDataMock({
4146+
isAuthenticated: true,
4147+
isBaanxLoginEnabled: true,
4148+
cardDetails: { type: CardType.VIRTUAL },
4149+
isLoading: false,
4150+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4151+
});
4152+
4153+
mockReauthenticate.mockResolvedValueOnce(undefined);
4154+
mockGeneratePinToken.mockResolvedValueOnce({
4155+
token: 'pin-token-123',
4156+
imageUrl: 'https://cards.baanx.com/pin-image?token=pin-token-123',
4157+
});
4158+
4159+
// When: button is pressed
4160+
render();
4161+
fireEvent.press(screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON));
4162+
4163+
// Then: resetPinToken is called after navigation
4164+
await waitFor(() => {
4165+
expect(mockResetPinToken).toHaveBeenCalled();
4166+
});
4167+
});
4168+
4169+
it('does not call generatePinToken when already loading', async () => {
4170+
// Given: Hook reports loading
4171+
setupMockSelectors({ isAuthenticated: true, userLocation: 'us' });
4172+
setupLoadCardDataMock({
4173+
isAuthenticated: true,
4174+
isBaanxLoginEnabled: true,
4175+
cardDetails: { type: CardType.VIRTUAL },
4176+
isLoading: false,
4177+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4178+
});
4179+
4180+
(useCardPinToken as jest.Mock).mockReturnValueOnce({
4181+
generatePinToken: mockGeneratePinToken,
4182+
isLoading: true,
4183+
error: null,
4184+
imageUrl: null,
4185+
reset: mockResetPinToken,
4186+
});
4187+
4188+
// When: button is pressed while loading
4189+
render();
4190+
fireEvent.press(screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON));
4191+
4192+
// Then: reauthenticate is not called
4193+
await waitFor(() => {
4194+
expect(mockReauthenticate).not.toHaveBeenCalled();
4195+
});
4196+
expect(mockGeneratePinToken).not.toHaveBeenCalled();
4197+
});
4198+
4199+
describe('Biometric Authentication', () => {
4200+
it('does not fetch pin when biometric authentication fails', async () => {
4201+
// Given: Authenticated US user with card but biometric auth fails
4202+
setupMockSelectors({ isAuthenticated: true, userLocation: 'us' });
4203+
setupLoadCardDataMock({
4204+
isAuthenticated: true,
4205+
isBaanxLoginEnabled: true,
4206+
cardDetails: { type: CardType.VIRTUAL },
4207+
isLoading: false,
4208+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4209+
});
4210+
4211+
mockReauthenticate.mockRejectedValueOnce(
4212+
new Error('BIOMETRIC_ERROR: User cancelled'),
4213+
);
4214+
4215+
// When: component renders and button is pressed
4216+
render();
4217+
fireEvent.press(
4218+
screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON),
4219+
);
4220+
4221+
// Then: reauthenticate is called but generatePinToken is NOT called
4222+
await waitFor(() => {
4223+
expect(mockReauthenticate).toHaveBeenCalled();
4224+
});
4225+
expect(mockGeneratePinToken).not.toHaveBeenCalled();
4226+
});
4227+
4228+
it('navigates to password bottom sheet with view pin description when biometrics not configured', async () => {
4229+
// Given: Authenticated US user with card but biometrics not configured
4230+
setupMockSelectors({ isAuthenticated: true, userLocation: 'us' });
4231+
setupLoadCardDataMock({
4232+
isAuthenticated: true,
4233+
isBaanxLoginEnabled: true,
4234+
cardDetails: { type: CardType.VIRTUAL },
4235+
isLoading: false,
4236+
kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
4237+
});
4238+
4239+
mockReauthenticate.mockRejectedValueOnce(
4240+
new Error(
4241+
'PASSWORD_NOT_SET_WITH_BIOMETRICS: Biometrics not configured',
4242+
),
4243+
);
4244+
4245+
// When: component renders and button is pressed
4246+
render();
4247+
fireEvent.press(
4248+
screen.getByTestId(CardHomeSelectors.VIEW_PIN_BUTTON),
4249+
);
4250+
4251+
// Then: navigation to password bottom sheet is triggered with view pin description
4252+
await waitFor(() => {
4253+
expect(mockReauthenticate).toHaveBeenCalled();
4254+
});
4255+
await waitFor(() => {
4256+
expect(mockNavigate).toHaveBeenCalledWith(
4257+
Routes.CARD.MODALS.ID,
4258+
expect.objectContaining({
4259+
screen: Routes.CARD.MODALS.PASSWORD,
4260+
params: expect.objectContaining({
4261+
onSuccess: expect.any(Function),
4262+
description:
4263+
'Enter your wallet password to view your card PIN.',
4264+
}),
4265+
}),
4266+
);
4267+
});
4268+
});
4269+
});
4270+
});
39414271
});
39424272

39434273
describe('Freeze Card Toggle', () => {

app/components/UI/Card/Views/CardHome/CardHome.testIds.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ export const CardHomeSelectors = {
3030
ORDER_METAL_CARD_ITEM: 'order-metal-card-item',
3131
CASHBACK_ITEM: 'cashback-item',
3232
FREEZE_CARD_TOGGLE: 'freeze-card-toggle',
33+
VIEW_PIN_BUTTON: 'view-pin-button',
3334
};

0 commit comments

Comments
 (0)