Skip to content

Commit a3ec12c

Browse files
authored
feat(card): Card Authentication migration from CardSDK to CardController (#27656)
<!-- 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** Migrates Card authentication to the CardController, introducing a controller-based auth flow that delegates to BaanxProvider and a new `useCardAuth` hook for UI consumption. **Why**: Centralizing auth logic in the CardController aligns with the MetaMask controller architecture, provides a single source of truth for auth state, and enables future provider-agnostic auth flows. The new `useCardAuth` hook exposes mutations (initiate, submit, stepAction, logout) backed by the controller, with React Query for cache invalidation on success. **What changed**: - **CardController**: Added auth methods `initiateAuth`, `submitCredentials`, `executeStepAction`, and `logout` that delegate to the active provider (BaanxProvider). Manages `currentSession` for multi-step flows (email/password → OTP → complete). - **BaanxProvider**: Implemented `executeStepAction` (generic OTP trigger) that posts `userId` to `/v1/auth/login/otp`. Renamed from provider-specific `sendOtp` to align with `ICardProvider` interface. - **provider-types**: Added optional `executeStepAction?(session: CardAuthSession): Promise<void>` to `ICardProvider`. - **useCardAuth hook**: New hook exposing `currentStep`, `initiate`, `submit`, `stepAction`, and `logout` mutations. Uses CardController for all auth operations; updates `currentStep` on submit results (OTP, onboarding required, done); invalidates card queries on login success; removes queries on logout. - **auth queries**: Added `authKeys` (initiate, submit, step-action, logout) to `cardQueries.auth.keys`. - **getCardProviderErrorMessage**: New utility mapping `CardProviderError` codes to localized strings for Card authentication UI. - **BaanxService / baanx-config**: Minor adjustments for config resolution and service usage. - **Tests**: Updated BaanxProvider tests (`sendOtp` → `executeStepAction`); fixed baanx-config test mock isolation with `beforeEach` mock clear; expanded CardController tests for auth methods; added `useCardAuth` unit tests. ## **Changelog** CHANGELOG entry: Migrated Card authentication to CardController with new `useCardAuth` hook for controller-based auth flow. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card authentication via CardController Scenario: User logs in with email and password (no OTP) Given I am on the Card authentication screen And I have valid email and password credentials When I enter my email and password And I tap the login button Then I should be authenticated and see the Card home/dashboard Scenario: User logs in with email, password, and OTP Given I am on the Card authentication screen And I have credentials that require OTP verification When I enter my email and password And I tap the login button Then I should see the OTP input screen When I tap "Resend code" (or wait for cooldown) Then an OTP should be sent to my device/email When I enter the OTP code and submit Then I should be authenticated and see the Card home/dashboard Scenario: User logs out Given I am authenticated in the Card flow When I log out (or navigate away and return to auth) Then I should see the login screen again And card queries should be cleared ``` ## **Screenshots/Recordings** No visual design changes — authentication flow (email/password, OTP, logout) behaves the same; only the underlying implementation (CardController + useCardAuth vs SDK) changed. ## **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] > **High Risk** > High risk because it introduces a new controller-driven authentication/session lifecycle (multi-step auth, token storage, refresh, and logout) and changes default provider selection/state, which can impact login persistence and security-sensitive flows. > > **Overview** > Migrates card authentication off the UI/SDK path into `CardController`, adding controller-level methods for `initiateAuth`, `submitCredentials`, `executeStepAction`, `logout`, and `validateAndRefreshSession` with token persistence via `CardTokenStore`, provider delegation, and session/step tracking. > > Adds a new UI hook `useCardAuth` backed by React Query mutations (with new `cardQueries.auth` keys) to drive the multi-step login flow and to invalidate/remove card queries on login/logout. > > Standardizes provider step actions by renaming `ICardProvider.sendOtp` to `executeStepAction` and updating `BaanxProvider` accordingly, plus adds localized error mapping via `getCardProviderErrorMessage` and extends Baanx config/service behavior (env override for base URL; per-request location override for `x-us-env`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a91ba19. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 62cbdf5 commit a3ec12c

18 files changed

Lines changed: 1159 additions & 23 deletions
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { renderHook, act } from '@testing-library/react-hooks';
2+
import { useMutation, useQueryClient } from '@tanstack/react-query';
3+
import Engine from '../../../../core/Engine';
4+
import { cardQueries } from '../queries';
5+
import { useCardAuth } from './useCardAuth';
6+
7+
jest.mock('@tanstack/react-query', () => ({
8+
useMutation: jest.fn(),
9+
useQueryClient: jest.fn(),
10+
}));
11+
12+
jest.mock('../../../../core/Engine', () => ({
13+
context: {
14+
CardController: {
15+
initiateAuth: jest.fn(),
16+
submitCredentials: jest.fn(),
17+
executeStepAction: jest.fn(),
18+
logout: jest.fn(),
19+
},
20+
},
21+
}));
22+
23+
jest.mock('../../../../util/Logger', () => ({
24+
error: jest.fn(),
25+
}));
26+
27+
const mockUseMutation = useMutation as jest.Mock;
28+
const mockUseQueryClient = useQueryClient as jest.Mock;
29+
const mockController = Engine.context.CardController as jest.Mocked<
30+
typeof Engine.context.CardController
31+
>;
32+
33+
const mockInvalidateQueries = jest.fn();
34+
const mockRemoveQueries = jest.fn();
35+
36+
describe('useCardAuth', () => {
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
40+
mockUseQueryClient.mockReturnValue({
41+
invalidateQueries: mockInvalidateQueries,
42+
removeQueries: mockRemoveQueries,
43+
});
44+
45+
mockUseMutation.mockImplementation(
46+
(opts: {
47+
mutationFn: (...args: unknown[]) => Promise<unknown>;
48+
onSuccess?: (result: unknown) => void;
49+
}) => ({
50+
mutateAsync: jest.fn(async (...args: unknown[]) => {
51+
const res = await opts.mutationFn(...args);
52+
opts.onSuccess?.(res);
53+
return res;
54+
}),
55+
isPending: false,
56+
error: null,
57+
data: null,
58+
}),
59+
);
60+
});
61+
62+
it('currentStep starts as email_password', () => {
63+
const { result } = renderHook(() => useCardAuth());
64+
65+
expect(result.current.currentStep).toStrictEqual({
66+
type: 'email_password',
67+
});
68+
});
69+
70+
it('exposes getErrorMessage for displaying auth errors', () => {
71+
const { result } = renderHook(() => useCardAuth());
72+
73+
expect(result.current.getErrorMessage).toBeDefined();
74+
expect(typeof result.current.getErrorMessage).toBe('function');
75+
});
76+
77+
describe('initiate', () => {
78+
it('calls controller.initiateAuth', async () => {
79+
mockController.initiateAuth.mockResolvedValue(undefined);
80+
const { result } = renderHook(() => useCardAuth());
81+
82+
await act(async () => {
83+
await result.current.initiate.mutateAsync('US');
84+
});
85+
86+
expect(mockController.initiateAuth).toHaveBeenCalledWith('US');
87+
});
88+
});
89+
90+
describe('submit', () => {
91+
it('calls controller.submitCredentials with credentials', async () => {
92+
mockController.submitCredentials.mockResolvedValue({
93+
done: true,
94+
tokenSet: {} as never,
95+
});
96+
const { result } = renderHook(() => useCardAuth());
97+
98+
await act(async () => {
99+
await result.current.submit.mutateAsync({
100+
type: 'email_password',
101+
email: 'a@b.com',
102+
password: 'p',
103+
});
104+
});
105+
106+
expect(mockController.submitCredentials).toHaveBeenCalledWith({
107+
type: 'email_password',
108+
email: 'a@b.com',
109+
password: 'p',
110+
});
111+
});
112+
113+
it('resets currentStep and invalidates queries on done:true', async () => {
114+
mockController.submitCredentials.mockResolvedValue({ done: true });
115+
const { result } = renderHook(() => useCardAuth());
116+
117+
await act(async () => {
118+
await result.current.submit.mutateAsync({
119+
type: 'email_password',
120+
email: 'a@b.com',
121+
password: 'p',
122+
});
123+
});
124+
125+
expect(result.current.currentStep).toStrictEqual({
126+
type: 'email_password',
127+
});
128+
expect(mockInvalidateQueries).toHaveBeenCalledWith({
129+
queryKey: cardQueries.keys.all(),
130+
});
131+
});
132+
133+
it('updates currentStep when nextStep is returned', async () => {
134+
mockController.submitCredentials.mockResolvedValue({
135+
done: false,
136+
nextStep: { type: 'otp', destination: '+1555****90' },
137+
});
138+
const { result } = renderHook(() => useCardAuth());
139+
140+
await act(async () => {
141+
await result.current.submit.mutateAsync({
142+
type: 'email_password',
143+
email: 'a@b.com',
144+
password: 'p',
145+
});
146+
});
147+
148+
expect(result.current.currentStep).toStrictEqual({
149+
type: 'otp',
150+
destination: '+1555****90',
151+
});
152+
});
153+
154+
it('resets currentStep when onboardingRequired is returned', async () => {
155+
mockController.submitCredentials.mockResolvedValue({
156+
done: false,
157+
onboardingRequired: { sessionId: 'ob-1', phase: 'kyc' },
158+
});
159+
const { result } = renderHook(() => useCardAuth());
160+
161+
await act(async () => {
162+
await result.current.submit.mutateAsync({
163+
type: 'email_password',
164+
email: 'a@b.com',
165+
password: 'p',
166+
});
167+
});
168+
169+
expect(result.current.currentStep).toStrictEqual({
170+
type: 'email_password',
171+
});
172+
});
173+
174+
it('resets currentStep when done:false without nextStep or onboardingRequired', async () => {
175+
mockController.submitCredentials.mockResolvedValue({ done: false });
176+
const { result } = renderHook(() => useCardAuth());
177+
178+
await act(async () => {
179+
await result.current.submit.mutateAsync({
180+
type: 'email_password',
181+
email: 'a@b.com',
182+
password: 'p',
183+
});
184+
});
185+
186+
expect(result.current.currentStep).toStrictEqual({
187+
type: 'email_password',
188+
});
189+
});
190+
});
191+
192+
describe('stepAction', () => {
193+
it('calls controller.executeStepAction', async () => {
194+
mockController.executeStepAction.mockResolvedValue(undefined);
195+
const { result } = renderHook(() => useCardAuth());
196+
197+
await act(async () => {
198+
await result.current.stepAction.mutateAsync();
199+
});
200+
201+
expect(mockController.executeStepAction).toHaveBeenCalled();
202+
});
203+
});
204+
205+
describe('logout', () => {
206+
it('calls controller.logout', async () => {
207+
mockController.logout.mockResolvedValue(undefined);
208+
const { result } = renderHook(() => useCardAuth());
209+
210+
await act(async () => {
211+
await result.current.logout.mutateAsync();
212+
});
213+
214+
expect(mockController.logout).toHaveBeenCalled();
215+
});
216+
217+
it('resets currentStep and removes queries on success', async () => {
218+
mockController.submitCredentials.mockResolvedValue({
219+
done: false,
220+
nextStep: { type: 'otp', destination: '+1555****90' },
221+
});
222+
mockController.logout.mockResolvedValue(undefined);
223+
const { result } = renderHook(() => useCardAuth());
224+
225+
await act(async () => {
226+
await result.current.submit.mutateAsync({
227+
type: 'email_password',
228+
email: 'a@b.com',
229+
password: 'p',
230+
});
231+
});
232+
expect(result.current.currentStep).toStrictEqual({
233+
type: 'otp',
234+
destination: '+1555****90',
235+
});
236+
237+
await act(async () => {
238+
await result.current.logout.mutateAsync();
239+
});
240+
241+
expect(result.current.currentStep).toStrictEqual({
242+
type: 'email_password',
243+
});
244+
expect(mockRemoveQueries).toHaveBeenCalledWith({
245+
queryKey: cardQueries.keys.all(),
246+
});
247+
});
248+
});
249+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useState } from 'react';
2+
import { useMutation, useQueryClient } from '@tanstack/react-query';
3+
import Engine from '../../../../core/Engine';
4+
import { cardQueries } from '../queries';
5+
import {
6+
CardAuthStep,
7+
CardCredentials,
8+
} from '../../../../core/Engine/controllers/card-controller/provider-types';
9+
import { getCardProviderErrorMessage } from '../util/getCardProviderErrorMessage';
10+
11+
function getController() {
12+
const controller = Engine.context?.CardController;
13+
if (!controller) {
14+
throw new Error('CardController not initialized');
15+
}
16+
return controller;
17+
}
18+
19+
const LOGIN_STEP: CardAuthStep = { type: 'email_password' };
20+
21+
export const useCardAuth = () => {
22+
const queryClient = useQueryClient();
23+
const [currentStep, setCurrentStep] = useState<CardAuthStep>(LOGIN_STEP);
24+
25+
const initiate = useMutation({
26+
mutationKey: cardQueries.auth.keys.initiate(),
27+
mutationFn: (country: string) => getController().initiateAuth(country),
28+
retry: false,
29+
});
30+
31+
const submit = useMutation({
32+
mutationKey: cardQueries.auth.keys.submit(),
33+
mutationFn: (credentials: CardCredentials) =>
34+
getController().submitCredentials(credentials),
35+
onSuccess: (result) => {
36+
if (result.done || result.onboardingRequired) {
37+
setCurrentStep(LOGIN_STEP);
38+
if (result.done) {
39+
queryClient.invalidateQueries({ queryKey: cardQueries.keys.all() });
40+
}
41+
} else if (result.nextStep) {
42+
setCurrentStep(result.nextStep);
43+
} else {
44+
// Controller cleared session (done:false without nextStep/onboardingRequired)
45+
setCurrentStep(LOGIN_STEP);
46+
}
47+
},
48+
retry: false,
49+
});
50+
51+
const stepAction = useMutation({
52+
mutationKey: cardQueries.auth.keys.stepAction(),
53+
mutationFn: () => getController().executeStepAction(),
54+
retry: false,
55+
});
56+
57+
const logout = useMutation({
58+
mutationKey: cardQueries.auth.keys.logout(),
59+
mutationFn: () => getController().logout(),
60+
onSuccess: () => {
61+
setCurrentStep(LOGIN_STEP);
62+
queryClient.removeQueries({ queryKey: cardQueries.keys.all() });
63+
},
64+
retry: false,
65+
});
66+
67+
return {
68+
currentStep,
69+
initiate,
70+
submit,
71+
stepAction,
72+
logout,
73+
getErrorMessage: getCardProviderErrorMessage,
74+
};
75+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const authKeys = {
2+
all: () => ['card', 'auth'] as const,
3+
initiate: () => [...authKeys.all(), 'initiate'] as const,
4+
submit: () => [...authKeys.all(), 'submit'] as const,
5+
stepAction: () => [...authKeys.all(), 'step-action'] as const,
6+
logout: () => [...authKeys.all(), 'logout'] as const,
7+
};

app/components/UI/Card/queries/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
cashbackWithdrawEstimationOptions,
66
} from './cashback';
77
import { dashboardKeys } from './dashboard';
8+
import { authKeys } from './auth';
89

910
export const cardQueries = {
1011
keys: {
@@ -22,4 +23,7 @@ export const cardQueries = {
2223
walletOptions: cashbackWalletOptions,
2324
withdrawEstimationOptions: cashbackWithdrawEstimationOptions,
2425
},
26+
auth: {
27+
keys: authKeys,
28+
},
2529
};

0 commit comments

Comments
 (0)