diff --git a/android/app/build.gradle b/android/app/build.gradle
index 76a0e520d55e..1920f4b8e08c 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -187,7 +187,7 @@ android {
applicationId "io.metamask"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionName "7.67.0"
+ versionName "7.68.0"
versionCode 3607
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index dcb9c527749c..f38190a67857 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -424,7 +424,7 @@ const SettingsFlow = () => {
{
- const { colors } = useTheme();
- const styles = createStyles(colors);
-
- const renderIcon = (type: BiometryType) => {
- if (Platform.OS === 'ios') {
- if (type === BIOMETRY_TYPE.TOUCH_ID) {
- return (
-
- );
- } else if (type?.includes(AUTHENTICATION_TYPE.PASSCODE)) {
- return (
-
- );
- }
- return (
-
- );
- }
-
- if (Platform.OS === 'android') {
- if (type === BIOMETRY_TYPE.FINGERPRINT) {
- return (
-
- );
- } else if (type === BIOMETRY_TYPE.FACE) {
- return (
-
- );
- } else if (type === BIOMETRY_TYPE.IRIS) {
- return (
-
- );
- } else if (type?.includes(AUTHENTICATION_TYPE.PASSCODE)) {
- return (
-
- );
- }
- }
-
- return (
-
- );
- };
-
- if (hidden) return null;
-
- return (
-
- {biometryType ? renderIcon(biometryType) : null}
-
- );
-};
-
-export default BiometryButton;
diff --git a/app/components/UI/BiometryButton/index.test.tsx b/app/components/UI/BiometryButton/index.test.tsx
deleted file mode 100644
index e4dcc731659e..000000000000
--- a/app/components/UI/BiometryButton/index.test.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import React from 'react';
-import { render } from '@testing-library/react-native';
-import { Platform } from 'react-native';
-import { BIOMETRY_TYPE } from 'react-native-keychain';
-import BiometryButton from './BiometryButton';
-import AUTHENTICATION_TYPE from '../../../constants/userProperties';
-import { LoginViewSelectors } from '../../Views/Login/LoginView.testIds';
-
-jest.mock('react-native', () => ({
- ...jest.requireActual('react-native'),
- Platform: { OS: 'ios' },
-}));
-
-const mockOnPress = jest.fn();
-
-describe('BiometryButton', () => {
- it('should hide when hidden is true', () => {
- const { toJSON } = render(
- ,
- );
-
- expect(toJSON()).toBeNull();
- });
-
- describe('ios', () => {
- beforeEach(() => {
- Platform.OS = 'ios';
- });
-
- it('should render touch id icon', () => {
- const { getByTestId } = render(
- ,
- );
-
- const touchIdIcon = getByTestId(LoginViewSelectors.IOS_TOUCH_ID_ICON);
- expect(touchIdIcon).toBeDefined();
- });
-
- it('should render passcode icon', () => {
- const { getByTestId } = render(
- ,
- );
-
- const passcodeIcon = getByTestId(LoginViewSelectors.IOS_PASSCODE_ICON);
- expect(passcodeIcon).toBeDefined();
- });
-
- it('should render fallback face id icon', () => {
- const { getByTestId } = render(
- ,
- );
-
- const fallbackFaceIdIcon = getByTestId(
- LoginViewSelectors.IOS_FACE_ID_ICON,
- );
- expect(fallbackFaceIdIcon).toBeDefined();
- });
- });
-
- describe('android', () => {
- beforeEach(() => {
- Platform.OS = 'android';
- });
-
- it('should render fingerprint icon', () => {
- const { getByTestId } = render(
- ,
- );
-
- const fingerprintIcon = getByTestId(
- LoginViewSelectors.ANDROID_FINGERPRINT_ICON,
- );
- expect(fingerprintIcon).toBeDefined();
- });
-
- it('should render face id icon', () => {
- const { getByTestId } = render(
- ,
- );
-
- const faceIdIcon = getByTestId(LoginViewSelectors.ANDROID_FACE_ID_ICON);
- expect(faceIdIcon).toBeDefined();
- });
-
- it('should render iris icon', () => {
- const { getByTestId } = render(
- ,
- );
-
- const irisIcon = getByTestId(LoginViewSelectors.ANDROID_IRIS_ICON);
- expect(irisIcon).toBeDefined();
- });
-
- it('should render passcode icon', () => {
- const { getByTestId } = render(
- ,
- );
-
- const passcodeIcon = getByTestId(
- LoginViewSelectors.ANDROID_PASSCODE_ICON,
- );
- expect(passcodeIcon).toBeDefined();
- });
- });
-
- it('should render fallback fingerprint icon', () => {
- const { getByTestId } = render(
- ,
- );
-
- const fallbackFingerprintIcon = getByTestId(
- LoginViewSelectors.FALLBACK_FINGERPRINT_ICON,
- );
- expect(fallbackFingerprintIcon).toBeDefined();
- });
-});
diff --git a/app/components/UI/BiometryButton/index.ts b/app/components/UI/BiometryButton/index.ts
deleted file mode 100644
index b9b0084f67cc..000000000000
--- a/app/components/UI/BiometryButton/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as BiometryButton } from './BiometryButton';
diff --git a/app/components/UI/BiometryButton/styles.ts b/app/components/UI/BiometryButton/styles.ts
deleted file mode 100644
index d2d00fa8741a..000000000000
--- a/app/components/UI/BiometryButton/styles.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/* eslint-disable import/prefer-default-export */
-import { StyleSheet } from 'react-native';
-
-// TODO: Replace "any" with type
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const createStyles = (colors: any) =>
- StyleSheet.create({
- fixCenterIcon: {
- marginBottom: -3,
- },
- image: {
- height: 24,
- width: 24,
- tintColor: colors.text.default,
- },
- hitSlop: {
- top: 10,
- left: 10,
- bottom: 10,
- right: 10,
- },
- });
diff --git a/app/components/UI/DeviceAuthenticationButton/DeviceAuthenticationButton.tsx b/app/components/UI/DeviceAuthenticationButton/DeviceAuthenticationButton.tsx
new file mode 100644
index 000000000000..7fe5dfdfce0a
--- /dev/null
+++ b/app/components/UI/DeviceAuthenticationButton/DeviceAuthenticationButton.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { TouchableOpacity, TouchableOpacityProps } from 'react-native';
+import styles from './styles';
+import Icon, {
+ IconName,
+ IconSize,
+ IconColor,
+} from '../../../component-library/components/Icons/Icon';
+import { LoginViewSelectors } from '../../Views/Login/LoginView.testIds';
+
+type DeviceAuthenticationButtonProps = {
+ hidden: boolean;
+} & TouchableOpacityProps;
+
+const DeviceAuthenticationButton = ({
+ hidden,
+ ...props
+}: DeviceAuthenticationButtonProps) => {
+ if (hidden) return null;
+
+ return (
+
+
+
+ );
+};
+
+export default DeviceAuthenticationButton;
diff --git a/app/components/UI/DeviceAuthenticationButton/index.test.tsx b/app/components/UI/DeviceAuthenticationButton/index.test.tsx
new file mode 100644
index 000000000000..b97da0490336
--- /dev/null
+++ b/app/components/UI/DeviceAuthenticationButton/index.test.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import DeviceAuthenticationButton from './DeviceAuthenticationButton';
+import { LoginViewSelectors } from '../../Views/Login/LoginView.testIds';
+
+const mockOnPress = jest.fn();
+
+describe('DeviceAuthenticationButton', () => {
+ beforeEach(() => {
+ mockOnPress.mockClear();
+ });
+
+ it('hides when hidden is true', () => {
+ const { toJSON } = render(
+ ,
+ );
+
+ expect(toJSON()).toBeNull();
+ });
+
+ it('renders button with device authentication icon when visible', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(LoginViewSelectors.BIOMETRY_BUTTON)).toBeOnTheScreen();
+ expect(
+ getByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls onPress when pressed', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(LoginViewSelectors.BIOMETRY_BUTTON));
+ expect(mockOnPress).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/components/UI/DeviceAuthenticationButton/index.ts b/app/components/UI/DeviceAuthenticationButton/index.ts
new file mode 100644
index 000000000000..5aece9f3b41d
--- /dev/null
+++ b/app/components/UI/DeviceAuthenticationButton/index.ts
@@ -0,0 +1 @@
+export { default as DeviceAuthenticationButton } from './DeviceAuthenticationButton';
diff --git a/app/components/UI/DeviceAuthenticationButton/styles.ts b/app/components/UI/DeviceAuthenticationButton/styles.ts
new file mode 100644
index 000000000000..8f32080aaf62
--- /dev/null
+++ b/app/components/UI/DeviceAuthenticationButton/styles.ts
@@ -0,0 +1,14 @@
+/* eslint-disable import/prefer-default-export */
+import { StyleSheet } from 'react-native';
+
+export default StyleSheet.create({
+ fixCenterIcon: {
+ marginBottom: -3,
+ },
+ hitSlop: {
+ top: 10,
+ left: 10,
+ bottom: 10,
+ right: 10,
+ },
+});
diff --git a/app/components/UI/Ramp/Deposit/Views/Root/Root.test.tsx b/app/components/UI/Ramp/Deposit/Views/Root/Root.test.tsx
index c3602761d549..91a6d937b813 100644
--- a/app/components/UI/Ramp/Deposit/Views/Root/Root.test.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/Root/Root.test.tsx
@@ -87,16 +87,7 @@ describe('Root Component', () => {
expect(screen.toJSON()).toMatchSnapshot();
});
- it('calls checkExistingToken on load', async () => {
- mockCheckExistingToken.mockResolvedValue(false);
- render(Root);
- await waitFor(() => {
- expect(mockCheckExistingToken).toHaveBeenCalled();
- });
- });
-
- it('redirects to BUILD_QUOTE when existing token has been checked', async () => {
- mockCheckExistingToken.mockResolvedValue(true);
+ it('redirects to BUILD_QUOTE immediately when no created orders exist', async () => {
render(Root);
await waitFor(() => {
expect(mockReset).toHaveBeenCalledWith({
@@ -109,6 +100,27 @@ describe('Root Component', () => {
],
});
});
+ expect(mockCheckExistingToken).not.toHaveBeenCalled();
+ });
+
+ it('calls checkExistingToken when a created order exists', async () => {
+ const mockOrders = [
+ {
+ id: 'test-order-id',
+ provider: FIAT_ORDER_PROVIDERS.DEPOSIT,
+ state: FIAT_ORDER_STATES.CREATED,
+ },
+ ] as FiatOrder[];
+
+ (
+ getAllDepositOrders as jest.MockedFunction
+ ).mockReturnValue(mockOrders);
+ mockCheckExistingToken.mockResolvedValue(false);
+ render(Root);
+
+ await waitFor(() => {
+ expect(mockCheckExistingToken).toHaveBeenCalled();
+ });
});
it('redirects to bank details when there is a created order and user is authenticated', async () => {
@@ -173,7 +185,18 @@ describe('Root Component', () => {
});
});
- it('falls back to BUILD_QUOTE when checkExistingToken rejects', async () => {
+ it('redirects to EnterEmail when checkExistingToken rejects and there is a created order', async () => {
+ const mockOrders = [
+ {
+ id: 'test-order-reject',
+ provider: FIAT_ORDER_PROVIDERS.DEPOSIT,
+ state: FIAT_ORDER_STATES.CREATED,
+ },
+ ] as FiatOrder[];
+
+ (
+ getAllDepositOrders as jest.MockedFunction
+ ).mockReturnValue(mockOrders);
mockCheckExistingToken.mockRejectedValue(
new Error('SecureKeychain unavailable'),
);
@@ -184,8 +207,11 @@ describe('Root Component', () => {
index: 0,
routes: [
{
- name: Routes.DEPOSIT.BUILD_QUOTE,
- params: { animationEnabled: false },
+ name: 'EnterEmail',
+ params: {
+ redirectToRootAfterAuth: true,
+ animationEnabled: false,
+ },
},
],
});
diff --git a/app/components/UI/Ramp/Deposit/Views/Root/Root.tsx b/app/components/UI/Ramp/Deposit/Views/Root/Root.tsx
index 43fd556a6a36..cb6d43cbcc04 100644
--- a/app/components/UI/Ramp/Deposit/Views/Root/Root.tsx
+++ b/app/components/UI/Ramp/Deposit/Views/Root/Root.tsx
@@ -14,7 +14,7 @@ import { useParams } from '../../../../../../util/navigation/navUtils';
import { useTheme } from '../../../../../../util/theme';
import Logger from '../../../../../../util/Logger';
-export const TOKEN_CHECK_TIMEOUT_MS = 5000;
+export const TOKEN_CHECK_TIMEOUT_MS = 2000;
function withTimeout(promise: Promise, ms: number): Promise {
let timeoutId: ReturnType;
@@ -65,6 +65,16 @@ const Root = () => {
const initializeFlow = async () => {
if (hasCheckedToken.current) return;
+ hasCheckedToken.current = true;
+
+ const createdOrder = orders.find(
+ (order) => order.state === FIAT_ORDER_STATES.CREATED,
+ );
+
+ if (!createdOrder) {
+ navigateToDefaultRoute();
+ return;
+ }
let isAuthenticatedFromToken = false;
try {
@@ -79,31 +89,9 @@ const Root = () => {
);
}
- hasCheckedToken.current = true;
-
- const createdOrder = orders.find(
- (order) => order.state === FIAT_ORDER_STATES.CREATED,
- );
-
- if (createdOrder) {
- if (!isAuthenticatedFromToken) {
- const [routeName, navParams] = createEnterEmailNavDetails({
- redirectToRootAfterAuth: true,
- });
- navigation.reset({
- index: 0,
- routes: [
- {
- name: routeName,
- params: { ...navParams, animationEnabled: false },
- },
- ],
- });
- return;
- }
-
- const [routeName, navParams] = createBankDetailsNavDetails({
- orderId: createdOrder.id,
+ if (!isAuthenticatedFromToken) {
+ const [routeName, navParams] = createEnterEmailNavDetails({
+ redirectToRootAfterAuth: true,
});
navigation.reset({
index: 0,
@@ -114,9 +102,21 @@ const Root = () => {
},
],
});
- } else {
- navigateToDefaultRoute();
+ return;
}
+
+ const [routeName, navParams] = createBankDetailsNavDetails({
+ orderId: createdOrder.id,
+ });
+ navigation.reset({
+ index: 0,
+ routes: [
+ {
+ name: routeName,
+ params: { ...navParams, animationEnabled: false },
+ },
+ ],
+ });
};
initializeFlow().catch((error) => {
diff --git a/app/components/UI/Ramp/Deposit/routes/index.tsx b/app/components/UI/Ramp/Deposit/routes/index.tsx
index 6488babd9993..67924b58f87b 100644
--- a/app/components/UI/Ramp/Deposit/routes/index.tsx
+++ b/app/components/UI/Ramp/Deposit/routes/index.tsx
@@ -82,7 +82,7 @@ const MainRoutes = ({ route }: MainRoutesProps) => {
name={Routes.DEPOSIT.ROOT}
component={Root}
initialParams={parentParams}
- options={{ animationEnabled: false }}
+ options={{ animationEnabled: false, headerShown: false }}
/>
{
+ useLayoutEffect(() => {
navigation.setOptions(
getDepositNavbarOptions(
navigation,
diff --git a/app/components/UI/Stake/components/LearnMoreModal/LearnMoreModalFooter.tsx b/app/components/UI/Stake/components/LearnMoreModal/LearnMoreModalFooter.tsx
index 0a8186e8526b..02f6c9542aeb 100644
--- a/app/components/UI/Stake/components/LearnMoreModal/LearnMoreModalFooter.tsx
+++ b/app/components/UI/Stake/components/LearnMoreModal/LearnMoreModalFooter.tsx
@@ -13,7 +13,8 @@ import Text, {
TextColor,
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
-import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { strings } from '../../../../../../locales/i18n';
import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events';
@@ -29,7 +30,7 @@ export const LearnMoreModalFooter = ({
style,
}: LearnMoreModalFooterProps) => {
const { navigate } = useNavigation();
- const { trackEvent, createEventBuilder } = useMetrics();
+ const { trackEvent, createEventBuilder } = useAnalytics();
const redirectToLearnMore = () => {
navigate('Webview', {
diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx
index 3f5e0bb0b3dd..ead5075c2c45 100644
--- a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx
+++ b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx
@@ -8,8 +8,8 @@ import {
MOCK_ETH_MAINNET_ASSET,
MOCK_USDC_MAINNET_ASSET,
} from '../../__mocks__/stakeMockData';
-import { useMetrics } from '../../../../hooks/useMetrics';
-import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder';
import { mockNetworkState } from '../../../../../util/test/network';
import useStakingEligibility from '../../hooks/useStakingEligibility';
import { RootState } from '../../../../../reducers';
@@ -35,7 +35,18 @@ jest.mock('@react-navigation/native', () => {
};
});
-jest.mock('../../../../hooks/useMetrics');
+jest.mock('../../../../hooks/useAnalytics/useAnalytics');
+
+jest.mock('../../../Earn/hooks/useStablecoinLendingRedirect', () => ({
+ useStablecoinLendingRedirect: jest.fn(({ asset }: Record) =>
+ jest.fn(() => {
+ mockNavigate('StakeScreens', {
+ screen: 'Stake',
+ params: { token: asset },
+ });
+ }),
+ ),
+}));
jest.mock('../../../../hooks/useBuildPortfolioUrl', () => ({
useBuildPortfolioUrl: jest.fn(() => (baseUrl: string) => {
@@ -79,9 +90,9 @@ jest.mock('../../../../../selectors/earnController/earn', () => ({
},
}));
-(useMetrics as jest.MockedFn).mockReturnValue({
+(useAnalytics as jest.MockedFn).mockReturnValue({
trackEvent: jest.fn(),
- createEventBuilder: MetricsEventBuilder.createEventBuilder,
+ createEventBuilder: AnalyticsEventBuilder.createEventBuilder,
enable: jest.fn(),
addTraitsToUser: jest.fn(),
createDataDeletionTask: jest.fn(),
@@ -90,7 +101,7 @@ jest.mock('../../../../../selectors/earnController/earn', () => ({
getDeleteRegulationId: jest.fn(),
isDataRecorded: jest.fn(),
isEnabled: jest.fn(),
- getMetaMetricsId: jest.fn(),
+ getAnalyticsId: jest.fn(),
});
jest.mock('../../../../../core/Engine', () => ({
diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx
index 9477de9979c9..77c2569a0e90 100644
--- a/app/components/UI/Stake/components/StakeButton/index.tsx
+++ b/app/components/UI/Stake/components/StakeButton/index.tsx
@@ -17,7 +17,8 @@ import {
selectNetworkConfigurationByChainId,
} from '../../../../../selectors/networkController';
import { getDecimalChainId } from '../../../../../util/networks';
-import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences';
import {
selectPooledStakingEnabledFlag,
@@ -51,7 +52,7 @@ interface StakeButtonContentProps {
const StakeButtonContent = ({ earnToken }: StakeButtonContentProps) => {
const { styles } = useStyles(styleSheet, {});
const navigation = useNavigation();
- const { trackEvent, createEventBuilder } = useMetrics();
+ const { trackEvent, createEventBuilder } = useAnalytics();
const chainId = useSelector(selectEvmChainId);
const { isStakingSupportedChain } = useStakingChain();
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx
index 724070a3ea81..dc3f9e1c01a8 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx
@@ -24,6 +24,8 @@ import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences';
import { selectPooledStakingEnabledFlag } from '../../../Earn/selectors/featureFlags';
import { TokenI } from '../../../Tokens/types';
import useStakingEligibility from '../../hooks/useStakingEligibility';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder';
const mockEarnTokenPair = getMockUseEarnTokens(EARN_EXPERIENCES.POOLED_STAKING);
jest.mock('../../../Earn/hooks/useEarnings', () => ({
@@ -88,6 +90,8 @@ const MOCK_APR_VALUES: { [symbol: string]: string } = {
DAI: '5.0',
};
+jest.mock('../../../../hooks/useAnalytics/useAnalytics');
+
jest.mock('../../../../hooks/useIpfsGateway', () => jest.fn());
Image.getSize = jest
@@ -191,6 +195,19 @@ afterEach(() => {
describe('StakingBalance', () => {
beforeEach(() => {
jest.resetAllMocks();
+ (useAnalytics as jest.MockedFn).mockReturnValue({
+ trackEvent: jest.fn(),
+ createEventBuilder: AnalyticsEventBuilder.createEventBuilder,
+ enable: jest.fn(),
+ addTraitsToUser: jest.fn(),
+ createDataDeletionTask: jest.fn(),
+ checkDataDeleteStatus: jest.fn(),
+ getDeleteRegulationCreationDate: jest.fn(),
+ getDeleteRegulationId: jest.fn(),
+ isDataRecorded: jest.fn(),
+ isEnabled: jest.fn(),
+ getAnalyticsId: jest.fn(),
+ });
mockUseStakingEligibility.mockReturnValue({
isEligible: true,
isLoadingEligibility: false,
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx
index 67fce0ecc6e4..6eb4fe4a66e1 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx
@@ -20,7 +20,8 @@ import { RootState } from '../../../../../reducers';
import { selectNetworkConfigurationByChainId } from '../../../../../selectors/networkController';
import { getTimeDifferenceFromNow } from '../../../../../util/date';
import { getDecimalChainId } from '../../../../../util/networks';
-import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import AssetElement from '../../../AssetElement';
import { NetworkBadgeSource } from '../../../AssetOverview/Balance/Balance';
import NetworkAssetLogo from '../../../NetworkAssetLogo';
@@ -74,7 +75,7 @@ const StakingBalanceContent = ({ asset }: StakingBalanceProps) => {
asset.chainId as Hex,
);
- const { trackEvent, createEventBuilder } = useMetrics();
+ const { trackEvent, createEventBuilder } = useAnalytics();
const decimalChainId = getDecimalChainId(asset.chainId);
const {
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.test.tsx b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.test.tsx
index a59dfdd1349c..951753c0cee1 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.test.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.test.tsx
@@ -27,14 +27,8 @@ import { getMockUseEarnTokens } from '../../../../Earn/__mocks__/earnMockData';
const mockEarnTokenPair = getMockUseEarnTokens(EARN_EXPERIENCES.POOLED_STAKING);
-// Prevent `useMetrics` from triggering async Engine readiness polling (`whenEngineReady`)
-// which can cause Jest timeouts / "import after environment torn down" errors.
-jest.mock('../../../../../hooks/useMetrics', () => ({
- MetaMetricsEvents: {
- STAKE_BUTTON_CLICKED: 'STAKE_BUTTON_CLICKED',
- STAKE_WITHDRAW_BUTTON_CLICKED: 'STAKE_WITHDRAW_BUTTON_CLICKED',
- },
- useMetrics: () => ({
+jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
trackEvent: jest.fn(),
createEventBuilder: () => ({
addProperties: jest.fn().mockReturnThis(),
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx
index c8121c3d6e7f..835fff6f6568 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx
@@ -12,7 +12,8 @@ import Engine from '../../../../../../core/Engine';
import { RootState } from '../../../../../../reducers';
import { earnSelectors } from '../../../../../../selectors/earnController';
import { selectEvmChainId } from '../../../../../../selectors/networkController';
-import { MetaMetricsEvents, useMetrics } from '../../../../../hooks/useMetrics';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
import { selectPooledStakingEnabledFlag } from '../../../../Earn/selectors/featureFlags';
import { TokenI } from '../../../../Tokens/types';
import { EVENT_LOCATIONS } from '../../../constants/events';
@@ -37,7 +38,7 @@ const StakingButtons = ({
const { styles } = useStyles(styleSheet, {});
- const { trackEvent, createEventBuilder } = useMetrics();
+ const { trackEvent, createEventBuilder } = useAnalytics();
const { isEligible } = useStakingEligibility();
diff --git a/app/components/UI/Stake/hooks/usePoolStakedDeposit/index.ts b/app/components/UI/Stake/hooks/usePoolStakedDeposit/index.ts
index d5b75a72738d..c13c42f35387 100644
--- a/app/components/UI/Stake/hooks/usePoolStakedDeposit/index.ts
+++ b/app/components/UI/Stake/hooks/usePoolStakedDeposit/index.ts
@@ -10,7 +10,8 @@ import { formatEther } from 'ethers/lib/utils';
import { NetworkClientId } from '@metamask/network-controller';
import { addTransaction } from '../../../../../util/transaction-controller';
import trackErrorAsAnalytics from '../../../../../util/metrics/TrackError/trackErrorAsAnalytics';
-import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { Stake } from '../../sdk/stakeSdkProvider';
import { EVENT_PROVIDERS } from '../../constants/events';
import { useStakeContext } from '../useStakeContext';
@@ -35,8 +36,8 @@ const attemptDepositTransaction =
(
pooledStakingContract: PooledStakingContract,
networkClientId: NetworkClientId,
- trackEvent: ReturnType['trackEvent'],
- createEventBuilder: ReturnType['createEventBuilder'],
+ trackEvent: ReturnType['trackEvent'],
+ createEventBuilder: ReturnType['createEventBuilder'],
) =>
async (
depositValueWei: string,
@@ -97,7 +98,7 @@ const attemptDepositTransaction =
const usePoolStakedDeposit = () => {
const { networkClientId, stakingContract } =
useStakeContext() as Required;
- const { trackEvent, createEventBuilder } = useMetrics();
+ const { trackEvent, createEventBuilder } = useAnalytics();
// Linter is complaining that function may use other dependencies
// We will simply ignore since we don't want to use inline function
diff --git a/app/components/UI/Stake/hooks/usePoolStakedDeposit/usePoolStakedDeposit.test.tsx b/app/components/UI/Stake/hooks/usePoolStakedDeposit/usePoolStakedDeposit.test.tsx
index 77487d3994a6..72788a78abff 100644
--- a/app/components/UI/Stake/hooks/usePoolStakedDeposit/usePoolStakedDeposit.test.tsx
+++ b/app/components/UI/Stake/hooks/usePoolStakedDeposit/usePoolStakedDeposit.test.tsx
@@ -1,11 +1,11 @@
import { toHex } from '@metamask/controller-utils';
import { ChainId, PooledStakingContract } from '@metamask/stake-sdk';
import { Contract } from 'ethers';
-import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder';
+import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder';
import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils';
import { backgroundState } from '../../../../../util/test/initial-root-state';
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
-import useMetrics from '../../../../hooks/useMetrics/useMetrics';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { EVENT_PROVIDERS } from '../../constants/events';
import { Stake } from '../../sdk/stakeSdkProvider';
import usePoolStakedDeposit from './index';
@@ -96,17 +96,17 @@ jest.mock('../useStakeContext', () => ({
useStakeContext: () => mockSdkContext,
}));
-jest.mock('../../../../hooks/useMetrics/useMetrics');
+jest.mock('../../../../hooks/useAnalytics/useAnalytics');
describe('usePoolStakedDeposit', () => {
const mockTrackEvent = jest.fn();
- const useMetricsMock = jest.mocked(useMetrics);
+ const useAnalyticsMock = jest.mocked(useAnalytics);
beforeEach(() => {
- useMetricsMock.mockReturnValue({
+ useAnalyticsMock.mockReturnValue({
trackEvent: mockTrackEvent,
- createEventBuilder: MetricsEventBuilder.createEventBuilder,
- } as unknown as ReturnType);
+ createEventBuilder: AnalyticsEventBuilder.createEventBuilder,
+ } as unknown as ReturnType);
});
afterEach(() => {
diff --git a/app/components/UI/Stake/utils/metaMetrics/withMetaMetrics.test.ts b/app/components/UI/Stake/utils/metaMetrics/withMetaMetrics.test.ts
index 3fd30dd5e998..b28110f27708 100644
--- a/app/components/UI/Stake/utils/metaMetrics/withMetaMetrics.test.ts
+++ b/app/components/UI/Stake/utils/metaMetrics/withMetaMetrics.test.ts
@@ -1,18 +1,21 @@
import { withMetaMetrics } from './withMetaMetrics';
-import { MetaMetrics } from '../../../../../core/Analytics';
-import { MetaMetricsEvents } from '../../../../hooks/useMetrics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { analytics } from '../../../../../util/analytics/analytics';
import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events';
-describe('withMetaMetrics', () => {
- let trackEventSpy: jest.SpyInstance;
+jest.mock('../../../../../util/analytics/analytics', () => ({
+ analytics: {
+ trackEvent: jest.fn(),
+ },
+}));
+describe('withMetaMetrics', () => {
const MOCK_HANDLER_RESULT = 123;
const mockHandler = () => MOCK_HANDLER_RESULT;
const mockAsyncHandler = async () => MOCK_HANDLER_RESULT;
beforeEach(() => {
jest.resetAllMocks();
- trackEventSpy = jest.spyOn(MetaMetrics.getInstance(), 'trackEvent');
});
it('fires single event when wrapping sync function', () => {
@@ -26,7 +29,7 @@ describe('withMetaMetrics', () => {
const result = mockHandlerWithMetaMetrics();
expect(result).toEqual(MOCK_HANDLER_RESULT);
- expect(trackEventSpy).toHaveBeenCalledTimes(1);
+ expect(analytics.trackEvent).toHaveBeenCalledTimes(1);
});
it('fires array of events when wrapping sync function', () => {
@@ -53,7 +56,7 @@ describe('withMetaMetrics', () => {
const result = mockHandlerWithMetaMetrics();
expect(result).toEqual(MOCK_HANDLER_RESULT);
- expect(trackEventSpy).toHaveBeenCalledTimes(2);
+ expect(analytics.trackEvent).toHaveBeenCalledTimes(2);
});
it('fires single event when wrapping async function', async () => {
@@ -67,7 +70,7 @@ describe('withMetaMetrics', () => {
const result = await mockAsyncHandlerWithMetaMetrics();
expect(result).toEqual(MOCK_HANDLER_RESULT);
- expect(trackEventSpy).toHaveBeenCalledTimes(1);
+ expect(analytics.trackEvent).toHaveBeenCalledTimes(1);
});
it('fires all events when wrapping async function', async () => {
@@ -95,6 +98,6 @@ describe('withMetaMetrics', () => {
const result = await mockAsyncHandlerWithMetaMetrics();
expect(result).toEqual(MOCK_HANDLER_RESULT);
- expect(trackEventSpy).toHaveBeenCalledTimes(2);
+ expect(analytics.trackEvent).toHaveBeenCalledTimes(2);
});
});
diff --git a/app/components/UI/Stake/utils/metaMetrics/withMetaMetrics.ts b/app/components/UI/Stake/utils/metaMetrics/withMetaMetrics.ts
index 13fe2a7e84a9..ba3813cc870a 100644
--- a/app/components/UI/Stake/utils/metaMetrics/withMetaMetrics.ts
+++ b/app/components/UI/Stake/utils/metaMetrics/withMetaMetrics.ts
@@ -1,16 +1,16 @@
-import {
+import type {
IMetaMetricsEvent,
JsonMap,
} from '../../../../../core/Analytics/MetaMetrics.types';
-import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder';
-import { MetaMetrics } from '../../../../../core/Analytics';
+import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder';
+import { analytics } from '../../../../../util/analytics/analytics';
export interface WithMetaMetricsEvent {
event: IMetaMetricsEvent;
properties?: JsonMap;
}
-const createEventBuilder = MetricsEventBuilder.createEventBuilder;
+const createEventBuilder = AnalyticsEventBuilder.createEventBuilder;
const shouldAddProperties = (properties?: JsonMap): properties is JsonMap => {
if (!properties) return false;
@@ -43,14 +43,12 @@ export const withMetaMetrics = any>(
if (result instanceof Promise) {
return result.then((res) => {
- builtEvents.forEach((event) =>
- MetaMetrics.getInstance().trackEvent(event),
- );
+ builtEvents.forEach((event) => analytics.trackEvent(event));
return res;
}) as Promise>;
}
- builtEvents.forEach((event) => MetaMetrics.getInstance().trackEvent(event));
+ builtEvents.forEach((event) => analytics.trackEvent(event));
return result as ReturnType;
};
diff --git a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx
index cff14baa137c..6fecd2ebdcf8 100644
--- a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx
+++ b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx
@@ -344,8 +344,7 @@ describe('TurnOffRememberMeModal', () => {
});
});
- it('restores previous auth type when disabling remember me', async () => {
- mockGetItem.mockResolvedValue(AUTHENTICATION_TYPE.BIOMETRIC);
+ it('disables remember me with PASSWORD and clears previous auth type storage', async () => {
mockDoesPasswordMatch.mockResolvedValue({ valid: true });
const { getByTestId } = renderWithProvider(, {
@@ -366,11 +365,8 @@ describe('TurnOffRememberMeModal', () => {
});
await waitFor(() => {
- expect(mockGetItem).toHaveBeenCalledWith(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.BIOMETRIC,
+ authType: AUTHENTICATION_TYPE.PASSWORD,
password: 'ValidPassword123!',
});
expect(mockRemoveItem).toHaveBeenCalledWith(
@@ -378,6 +374,10 @@ describe('TurnOffRememberMeModal', () => {
);
expect(mockDismissModal).toHaveBeenCalled();
});
+
+ await act(async () => {
+ await Promise.resolve();
+ });
});
it('falls back to PASSWORD when no previous auth type is stored', async () => {
@@ -407,6 +407,10 @@ describe('TurnOffRememberMeModal', () => {
password: 'ValidPassword123!',
});
});
+
+ await act(async () => {
+ await Promise.resolve();
+ });
});
it('shows loading indicator during password submission', async () => {
@@ -445,6 +449,9 @@ describe('TurnOffRememberMeModal', () => {
if (resolveUpdateAuthPreference) {
resolveUpdateAuthPreference();
+ await act(async () => {
+ await Promise.resolve();
+ });
}
});
@@ -484,6 +491,9 @@ describe('TurnOffRememberMeModal', () => {
if (resolveUpdateAuthPreference) {
resolveUpdateAuthPreference();
+ await act(async () => {
+ await Promise.resolve();
+ });
}
});
@@ -512,6 +522,10 @@ describe('TurnOffRememberMeModal', () => {
expect(mockUpdateAuthPreference).toHaveBeenCalled();
expect(mockDismissModal).toHaveBeenCalled();
});
+
+ await act(async () => {
+ await Promise.resolve();
+ });
});
it('prevents modal dismissal during loading', async () => {
@@ -551,6 +565,9 @@ describe('TurnOffRememberMeModal', () => {
await waitFor(() => {
expect(mockDismissModal).toHaveBeenCalled();
});
+ await act(async () => {
+ await Promise.resolve();
+ });
}
});
@@ -577,5 +594,9 @@ describe('TurnOffRememberMeModal', () => {
expect(mockUpdateAuthPreference).toHaveBeenCalled();
expect(mockDismissModal).toHaveBeenCalled();
});
+
+ await act(async () => {
+ await Promise.resolve();
+ });
});
});
diff --git a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx
index dfbebdd525a8..907ad4593ce7 100644
--- a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx
+++ b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx
@@ -18,8 +18,6 @@ import { useTheme } from '../../../util/theme';
import Routes from '../../../constants/navigation/Routes';
import { createNavigationDetails } from '../../../util/navigation/navUtils';
import { doesPasswordMatch } from '../../../util/password';
-import { setAllowLoginWithRememberMe } from '../../../actions/security';
-import { useDispatch } from 'react-redux';
import { Authentication } from '../../../core';
import AUTHENTICATION_TYPE from '../../../constants/userProperties';
import Logger from '../../../util/Logger';
@@ -39,7 +37,6 @@ export const createTurnOffRememberMeModalNavDetails = createNavigationDetails(
const TurnOffRememberMeModal = () => {
const { colors, themeAppearance } = useTheme();
const styles = createStyles(colors);
- const dispatch = useDispatch();
const modalRef = useRef(null);
@@ -80,32 +77,16 @@ const TurnOffRememberMeModal = () => {
const turnOffRememberMeAndLockApp = useCallback(async () => {
setIsLoading(true);
try {
- // Get the previous auth type that was stored before enabling remember me
- const previousAuthType = await StorageWrapper.getItem(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
-
- // Determine which auth method to restore
- // Use stored previous auth type if available, otherwise fall back to password
- const authTypeToRestore = previousAuthType
- ? (previousAuthType as AUTHENTICATION_TYPE)
- : AUTHENTICATION_TYPE.PASSWORD;
-
// Use the password entered in the modal to restore auth method
await Authentication.updateAuthPreference({
- authType: authTypeToRestore,
+ authType: AUTHENTICATION_TYPE.PASSWORD,
password: passwordText,
});
// Clear the stored previous auth type after successful restoration
await StorageWrapper.removeItem(PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME);
- // Only set Redux state after operation completes successfully
- dispatch(setAllowLoginWithRememberMe(false));
dismissModal();
} catch (error) {
- // If update fails, still disable remember me and lock app
- // The user will need to re-enable their preferred auth method
- dispatch(setAllowLoginWithRememberMe(false));
Logger.error(
error as Error,
'Failed to restore auth preference when disabling remember me',
@@ -116,7 +97,7 @@ const TurnOffRememberMeModal = () => {
} finally {
setIsLoading(false);
}
- }, [dispatch, passwordText]);
+ }, [passwordText]);
const disableRememberMe = useCallback(async () => {
// Don't dismiss modal here - let turnOffRememberMeAndLockApp handle it
@@ -133,6 +114,7 @@ const TurnOffRememberMeModal = () => {
onCancelPress={disableRememberMe}
onRequestClose={triggerClose}
onConfirmPress={triggerClose}
+ cancelButtonMode="confirm"
>
diff --git a/app/components/UI/WarningExistingUserModal/index.js b/app/components/UI/WarningExistingUserModal/index.js
index b9ebb36136ab..c124e1e68f59 100644
--- a/app/components/UI/WarningExistingUserModal/index.js
+++ b/app/components/UI/WarningExistingUserModal/index.js
@@ -69,6 +69,7 @@ export default function WarningExistingUserModal({
confirmText,
confirmTestID,
cancelTestID,
+ cancelButtonMode = 'warning',
}) {
return (
@@ -114,4 +115,8 @@ WarningExistingUserModal.propTypes = {
* Confirm callback
*/
onConfirmPress: PropTypes.func.isRequired,
+ /**
+ * Type of button to show as the cancel button
+ */
+ cancelButtonMode: PropTypes.string,
};
diff --git a/app/components/Views/EnterPasswordSimple/index.js b/app/components/Views/EnterPasswordSimple/index.js
index 728cfbdc9097..09ef1518d14e 100644
--- a/app/components/Views/EnterPasswordSimple/index.js
+++ b/app/components/Views/EnterPasswordSimple/index.js
@@ -91,6 +91,8 @@ export default class EnterPasswordSimple extends PureComponent {
};
componentWillUnmount = () => {
+ // Used by Settings/SecuritySettings/Sections/DeviceSecurityToggle.tsx to clear optimistic toggle value
+ this.props.route?.params?.onCancel?.();
this.mounted = false;
};
@@ -102,7 +104,7 @@ export default class EnterPasswordSimple extends PureComponent {
strings('choose_password.password_length_error'),
);
} else {
- this.props.route.params.onPasswordSet(this.state.password);
+ await this.props.route.params.onPasswordSet(this.state.password);
this.props.navigation.pop();
return;
}
diff --git a/app/components/Views/EnterPasswordSimple/index.test.tsx b/app/components/Views/EnterPasswordSimple/index.test.tsx
index 2169eaf28de6..cf6f2a735956 100644
--- a/app/components/Views/EnterPasswordSimple/index.test.tsx
+++ b/app/components/Views/EnterPasswordSimple/index.test.tsx
@@ -21,11 +21,6 @@ const mockNavigation = {
setOptions: jest.fn(),
goBack: jest.fn(),
navigate: jest.fn(),
- route: {
- params: {
- accountAddress: '0x123',
- },
- },
};
describe('EnterPasswordSimple', () => {
diff --git a/app/components/Views/Login/LoginView.testIds.ts b/app/components/Views/Login/LoginView.testIds.ts
index aeed81d07179..ab513b2ffd55 100644
--- a/app/components/Views/Login/LoginView.testIds.ts
+++ b/app/components/Views/Login/LoginView.testIds.ts
@@ -8,13 +8,6 @@ export const LoginViewSelectors = {
BIOMETRIC_SWITCH: 'login-with-biometric-switch',
PASSWORD_INPUT: 'login-password-input',
BIOMETRY_BUTTON: 'biometry-button',
- IOS_TOUCH_ID_ICON: 'ios-touch-id-icon',
- IOS_PASSCODE_ICON: 'ios-passcode-icon',
- IOS_FACE_ID_ICON: 'ios-face-id-icon',
- ANDROID_FINGERPRINT_ICON: 'android-fingerprint-icon',
- ANDROID_FACE_ID_ICON: 'android-face-id-icon',
- ANDROID_IRIS_ICON: 'android-iris-icon',
- ANDROID_PASSCODE_ICON: 'android-passcode-icon',
- FALLBACK_FINGERPRINT_ICON: 'fallback-fingerprint-icon',
+ DEVICE_AUTHENTICATION_ICON: 'device-authentication-icon',
OTHER_METHODS_BUTTON: 'other-methods-button',
};
diff --git a/app/components/Views/Login/__snapshots__/index.test.tsx.snap b/app/components/Views/Login/__snapshots__/index.test.tsx.snap
index 07a6b35116ff..84e580bc6577 100644
--- a/app/components/Views/Login/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/Login/__snapshots__/index.test.tsx.snap
@@ -142,7 +142,37 @@ exports[`Login renders matching snapshot 1`] = `
}
}
testID="textfield-endacccessory"
- />
+ >
+
+
+
+
+ >
+
+
+
+
({
}),
}));
+const defaultCapabilities = {
+ authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
+ isBiometricsAvailable: true,
+ passcodeAvailable: true,
+ authLabel: 'Device Authentication',
+ osAuthEnabled: true,
+ allowLoginWithRememberMe: false,
+ deviceAuthRequiresSettings: false,
+};
+
+const mockUseAuthCapabilities = jest.fn(() => ({
+ capabilities: defaultCapabilities,
+ isLoading: false,
+}));
+
+jest.mock('../../../core/Authentication/hooks/useAuthCapabilities', () => ({
+ __esModule: true,
+ default: () => mockUseAuthCapabilities(),
+}));
+
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
@@ -229,14 +247,23 @@ jest.mock('../../../util/metrics/TrackOnboarding/trackOnboarding', () =>
jest.mock('../../../util/trace', () => {
const actualTrace = jest.requireActual('../../../util/trace');
+ const traceCallbackPromiseRef: { current: Promise | null } = {
+ current: null,
+ };
+ const traceFn = jest.fn().mockImplementation(async (_request, callback) => {
+ if (callback) {
+ traceCallbackPromiseRef.current = callback();
+ return await traceCallbackPromiseRef.current;
+ }
+ return 'mockTraceContext';
+ });
+ // Expose ref so tests can await the trace callback promise inside act()
+ Object.assign(traceFn, {
+ __traceCallbackPromiseRef: traceCallbackPromiseRef,
+ });
return {
...actualTrace,
- trace: jest.fn().mockImplementation((_request, callback) => {
- if (callback) {
- return callback();
- }
- return 'mockTraceContext';
- }),
+ trace: traceFn,
endTrace: jest.fn(),
};
});
@@ -319,6 +346,10 @@ describe('Login', () => {
currentAuthType: 'password',
availableBiometryType: null,
});
+ mockUseAuthCapabilities.mockReturnValue({
+ capabilities: defaultCapabilities,
+ isLoading: false,
+ });
(StorageWrapper.getItem as jest.Mock).mockResolvedValue(null);
mockBackHandlerAddEventListener.mockClear();
mockBackHandlerRemoveEventListener.mockClear();
@@ -417,49 +448,8 @@ describe('Login', () => {
});
});
- describe('Remember Me Authentication', () => {
- it('set up remember me authentication when auth type is REMEMBER_ME', async () => {
- mockGetAuthType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME,
- availableBiometryType: null,
- });
-
- renderWithProvider();
-
- // Wait for useEffect to complete
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 0));
- });
-
- expect(setAllowLoginWithRememberMe).toHaveBeenCalledWith(true);
- });
- });
-
- describe('Passcode Authentication', () => {
+ describe('Device authentication button visibility', () => {
beforeEach(() => {
- (StorageWrapper.getItem as jest.Mock).mockReset();
- });
-
- it('set up passcode authentication when auth type is PASSCODE', async () => {
- mockGetAuthType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.PASSCODE,
- availableBiometryType: 'TouchID',
- });
-
- renderWithProvider();
-
- // Wait for useEffect to complete
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 0));
- });
-
- expect(passcodeType).toHaveBeenCalledWith(AUTHENTICATION_TYPE.PASSCODE);
- });
- });
-
- describe('Biometric Authentication Setup', () => {
- beforeEach(() => {
- (StorageWrapper.getItem as jest.Mock).mockReset();
mockRoute.mockReturnValue({
params: {
locked: false,
@@ -468,24 +458,25 @@ describe('Login', () => {
});
});
- it('biometric authentication is setup when availableBiometryType is present', async () => {
- mockGetAuthType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'TouchID',
+ it('renders device authentication button when capabilities allow device auth', async () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ capabilities: {
+ ...defaultCapabilities,
+ authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
+ },
+ isLoading: false,
});
const { getByTestId } = renderWithProvider();
- // Wait for useEffect to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
- // Should render biometric button when biometric is available
expect(getByTestId(LoginViewSelectors.BIOMETRY_BUTTON)).toBeOnTheScreen();
});
- it('biometric button is not shown when device is locked', async () => {
+ it('hides device authentication button when device is locked', async () => {
mockRoute.mockReturnValue({
params: {
locked: true,
@@ -493,43 +484,31 @@ describe('Login', () => {
},
});
- mockGetAuthType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'FaceID',
- });
-
const { queryByTestId } = renderWithProvider();
- // Wait for useEffect to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
- // Should NOT render biometric button when device is locked
expect(queryByTestId(LoginViewSelectors.BIOMETRY_BUTTON)).toBeNull();
});
- it('biometric button is shown when biometric credentials exist', async () => {
- // With toggle removed, biometric button shows based on credentials, not storage flags
- (StorageWrapper.getItem as jest.Mock).mockImplementation((key) => {
- if (key === BIOMETRY_CHOICE_DISABLED) return Promise.resolve(TRUE);
- return Promise.resolve(null);
- });
-
- mockGetAuthType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'TouchID',
+ it('hides device authentication button when capabilities do not support device auth', async () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ capabilities: {
+ ...defaultCapabilities,
+ authType: AUTHENTICATION_TYPE.PASSWORD,
+ },
+ isLoading: false,
});
- const { getByTestId } = renderWithProvider();
+ const { queryByTestId } = renderWithProvider();
- // Wait for useEffect to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
- // Should render biometric button when biometric credentials exist
- expect(getByTestId(LoginViewSelectors.BIOMETRY_BUTTON)).toBeOnTheScreen();
+ expect(queryByTestId(LoginViewSelectors.BIOMETRY_BUTTON)).toBeNull();
});
});
@@ -880,7 +859,17 @@ describe('Login', () => {
oauthLoginSuccess: false,
},
});
+ // Isolate auth mocks so previous tests cannot leave stale implementations
+ // (e.g. mockRejectedValue or mockResolvedValueOnce from other describe blocks).
+ mockUnlockWallet.mockReset();
mockUnlockWallet.mockResolvedValue(true);
+ mockGetAuthType.mockReset();
+ mockGetAuthType.mockResolvedValue({
+ currentAuthType: 'password',
+ availableBiometryType: null,
+ });
+ mockCheckIsSeedlessPasswordOutdated.mockReset();
+ mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(false);
});
it('checks seedless password status and calls getAuthType when outdated', async () => {
@@ -890,30 +879,34 @@ describe('Login', () => {
currentAuthType: 'password',
availableBiometryType: 'FaceID',
});
- const getAuthTypeCallCountBefore = mockGetAuthType.mock.calls.length;
const { getByTestId } = renderWithProvider();
const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT);
- // Act
- fireEvent.changeText(passwordInput, 'valid-password123');
+ // Act - commit password, then submit and wait for the trace callback inside act
+ // so the async unlock flow (checkIsSeedlessPasswordOutdated -> unlockWallet -> getAuthType) completes before we assert.
+ await act(async () => {
+ fireEvent.changeText(passwordInput, 'valid-password123');
+ });
await act(async () => {
fireEvent(passwordInput, 'submitEditing');
+ const promiseRef = (
+ trace as {
+ __traceCallbackPromiseRef?: { current: Promise | null };
+ }
+ ).__traceCallbackPromiseRef;
+ if (promiseRef?.current) {
+ await promiseRef.current;
+ promiseRef.current = null;
+ }
});
- // Assert - verify the full code path executed
- await waitFor(() => {
- expect(mockCheckIsSeedlessPasswordOutdated).toHaveBeenCalledWith(false);
- });
- await waitFor(() => {
- expect(mockUnlockWallet).toHaveBeenCalled();
- });
- // getAuthType called extra time inside the if(isSeedlessPasswordOutdated) block
- await waitFor(() => {
- expect(mockGetAuthType.mock.calls.length).toBeGreaterThan(
- getAuthTypeCallCountBefore,
- );
+ // Assert
+ expect(mockCheckIsSeedlessPasswordOutdated).toHaveBeenCalledWith(false);
+ expect(mockUnlockWallet).toHaveBeenCalledWith({
+ password: 'valid-password123',
});
+ expect(mockGetAuthType).toHaveBeenCalled();
});
it('does not call getAuthType after unlock when seedless password is not outdated', async () => {
diff --git a/app/components/Views/Login/index.tsx b/app/components/Views/Login/index.tsx
index 4c0a8b8c7213..0774190869e8 100644
--- a/app/components/Views/Login/index.tsx
+++ b/app/components/Views/Login/index.tsx
@@ -27,15 +27,12 @@ import {
OnboardingActionTypes,
saveOnboardingEvent as saveEvent,
} from '../../../actions/onboarding';
-import { setAllowLoginWithRememberMe as setAllowLoginWithRememberMeUtil } from '../../../actions/security';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
-import { passcodeType } from '../../../util/authentication';
-import { BiometryButton } from '../../UI/BiometryButton';
+import { DeviceAuthenticationButton } from '../../UI/DeviceAuthenticationButton';
import Logger from '../../../util/Logger';
import Routes from '../../../constants/navigation/Routes';
import ErrorBoundary from '../ErrorBoundary';
-import AUTHENTICATION_TYPE from '../../../constants/userProperties';
import { createRestoreWalletNavDetailsNested } from '../RestoreWallet/RestoreWallet';
import { parseVaultValue } from '../../../util/validators';
@@ -76,7 +73,6 @@ import { useStyles } from '../../../component-library/hooks/useStyles';
import stylesheet from './styles';
import ReduxService from '../../../core/redux';
import { StackNavigationProp } from '@react-navigation/stack';
-import { BIOMETRY_TYPE } from 'react-native-keychain';
import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding';
import type { AnalyticsTrackingEvent } from '../../../util/analytics/AnalyticsEventBuilder';
import FoxAnimation from '../../UI/FoxAnimation/FoxAnimation';
@@ -84,6 +80,8 @@ import { isE2E } from '../../../util/test/utils';
import { ScreenshotDeterrent } from '../../UI/ScreenshotDeterrent';
import useAuthentication from '../../../core/Authentication/hooks/useAuthentication';
import { SeedlessOnboardingControllerError } from '../../../core/Engine/controllers/seedless-onboarding-controller/error';
+import useAuthCapabilities from '../../../core/Authentication/hooks/useAuthCapabilities';
+import AUTHENTICATION_TYPE from '../../../constants/userProperties';
// In android, having {} will cause the styles to update state
// using a constant will prevent this
@@ -104,13 +102,8 @@ const Login: React.FC = ({ saveOnboardingEvent }) => {
const fieldRef = useRef(null);
const [password, setPassword] = useState('');
- const [biometryType, setBiometryType] = useState<
- BIOMETRY_TYPE | AUTHENTICATION_TYPE | string | null
- >(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
-
- const [hasBiometricCredentials, setHasBiometricCredentials] = useState(false);
const [startFoxAnimation, setStartFoxAnimation] = useState<
undefined | 'Start' | 'Loader'
>(undefined);
@@ -121,8 +114,6 @@ const Login: React.FC = ({ saveOnboardingEvent }) => {
styles,
theme: { themeAppearance },
} = useStyles(stylesheet, EmptyRecordConstant);
- const setAllowLoginWithRememberMe = (enabled: boolean) =>
- setAllowLoginWithRememberMeUtil(enabled);
const {
unlockWallet,
@@ -130,6 +121,7 @@ const Login: React.FC = ({ saveOnboardingEvent }) => {
getAuthType,
checkIsSeedlessPasswordOutdated,
} = useAuthentication();
+ const { capabilities } = useAuthCapabilities();
const handleBackPress = () => {
lockApp({ reset: false });
@@ -164,29 +156,6 @@ const Login: React.FC = ({ saveOnboardingEvent }) => {
}
}, []);
- useEffect(() => {
- const getUserAuthPreferences = async () => {
- const authData = await getAuthType();
-
- //Setup UI to handle Biometric
- if (authData.currentAuthType === AUTHENTICATION_TYPE.PASSCODE) {
- setBiometryType(passcodeType(authData.currentAuthType));
- setHasBiometricCredentials(!route?.params?.locked);
- } else if (authData.currentAuthType === AUTHENTICATION_TYPE.REMEMBER_ME) {
- setHasBiometricCredentials(false);
- setAllowLoginWithRememberMe(true);
- } else if (authData.availableBiometryType) {
- Logger.log('authData', authData);
- setBiometryType(authData.availableBiometryType);
- setHasBiometricCredentials(
- authData.currentAuthType === AUTHENTICATION_TYPE.BIOMETRIC,
- );
- }
- };
-
- getUserAuthPreferences();
- }, [route?.params?.locked, getAuthType]);
-
const handleVaultCorruption = useCallback(async () => {
const LOGIN_VAULT_CORRUPTION_TAG = 'Login/ handleVaultCorruption:';
@@ -357,7 +326,7 @@ const Login: React.FC = ({ saveOnboardingEvent }) => {
checkIsSeedlessPasswordOutdated,
]);
- const unlockWithBiometrics = useCallback(async () => {
+ const unlockWithDeviceAuthentication = useCallback(async () => {
if (loading) return;
fieldRef.current?.blur();
@@ -377,7 +346,6 @@ const Login: React.FC = ({ saveOnboardingEvent }) => {
},
);
} catch (error) {
- setHasBiometricCredentials(true);
await handleLoginError(error as Error);
} finally {
setLoading(false);
@@ -402,11 +370,12 @@ const Login: React.FC = ({ saveOnboardingEvent }) => {
downloadStateLogs(fullState, false);
};
- const shouldHideBiometricAccessoryButton = !(
- biometryType &&
- hasBiometricCredentials &&
- !route?.params?.locked
- );
+ const isDeviceAuthenticationAvailable =
+ capabilities?.authType === AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION ||
+ capabilities?.authType === AUTHENTICATION_TYPE.BIOMETRIC ||
+ capabilities?.authType === AUTHENTICATION_TYPE.PASSCODE;
+ const shouldHideDeviceAuthenticationButton =
+ route?.params?.locked || !isDeviceAuthenticationAvailable;
const handlePasswordChange = (newPassword: string) => {
setPassword(newPassword);
@@ -431,7 +400,6 @@ const Login: React.FC = ({ saveOnboardingEvent }) => {
resizeMode="contain"
resizeMethod={'auto'}
/>
-
= ({ saveOnboardingEvent }) => {
value={password}
onSubmitEditing={unlockWithPassword}
endAccessory={
-
}
keyboardAppearance={themeAppearance}
diff --git a/app/components/Views/Login/index2.test.tsx b/app/components/Views/Login/index2.test.tsx
index 58620400e64b..57407729986f 100644
--- a/app/components/Views/Login/index2.test.tsx
+++ b/app/components/Views/Login/index2.test.tsx
@@ -1,15 +1,13 @@
import React from 'react';
import { LoginViewSelectors } from './LoginView.testIds';
import Login from './index';
-import { fireEvent, act, screen, waitFor } from '@testing-library/react-native';
+import { fireEvent, act, waitFor } from '@testing-library/react-native';
import { VAULT_ERROR } from './constants';
import { getVaultFromBackup } from '../../../core/BackupVault';
import { parseVaultValue } from '../../../util/validators';
-import renderWithProvider, {
- DeepPartial,
-} from '../../../util/test/renderWithProvider';
+import renderWithProvider from '../../../util/test/renderWithProvider';
import Routes from '../../../constants/navigation/Routes';
import Logger from '../../../util/Logger';
import { UNLOCK_WALLET_ERROR_MESSAGES } from '../../../core/Authentication/constants';
@@ -17,14 +15,11 @@ import { UNLOCK_WALLET_ERROR_MESSAGES } from '../../../core/Authentication/const
// Mock dependencies
import AUTHENTICATION_TYPE from '../../../constants/userProperties';
-import StorageWrapper from '../../../store/storage-wrapper';
-import { BIOMETRY_CHOICE_DISABLED } from '../../../constants/storage';
import { EndTraceRequest } from '../../../util/trace';
import ReduxService from '../../../core/redux/ReduxService';
import { RecursivePartial } from '../../../core/Authentication/Authentication.test';
import { RootState } from '../../../reducers';
import { ReduxStore } from '../../../core/redux/types';
-import { BIOMETRY_TYPE } from 'react-native-keychain';
jest.mock('../../../util/Logger');
const mockLogger = Logger as jest.Mocked;
@@ -54,6 +49,26 @@ jest.mock('../../../core/Authentication/hooks/useAuthentication', () => ({
}),
}));
+const defaultCapabilities = {
+ authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
+ isBiometricsAvailable: true,
+ passcodeAvailable: true,
+ authLabel: 'Face ID',
+ osAuthEnabled: false,
+ allowLoginWithRememberMe: false,
+ deviceAuthRequiresSettings: false,
+};
+
+const mockUseAuthCapabilities = jest.fn(() => ({
+ capabilities: defaultCapabilities,
+ isLoading: false,
+}));
+
+jest.mock('../../../core/Authentication/hooks/useAuthCapabilities', () => ({
+ __esModule: true,
+ default: () => mockUseAuthCapabilities(),
+}));
+
const mockNavigate = jest.fn();
const mockReplace = jest.fn();
const mockReset = jest.fn();
@@ -180,10 +195,19 @@ describe('Login test suite 2', () => {
jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
// Default mock for checkIsSeedlessPasswordOutdated - returns false (password not outdated)
mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(false);
+ mockRoute.mockReturnValue({
+ params: { locked: false, oauthLoginSuccess: false },
+ });
+ mockUseAuthCapabilities.mockReturnValue({
+ capabilities: defaultCapabilities,
+ isLoading: false,
+ });
});
afterEach(() => {
- jest.runOnlyPendingTimers();
+ act(() => {
+ jest.runOnlyPendingTimers();
+ });
jest.clearAllTimers();
jest.clearAllMocks();
// Restore Redux store mock after clearing mocks
@@ -357,62 +381,28 @@ describe('Login test suite 2', () => {
jest.clearAllTimers();
});
- it('show biometric when password is not outdated', async () => {
+ it('shows device authentication button when capabilities allow device auth', async () => {
mockRoute.mockReturnValue({
params: {
locked: false,
oauthLoginSuccess: false,
},
});
- const mockState: DeepPartial = {
- engine: {
- backgroundState: {
- SeedlessOnboardingController: {
- vault: 'mock-vault',
- passwordOutdatedCache: {
- isExpiredPwd: false,
- timestamp: 1718332800,
- },
- },
- },
+ mockUseAuthCapabilities.mockReturnValue({
+ capabilities: {
+ ...defaultCapabilities,
+ authType: AUTHENTICATION_TYPE.BIOMETRIC,
},
- };
- // mock redux service
- jest.spyOn(ReduxService, 'store', 'get').mockImplementation(() => ({
- dispatch: jest.fn(),
- subscribe: jest.fn(),
- replaceReducer: jest.fn(),
- [Symbol.observable]: jest.fn(),
- getState: jest.fn().mockReturnValue(mockState),
- }));
-
- // mock storage wrapper
- jest.spyOn(StorageWrapper, 'getItem').mockImplementation(async (key) => {
- if (key === BIOMETRY_CHOICE_DISABLED) return false;
- return null;
+ isLoading: false,
});
- mockGetAuthType.mockImplementation(async () => ({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: BIOMETRY_TYPE.FACE_ID,
- }));
+ const { getByTestId } = renderWithProvider();
- renderWithProvider(, {
- state: mockState,
+ await waitFor(() => {
+ expect(
+ getByTestId(LoginViewSelectors.BIOMETRY_BUTTON),
+ ).toBeOnTheScreen();
});
-
- expect(
- screen.queryByTestId(LoginViewSelectors.BIOMETRY_BUTTON),
- ).not.toBeTruthy();
-
- await waitFor(
- () => {
- expect(
- screen.queryByTestId(LoginViewSelectors.BIOMETRY_BUTTON),
- ).toBeTruthy();
- },
- { timeout: 4000 },
- );
});
});
diff --git a/app/components/Views/Settings/Contacts/ContactsView.testIds.ts b/app/components/Views/Settings/Contacts/ContactsView.testIds.ts
index 19efe0007095..774ed26a0aa4 100644
--- a/app/components/Views/Settings/Contacts/ContactsView.testIds.ts
+++ b/app/components/Views/Settings/Contacts/ContactsView.testIds.ts
@@ -1,6 +1,8 @@
export const ContactsViewSelectorIDs = {
ADD_BUTTON: 'contact-add-contact-button',
CONTAINER: 'contacts-screen',
+ HEADER: 'contacts-header',
+ HEADER_BACK_BUTTON: 'back-arrow-button',
};
export const ContactsViewSelectorsText = {
diff --git a/app/components/Views/Settings/Contacts/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/Contacts/__snapshots__/index.test.tsx.snap
index 2d7dbe444394..c0ec0299f3a1 100644
--- a/app/components/Views/Settings/Contacts/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/Settings/Contacts/__snapshots__/index.test.tsx.snap
@@ -1,32 +1,502 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Contacts should render correctly 1`] = `
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Contacts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add contact
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
diff --git a/app/components/Views/Settings/Contacts/index.js b/app/components/Views/Settings/Contacts/index.js
index 37ef2116914d..ace2ae05af23 100644
--- a/app/components/Views/Settings/Contacts/index.js
+++ b/app/components/Views/Settings/Contacts/index.js
@@ -3,8 +3,8 @@ import { StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import PropTypes from 'prop-types';
import { strings } from '../../../../../locales/i18n';
-import { getNavigationOptionsTitle } from '../../../UI/Navbar';
import { connect } from 'react-redux';
+import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard';
import AddressList from '../../confirmations/legacy/components/AddressList';
import StyledButton from '../../../UI/StyledButton';
import Engine from '../../../../core/Engine';
@@ -21,7 +21,6 @@ const createStyles = (colors) =>
wrapper: {
backgroundColor: colors.background.default,
flex: 1,
- marginTop: 16,
},
addContact: {
marginHorizontal: 24,
@@ -58,25 +57,7 @@ class Contacts extends PureComponent {
actionSheet;
contactAddressToRemove;
- updateNavBar = () => {
- const { navigation } = this.props;
- const colors = this.context.colors || mockTheme.colors;
- navigation.setOptions(
- getNavigationOptionsTitle(
- strings('app_settings.contacts_title'),
- navigation,
- false,
- colors,
- ),
- );
- };
-
- componentDidMount = () => {
- this.updateNavBar();
- };
-
componentDidUpdate = (prevProps) => {
- this.updateNavBar();
const { chainId } = this.props;
if (
prevProps.addressBook &&
@@ -143,6 +124,15 @@ class Contacts extends PureComponent {
testID={ContactsViewSelectorIDs.CONTAINER}
edges={{ bottom: 'additive' }}
>
+ this.props.navigation.goBack()}
+ includesTopInset
+ testID={ContactsViewSelectorIDs.HEADER}
+ backButtonProps={{
+ testID: ContactsViewSelectorIDs.HEADER_BACK_BUTTON,
+ }}
+ />
void };
+}) {
+ return (
+
+ Placeholder
+ navigation.navigate('ContactsSettings')}
+ >
+ Go to Contacts
+
+
+ );
+}
describe('Contacts', () => {
- it('should render correctly', () => {
- const wrapper = shallow(
-
-
- ,
+ it('renders correctly', () => {
+ const { toJSON } = renderScreen(
+ Contacts,
+ { name: 'ContactsSettings', options: { headerShown: false } },
+ { state: initialState },
+ );
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders inline header with Contacts title', () => {
+ const { getByTestId, getByText } = renderScreen(
+ Contacts,
+ { name: 'ContactsSettings', options: { headerShown: false } },
+ { state: initialState },
);
- expect(wrapper).toMatchSnapshot();
+ expect(getByTestId(ContactsViewSelectorIDs.HEADER)).toBeOnTheScreen();
+ expect(getByText(strings('app_settings.contacts_title'))).toBeOnTheScreen();
+ });
+
+ it('navigates back when header back button is pressed', () => {
+ const { getByTestId } = renderWithProvider(
+
+
+
+ ,
+ { state: initialState },
+ );
+
+ expect(getByTestId(PLACEHOLDER_SCREEN_TEST_ID)).toBeOnTheScreen();
+ fireEvent.press(getByTestId(GO_TO_CONTACTS_TEST_ID));
+
+ const backButton = getByTestId(ContactsViewSelectorIDs.HEADER_BACK_BUTTON);
+ expect(backButton).toBeOnTheScreen();
+ fireEvent.press(backButton);
+
+ expect(getByTestId(PLACEHOLDER_SCREEN_TEST_ID)).toBeOnTheScreen();
});
});
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/DeviceSecurityToggle.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/DeviceSecurityToggle.test.tsx
new file mode 100644
index 000000000000..1f4247e9343f
--- /dev/null
+++ b/app/components/Views/Settings/SecuritySettings/Sections/DeviceSecurityToggle.test.tsx
@@ -0,0 +1,481 @@
+import React from 'react';
+import { act, fireEvent, waitFor } from '@testing-library/react-native';
+import { Linking } from 'react-native';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import DeviceSecurityToggle from './DeviceSecurityToggle';
+import AUTHENTICATION_TYPE from '../../../../../constants/userProperties';
+import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds';
+import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error';
+import Logger from '../../../../../util/Logger';
+
+const mockNavigate = jest.fn();
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return { ...actual, useNavigation: () => ({ navigate: mockNavigate }) };
+});
+
+jest.mock('../../../../../util/Logger', () => ({ error: jest.fn() }));
+
+jest.mock('../../../../../core/Authentication/AuthenticationError', () => {
+ class AuthenticationError extends Error {
+ customErrorMessage: string;
+ constructor(message: string, code: string) {
+ super(message);
+ this.customErrorMessage = code;
+ this.name = 'AuthenticationError';
+ }
+ }
+ return { __esModule: true, default: AuthenticationError };
+});
+
+const MockedAuthenticationError = jest.requireMock(
+ '../../../../../core/Authentication/AuthenticationError',
+).default as new (
+ message: string,
+ code: string,
+) => Error & { customErrorMessage: string };
+
+const mockUpdateAuthPreference = jest.fn();
+const mockGetAuthCapabilities = jest.fn();
+const mockUpdateOsAuthEnabled = jest.fn();
+
+jest.mock('../../../../../core/Authentication', () => ({
+ useAuthentication: () => ({
+ updateAuthPreference: mockUpdateAuthPreference,
+ getAuthCapabilities: mockGetAuthCapabilities,
+ updateOsAuthEnabled: mockUpdateOsAuthEnabled,
+ }),
+}));
+
+const mockUseAuthCapabilities = jest.fn();
+jest.mock(
+ '../../../../../core/Authentication/hooks/useAuthCapabilities',
+ () => ({
+ __esModule: true,
+ default: () => mockUseAuthCapabilities(),
+ }),
+);
+
+jest.mock(
+ '../../../../UI/TurnOffRememberMeModal/TurnOffRememberMeModal',
+ () => ({
+ createTurnOffRememberMeModalNavDetails: () => [
+ 'TurnOffRememberMeModal',
+ {},
+ ],
+ }),
+);
+
+jest.mock('react-native/Libraries/Linking/Linking', () => ({
+ openSettings: jest.fn(),
+ openURL: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ getInitialURL: jest.fn(() => Promise.resolve(null)),
+ canOpenURL: jest.fn(() => Promise.resolve(true)),
+}));
+
+const defaultCapabilities = {
+ isBiometricsAvailable: true,
+ passcodeAvailable: true,
+ authLabel: 'Face ID',
+ authDescription: '',
+ osAuthEnabled: false,
+ allowLoginWithRememberMe: false,
+ authType: AUTHENTICATION_TYPE.PASSWORD,
+ deviceAuthRequiresSettings: false,
+};
+
+const initialState = { security: { allowLoginWithRememberMe: false } };
+
+function renderComponent(props?: { requiresReauthentication?: boolean }) {
+ return renderWithProvider(, {
+ state: initialState,
+ });
+}
+
+describe('DeviceSecurityToggle', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseAuthCapabilities.mockReturnValue({
+ isLoading: false,
+ capabilities: defaultCapabilities,
+ });
+ mockUpdateAuthPreference.mockResolvedValue(undefined);
+ mockGetAuthCapabilities.mockImplementation(
+ ({ osAuthEnabled }: { osAuthEnabled: boolean }) =>
+ Promise.resolve({
+ ...defaultCapabilities,
+ authType: osAuthEnabled
+ ? AUTHENTICATION_TYPE.BIOMETRIC
+ : AUTHENTICATION_TYPE.PASSWORD,
+ }),
+ );
+ });
+
+ describe('render', () => {
+ it('returns null when capabilities are not yet loaded', () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ isLoading: true,
+ capabilities: null,
+ });
+ const { queryByTestId } = renderComponent();
+ expect(
+ queryByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ ).toBeNull();
+ });
+
+ it('renders device settings button when deviceAuthRequiresSettings is true', async () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ isLoading: false,
+ capabilities: {
+ ...defaultCapabilities,
+ deviceAuthRequiresSettings: true,
+ },
+ });
+ const { getByText } = renderComponent();
+ await waitFor(() => {
+ // strings('app_settings.enable_biometrics_in_settings') from en.json
+ expect(getByText('Enable Device Authentication')).toBeOnTheScreen();
+ });
+ });
+
+ it('calls Linking.openSettings when device settings button is pressed', async () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ isLoading: false,
+ capabilities: {
+ ...defaultCapabilities,
+ deviceAuthRequiresSettings: true,
+ },
+ });
+ const { getByText } = renderComponent();
+ const button = await waitFor(() =>
+ getByText('Enable Device Authentication'),
+ );
+ fireEvent.press(button);
+ expect(Linking.openSettings).toHaveBeenCalled();
+ });
+
+ it('renders toggle when deviceAuthRequiresSettings is false', async () => {
+ const { getByTestId } = renderComponent();
+ await waitFor(() => {
+ expect(
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ ).toBeOnTheScreen();
+ });
+ });
+ });
+
+ describe('toggle', () => {
+ it('calls getAuthCapabilities and updateAuthPreference when turning on', async () => {
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', true);
+
+ await waitFor(() => {
+ expect(mockGetAuthCapabilities).toHaveBeenCalledWith({
+ osAuthEnabled: true,
+ allowLoginWithRememberMe: false,
+ });
+ expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
+ authType: AUTHENTICATION_TYPE.BIOMETRIC,
+ });
+ });
+ });
+
+ it('calls getAuthCapabilities and updateAuthPreference when turning off', async () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ isLoading: false,
+ capabilities: {
+ ...defaultCapabilities,
+ osAuthEnabled: true,
+ authType: AUTHENTICATION_TYPE.BIOMETRIC,
+ },
+ });
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', false);
+
+ await waitFor(() => {
+ expect(mockGetAuthCapabilities).toHaveBeenCalledWith({
+ osAuthEnabled: false,
+ allowLoginWithRememberMe: false,
+ });
+ expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
+ authType: AUTHENTICATION_TYPE.PASSWORD,
+ });
+ });
+ });
+
+ it('calls updateOsAuthEnabled only when requiresReauthentication is false (turning on)', async () => {
+ const { getByTestId } = renderComponent({
+ requiresReauthentication: false,
+ });
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', true);
+
+ await waitFor(() => {
+ expect(mockUpdateOsAuthEnabled).toHaveBeenCalledWith(true);
+ expect(mockUpdateAuthPreference).not.toHaveBeenCalled();
+ expect(mockGetAuthCapabilities).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls updateOsAuthEnabled only when requiresReauthentication is false (turning off)', async () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ isLoading: false,
+ capabilities: { ...defaultCapabilities, osAuthEnabled: true },
+ });
+ const { getByTestId } = renderComponent({
+ requiresReauthentication: false,
+ });
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', false);
+
+ await waitFor(() => {
+ expect(mockUpdateOsAuthEnabled).toHaveBeenCalledWith(false);
+ expect(mockUpdateAuthPreference).not.toHaveBeenCalled();
+ expect(mockGetAuthCapabilities).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('remember me', () => {
+ it('navigates to TurnOffRememberMeModal when turning off from REMEMBER_ME', async () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ isLoading: false,
+ capabilities: {
+ ...defaultCapabilities,
+ authType: AUTHENTICATION_TYPE.REMEMBER_ME,
+ allowLoginWithRememberMe: true,
+ osAuthEnabled: false,
+ },
+ });
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', false);
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('TurnOffRememberMeModal', {});
+ });
+ expect(mockUpdateAuthPreference).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('password required', () => {
+ it('navigates to EnterPasswordSimple when updateAuthPreference throws password-required error', async () => {
+ mockUpdateAuthPreference.mockRejectedValueOnce(
+ new MockedAuthenticationError(
+ 'Password required',
+ AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
+ ),
+ );
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', true);
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('EnterPasswordSimple', {
+ onPasswordSet: expect.any(Function),
+ onCancel: expect.any(Function),
+ });
+ });
+ });
+
+ it('calls updateAuthPreference with password when user provides password via callback', async () => {
+ let onPasswordSet: ((password: string) => Promise) | undefined;
+ mockUpdateAuthPreference
+ .mockRejectedValueOnce(
+ new MockedAuthenticationError(
+ 'Password required',
+ AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
+ ),
+ )
+ .mockResolvedValueOnce(undefined);
+ mockNavigate.mockImplementation(
+ (
+ _: string,
+ params?: { onPasswordSet?: (p: string) => Promise },
+ ) => {
+ if (params?.onPasswordSet) onPasswordSet = params.onPasswordSet;
+ },
+ );
+
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', true);
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled());
+ expect(onPasswordSet).toBeDefined();
+ await act(async () => {
+ if (onPasswordSet) await onPasswordSet('test-password');
+ });
+
+ await waitFor(() => {
+ expect(mockUpdateAuthPreference).toHaveBeenLastCalledWith({
+ authType: AUTHENTICATION_TYPE.BIOMETRIC,
+ password: 'test-password',
+ });
+ });
+ });
+
+ it('clears optimistic state when user cancels password entry', async () => {
+ let onCancel: (() => void) | undefined;
+ mockUpdateAuthPreference.mockRejectedValueOnce(
+ new MockedAuthenticationError(
+ 'Password required',
+ AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
+ ),
+ );
+ mockNavigate.mockImplementation(
+ (_: string, params?: { onCancel?: () => void }) => {
+ if (params?.onCancel) onCancel = params.onCancel;
+ },
+ );
+
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', true);
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled());
+ expect(onCancel).toBeDefined();
+ act(() => {
+ onCancel?.();
+ });
+
+ await waitFor(() => {
+ const toggleAfterCancel = getByTestId(
+ SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE,
+ );
+ expect(toggleAfterCancel.props.value).toBe(false);
+ });
+ });
+
+ it('logs error and clears optimistic state when updateAuthPreference fails after password entry', async () => {
+ const updateError = new Error('Update failed after password');
+ let onPasswordSet: ((password: string) => Promise) | undefined;
+ mockUpdateAuthPreference
+ .mockRejectedValueOnce(
+ new MockedAuthenticationError(
+ 'Password required',
+ AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
+ ),
+ )
+ .mockRejectedValueOnce(updateError);
+ mockNavigate.mockImplementation(
+ (
+ _: string,
+ params?: { onPasswordSet?: (p: string) => Promise },
+ ) => {
+ if (params?.onPasswordSet) onPasswordSet = params.onPasswordSet;
+ },
+ );
+
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', true);
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled());
+ expect(onPasswordSet).toBeDefined();
+ await act(async () => {
+ if (onPasswordSet) await onPasswordSet('test-password');
+ });
+
+ await waitFor(() => {
+ expect(Logger.error).toHaveBeenCalledWith(
+ updateError,
+ 'Failed to update auth preference after password entry',
+ );
+ });
+ await waitFor(() => {
+ const toggleAfterError = getByTestId(
+ SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE,
+ );
+ expect(toggleAfterError.props.value).toBe(false);
+ });
+ });
+ });
+
+ describe('onDeviceSecurityToggle success', () => {
+ it('clears optimistic value after updateAuthPreference succeeds', async () => {
+ jest.useFakeTimers();
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ fireEvent(toggle, 'onValueChange', true);
+
+ await waitFor(() => expect(mockUpdateAuthPreference).toHaveBeenCalled());
+ expect(toggle.props.value).toBe(true);
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ await waitFor(() => {
+ const toggleAfterSuccess = getByTestId(
+ SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE,
+ );
+ expect(toggleAfterSuccess.props.disabled).toBe(false);
+ });
+ jest.useRealTimers();
+ });
+ });
+
+ describe('loading and errors', () => {
+ it('disables toggle when capabilities are loading', async () => {
+ mockUseAuthCapabilities.mockReturnValue({
+ isLoading: true,
+ capabilities: defaultCapabilities,
+ });
+ const { getByTestId } = renderComponent();
+ await waitFor(() => {
+ const toggle = getByTestId(
+ SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE,
+ );
+ expect(toggle.props.disabled).toBe(true);
+ });
+ });
+
+ it('handles updateAuthPreference rejection and clears optimistic state', async () => {
+ mockUpdateAuthPreference.mockRejectedValueOnce(
+ new Error('Update failed'),
+ );
+ const { getByTestId } = renderComponent();
+ const toggle = await waitFor(() =>
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ );
+ expect(toggle.props.value).toBe(false);
+
+ fireEvent(toggle, 'onValueChange', true);
+
+ await waitFor(() => expect(mockUpdateAuthPreference).toHaveBeenCalled());
+
+ // Optimistic state should be cleared in catch: toggle reverts to capabilities (off)
+ await waitFor(() => {
+ const toggleAfterError = getByTestId(
+ SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE,
+ );
+ expect(toggleAfterError.props.value).toBe(false);
+ });
+ });
+ });
+});
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/DeviceSecurityToggle.tsx b/app/components/Views/Settings/SecuritySettings/Sections/DeviceSecurityToggle.tsx
new file mode 100644
index 000000000000..f8d068af6e35
--- /dev/null
+++ b/app/components/Views/Settings/SecuritySettings/Sections/DeviceSecurityToggle.tsx
@@ -0,0 +1,187 @@
+import React, { useState, useCallback } from 'react';
+import { SecurityOptionToggle } from '../../../../UI/SecurityOptionToggle';
+import AUTHENTICATION_TYPE from '../../../../../constants/userProperties';
+import createStyles from '../SecuritySettings.styles';
+import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds';
+import {
+ Box,
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
+import { useNavigation } from '@react-navigation/native';
+import Logger from '../../../../../util/Logger';
+import AuthenticationError from '../../../../../core/Authentication/AuthenticationError';
+import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error';
+import { useStyles } from '../../../../hooks/useStyles';
+import useAuthCapabilities from '../../../../../core/Authentication/hooks/useAuthCapabilities';
+import { createTurnOffRememberMeModalNavDetails } from '../../../../UI/TurnOffRememberMeModal/TurnOffRememberMeModal';
+import { useAuthentication } from '../../../../../core/Authentication';
+import { Linking } from 'react-native';
+import { strings } from '../../../../../../locales/i18n';
+
+interface DeviceSecurityToggleProps {
+ /**
+ * When true (default), toggling triggers reauthentication and credential migration.
+ * When false, toggling only updates the Redux preference without reauthentication.
+ * Use `false` for preset/onboarding flows where no wallet exists yet.
+ */
+ requiresReauthentication?: boolean;
+}
+
+/**
+ * DeviceSecurityToggle component that renders a single toggle for device security
+ * (biometrics or device passcode) based on device capabilities.
+ *
+ * Uses the useAuthCapabilities hook to determine:
+ * - Whether the toggle should be visible
+ * - What label to display (e.g., "Face ID", "Biometrics", "Device Passcode")
+ * - Whether the toggle is enabled
+ *
+ * @param requiresReauthentication - When true (default), toggling triggers reauthentication.
+ * Set to false for preset flows (e.g., onboarding) where only the preference should be saved.
+ *
+ * The toggle is disabled when Remember Me is enabled.
+ */
+const DeviceSecurityToggle = ({
+ requiresReauthentication = true,
+}: DeviceSecurityToggleProps) => {
+ const navigation = useNavigation();
+ const { styles } = useStyles(createStyles, {});
+ const { updateAuthPreference, getAuthCapabilities, updateOsAuthEnabled } =
+ useAuthentication();
+ const { isLoading, capabilities } = useAuthCapabilities();
+
+ // Optimistic value for immediate UI feedback - also serves as "updating" indicator
+ const [optimisticValue, setOptimisticValue] = useState(null);
+ const isUpdating = optimisticValue !== null;
+ const isToggleDisabled = isUpdating || isLoading;
+
+ const onDeviceSecurityToggle = useCallback(
+ async (enabled: boolean) => {
+ // Update Redux state without reauthentication
+ if (!requiresReauthentication) {
+ updateOsAuthEnabled(enabled);
+ return;
+ }
+
+ // Handle turning off Remember Me
+ // Since we're deprecating Remember Me, once Remember Me is turned off, it cannot be turned back on.
+ if (
+ !enabled &&
+ capabilities?.authType === AUTHENTICATION_TYPE.REMEMBER_ME
+ ) {
+ navigation.navigate(...createTurnOffRememberMeModalNavDetails());
+ return;
+ }
+
+ // Set optimistic value immediately for instant UI feedback
+ setOptimisticValue(enabled);
+
+ // Single source of truth: derived auth type for the target toggle state
+ const { authType } = await getAuthCapabilities({
+ osAuthEnabled: enabled,
+ // Remember Me is already deprecated at this point, so we can safely set it to false
+ allowLoginWithRememberMe: false,
+ });
+
+ try {
+ await updateAuthPreference({ authType });
+ // This setTimeout is intentional for two reasons:
+ // 1. Ensure that useAuthCapabilities hook resolves the latest capabilites (prevents toggle flicker)
+ // 2. Prevent spamming the toggle
+ setTimeout(() => {
+ setOptimisticValue(null);
+ }, 100);
+ } catch (error) {
+ // Check if error is "password required" - navigate to password entry
+ const isPasswordRequiredError =
+ error instanceof AuthenticationError &&
+ error.customErrorMessage ===
+ AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS;
+
+ if (isPasswordRequiredError) {
+ // Navigate to password entry - keep optimistic value until callback completes
+ navigation.navigate('EnterPasswordSimple', {
+ onPasswordSet: async (enteredPassword: string) => {
+ try {
+ await updateAuthPreference({
+ authType,
+ password: enteredPassword,
+ });
+ } catch (updateError) {
+ Logger.error(
+ updateError as Error,
+ 'Failed to update auth preference after password entry',
+ );
+ } finally {
+ setOptimisticValue(null);
+ }
+ },
+ onCancel: () => {
+ setOptimisticValue(null);
+ },
+ });
+ return;
+ }
+
+ // Other errors - clear optimistic value and log
+ Logger.error(
+ error as Error,
+ 'Failed to update device security preference',
+ );
+ setOptimisticValue(null);
+ }
+ },
+ [
+ capabilities,
+ getAuthCapabilities,
+ navigation,
+ requiresReauthentication,
+ updateAuthPreference,
+ updateOsAuthEnabled,
+ ],
+ );
+
+ /** Opens device settings for enabling OS biometrics or passcode */
+ const onOpenDeviceSettings = useCallback(() => Linking.openSettings(), []);
+
+ // Don't render toggle if capabilites are not available
+ if (!capabilities) {
+ return null;
+ }
+
+ // Use optimistic value while updating, otherwise derive from capabilities
+ const actualValue =
+ capabilities?.authType === AUTHENTICATION_TYPE.REMEMBER_ME
+ ? capabilities?.allowLoginWithRememberMe
+ : capabilities?.osAuthEnabled;
+ const displayValue = optimisticValue !== null ? optimisticValue : actualValue;
+
+ return (
+
+ {/** Priority: biometrics toggle → device passcode toggle → device settings link when neither available */}
+ {capabilities?.deviceAuthRequiresSettings ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default DeviceSecurityToggle;
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx
deleted file mode 100644
index 22db96937a1d..000000000000
--- a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx
+++ /dev/null
@@ -1,1017 +0,0 @@
-// Mock StorageWrapper FIRST (before any imports that use it)
-jest.mock('../../../../../store/storage-wrapper', () => ({
- __esModule: true,
- default: {
- getItem: jest.fn(),
- setItem: jest.fn(),
- removeItem: jest.fn(),
- },
-}));
-
-// Mock Authentication
-jest.mock('../../../../../core', () => ({
- Authentication: {
- getType: jest.fn(),
- updateAuthPreference: jest.fn(),
- },
-}));
-
-// Mock navigation - define navigate function that can be accessed
-const mockNavigateFn = jest.fn();
-jest.mock('@react-navigation/native', () => {
- const actualReactNavigation = jest.requireActual('@react-navigation/native');
- return {
- ...actualReactNavigation,
- useNavigation: () => ({
- navigate: mockNavigateFn,
- }),
- };
-});
-
-// Mock useTheme
-jest.mock('../../../../../util/theme', () => ({
- useTheme: () => ({
- colors: {
- primary: { default: '#0376C9' },
- background: { default: '#FFFFFF' },
- text: { default: '#000000' },
- },
- }),
-}));
-
-// Mock createStyles
-jest.mock('../SecuritySettings.styles', () => ({
- __esModule: true,
- default: () => ({
- setting: {},
- }),
-}));
-
-// Mock Box and other design system components
-jest.mock('@metamask/design-system-react-native', () => {
- const { View } = jest.requireActual('react-native');
- return {
- Box: ({
- children,
- testID,
- ...props
- }: {
- children?: React.ReactNode;
- testID?: string;
- [key: string]: unknown;
- }) => (
-
- {children}
-
- ),
- BoxFlexDirection: { Row: 'row', Column: 'column' },
- BoxAlignItems: { Center: 'center' },
- };
-});
-
-// Mock SecurityOptionToggle
-jest.mock('../../../../UI/SecurityOptionToggle', () => {
- const { Switch } = jest.requireActual('react-native');
- return {
- SecurityOptionToggle: ({
- testId,
- value,
- onOptionUpdated,
- disabled,
- }: {
- testId: string;
- value: boolean;
- onOptionUpdated: (val: boolean) => void;
- disabled?: boolean;
- }) => (
-
- ),
- };
-});
-
-import React from 'react';
-import { fireEvent, waitFor } from '@testing-library/react-native';
-import renderWithProvider from '../../../../../util/test/renderWithProvider';
-import LoginOptionsSettings from './LoginOptionsSettings';
-import AUTHENTICATION_TYPE from '../../../../../constants/userProperties';
-import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds';
-import {
- PASSCODE_DISABLED,
- BIOMETRY_CHOICE_DISABLED,
- TRUE,
-} from '../../../../../constants/storage';
-
-// Mock Device
-jest.mock('../../../../../util/device', () => ({
- isAndroid: jest.fn(() => false),
- isIos: jest.fn(() => true),
-}));
-
-// Mock Logger
-jest.mock('../../../../../util/Logger', () => ({
- error: jest.fn(),
-}));
-
-// Import the actual constant
-import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error';
-
-// Mock AuthenticationError as a proper class for instanceof to work
-// Must be defined inside the factory because jest.mock is hoisted
-jest.mock('../../../../../core/Authentication/AuthenticationError', () => {
- class AuthenticationError extends Error {
- customErrorMessage: string;
- constructor(message: string, code: string) {
- super(message);
- this.customErrorMessage = code;
- this.name = 'AuthenticationError';
- }
- }
- return {
- __esModule: true,
- default: AuthenticationError,
- };
-});
-
-// Get the mocked AuthenticationError class
-const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
-).default as new (
- message: string,
- code: string,
-) => Error & { customErrorMessage: string };
-
-describe('LoginOptionsSettings', () => {
- let mockGetType: jest.Mock;
- let mockUpdateAuthPreference: jest.Mock;
- let mockGetItem: jest.Mock;
-
- beforeEach(() => {
- jest.clearAllMocks();
-
- // Get the mocked functions from the modules
- const coreModule = jest.requireMock('../../../../../core');
- mockGetType = coreModule.Authentication.getType as jest.Mock;
- mockUpdateAuthPreference = coreModule.Authentication
- .updateAuthPreference as jest.Mock;
-
- const storageModule = jest.requireMock(
- '../../../../../store/storage-wrapper',
- );
- mockGetItem = storageModule.default.getItem as jest.Mock;
-
- // Set default mock implementations
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- availableBiometryType: 'FaceID',
- });
- mockGetItem.mockResolvedValue(null);
- mockUpdateAuthPreference.mockResolvedValue(undefined);
- });
-
- const initialState = {
- security: {
- allowLoginWithRememberMe: false,
- },
- };
-
- it('renders correctly', async () => {
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- await waitFor(() => {
- expect(
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- ).toBeTruthy();
- });
- });
-
- it('enables biometrics when toggle is turned on', async () => {
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.BIOMETRIC,
- });
- });
- });
-
- it('disables biometrics when toggle is turned off', async () => {
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'FaceID',
- });
- // When biometrics is enabled, passcode is disabled (mutually exclusive)
- mockGetItem.mockImplementation((key: string) => {
- if (key === BIOMETRY_CHOICE_DISABLED) {
- return Promise.resolve(null); // Biometrics not disabled (enabled)
- }
- if (key === PASSCODE_DISABLED) {
- return Promise.resolve(TRUE); // Passcode is disabled
- }
- return Promise.resolve(null);
- });
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.PASSWORD,
- });
- });
- });
-
- it('navigates to password entry when password is required for biometrics', async () => {
- mockUpdateAuthPreference.mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalledWith('EnterPasswordSimple', {
- onPasswordSet: expect.any(Function),
- });
- });
- });
-
- it('updates auth preference when password is provided via callback', async () => {
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockResolvedValueOnce(undefined);
-
- mockNavigateFn.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalled();
- });
-
- // Simulate password entry
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.BIOMETRIC,
- password: 'test-password',
- });
- });
- }
- });
-
- it('clears loading state when user cancels password entry', async () => {
- mockUpdateAuthPreference.mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalled();
- });
-
- // Loading should be cleared in finally block even if callback is never called
- // This is tested by ensuring the component doesn't get stuck in loading state
- await waitFor(() => {
- // Component should be interactive again
- expect(toggle).toBeTruthy();
- });
- });
-
- it('disables biometrics toggle when remember me is enabled', async () => {
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- expect(toggle.props.disabled).toBe(true);
- });
-
- it('disables passcode toggle when remember me is enabled', async () => {
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- availableBiometryType: 'FaceID',
- });
-
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- );
- expect(toggle.props.disabled).toBe(true);
- });
-
- it('disables passcode toggle when biometrics is loading', async () => {
- let resolveUpdateAuthPreference: (() => void) | undefined;
- const updatePromise = new Promise((resolve) => {
- resolveUpdateAuthPreference = resolve;
- });
- mockUpdateAuthPreference.mockReturnValue(updatePromise);
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const biometricToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(biometricToggle, 'onValueChange', true);
-
- // Wait for the passcode toggle to be disabled while biometrics is loading
- await waitFor(() => {
- const passcodeToggle = getByTestId(
- SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE,
- );
- expect(passcodeToggle.props.disabled).toBe(true);
- });
-
- // Resolve the promise
- if (resolveUpdateAuthPreference) {
- resolveUpdateAuthPreference();
- await waitFor(() => {
- // Loading should be cleared
- });
- }
- });
-
- it('disables biometrics toggle when passcode is loading', async () => {
- let resolveUpdateAuthPreference: (() => void) | undefined;
- const updatePromise = new Promise((resolve) => {
- resolveUpdateAuthPreference = resolve;
- });
- mockUpdateAuthPreference.mockReturnValue(updatePromise);
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const passcodeToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- );
- fireEvent(passcodeToggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalled();
- });
-
- const biometricToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- expect(biometricToggle.props.disabled).toBe(true);
-
- // Resolve the promise
- if (resolveUpdateAuthPreference) {
- resolveUpdateAuthPreference();
- await waitFor(() => {
- // Loading should be cleared
- });
- }
- });
-
- it('handles error when updating auth preference fails', async () => {
- const error = new Error('Update failed');
- mockUpdateAuthPreference.mockRejectedValueOnce(error);
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalled();
- });
-
- // Toggle should revert to original state on error
- await waitFor(() => {
- // Component should handle error gracefully
- });
- });
-
- it('reverts toggle state when password entry callback fails', async () => {
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockRejectedValueOnce(new Error('Update failed'));
-
- mockNavigateFn.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalled();
- });
-
- // Simulate password entry that fails
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.BIOMETRIC,
- password: 'test-password',
- });
- });
- }
- });
-
- it('navigates to password entry when password is required for passcode', async () => {
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- availableBiometryType: 'FaceID',
- });
-
- mockUpdateAuthPreference.mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const passcodeToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- );
- fireEvent(passcodeToggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalledWith('EnterPasswordSimple', {
- onPasswordSet: expect.any(Function),
- });
- });
- });
-
- it('updates auth preference when password is provided via callback for passcode', async () => {
- let passwordCallback: ((password: string) => Promise) | undefined;
-
- // Initial load: PASSWORD with FaceID available (shows passcode toggle)
- mockGetType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- availableBiometryType: 'FaceID',
- });
-
- // After password entry: PASSCODE
- mockGetType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.PASSCODE,
- availableBiometryType: 'FaceID',
- });
-
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockResolvedValueOnce(undefined);
-
- // Mock getItem for the re-fetch after password entry
- mockGetItem
- .mockResolvedValueOnce(null) // Initial load
- .mockResolvedValueOnce(null); // After password entry
-
- mockNavigateFn.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- // Wait for component to load and passcode toggle to appear
- const passcodeToggle = await waitFor(
- () => getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- { timeout: 3000 },
- );
-
- fireEvent(passcodeToggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.PASSCODE,
- password: 'test-password',
- });
- });
- }
- });
-
- it('reverts toggle state when passcode password entry callback fails', async () => {
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- availableBiometryType: 'FaceID',
- });
-
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockRejectedValueOnce(new Error('Update failed'));
-
- mockNavigateFn.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const passcodeToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- );
- fireEvent(passcodeToggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.PASSCODE,
- password: 'test-password',
- });
- });
- }
- });
-
- it('re-fetches auth type after successful password entry for biometrics', async () => {
- let passwordCallback: ((password: string) => Promise) | undefined;
-
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockResolvedValueOnce(undefined);
-
- // First call: initial load in useEffect
- mockGetType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- availableBiometryType: 'FaceID',
- });
-
- // Second call: after password entry (re-fetch)
- mockGetType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'FaceID',
- });
-
- // Mock getItem for initial load and re-fetch after password entry
- mockGetItem
- .mockResolvedValueOnce(null) // Initial load
- .mockResolvedValueOnce(null); // After password entry (re-fetch)
-
- mockNavigateFn.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(
- () => {
- // Should re-fetch auth type after successful update
- // Call 1: initial load in useEffect
- // Call 2: re-fetch after password entry
- expect(mockGetType).toHaveBeenCalledTimes(2);
- },
- { timeout: 3000 },
- );
- }
- });
-
- it('re-fetches auth type after successful password entry for passcode', async () => {
- let passwordCallback: ((password: string) => Promise) | undefined;
-
- // First call: initial load in useEffect
- mockGetType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- availableBiometryType: 'FaceID',
- });
-
- // Second call: after password entry (re-fetch)
- mockGetType.mockResolvedValueOnce({
- currentAuthType: AUTHENTICATION_TYPE.PASSCODE,
- availableBiometryType: 'FaceID',
- });
-
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockResolvedValueOnce(undefined);
-
- // Mock getItem for initial load (BIOMETRY_CHOICE_DISABLED and PASSCODE_DISABLED)
- // and re-fetch after password entry (PASSCODE_DISABLED)
- mockGetItem
- .mockResolvedValueOnce(null) // Initial load: BIOMETRY_CHOICE_DISABLED
- .mockResolvedValueOnce(null) // Initial load: PASSCODE_DISABLED
- .mockResolvedValueOnce(null); // After password entry: PASSCODE_DISABLED
-
- mockNavigateFn.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- // Wait for component to load and passcode toggle to appear
- const passcodeToggle = await waitFor(
- () => getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- { timeout: 3000 },
- );
-
- fireEvent(passcodeToggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigateFn).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(
- () => {
- // Should re-fetch auth type after successful update
- // Call 1: initial load in useEffect
- // Call 2: re-fetch after password entry
- expect(mockGetType).toHaveBeenCalledTimes(2);
- },
- { timeout: 3000 },
- );
- }
- });
-
- it('handles error when updating passcode auth preference fails', async () => {
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- availableBiometryType: 'FaceID',
- });
-
- const error = new Error('Update failed');
- mockUpdateAuthPreference.mockRejectedValueOnce(error);
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const passcodeToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- );
- fireEvent(passcodeToggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalled();
- });
- });
-
- describe('mutual exclusivity and toggle visibility', () => {
- it('shows biometrics toggle when biometrics are enabled regardless of storage state', async () => {
- // Arrange: Simulate inconsistent storage state (bug scenario)
- // Storage says passcode is not disabled, but currentAuthType says biometrics are enabled
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'FaceID',
- });
- mockGetItem.mockImplementation((key: string) => {
- if (key === BIOMETRY_CHOICE_DISABLED) {
- return Promise.resolve(null); // Biometrics not disabled
- }
- if (key === PASSCODE_DISABLED) {
- // This is the bug scenario: storage says passcode is NOT disabled
- // but currentAuthType says biometrics are enabled
- return Promise.resolve(null);
- }
- return Promise.resolve(null);
- });
-
- const { getByTestId, queryByTestId } = renderWithProvider(
- ,
- {
- state: initialState,
- },
- );
-
- // Act & Assert: Biometrics toggle should be visible (currentAuthType is source of truth)
- const biometricsToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
-
- expect(biometricsToggle).toBeTruthy();
- expect(biometricsToggle.props.value).toBe(true);
-
- // Assert: Passcode toggle should NOT be visible (mutually exclusive)
- await waitFor(() => {
- expect(
- queryByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- ).toBeNull();
- });
- });
-
- it('shows passcode toggle when passcode is enabled regardless of storage state', async () => {
- // Arrange: Simulate inconsistent storage state (bug scenario)
- // Storage says biometrics is not disabled, but currentAuthType says passcode is enabled
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSCODE,
- availableBiometryType: 'FaceID',
- });
- mockGetItem.mockImplementation((key: string) => {
- if (key === BIOMETRY_CHOICE_DISABLED) {
- // This is the bug scenario: storage says biometrics is NOT disabled
- // but currentAuthType says passcode is enabled
- return Promise.resolve(null);
- }
- if (key === PASSCODE_DISABLED) {
- return Promise.resolve(null); // Passcode not disabled
- }
- return Promise.resolve(null);
- });
-
- const { getByTestId, queryByTestId } = renderWithProvider(
- ,
- {
- state: initialState,
- },
- );
-
- // Act & Assert: Passcode toggle should be visible (currentAuthType is source of truth)
- const passcodeToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- );
-
- expect(passcodeToggle).toBeTruthy();
- expect(passcodeToggle.props.value).toBe(true);
-
- // Assert: Biometrics toggle should NOT be visible (mutually exclusive)
- await waitFor(() => {
- expect(
- queryByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- ).toBeNull();
- });
- });
-
- it('enforces mutual exclusivity when biometrics are enabled', async () => {
- // Arrange
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'FaceID',
- });
- mockGetItem.mockImplementation((key: string) => {
- if (key === BIOMETRY_CHOICE_DISABLED) {
- return Promise.resolve(null);
- }
- if (key === PASSCODE_DISABLED) {
- return Promise.resolve(TRUE);
- }
- return Promise.resolve(null);
- });
-
- const { getByTestId, queryByTestId } = renderWithProvider(
- ,
- {
- state: initialState,
- },
- );
-
- // Act
- const biometricsToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
-
- // Assert
- expect(biometricsToggle.props.value).toBe(true);
- expect(
- queryByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- ).toBeNull();
- });
-
- it('enforces mutual exclusivity when passcode is enabled', async () => {
- // Arrange
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSCODE,
- availableBiometryType: 'FaceID',
- });
- mockGetItem.mockImplementation((key: string) => {
- if (key === BIOMETRY_CHOICE_DISABLED) {
- return Promise.resolve(TRUE);
- }
- if (key === PASSCODE_DISABLED) {
- return Promise.resolve(null);
- }
- return Promise.resolve(null);
- });
-
- const { getByTestId, queryByTestId } = renderWithProvider(
- ,
- {
- state: initialState,
- },
- );
-
- // Act
- const passcodeToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- );
-
- // Assert
- expect(passcodeToggle.props.value).toBe(true);
- expect(
- queryByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- ).toBeNull();
- });
-
- it('uses currentAuthType as source of truth over storage state', async () => {
- // Arrange: Storage is completely inconsistent, but currentAuthType is correct
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'FaceID',
- });
- // Storage incorrectly says both are not disabled (inconsistent state)
- mockGetItem.mockResolvedValue(null);
-
- const { getByTestId, queryByTestId } = renderWithProvider(
- ,
- {
- state: initialState,
- },
- );
-
- // Act
- const biometricsToggle = await waitFor(() =>
- getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE),
- );
-
- // Assert: Should show biometrics toggle because currentAuthType says BIOMETRIC
- expect(biometricsToggle.props.value).toBe(true);
- expect(
- queryByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE),
- ).toBeNull();
- });
- });
-});
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx
deleted file mode 100644
index f9f851e69276..000000000000
--- a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx
+++ /dev/null
@@ -1,320 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import { SecurityOptionToggle } from '../../../../UI/SecurityOptionToggle';
-import { strings } from '../../../../../../locales/i18n';
-import { BIOMETRY_TYPE } from 'react-native-keychain';
-import { Authentication } from '../../../../../core';
-import AUTHENTICATION_TYPE from '../../../../../constants/userProperties';
-import Device from '../../../../../util/device';
-import { useTheme } from '../../../../../util/theme';
-import StorageWrapper from '../../../../../store/storage-wrapper';
-import {
- BIOMETRY_CHOICE_DISABLED,
- PASSCODE_DISABLED,
- TRUE,
-} from '../../../../../constants/storage';
-import { ActivityIndicator } from 'react-native';
-import { LOGIN_OPTIONS } from '../SecuritySettings.constants';
-import createStyles from '../SecuritySettings.styles';
-import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds';
-import {
- Box,
- BoxFlexDirection,
- BoxAlignItems,
-} from '@metamask/design-system-react-native';
-import { useNavigation } from '@react-navigation/native';
-import { useSelector } from 'react-redux';
-import Logger from '../../../../../util/Logger';
-import AuthenticationError from '../../../../../core/Authentication/AuthenticationError';
-import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error';
-import { RootState } from '../../../../../reducers';
-
-const LoginOptionsSettings = () => {
- const navigation = useNavigation();
- const allowLoginWithRememberMe = useSelector(
- (state: RootState) => state.security?.allowLoginWithRememberMe,
- );
- const [biometryType, setBiometryType] = useState<
- BIOMETRY_TYPE | AUTHENTICATION_TYPE.BIOMETRIC | undefined
- >(undefined);
- const [biometryChoice, setBiometryChoice] = useState(false);
- const [passcodeChoice, setPasscodeChoice] = useState(false);
- const [isBiometricLoading, setIsBiometricLoading] = useState(false);
- const [isPasscodeLoading, setIsPasscodeLoading] = useState(false);
- const { colors } = useTheme();
- const styles = createStyles(colors);
-
- useEffect(() => {
- const getOptions = async () => {
- const authType = await Authentication.getType();
- const previouslyDisabled = await StorageWrapper.getItem(
- BIOMETRY_CHOICE_DISABLED,
- );
- const passcodePreviouslyDisabled =
- await StorageWrapper.getItem(PASSCODE_DISABLED);
- if (
- authType.currentAuthType === AUTHENTICATION_TYPE.BIOMETRIC ||
- authType.currentAuthType === AUTHENTICATION_TYPE.PASSCODE
- ) {
- const stateValue = Device.isAndroid()
- ? AUTHENTICATION_TYPE.BIOMETRIC
- : authType.availableBiometryType;
- setBiometryType(stateValue);
-
- if (authType.currentAuthType === AUTHENTICATION_TYPE.BIOMETRIC) {
- // Biometrics are enabled - passcode must be disabled (mutually exclusive)
- setBiometryChoice(
- !(previouslyDisabled && previouslyDisabled === TRUE),
- );
- setPasscodeChoice(false);
- } else {
- // Passcode is enabled - biometrics must be disabled (mutually exclusive)
- setBiometryChoice(false);
- setPasscodeChoice(
- !(
- passcodePreviouslyDisabled && passcodePreviouslyDisabled === TRUE
- ),
- );
- }
- } else {
- const stateValue =
- Device.isAndroid() && authType.availableBiometryType
- ? AUTHENTICATION_TYPE.BIOMETRIC
- : authType.availableBiometryType;
- setBiometryType(stateValue);
- }
- };
- getOptions();
- }, []);
-
- const onBiometricsOptionUpdated = useCallback(
- async (enabled: boolean) => {
- // Prevent toggling biometrics when remember me is enabled
- if (allowLoginWithRememberMe) {
- return;
- }
-
- setIsBiometricLoading(true);
- try {
- const authType = enabled
- ? AUTHENTICATION_TYPE.BIOMETRIC
- : AUTHENTICATION_TYPE.PASSWORD;
-
- // Enabling biometrics is handled by the catch condition "isPasswordRequiredError"
- await Authentication.updateAuthPreference({ authType });
-
- // Only update UI if operation completed successfully
- setBiometryChoice(enabled);
- // Biometrics and passcode are mutually exclusive - enabling one disables the other
- // Disabling biometrics switches to PASSWORD which disables both
- setPasscodeChoice(false);
- } catch (error) {
- // Check if error is "password required" - navigate to password entry
- const isPasswordRequiredError =
- error instanceof AuthenticationError &&
- error.customErrorMessage ===
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS;
-
- if (isPasswordRequiredError) {
- // Navigate to password entry
- const authType = enabled
- ? AUTHENTICATION_TYPE.BIOMETRIC
- : AUTHENTICATION_TYPE.PASSWORD;
-
- navigation.navigate('EnterPasswordSimple', {
- onPasswordSet: async (enteredPassword: string) => {
- // Set loading back to true when callback is invoked
- setIsBiometricLoading(true);
- try {
- await Authentication.updateAuthPreference({
- authType,
- password: enteredPassword,
- });
-
- // Update UI state after successful password entry and update
- setBiometryChoice(enabled);
- // Biometrics and passcode are mutually exclusive - enabling one disables the other
- // Disabling biometrics switches to PASSWORD which disables both
- setPasscodeChoice(false);
-
- // Re-fetch to ensure UI matches actual state
- const currentAuthType = await Authentication.getType();
- const previouslyDisabled = await StorageWrapper.getItem(
- BIOMETRY_CHOICE_DISABLED,
- );
- setBiometryChoice(
- currentAuthType.currentAuthType ===
- AUTHENTICATION_TYPE.BIOMETRIC &&
- !(previouslyDisabled && previouslyDisabled === TRUE),
- );
- } catch (updateError) {
- // On error, revert UI state
- setBiometryChoice(!enabled);
- Logger.error(
- updateError as Error,
- 'Failed to update auth preference after password entry',
- );
- } finally {
- // Clear loading after callback completes
- setIsBiometricLoading(false);
- }
- },
- });
- // Don't update UI state here - wait for callback
- return;
- }
- // Other error - revert toggle state
- Logger.error(
- error as Error,
- 'Failed to update auth preference after password entry',
- );
- setBiometryChoice(!enabled);
- } finally {
- setIsBiometricLoading(false);
- }
- },
- [navigation, allowLoginWithRememberMe],
- );
- const onPasscodeOptionUpdated = useCallback(
- async (enabled: boolean) => {
- // Prevent toggling passcode when remember me is enabled
- if (allowLoginWithRememberMe) {
- return;
- }
-
- setIsPasscodeLoading(true);
- try {
- const authType = enabled
- ? AUTHENTICATION_TYPE.PASSCODE
- : AUTHENTICATION_TYPE.PASSWORD;
-
- // Enabling passcode is handled by the catch condition "isPasswordRequiredError"
- await Authentication.updateAuthPreference({ authType });
-
- // Only update UI if operation completed successfully
- setPasscodeChoice(enabled);
- // Biometrics and passcode are mutually exclusive - enabling one disables the other
- // Disabling passcode switches to PASSWORD which disables both
- setBiometryChoice(false);
- } catch (error) {
- // Check if error is "password required" - navigate to password entry
- const isPasswordRequiredError =
- error instanceof AuthenticationError &&
- error.customErrorMessage ===
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS;
-
- if (isPasswordRequiredError) {
- // Navigate to password entry
- const authType = enabled
- ? AUTHENTICATION_TYPE.PASSCODE
- : AUTHENTICATION_TYPE.PASSWORD;
-
- navigation.navigate('EnterPasswordSimple', {
- onPasswordSet: async (enteredPassword: string) => {
- // Set loading back to true when callback is invoked
- setIsPasscodeLoading(true);
- try {
- await Authentication.updateAuthPreference({
- authType,
- password: enteredPassword,
- });
-
- // Update UI state after successful password entry and update
- setPasscodeChoice(enabled);
- // Biometrics and passcode are mutually exclusive - enabling one disables the other
- // Disabling passcode switches to PASSWORD which disables both
- setBiometryChoice(false);
-
- // Re-fetch to ensure UI matches actual state
- const currentAuthType = await Authentication.getType();
- const passcodePreviouslyDisabled =
- await StorageWrapper.getItem(PASSCODE_DISABLED);
- setPasscodeChoice(
- currentAuthType.currentAuthType ===
- AUTHENTICATION_TYPE.PASSCODE &&
- !(
- passcodePreviouslyDisabled &&
- passcodePreviouslyDisabled === TRUE
- ),
- );
- } catch (updateError) {
- // On error, revert UI state
- setPasscodeChoice(!enabled);
- Logger.error(
- updateError as Error,
- 'Failed to update auth preference after password entry',
- );
- } finally {
- // Clear loading after callback completes
- setIsPasscodeLoading(false);
- }
- },
- });
- // Don't update UI state here - wait for callback
- return;
- }
- // Other error - revert toggle state
- Logger.error(
- error as Error,
- 'Failed to update auth preference after password entry',
- );
- setPasscodeChoice(!enabled);
- } finally {
- setIsPasscodeLoading(false);
- }
- },
- [navigation, allowLoginWithRememberMe],
- );
-
- return (
-
- {biometryType && !passcodeChoice ? (
-
- {isBiometricLoading ? (
-
-
-
- ) : (
-
- )}
-
- ) : null}
- {biometryType && !biometryChoice ? (
-
- {isPasscodeLoading ? (
-
-
-
- ) : (
-
- )}
-
- ) : null}
-
- );
-};
-
-export default React.memo(LoginOptionsSettings);
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx
index 89f5002d2c68..2d792a64e316 100644
--- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx
+++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx
@@ -16,7 +16,6 @@ import Button, {
import { HOW_TO_MANAGE_METRAMETRICS_SETTINGS } from '../../../../../../constants/urls';
import React, { useEffect, useState } from 'react';
import createStyles from '../../SecuritySettings.styles';
-import { useTheme } from '../../../../../../util/theme';
import generateDeviceAnalyticsMetaData, {
UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData,
} from '../../../../../../util/metrics';
@@ -36,6 +35,7 @@ import { selectSeedlessOnboardingLoginFlow } from '../../../../../../selectors/s
import { storePna25Acknowledged } from '../../../../../../actions/legalNotices';
import { selectIsPna25Acknowledged } from '../../../../../../selectors/legalNotices';
import { selectIsPna25FlagEnabled } from '../../../../../../selectors/featureFlagController/legalNotices';
+import { useStyles } from '../../../../../../component-library/hooks/useStyles';
interface MetaMetricsAndDataCollectionSectionProps {
hideMarketingSection?: boolean;
@@ -44,9 +44,8 @@ interface MetaMetricsAndDataCollectionSectionProps {
const MetaMetricsAndDataCollectionSection: React.FC<
MetaMetricsAndDataCollectionSectionProps
> = ({ hideMarketingSection = false }) => {
- const theme = useTheme();
+ const { styles, theme } = useStyles(createStyles, {});
const { colors } = theme;
- const styles = createStyles(colors);
const [analyticsEnabled, setAnalyticsEnabled] = useState(false);
const dispatch = useDispatch();
const isDataCollectionForMarketingEnabled = useSelector(
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx
deleted file mode 100644
index e55a2955c132..000000000000
--- a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx
+++ /dev/null
@@ -1,832 +0,0 @@
-jest.mock('../../../../../store/storage-wrapper', () => ({
- __esModule: true,
- default: {
- getItem: jest.fn(),
- removeItem: jest.fn(),
- },
-}));
-
-// Mock locales/i18n to prevent it from using StorageWrapper during import
-jest.mock('../../../../../../locales/i18n', () => ({
- strings: jest.fn((key: string) => key),
-}));
-
-// Mock Authentication
-jest.mock('../../../../../core', () => {
- const mockGetTypeFn = jest.fn();
- const mockUpdateAuthPreferenceFn = jest.fn();
- return {
- Authentication: {
- getType: mockGetTypeFn,
- updateAuthPreference: mockUpdateAuthPreferenceFn,
- },
- __mockGetType: mockGetTypeFn,
- __mockUpdateAuthPreference: mockUpdateAuthPreferenceFn,
- };
-});
-
-import React from 'react';
-import { fireEvent, waitFor } from '@testing-library/react-native';
-import renderWithProvider from '../../../../../util/test/renderWithProvider';
-import RememberMeOptionSection from './RememberMeOptionSection';
-import AUTHENTICATION_TYPE from '../../../../../constants/userProperties';
-import { TURN_ON_REMEMBER_ME } from '../SecuritySettings.constants';
-import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error';
-import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../../../constants/storage';
-import Logger from '../../../../../util/Logger';
-
-// Mock navigation
-const mockNavigate = jest.fn();
-jest.mock('@react-navigation/native', () => {
- const actualReactNavigation = jest.requireActual('@react-navigation/native');
- return {
- ...actualReactNavigation,
- useNavigation: () => ({
- navigate: mockNavigate,
- }),
- };
-});
-
-// Mock TurnOffRememberMeModal
-jest.mock(
- '../../../../UI/TurnOffRememberMeModal/TurnOffRememberMeModal',
- () => ({
- createTurnOffRememberMeModalNavDetails: jest.fn(() => [
- 'TurnOffRememberMe',
- {},
- ]),
- }),
-);
-
-// Mock AuthenticationError
-jest.mock('../../../../../core/Authentication/AuthenticationError', () => {
- class AuthenticationError extends Error {
- customErrorMessage: string;
-
- constructor(message: string, code: string) {
- super(message);
- this.customErrorMessage = code;
- this.name = 'AuthenticationError';
- }
- }
-
- return {
- __esModule: true,
- default: AuthenticationError,
- };
-});
-
-// Mock Logger
-jest.mock('../../../../../util/Logger', () => ({
- error: jest.fn(),
-}));
-
-describe('RememberMeOptionSection', () => {
- let mockGetType: jest.Mock;
- let mockUpdateAuthPreference: jest.Mock;
- let mockGetItem: jest.Mock;
- let mockRemoveItem: jest.Mock;
-
- beforeEach(() => {
- jest.clearAllMocks();
- const AuthenticationMock = jest.requireMock('../../../../../core');
- mockGetType = AuthenticationMock.__mockGetType;
- mockUpdateAuthPreference = AuthenticationMock.__mockUpdateAuthPreference;
-
- // Get mocked StorageWrapper functions
- const storageModule = jest.requireMock(
- '../../../../../store/storage-wrapper',
- );
- mockGetItem = storageModule.default.getItem as jest.Mock;
- mockRemoveItem = storageModule.default.removeItem as jest.Mock;
-
- // Reset mocks to default behavior
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockUpdateAuthPreference.mockResolvedValue(undefined);
- mockGetItem.mockResolvedValue(null);
- mockRemoveItem.mockResolvedValue(undefined);
- mockNavigate.mockClear();
- });
-
- const initialState = {
- security: {
- allowLoginWithRememberMe: false,
- },
- };
-
- it('renders correctly', () => {
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
- expect(getByTestId(TURN_ON_REMEMBER_ME)).toBeTruthy();
- });
-
- it('calls getType when attempting to disable remember me', async () => {
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME,
- });
-
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockGetType).toHaveBeenCalled();
- });
- });
-
- it('calls updateAuthPreference when enabling remember me', async () => {
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.REMEMBER_ME,
- });
- });
- });
-
- it('does not call updateAuthPreference when disabling remember me', async () => {
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME,
- });
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- // Should navigate to turn off modal, not call updateAuthPreference
- expect(mockNavigate).toHaveBeenCalled();
- });
-
- expect(mockUpdateAuthPreference).not.toHaveBeenCalled();
- });
-
- it('reverts flag if updateAuthPreference fails when enabling', async () => {
- mockUpdateAuthPreference.mockRejectedValueOnce(new Error('Update failed'));
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalled();
- });
-
- // The component should handle the error and revert the flag
- // We verify updateAuthPreference was called and failed
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.REMEMBER_ME,
- });
- });
-
- it('displays correct toggle value based on Redux state', () => {
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- expect(toggle.props.value).toBe(true);
- });
-
- it('navigates to password entry when password is required for enabling remember me', async () => {
- const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
- ).default;
-
- mockUpdateAuthPreference.mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith('EnterPasswordSimple', {
- onPasswordSet: expect.any(Function),
- });
- });
- });
-
- it('updates auth preference when password is provided via callback when enabling', async () => {
- const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
- ).default;
-
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockResolvedValueOnce(undefined);
-
- mockNavigate.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.REMEMBER_ME,
- password: 'test-password',
- });
- });
- }
- });
-
- it('reverts flag when password entry callback fails when enabling', async () => {
- const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
- ).default;
-
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockRejectedValueOnce(new Error('Update failed'));
-
- mockNavigate.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.REMEMBER_ME,
- password: 'test-password',
- });
- });
- }
- });
-
- it('calls Logger.error when updateAuthPreference fails when enabling', async () => {
- const error = new Error('Update failed');
- mockUpdateAuthPreference.mockRejectedValueOnce(error);
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(Logger.error).toHaveBeenCalledWith(
- error,
- 'Failed to update auth preference for remember me',
- );
- });
- });
-
- it('calls Logger.error when password entry callback fails when enabling', async () => {
- const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
- ).default;
-
- const updateError = new Error('Update failed');
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockRejectedValueOnce(updateError);
-
- mockNavigate.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', true);
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(Logger.error).toHaveBeenCalledWith(
- updateError,
- 'Failed to update auth preference after password entry',
- );
- });
- }
- });
-
- it('successfully disables remember me and restores password auth type', async () => {
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockGetItem.mockResolvedValue(null);
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- // Wait for getType to be called (from onValueChanged)
- await waitFor(
- () => {
- expect(mockGetType).toHaveBeenCalled();
- },
- { timeout: 3000 },
- );
-
- // Wait for getItem to be called (from toggleRememberMe)
- await waitFor(
- () => {
- expect(mockGetItem).toHaveBeenCalledWith(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
- },
- { timeout: 3000 },
- );
-
- // Wait for updateAuthPreference to be called
- await waitFor(
- () => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.PASSWORD,
- });
- },
- { timeout: 3000 },
- );
-
- // Wait for removeItem to be called
- await waitFor(
- () => {
- expect(mockRemoveItem).toHaveBeenCalledWith(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
- },
- { timeout: 3000 },
- );
- });
-
- it('successfully disables remember me and restores stored previous auth type', async () => {
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockGetItem.mockResolvedValue(AUTHENTICATION_TYPE.BIOMETRIC);
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockGetType).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(mockGetItem).toHaveBeenCalledWith(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
- });
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.BIOMETRIC,
- });
- });
-
- await waitFor(() => {
- expect(mockRemoveItem).toHaveBeenCalledWith(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
- });
- });
-
- it('navigates to password entry when password is required for disabling remember me', async () => {
- const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
- ).default;
-
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockGetItem.mockResolvedValue(null);
- mockUpdateAuthPreference.mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockGetType).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith('EnterPasswordSimple', {
- onPasswordSet: expect.any(Function),
- });
- });
- });
-
- it('restores auth preference when password is provided via callback when disabling', async () => {
- const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
- ).default;
-
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockGetItem.mockResolvedValue(null);
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockResolvedValueOnce(undefined);
-
- mockNavigate.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockGetType).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.PASSWORD,
- password: 'test-password',
- });
- });
-
- await waitFor(() => {
- expect(mockRemoveItem).toHaveBeenCalledWith(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
- });
- }
- });
-
- it('restores stored previous auth type when password is provided via callback when disabling', async () => {
- const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
- ).default;
-
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockGetItem
- .mockResolvedValueOnce(AUTHENTICATION_TYPE.BIOMETRIC)
- .mockResolvedValueOnce(AUTHENTICATION_TYPE.BIOMETRIC);
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockResolvedValueOnce(undefined);
-
- mockNavigate.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockGetType).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalledWith({
- authType: AUTHENTICATION_TYPE.BIOMETRIC,
- password: 'test-password',
- });
- });
- }
- });
-
- it('reverts flag when password entry callback fails when disabling', async () => {
- const MockedAuthenticationError = jest.requireMock(
- '../../../../../core/Authentication/AuthenticationError',
- ).default;
-
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- const updateError = new Error('Update failed');
- let passwordCallback: ((password: string) => Promise) | undefined;
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockGetItem.mockResolvedValue(null);
- mockUpdateAuthPreference
- .mockRejectedValueOnce(
- new MockedAuthenticationError(
- 'Password required',
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS,
- ),
- )
- .mockRejectedValueOnce(updateError);
-
- mockNavigate.mockImplementation(
- (
- screen: string,
- params?: { onPasswordSet?: (password: string) => Promise },
- ) => {
- if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) {
- passwordCallback = params.onPasswordSet;
- }
- },
- );
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockGetType).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalled();
- });
-
- if (passwordCallback) {
- await passwordCallback('test-password');
-
- await waitFor(() => {
- expect(Logger.error).toHaveBeenCalledWith(
- updateError,
- 'Failed to restore auth preference after password entry',
- );
- });
- }
- });
-
- it('calls Logger.error when updateAuthPreference fails when disabling', async () => {
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- const error = new Error('Restore failed');
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockGetItem.mockResolvedValue(null);
- mockUpdateAuthPreference.mockRejectedValueOnce(error);
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockGetType).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(Logger.error).toHaveBeenCalledWith(
- error,
- 'Failed to restore auth preference when disabling remember me',
- );
- });
- });
-
- it('proceeds with toggle when getType returns non-REMEMBER_ME when trying to disable', async () => {
- const stateWithRememberMe = {
- security: {
- allowLoginWithRememberMe: true,
- },
- };
-
- mockGetType.mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
- });
- mockGetItem.mockResolvedValue(null);
-
- const { getByTestId } = renderWithProvider(, {
- state: stateWithRememberMe,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockGetType).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalled();
- });
- });
-
- it('proceeds with toggle when allowLoginWithRememberMe is false but user tries to disable', async () => {
- mockGetItem.mockResolvedValue(null);
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const toggle = getByTestId(TURN_ON_REMEMBER_ME);
- fireEvent(toggle, 'onValueChange', false);
-
- await waitFor(() => {
- expect(mockUpdateAuthPreference).toHaveBeenCalled();
- });
- });
-});
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx
deleted file mode 100644
index 28d57be4a85c..000000000000
--- a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import React, { useCallback } from 'react';
-import { SecurityOptionToggle } from '../../../../UI/SecurityOptionToggle';
-import { strings } from '../../../../../../locales/i18n';
-import { useSelector, useDispatch } from 'react-redux';
-import { setAllowLoginWithRememberMe } from '../../../../../actions/security';
-import { useNavigation } from '@react-navigation/native';
-import { createTurnOffRememberMeModalNavDetails } from '../../../..//UI/TurnOffRememberMeModal/TurnOffRememberMeModal';
-
-import { Authentication } from '../../../../../core';
-import AUTHENTICATION_TYPE from '../../../../../constants/userProperties';
-import { TURN_ON_REMEMBER_ME } from '../SecuritySettings.constants';
-import Logger from '../../../../../util/Logger';
-import AuthenticationError from '../../../../../core/Authentication/AuthenticationError';
-import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error';
-import StorageWrapper from '../../../../../store/storage-wrapper';
-import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../../../constants/storage';
-
-const RememberMeOptionSection = () => {
- const { navigate } = useNavigation();
- const allowLoginWithRememberMe = useSelector(
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (state: any) => state.security?.allowLoginWithRememberMe,
- );
-
- const dispatch = useDispatch();
-
- const toggleRememberMe = useCallback(
- async (value: boolean) => {
- // If enabling remember me, update the password storage type first
- if (value) {
- try {
- await Authentication.updateAuthPreference({
- authType: AUTHENTICATION_TYPE.REMEMBER_ME,
- });
- // Only set Redux state after operation completes successfully
- dispatch(setAllowLoginWithRememberMe(value));
- } catch (error) {
- // Check if error is "password required" - navigate to password entry
- const isPasswordRequiredError =
- error instanceof AuthenticationError &&
- error.customErrorMessage ===
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS;
-
- if (isPasswordRequiredError) {
- // Navigate to password entry
- navigate('EnterPasswordSimple', {
- onPasswordSet: async (enteredPassword: string) => {
- try {
- await Authentication.updateAuthPreference({
- authType: AUTHENTICATION_TYPE.REMEMBER_ME,
- password: enteredPassword,
- });
- // Only set Redux state after operation completes successfully
- dispatch(setAllowLoginWithRememberMe(value));
- } catch (updateError) {
- // If update fails, revert the flag to ensure UI matches actual state
- dispatch(setAllowLoginWithRememberMe(false));
- Logger.error(
- updateError as Error,
- 'Failed to update auth preference after password entry',
- );
- }
- },
- });
- return;
- }
- // Other error - revert the flag to ensure UI matches actual state
- dispatch(setAllowLoginWithRememberMe(false));
- Logger.error(
- error as Error,
- 'Failed to update auth preference for remember me',
- );
- }
- } else {
- // Disabling remember me - restore previous authentication method
- try {
- // Get the previous auth type that was stored before enabling remember me
- const previousAuthType = await StorageWrapper.getItem(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
-
- // Determine which auth method to restore
- // Use stored previous auth type if available, otherwise fall back to password
- const authTypeToRestore = previousAuthType
- ? (previousAuthType as AUTHENTICATION_TYPE)
- : AUTHENTICATION_TYPE.PASSWORD;
-
- await Authentication.updateAuthPreference({
- authType: authTypeToRestore,
- });
- // Clear the stored previous auth type after successful restoration
- await StorageWrapper.removeItem(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
- // Only set Redux state after operation completes successfully
- dispatch(setAllowLoginWithRememberMe(value));
- } catch (error) {
- // Check if error is "password required" - navigate to password entry
- const isPasswordRequiredError =
- error instanceof AuthenticationError &&
- error.customErrorMessage ===
- AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS;
-
- if (isPasswordRequiredError) {
- // Navigate to password entry
- const previousAuthType = await StorageWrapper.getItem(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
-
- // Use stored previous auth type if available, otherwise fall back to password
- const authTypeToRestore = previousAuthType
- ? (previousAuthType as AUTHENTICATION_TYPE)
- : AUTHENTICATION_TYPE.PASSWORD;
-
- navigate('EnterPasswordSimple', {
- onPasswordSet: async (enteredPassword: string) => {
- try {
- await Authentication.updateAuthPreference({
- authType: authTypeToRestore,
- password: enteredPassword,
- });
- // Clear the stored previous auth type after successful restoration
- await StorageWrapper.removeItem(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
- // Only set Redux state after operation completes successfully
- dispatch(setAllowLoginWithRememberMe(value));
- } catch (updateError) {
- // If update fails, revert the flag to ensure UI matches actual state
- dispatch(setAllowLoginWithRememberMe(true));
- Logger.error(
- updateError as Error,
- 'Failed to restore auth preference after password entry',
- );
- }
- },
- });
- // Don't set Redux state here - wait for callback to complete
- return;
- }
- // Other error - revert the flag to ensure UI matches actual state
- dispatch(setAllowLoginWithRememberMe(true));
- Logger.error(
- error as Error,
- 'Failed to restore auth preference when disabling remember me',
- );
- }
- }
- },
- [dispatch, navigate],
- );
-
- const onValueChanged = useCallback(
- async (enabled: boolean) => {
- // Check if remember me is currently active by checking the actual auth type
- // This ensures we always have the current state
- if (!enabled && allowLoginWithRememberMe) {
- // User is trying to disable remember me - check if it's actually active
- const authType = await Authentication.getType();
- if (authType.currentAuthType === AUTHENTICATION_TYPE.REMEMBER_ME) {
- navigate(...createTurnOffRememberMeModalNavDetails());
- return;
- }
- }
- // Otherwise, proceed with normal toggle
- await toggleRememberMe(enabled);
- },
- [allowLoginWithRememberMe, navigate, toggleRememberMe],
- );
-
- return (
- onValueChanged(value)}
- testId={TURN_ON_REMEMBER_ME}
- />
- );
-};
-
-export default React.memo(RememberMeOptionSection);
diff --git a/app/components/Views/Settings/SecuritySettings/Sections/index.ts b/app/components/Views/Settings/SecuritySettings/Sections/index.ts
index 26e38e594dc5..71d56fb28719 100644
--- a/app/components/Views/Settings/SecuritySettings/Sections/index.ts
+++ b/app/components/Views/Settings/SecuritySettings/Sections/index.ts
@@ -1,9 +1,8 @@
import ClearCookiesSection from './ClearCookiesSection';
import DeleteMetaMetricsData from './DeleteMetaMetricsData';
import DeleteWalletData from './DeleteWalletData';
-import RememberMeOptionSection from './RememberMeOptionSection';
import ProtectYourWallet from './ProtectYourWallet/ProtectYourWallet';
-import LoginOptionsSettings from './LoginOptionsSettings';
+import DeviceSecurityToggle from './DeviceSecurityToggle';
import ChangePassword from './ChangePassword/ChangePassword';
import AutoLock from './AutoLock/AutoLock';
import ClearPrivacy from './ClearPrivacy/ClearPrivacy';
@@ -13,9 +12,8 @@ export {
ClearCookiesSection,
DeleteMetaMetricsData,
DeleteWalletData,
- RememberMeOptionSection,
ProtectYourWallet,
- LoginOptionsSettings,
+ DeviceSecurityToggle,
ChangePassword,
AutoLock,
ClearPrivacy,
diff --git a/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts b/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts
index b138df820646..d28261b5ef54 100644
--- a/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts
+++ b/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts
@@ -10,8 +10,7 @@ export const SecurityPrivacyViewSelectorsIDs = {
AUTO_LOCK_SECTION: 'auto-lock-section',
REMEMBER_ME_TOGGLE: 'turn-on-remember-me',
SHOW_PRIVATE_KEY: 'show-private-key',
- BIOMETRICS_TOGGLE: 'biometrics-option',
- DEVICE_PASSCODE_TOGGLE: 'device-passcode-option',
+ DEVICE_SECURITY_TOGGLE: 'device-security-toggle',
CLEAR_PRIVACY_DATA_BUTTON: 'clear-privacy-data-button',
PROTECT_YOUR_WALLET: 'protect-your-wallet',
};
diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.constants.ts b/app/components/Views/Settings/SecuritySettings/SecuritySettings.constants.ts
index 85c5d58ef7e9..dd5348a07462 100644
--- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.constants.ts
+++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.constants.ts
@@ -1,7 +1,5 @@
export const PASSCODE_CHOICE_STRING = 'passcodeChoice';
export const BIOMETRY_CHOICE_STRING = 'biometryChoice';
-export const LOGIN_OPTIONS = 'login-options';
-export const TURN_ON_REMEMBER_ME = 'turn-on-remember-me';
export const REVEAL_PRIVATE_KEY_SECTION = 'reveal-private-key-section';
export const SDK_SECTION = 'sdk-section';
export const CLEAR_PRIVACY_SECTION = 'clear-privacy-section';
diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.styles.ts b/app/components/Views/Settings/SecuritySettings/SecuritySettings.styles.ts
index 43a5db305cd5..82b3031baa4f 100644
--- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.styles.ts
+++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.styles.ts
@@ -1,7 +1,7 @@
import { StyleSheet } from 'react-native';
-import { Colors } from '../../../../util/theme/models';
+import type { Theme } from '@metamask/design-tokens';
-const createStyles = (colors: Colors) =>
+const createStyles = ({ theme: { colors } }: { theme: Theme }) =>
StyleSheet.create({
wrapper: {
backgroundColor: colors.background.default,
diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx
index 064f385da76a..25ee395fcd25 100644
--- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx
+++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx
@@ -8,12 +8,10 @@ import {
CLEAR_BROWSER_HISTORY_SECTION,
CLEAR_PRIVACY_SECTION,
DELETE_METRICS_BUTTON,
- LOGIN_OPTIONS,
META_METRICS_DATA_MARKETING_SECTION,
META_METRICS_SECTION,
SDK_SECTION,
SECURITY_SETTINGS_DELETE_WALLET_BUTTON,
- TURN_ON_REMEMBER_ME,
} from './SecuritySettings.constants';
import { useAccountMenuEnabled } from '../../../../selectors/featureFlagController/accountMenu/useAccountMenuEnabled';
import { SecurityPrivacyViewSelectorsIDs } from './SecurityPrivacyView.testIds';
@@ -82,6 +80,23 @@ jest.mock(
}),
);
+// DeviceSecurityToggle uses useAuthCapabilities; mock so it renders the toggle instead of null
+jest.mock('../../../../core/Authentication/hooks/useAuthCapabilities', () => ({
+ __esModule: true,
+ default: () => ({
+ isLoading: false,
+ capabilities: {
+ isBiometricsAvailable: true,
+ passcodeAvailable: true,
+ authLabel: 'Face ID',
+ osAuthEnabled: false,
+ allowLoginWithRememberMe: false,
+ authType: 'biometrics',
+ deviceAuthRequiresSettings: false,
+ },
+ }),
+}));
+
jest.mock(
'../../../../selectors/featureFlagController/accountMenu/useAccountMenuEnabled',
() => ({
@@ -129,8 +144,9 @@ describe('SecuritySettings', () => {
getByTestId(SecurityPrivacyViewSelectorsIDs.CHANGE_PASSWORD_CONTAINER),
).toBeTruthy();
expect(getByTestId(AUTO_LOCK_SECTION)).toBeTruthy();
- expect(getByTestId(LOGIN_OPTIONS)).toBeTruthy();
- expect(getByTestId(TURN_ON_REMEMBER_ME)).toBeTruthy();
+ expect(
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ ).toBeTruthy();
expect(getByTestId(SDK_SECTION)).toBeTruthy();
expect(getByTestId(CLEAR_PRIVACY_SECTION)).toBeTruthy();
expect(getByTestId(CLEAR_BROWSER_HISTORY_SECTION)).toBeTruthy();
@@ -154,8 +170,9 @@ describe('SecuritySettings', () => {
getByTestId(SecurityPrivacyViewSelectorsIDs.CHANGE_PASSWORD_CONTAINER),
).toBeTruthy();
expect(getByTestId(AUTO_LOCK_SECTION)).toBeTruthy();
- expect(getByTestId(LOGIN_OPTIONS)).toBeTruthy();
- expect(getByTestId(TURN_ON_REMEMBER_ME)).toBeTruthy();
+ expect(
+ getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_SECURITY_TOGGLE),
+ ).toBeTruthy();
expect(queryByTestId(SDK_SECTION)).toBeNull();
expect(getByTestId(CLEAR_PRIVACY_SECTION)).toBeTruthy();
expect(getByTestId(CLEAR_BROWSER_HISTORY_SECTION)).toBeTruthy();
diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx
index a4e77f1e7da9..dd074aeed341 100644
--- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx
+++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx
@@ -14,14 +14,12 @@ import { SEED_PHRASE_HINTS } from '../../../../constants/storage';
import HintModal from '../../../UI/HintModal';
import { MetaMetricsEvents } from '../../../../core/Analytics';
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
-import { useTheme } from '../../../../util/theme';
import {
ClearCookiesSection,
DeleteMetaMetricsData,
DeleteWalletData,
- RememberMeOptionSection,
ProtectYourWallet,
- LoginOptionsSettings,
+ DeviceSecurityToggle,
ChangePassword,
AutoLock,
ClearPrivacy,
@@ -61,11 +59,12 @@ import IPFSGatewaySettings from '../../Settings/IPFSGatewaySettings';
import BatchAccountBalanceSettings from '../../Settings/BatchAccountBalanceSettings';
import useCheckNftAutoDetectionModal from '../../../hooks/useCheckNftAutoDetectionModal';
import useCheckMultiRpcModal from '../../../hooks/useCheckMultiRpcModal';
+import { useStyles } from '../../../../component-library/hooks/useStyles';
import { useAccountMenuEnabled } from '../../../../selectors/featureFlagController/accountMenu/useAccountMenuEnabled';
const Heading: React.FC = ({ children, first }) => {
- const { colors } = useTheme();
- const styles = createStyles(colors);
+ const { styles } = useStyles(createStyles, {});
+
return (
@@ -77,9 +76,10 @@ const Heading: React.FC = ({ children, first }) => {
const Settings: React.FC = () => {
const { trackEvent, isEnabled, createEventBuilder } = useAnalytics();
- const theme = useTheme();
- const { colors } = theme;
- const styles = createStyles(colors);
+ const {
+ styles,
+ theme: { colors, brandColors },
+ } = useStyles(createStyles, {});
const navigation = useNavigation();
const params = useParams();
const dispatch = useDispatch();
@@ -329,7 +329,7 @@ const Settings: React.FC = () => {
true: colors.primary.default,
false: colors.border.muted,
}}
- thumbColor={theme.brandColors.white}
+ thumbColor={brandColors.white}
style={styles.switch}
ios_backgroundColor={colors.border.muted}
/>
@@ -365,7 +365,7 @@ const Settings: React.FC = () => {
colors,
styles,
useTransactionSimulations,
- theme.brandColors.white,
+ brandColors.white,
createEventBuilder,
trackEvent,
],
@@ -408,10 +408,7 @@ const Settings: React.FC = () => {
/>
-
-
-
-
+
{strings('app_settings.privacy_heading')}
diff --git a/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap b/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap
index e89faf1f9c16..b1c7efcefe68 100644
--- a/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap
+++ b/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap
@@ -405,17 +405,11 @@ exports[`SecuritySettings renders correctly 1`] = `
{
"display": "flex",
},
- undefined,
+ {
+ "marginTop": 32,
+ },
]
}
- testID="login-options"
- />
-
- Turn on Remember me
+ Face ID
-
- When Remember me is on, anyone with access to your phone can access your MetaMask account.
-
({
clearAllVaultBackups: jest.fn(),
}));
-jest.mock('../Analytics/MetaMetrics', () => {
- const mockInstance = {};
- return {
- __esModule: true,
- default: {
- getInstance: jest.fn(() => mockInstance),
- },
- };
-});
+jest.mock('../../util/analytics/analytics', () => ({
+ analytics: {
+ isEnabled: jest.fn().mockReturnValue(true),
+ },
+}));
jest.mock('../../util/analytics/analyticsDataDeletion', () => ({
createDataDeletionTask: jest.fn().mockResolvedValue(undefined),
@@ -673,23 +672,6 @@ describe('Authentication', () => {
expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD);
});
- it('prioritizes REMEMBER_ME over BIOMETRIC when both are enabled', async () => {
- SecureKeychain.getSupportedBiometryType = jest
- .fn()
- .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT);
- // Don't set PASSCODE_DISABLED - this allows BIOMETRIC condition to potentially match
- // but REMEMBER_ME should still take priority when rememberMe is true
-
- // Mock Redux store to return allowLoginWithRememberMe: true
- jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({
- getState: () => ({ security: { allowLoginWithRememberMe: true } }),
- } as unknown as ReduxStore);
-
- const result = await Authentication.componentAuthenticationType(true, true);
- expect(result.availableBiometryType).toEqual('Fingerprint');
- expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME);
- });
-
it('returns BIOMETRIC type when PASSCODE_DISABLED is TRUE and biometryChoice is true, even if BIOMETRY_CHOICE_DISABLED was previously set', async () => {
SecureKeychain.getSupportedBiometryType = jest
.fn()
@@ -808,7 +790,7 @@ describe('Authentication', () => {
jest
.spyOn(SecureKeychain, 'setGenericPassword')
- .mockResolvedValue(undefined);
+ .mockResolvedValue(undefined as never);
SecureKeychain.getSupportedBiometryType = jest
.fn()
@@ -823,7 +805,6 @@ describe('Authentication', () => {
it('stores password with BIOMETRIC and manages storage flags correctly', async () => {
const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
await Authentication.storePassword(
mockPassword,
@@ -832,16 +813,18 @@ describe('Authentication', () => {
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- SecureKeychain.TYPES.BIOMETRICS,
+ AUTHENTICATION_TYPE.BIOMETRIC,
);
expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
+ expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
+ PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
+ );
expect(mockDispatch).toHaveBeenCalledWith(passwordSet());
});
it('stores password with PASSCODE and manages storage flags correctly', async () => {
const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
await Authentication.storePassword(
mockPassword,
@@ -850,85 +833,17 @@ describe('Authentication', () => {
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- SecureKeychain.TYPES.PASSCODE,
+ AUTHENTICATION_TYPE.PASSCODE,
);
expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
- expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE);
- expect(mockDispatch).toHaveBeenCalledWith(passwordSet());
- });
-
- it('stores password with REMEMBER_ME and manages storage flags correctly', async () => {
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
-
- jest
- .spyOn(
- Authentication as unknown as {
- checkAuthenticationMethod: () => Promise<{
- currentAuthType: string;
- availableBiometryType: string;
- }>;
- },
- 'checkAuthenticationMethod',
- )
- .mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
- availableBiometryType: 'Face ID',
- });
-
- await Authentication.storePassword(
- mockPassword,
- AUTHENTICATION_TYPE.REMEMBER_ME,
- );
-
- expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
- mockPassword,
- SecureKeychain.TYPES.REMEMBER_ME,
- );
- expect(setItemSpy).toHaveBeenCalledWith(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- AUTHENTICATION_TYPE.BIOMETRIC,
- );
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
- expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE);
- expect(mockDispatch).toHaveBeenCalledWith(passwordSet());
- });
-
- it('does not store previous auth type when already on REMEMBER_ME', async () => {
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
-
- jest
- .spyOn(
- Authentication as unknown as {
- checkAuthenticationMethod: () => Promise<{
- currentAuthType: string;
- availableBiometryType: string;
- }>;
- },
- 'checkAuthenticationMethod',
- )
- .mockResolvedValue({
- currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME,
- availableBiometryType: 'Face ID',
- });
-
- await Authentication.storePassword(
- mockPassword,
- AUTHENTICATION_TYPE.REMEMBER_ME,
- );
-
- expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
- mockPassword,
- SecureKeychain.TYPES.REMEMBER_ME,
- );
- expect(setItemSpy).not.toHaveBeenCalledWith(
+ expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- expect.any(String),
);
expect(mockDispatch).toHaveBeenCalledWith(passwordSet());
});
it('stores password with PASSWORD and disables both biometric and passcode', async () => {
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
await Authentication.storePassword(
@@ -938,17 +853,17 @@ describe('Authentication', () => {
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- undefined,
+ AUTHENTICATION_TYPE.PASSWORD,
);
- expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE);
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
+ expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
expect(removeItemSpy).toHaveBeenCalledWith(
PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
);
expect(mockDispatch).toHaveBeenCalledWith(passwordSet());
});
- it('stores password with PASSWORD and skips clearing previous auth type when remember me is disabled', async () => {
+ it('stores password with PASSWORD and clears all legacy auth flags', async () => {
jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({
dispatch: mockDispatch,
getState: () => ({
@@ -956,7 +871,6 @@ describe('Authentication', () => {
}),
} as unknown as ReduxStore);
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
await Authentication.storePassword(
@@ -966,30 +880,54 @@ describe('Authentication', () => {
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- undefined,
+ AUTHENTICATION_TYPE.PASSWORD,
);
- expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE);
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
- expect(removeItemSpy).not.toHaveBeenCalledWith(
+ expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
);
expect(mockDispatch).toHaveBeenCalledWith(passwordSet());
});
it('stores password with unknown authType using default (password) behavior', async () => {
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
+ const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
await Authentication.storePassword(mockPassword, 'UnknownType' as never);
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- undefined,
+ 'UnknownType',
+ );
+ expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
+ PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
);
- expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE);
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
expect(mockDispatch).toHaveBeenCalledWith(passwordSet());
});
+ it('dispatches setOsAuthEnabled and setAllowLoginWithRememberMe when storePassword runs', async () => {
+ await Authentication.updateAuthPreference({
+ authType: AUTHENTICATION_TYPE.BIOMETRIC,
+ password: mockPassword,
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(setOsAuthEnabled(true));
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setAllowLoginWithRememberMe(false),
+ );
+
+ mockDispatch.mockClear();
+
+ await Authentication.updateAuthPreference({
+ authType: AUTHENTICATION_TYPE.REMEMBER_ME,
+ password: mockPassword,
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(setOsAuthEnabled(false));
+ });
+
it('throws AuthenticationError with AUTHENTICATION_STORE_PASSWORD_FAILED when SecureKeychain fails', async () => {
const keychainError = new Error('Keychain error');
jest
@@ -1049,7 +987,7 @@ describe('Authentication', () => {
const setGenericPasswordSpy = jest
.spyOn(SecureKeychain, 'setGenericPassword')
.mockRejectedValueOnce(new Error('Biometric storage failed'))
- .mockResolvedValueOnce(undefined);
+ .mockResolvedValueOnce(undefined as never);
await Authentication.storePassword(
mockPassword,
@@ -1061,12 +999,12 @@ describe('Authentication', () => {
expect(setGenericPasswordSpy).toHaveBeenNthCalledWith(
1,
mockPassword,
- SecureKeychain.TYPES.BIOMETRICS,
+ AUTHENTICATION_TYPE.BIOMETRIC,
);
expect(setGenericPasswordSpy).toHaveBeenNthCalledWith(
2,
mockPassword,
- undefined,
+ AUTHENTICATION_TYPE.PASSWORD,
);
expect(mockDispatch).toHaveBeenCalledWith(passwordSet());
});
@@ -1226,7 +1164,7 @@ describe('Authentication', () => {
const setGenericPasswordSpy = jest
.spyOn(SecureKeychain, 'setGenericPassword')
.mockRejectedValueOnce(new Error('Biometric storage failed'))
- .mockResolvedValueOnce(undefined);
+ .mockResolvedValueOnce(undefined as never);
await Authentication.newWalletAndKeychain('password', {
currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC,
@@ -1236,12 +1174,12 @@ describe('Authentication', () => {
expect(setGenericPasswordSpy).toHaveBeenNthCalledWith(
1,
'password',
- SecureKeychain.TYPES.BIOMETRICS,
+ AUTHENTICATION_TYPE.BIOMETRIC,
);
expect(setGenericPasswordSpy).toHaveBeenNthCalledWith(
2,
'password',
- undefined,
+ AUTHENTICATION_TYPE.PASSWORD,
);
expect(fallbackMockDispatch).toHaveBeenCalledWith(
setExistingUser(true),
@@ -1266,7 +1204,7 @@ describe('Authentication', () => {
const setGenericPasswordSpy = jest
.spyOn(SecureKeychain, 'setGenericPassword')
.mockRejectedValueOnce(new Error('Biometric storage failed'))
- .mockResolvedValueOnce(undefined);
+ .mockResolvedValueOnce(undefined as never);
await Authentication.newWalletAndRestore(
'password',
@@ -1279,12 +1217,12 @@ describe('Authentication', () => {
expect(setGenericPasswordSpy).toHaveBeenNthCalledWith(
1,
'password',
- SecureKeychain.TYPES.BIOMETRICS,
+ AUTHENTICATION_TYPE.BIOMETRIC,
);
expect(setGenericPasswordSpy).toHaveBeenNthCalledWith(
2,
'password',
- undefined,
+ AUTHENTICATION_TYPE.PASSWORD,
);
expect(restoreMockDispatch).toHaveBeenCalledWith(setExistingUser(true));
expect(restoreMockDispatch).toHaveBeenCalledWith(logIn());
@@ -1309,9 +1247,7 @@ describe('Authentication', () => {
}),
} as unknown as ReduxStore);
- jest
- .spyOn(MetaMetrics, 'getInstance')
- .mockReturnValue({ isEnabled: () => true } as MetaMetrics);
+ jest.spyOn(analytics, 'isEnabled').mockReturnValue(true);
await Authentication.unlockWallet();
@@ -1344,9 +1280,7 @@ describe('Authentication', () => {
}),
} as unknown as ReduxStore);
- jest
- .spyOn(MetaMetrics, 'getInstance')
- .mockReturnValue({ isEnabled: () => true } as MetaMetrics);
+ jest.spyOn(analytics, 'isEnabled').mockReturnValue(true);
await Authentication.unlockWallet();
@@ -1695,10 +1629,7 @@ describe('Authentication', () => {
}),
} as unknown as ReduxStore);
- // Mock MetaMetrics.getInstance to return true for isEnabled
- jest
- .spyOn(MetaMetrics, 'getInstance')
- .mockReturnValue({ isEnabled: () => true } as MetaMetrics);
+ jest.spyOn(analytics, 'isEnabled').mockReturnValue(true);
const mockKeyring = {
getAccounts: jest.fn().mockResolvedValue(['0x1234567890abcdef']),
@@ -1771,7 +1702,7 @@ describe('Authentication', () => {
uint8ArrayToMnemonic(mockSeedPhrase1, []),
false,
);
- expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(5); // logIn, passwordSet (from storePassword -> dispatchPasswordSet), dispatchLogin, dispatchOauthReset, and setExistingUser
+ expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(7); // logIn, passwordSet, setOsAuthEnabled, setAllowLoginWithRememberMe (from storePassword), dispatchLogin, dispatchOauthReset, and setExistingUser
expect(OAuthService.resetOauthState).toHaveBeenCalled();
});
@@ -1791,6 +1722,7 @@ describe('Authentication', () => {
]);
const mockStateLocal: RecursivePartial = {
user: { existingUser: true },
+ security: { allowLoginWithRememberMe: true },
engine: {
backgroundState: {
SeedlessOnboardingController: {
@@ -1848,7 +1780,7 @@ describe('Authentication', () => {
keyringId: 'new-keyring-id',
type: 'mnemonic',
});
- expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(5); // logIn, passwordSet (from storePassword -> dispatchPasswordSet), dispatchLogin, dispatchOauthReset, and setExistingUser
+ expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(7); // logIn, passwordSet, setOsAuthEnabled, setAllowLoginWithRememberMe (from storePassword), dispatchLogin, dispatchOauthReset, and setExistingUser
expect(OAuthService.resetOauthState).toHaveBeenCalled();
});
@@ -1894,7 +1826,7 @@ describe('Authentication', () => {
shouldSelectAccount: false,
},
);
- expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(5); // logIn, passwordSet (from storePassword -> dispatchPasswordSet), dispatchLogin, dispatchOauthReset, and setExistingUser
+ expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(7); // logIn, passwordSet, setOsAuthEnabled, setAllowLoginWithRememberMe (from storePassword), dispatchLogin, dispatchOauthReset, and setExistingUser
expect(OAuthService.resetOauthState).toHaveBeenCalled();
});
@@ -1924,7 +1856,7 @@ describe('Authentication', () => {
expect(newWalletAndRestoreSpy).toHaveBeenCalled();
expect(Logger.error).toHaveBeenCalledWith(expect.any(Error), 'unknown');
- expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(5); // logIn, passwordSet (from storePassword -> dispatchPasswordSet), dispatchLogin, dispatchOauthReset, and setExistingUser
+ expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(7); // logIn, passwordSet, setOsAuthEnabled, setAllowLoginWithRememberMe (from storePassword), dispatchLogin, dispatchOauthReset, and setExistingUser
expect(OAuthService.resetOauthState).toHaveBeenCalled();
});
@@ -1963,7 +1895,7 @@ describe('Authentication', () => {
importError,
'Error in rehydrateSeedPhrase- SeedlessOnboardingController',
);
- expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(5); // logIn, passwordSet (from storePassword -> dispatchPasswordSet), dispatchLogin, dispatchOauthReset, and setExistingUser
+ expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(7); // logIn, passwordSet, setOsAuthEnabled, setAllowLoginWithRememberMe (from storePassword), dispatchLogin, dispatchOauthReset, and setExistingUser
expect(OAuthService.resetOauthState).toHaveBeenCalled();
});
@@ -2019,6 +1951,7 @@ describe('Authentication', () => {
const mockState = {
user: { existingUser: true },
+ security: { allowLoginWithRememberMe: true },
engine: {
backgroundState: {
SeedlessOnboardingController: {
@@ -2051,7 +1984,7 @@ describe('Authentication', () => {
error,
'Error in rehydrateSeedPhrase- SeedlessOnboardingController',
);
- expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(5); // logIn, passwordSet (from storePassword -> dispatchPasswordSet), dispatchLogin, dispatchOauthReset, and setExistingUser
+ expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(7); // logIn, passwordSet, setOsAuthEnabled, setAllowLoginWithRememberMe (from storePassword), dispatchLogin, dispatchOauthReset, and setExistingUser
expect(OAuthService.resetOauthState).toHaveBeenCalled();
});
@@ -2142,10 +2075,7 @@ describe('Authentication', () => {
getState: jest.fn(() => mockState),
} as unknown as ReduxStore);
- // Mock MetaMetrics.getInstance to return true for isEnabled
- jest
- .spyOn(MetaMetrics, 'getInstance')
- .mockReturnValue({ isEnabled: () => true } as MetaMetrics);
+ jest.spyOn(analytics, 'isEnabled').mockReturnValue(true);
Engine.context.SeedlessOnboardingController = {
state: { vault: {} },
@@ -2632,10 +2562,7 @@ describe('Authentication', () => {
},
} as unknown as KeyringController;
- // Mock MetaMetrics.getInstance to return true for isEnabled
- jest
- .spyOn(MetaMetrics, 'getInstance')
- .mockReturnValue({ isEnabled: () => true } as MetaMetrics);
+ jest.spyOn(analytics, 'isEnabled').mockReturnValue(true);
});
it('throw an error if not using seedless onboarding flow', async () => {
@@ -3958,7 +3885,7 @@ describe('Authentication', () => {
jest.spyOn(Authentication, 'resetPassword').mockResolvedValue(undefined);
jest
.spyOn(SecureKeychain, 'setGenericPassword')
- .mockResolvedValue(undefined);
+ .mockResolvedValue(undefined as never);
});
afterEach(() => {
@@ -3968,7 +3895,6 @@ describe('Authentication', () => {
it('updates auth preference to BIOMETRIC with password from keychain', async () => {
const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
await Authentication.updateAuthPreference({
authType: AUTHENTICATION_TYPE.BIOMETRIC,
@@ -3979,16 +3905,18 @@ describe('Authentication', () => {
).toHaveBeenCalledWith(mockPassword);
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- SecureKeychain.TYPES.BIOMETRICS,
+ AUTHENTICATION_TYPE.BIOMETRIC,
);
expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
+ expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
+ PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
+ );
expect(updateAuthMockDispatch).toHaveBeenCalledWith(passwordSet());
});
it('updates auth preference to BIOMETRIC with provided password', async () => {
const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
await Authentication.updateAuthPreference({
authType: AUTHENTICATION_TYPE.BIOMETRIC,
@@ -4001,16 +3929,18 @@ describe('Authentication', () => {
).toHaveBeenCalledWith(mockPassword);
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- SecureKeychain.TYPES.BIOMETRICS,
+ AUTHENTICATION_TYPE.BIOMETRIC,
);
expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
+ expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
+ PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
+ );
expect(updateAuthMockDispatch).toHaveBeenCalledWith(passwordSet());
});
it('updates auth preference to PASSCODE with password from keychain', async () => {
const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
await Authentication.updateAuthPreference({
authType: AUTHENTICATION_TYPE.PASSCODE,
@@ -4021,15 +3951,18 @@ describe('Authentication', () => {
).toHaveBeenCalledWith(mockPassword);
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- SecureKeychain.TYPES.PASSCODE,
+ AUTHENTICATION_TYPE.PASSCODE,
);
expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
- expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE);
+ expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
+ PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
+ );
expect(updateAuthMockDispatch).toHaveBeenCalledWith(passwordSet());
});
it('updates auth preference to PASSWORD with password from keychain', async () => {
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
+ const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
await Authentication.updateAuthPreference({
authType: AUTHENTICATION_TYPE.PASSWORD,
@@ -4040,10 +3973,13 @@ describe('Authentication', () => {
).toHaveBeenCalledWith(mockPassword);
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- undefined,
+ AUTHENTICATION_TYPE.PASSWORD,
+ );
+ expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
+ PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
);
- expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE);
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
expect(updateAuthMockDispatch).toHaveBeenCalledWith(passwordSet());
});
@@ -4142,16 +4078,13 @@ describe('Authentication', () => {
alertSpy.mockRestore();
});
- it('skips password validation when skipValidation is true', async () => {
+ it('validates password and stores with BIOMETRIC when password provided', async () => {
const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem');
- const setItemSpy = jest.spyOn(StorageWrapper, 'setItem');
const verifyPasswordSpy = jest.spyOn(
Engine.context.KeyringController,
'verifyPassword',
);
- // Note: The actual implementation doesn't have skipValidation parameter
- // This test should verify normal behavior
await Authentication.updateAuthPreference({
authType: AUTHENTICATION_TYPE.BIOMETRIC,
password: mockPassword,
@@ -4160,10 +4093,13 @@ describe('Authentication', () => {
expect(verifyPasswordSpy).toHaveBeenCalledWith(mockPassword);
expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith(
mockPassword,
- SecureKeychain.TYPES.BIOMETRICS,
+ AUTHENTICATION_TYPE.BIOMETRIC,
);
expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED);
- expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE);
+ expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED);
+ expect(removeItemSpy).toHaveBeenCalledWith(
+ PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
+ );
expect(updateAuthMockDispatch).toHaveBeenCalledWith(passwordSet());
});
@@ -4205,7 +4141,7 @@ describe('Authentication', () => {
jest
.spyOn(SecureKeychain, 'setGenericPassword')
.mockRejectedValueOnce(new Error('Biometric keychain failed'))
- .mockResolvedValueOnce(undefined);
+ .mockResolvedValueOnce(undefined as never);
await Authentication.updateAuthPreference({
authType: AUTHENTICATION_TYPE.BIOMETRIC,
@@ -4223,7 +4159,7 @@ describe('Authentication', () => {
expect(SecureKeychain.setGenericPassword).toHaveBeenNthCalledWith(
2,
mockPassword,
- undefined,
+ AUTHENTICATION_TYPE.PASSWORD,
);
expect(updateAuthMockDispatch).toHaveBeenCalledWith(passwordSet());
});
@@ -4494,10 +4430,7 @@ describe('Authentication', () => {
beforeEach(() => {
// Mock lockApp.
jest.spyOn(Authentication, 'lockApp').mockResolvedValueOnce(undefined);
- // Mock MetaMetrics.getInstance to return true for isEnabled.
- jest
- .spyOn(MetaMetrics, 'getInstance')
- .mockReturnValueOnce({ isEnabled: () => true } as MetaMetrics);
+ jest.spyOn(analytics, 'isEnabled').mockReturnValue(true);
const Engine = jest.requireMock('../Engine');
// Restore the KeyringController mock that may have been replaced by other test suites.
@@ -4509,10 +4442,11 @@ describe('Authentication', () => {
},
};
- // Mock existing user state.
+ // Mock existing user state (include security for storePassword/updateAuthPreference).
jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({
getState: () => ({
user: { existingUser: true },
+ security: { allowLoginWithRememberMe: false },
}),
dispatch: mockDispatch,
} as unknown as ReduxStore);
@@ -4590,11 +4524,7 @@ describe('Authentication', () => {
it('navigates to the optin metrics flow when metrics are not enabled and UI has not been seen', async () => {
// Mock StorageWrapper.getItem to return null for OPTIN_META_METRICS_UI_SEEN.
jest.spyOn(StorageWrapper, 'getItem').mockResolvedValue(null);
- // Clear beforeEach mock and set MetaMetrics.getInstance to return false for isEnabled.
- jest.spyOn(MetaMetrics, 'getInstance').mockReset();
- jest
- .spyOn(MetaMetrics, 'getInstance')
- .mockReturnValue({ isEnabled: () => false } as MetaMetrics);
+ jest.spyOn(analytics, 'isEnabled').mockReturnValue(false);
// Call unlockWallet with a password.
await Authentication.unlockWallet({ password: passwordToUse });
@@ -4908,31 +4838,32 @@ describe('Authentication', () => {
it('returns REMEMBER_ME storage type regardless of other settings', async () => {
const osAuthEnabled = true;
const allowLoginWithRememberMe = true;
- const result = await Authentication.getAuthCapabilities(
+ const result = await Authentication.getAuthCapabilities({
osAuthEnabled,
allowLoginWithRememberMe,
- );
+ });
expect(result).toEqual({
isBiometricsAvailable: true,
- biometricsDisabledOnOS: false,
- isAuthToggleVisible: true,
- authToggleLabel: expect.any(String),
+ passcodeAvailable: true,
+ deviceAuthRequiresSettings: false,
+ authLabel: expect.any(String),
+ authDescription: undefined,
osAuthEnabled,
allowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.REMEMBER_ME,
+ authType: AUTHENTICATION_TYPE.REMEMBER_ME,
});
});
it('returns REMEMBER_ME even when osAuthEnabled is false', async () => {
const osAuthEnabled = false;
const allowLoginWithRememberMe = true;
- const result = await Authentication.getAuthCapabilities(
+ const result = await Authentication.getAuthCapabilities({
osAuthEnabled,
allowLoginWithRememberMe,
- );
+ });
- expect(result.authStorageType).toBe(AUTHENTICATION_TYPE.REMEMBER_ME);
+ expect(result.authType).toBe(AUTHENTICATION_TYPE.REMEMBER_ME);
});
});
@@ -4950,38 +4881,40 @@ describe('Authentication', () => {
it('returns BIOMETRIC storage type when osAuthEnabled is true', async () => {
const osAuthEnabled = true;
const allowLoginWithRememberMe = false;
- const result = await Authentication.getAuthCapabilities(
+ const result = await Authentication.getAuthCapabilities({
osAuthEnabled,
allowLoginWithRememberMe,
- );
+ });
expect(result).toEqual({
isBiometricsAvailable: true,
- biometricsDisabledOnOS: false,
- isAuthToggleVisible: true,
- authToggleLabel: expect.any(String),
+ passcodeAvailable: true,
+ deviceAuthRequiresSettings: false,
+ authLabel: expect.any(String),
+ authDescription: 'app_settings.enable_device_authentication_desc',
osAuthEnabled,
allowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.BIOMETRIC,
+ authType: AUTHENTICATION_TYPE.BIOMETRIC,
});
});
it('returns PASSWORD storage type when osAuthEnabled is false', async () => {
const osAuthEnabled = false;
const allowLoginWithRememberMe = false;
- const result = await Authentication.getAuthCapabilities(
+ const result = await Authentication.getAuthCapabilities({
osAuthEnabled,
allowLoginWithRememberMe,
- );
+ });
expect(result).toEqual({
isBiometricsAvailable: true,
- biometricsDisabledOnOS: false,
- isAuthToggleVisible: true,
- authToggleLabel: expect.any(String),
+ passcodeAvailable: true,
+ deviceAuthRequiresSettings: false,
+ authLabel: expect.any(String),
+ authDescription: 'app_settings.enable_device_authentication_desc',
osAuthEnabled,
allowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.PASSWORD,
+ authType: AUTHENTICATION_TYPE.PASSWORD,
});
});
});
@@ -4998,38 +4931,40 @@ describe('Authentication', () => {
it('returns PASSCODE storage type when osAuthEnabled is true', async () => {
const osAuthEnabled = true;
const allowLoginWithRememberMe = false;
- const result = await Authentication.getAuthCapabilities(
+ const result = await Authentication.getAuthCapabilities({
osAuthEnabled,
allowLoginWithRememberMe,
- );
+ });
expect(result).toEqual({
isBiometricsAvailable: false,
- biometricsDisabledOnOS: true,
- isAuthToggleVisible: true,
- authToggleLabel: expect.any(String),
+ passcodeAvailable: true,
+ deviceAuthRequiresSettings: false,
+ authLabel: expect.any(String),
+ authDescription: 'app_settings.enable_device_authentication_desc',
osAuthEnabled,
allowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.PASSCODE,
+ authType: AUTHENTICATION_TYPE.PASSCODE,
});
});
it('returns PASSWORD storage type when osAuthEnabled is false', async () => {
const osAuthEnabled = false;
const allowLoginWithRememberMe = false;
- const result = await Authentication.getAuthCapabilities(
+ const result = await Authentication.getAuthCapabilities({
osAuthEnabled,
allowLoginWithRememberMe,
- );
+ });
expect(result).toEqual({
isBiometricsAvailable: false,
- biometricsDisabledOnOS: true,
- isAuthToggleVisible: true,
- authToggleLabel: expect.any(String),
+ passcodeAvailable: true,
+ deviceAuthRequiresSettings: false,
+ authLabel: expect.any(String),
+ authDescription: 'app_settings.enable_device_authentication_desc',
osAuthEnabled,
allowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.PASSWORD,
+ authType: AUTHENTICATION_TYPE.PASSWORD,
});
});
});
@@ -5042,20 +4977,23 @@ describe('Authentication', () => {
});
it('returns PASSWORD storage type regardless of osAuthEnabled', async () => {
- const resultEnabled = await Authentication.getAuthCapabilities(
- true,
- false,
- );
+ const resultEnabled = await Authentication.getAuthCapabilities({
+ osAuthEnabled: true,
+ allowLoginWithRememberMe: false,
+ });
- expect(resultEnabled.authStorageType).toBe(
- AUTHENTICATION_TYPE.PASSWORD,
- );
+ expect(resultEnabled.authType).toBe(AUTHENTICATION_TYPE.PASSWORD);
+ expect(resultEnabled.authDescription).toBeUndefined();
});
- it('sets isAuthToggleVisible to false', async () => {
- const result = await Authentication.getAuthCapabilities(true, false);
+ it('sets deviceAuthRequiresSettings to true when no device auth available', async () => {
+ const result = await Authentication.getAuthCapabilities({
+ osAuthEnabled: true,
+ allowLoginWithRememberMe: false,
+ });
- expect(result.isAuthToggleVisible).toBe(false);
+ expect(result.deviceAuthRequiresSettings).toBe(true);
+ expect(result.authDescription).toBeUndefined();
});
});
@@ -5063,21 +5001,22 @@ describe('Authentication', () => {
it('returns default capabilities when LocalAuthentication APIs fail', async () => {
const osAuthEnabled = true;
const allowLoginWithRememberMe = false;
- mockIsEnrolledAsync.mockRejectedValue(new Error('API error'));
+ mockGetEnrolledLevelAsync.mockRejectedValue(new Error('API error'));
- const result = await Authentication.getAuthCapabilities(
+ const result = await Authentication.getAuthCapabilities({
osAuthEnabled,
allowLoginWithRememberMe,
- );
+ });
expect(result).toEqual({
isBiometricsAvailable: false,
- biometricsDisabledOnOS: false,
- isAuthToggleVisible: false,
- authToggleLabel: '',
+ passcodeAvailable: false,
+ deviceAuthRequiresSettings: true,
+ authLabel: '',
+ authDescription: '',
osAuthEnabled,
allowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.PASSWORD,
+ authType: AUTHENTICATION_TYPE.PASSWORD,
});
});
});
@@ -5093,10 +5032,12 @@ describe('Authentication', () => {
);
});
- it('calls all LocalAuthentication APIs in parallel', async () => {
- await Authentication.getAuthCapabilities(true, false);
+ it('calls supportedAuthenticationTypesAsync and getEnrolledLevelAsync in parallel', async () => {
+ await Authentication.getAuthCapabilities({
+ osAuthEnabled: true,
+ allowLoginWithRememberMe: false,
+ });
- expect(mockIsEnrolledAsync).toHaveBeenCalledTimes(1);
expect(mockSupportedAuthenticationTypesAsync).toHaveBeenCalledTimes(1);
expect(mockGetEnrolledLevelAsync).toHaveBeenCalledTimes(1);
});
diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts
index 0a77d66997f7..bae27c3966bb 100644
--- a/app/core/Authentication/Authentication.ts
+++ b/app/core/Authentication/Authentication.ts
@@ -67,10 +67,13 @@ import { toChecksumHexAddress } from '@metamask/controller-utils';
import AccountTreeInitService from '../../multichain-accounts/AccountTreeInitService';
import { renewSeedlessControllerRefreshTokens } from '../OAuthService/SeedlessControllerHelper';
import { EntropySourceId } from '@metamask/keyring-api';
-import MetaMetrics from '../Analytics/MetaMetrics';
+import { analytics } from '../../util/analytics/analytics';
import { createDataDeletionTask as createDataDeletionTaskUtil } from '../../util/analytics/analyticsDataDeletion';
import { resetProviderToken as depositResetProviderToken } from '../../components/UI/Ramp/Deposit/utils/ProviderTokenVault';
-import { setAllowLoginWithRememberMe } from '../../actions/security';
+import {
+ setAllowLoginWithRememberMe,
+ setOsAuthEnabled,
+} from '../../actions/security';
import { Alert, Platform } from 'react-native';
import { strings } from '../../../locales/i18n';
import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics';
@@ -83,7 +86,7 @@ import {
SecurityLevel,
authenticateAsync,
} from 'expo-local-authentication';
-import { getAuthToggleLabel } from './utils';
+import { getAuthLabel, getAuthType } from './utils';
/**
* Holds auth data used to determine auth configuration
@@ -115,6 +118,15 @@ class AuthenticationService {
ReduxService.store.dispatch(logIn());
}
+ /**
+ * Updates the Redux state for OS authentication enabled status.
+ *
+ * @param enabled - whether OS authentication is enabled
+ */
+ updateOsAuthEnabled(enabled: boolean): void {
+ ReduxService.store.dispatch(setOsAuthEnabled(enabled));
+ }
+
private dispatchPasswordSet(): void {
ReduxService.store.dispatch(passwordSet());
}
@@ -348,79 +360,23 @@ class AuthenticationService {
): Promise => {
try {
// Store password in keychain with appropriate type
- switch (authType) {
- case AUTHENTICATION_TYPE.BIOMETRIC:
- await SecureKeychain.setGenericPassword(
- password,
- SecureKeychain.TYPES.BIOMETRICS,
- );
-
- // TODO: Remove this once we have a proper way to handle biometrics
- await StorageWrapper.removeItem(BIOMETRY_CHOICE_DISABLED);
- await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE);
-
- break;
- case AUTHENTICATION_TYPE.PASSCODE:
- await SecureKeychain.setGenericPassword(
- password,
- SecureKeychain.TYPES.PASSCODE,
- );
-
- // TODO: Remove this once we have a proper way to handle biometrics
- await StorageWrapper.removeItem(PASSCODE_DISABLED);
- await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE);
-
- break;
- case AUTHENTICATION_TYPE.REMEMBER_ME: {
- // Store the current auth type before switching to remember me
- const currentAuthData = await this.checkAuthenticationMethod();
- // Only store if we're not already on remember me
- if (
- currentAuthData.currentAuthType !== AUTHENTICATION_TYPE.REMEMBER_ME
- ) {
- await StorageWrapper.setItem(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- currentAuthData.currentAuthType,
- );
- }
-
- await SecureKeychain.setGenericPassword(
- password,
- SecureKeychain.TYPES.REMEMBER_ME,
- );
-
- // TODO: Remove this once we have a proper way to handle biometrics
- await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE);
- await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE);
+ await SecureKeychain.setGenericPassword(password, authType);
+
+ // Remove legacy authentication flags
+ await StorageWrapper.removeItem(BIOMETRY_CHOICE_DISABLED);
+ await StorageWrapper.removeItem(PASSCODE_DISABLED);
+ await StorageWrapper.removeItem(PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME);
+ if (ReduxService.store.getState().security?.allowLoginWithRememberMe) {
+ ReduxService.store.dispatch(setAllowLoginWithRememberMe(false));
+ }
- break;
- }
- case AUTHENTICATION_TYPE.PASSWORD: {
- await SecureKeychain.setGenericPassword(password, undefined);
-
- // Password only: disable both biometrics and passcode
- await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE);
- await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE);
-
- // If remember me is enabled, clear the stored previous auth type
- // because the user is disabling biometrics/passcode, so we shouldn't restore to them
- const allowLoginWithRememberMe =
- ReduxService.store.getState().security?.allowLoginWithRememberMe;
- if (allowLoginWithRememberMe) {
- await StorageWrapper.removeItem(
- PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME,
- );
- }
- break;
- }
- default:
- await SecureKeychain.setGenericPassword(password, undefined);
+ // Keep Redux in sync with keychain so getAuthCapabilities reflects actual access control
+ this.updateOsAuthEnabled(
+ authType === AUTHENTICATION_TYPE.BIOMETRIC ||
+ authType === AUTHENTICATION_TYPE.PASSCODE ||
+ authType === AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
+ );
- // Default to password behavior: disable both
- await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE);
- await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE);
- break;
- }
this.dispatchPasswordSet();
} catch (error) {
if (fallbackToPassword) {
@@ -623,81 +579,103 @@ class AuthenticationService {
/**
* Fetches the authentication capabilities of the device.
- * Prioritizes Remember Me first, then biometrics, then passcode/pincode/pattern, then password.
- * iOS: "Face ID" | "Touch ID" | "Device Passcode"
- * Android: "Biometrics" | "Device PIN/Pattern"
+ * Prioritizes Remember Me first, then respects legacy user choice (biometrics vs passcode),
+ * then falls back to available capabilities.
+ * iOS: "Face ID" | "Touch ID" | "Device Passcode" | "Password"
+ * Android: "Biometrics" | "Device PIN/Pattern" | "Password"
*
* @param osAuthEnabled - Whether the OS-level authentication is enabled from user settings (from user preference state in Redux)
* @param allowLoginWithRememberMe - Whether Remember Me is enabled from user settings (from user preference state in Redux)
- * @returns {AuthCapabilities} - The authentication capabilities of the device.
+ * @returns {AuthCapabilities} - The authentication capabilities of the app.
*/
- getAuthCapabilities = async (
- osAuthEnabled: boolean,
- allowLoginWithRememberMe: boolean,
- ): Promise => {
+ getAuthCapabilities = async ({
+ osAuthEnabled,
+ allowLoginWithRememberMe,
+ }: {
+ osAuthEnabled: boolean;
+ allowLoginWithRememberMe: boolean;
+ }): Promise => {
try {
- // Fetch all capabilities in parallel
+ // Fetch all capabilities and legacy flags in parallel
const [
isBiometricsAvailable,
- supportedOSAuthenticationTypes,
+ supportedBiometricTypes,
capabilitySecurityLevel,
+ biometryChoiceDisabled,
+ passcodeDisabled,
] = await Promise.all([
isEnrolledAsync(),
supportedAuthenticationTypesAsync(),
getEnrolledLevelAsync(),
+ StorageWrapper.getItem(BIOMETRY_CHOICE_DISABLED),
+ StorageWrapper.getItem(PASSCODE_DISABLED),
]);
- // Device supports biometrics but they're not available
- // iOS: user denied permission for this app
- // Android: user hasn't enrolled biometrics on device
- const biometricsDisabledOnOS =
- !isBiometricsAvailable && supportedOSAuthenticationTypes.length > 0;
-
// Check if passcode is available
- // iOS: if passcode is available
- // Android: if pincode/pattern is available
+ // Ex on iOS - if passcode is available
+ // Ex on Android - if pincode/pattern is available
const passcodeAvailable = capabilitySecurityLevel >= SecurityLevel.SECRET;
- const isAuthToggleVisible = isBiometricsAvailable || passcodeAvailable;
+ // Legacy user preference selected device biometrics
+ const legacyUserChoseBiometrics =
+ passcodeDisabled === TRUE && !biometryChoiceDisabled;
- // Derive the authentication type for keychain storage based on capabilities + user preference
- // Priority: REMEMBER_ME > BIOMETRIC > PASSCODE > PASSWORD
- let authStorageType: AUTHENTICATION_TYPE;
+ // Legacy user preference selected device passcode
+ const legacyUserChosePasscode =
+ biometryChoiceDisabled === TRUE && !passcodeDisabled;
- if (allowLoginWithRememberMe) {
- authStorageType = AUTHENTICATION_TYPE.REMEMBER_ME;
- } else if (isBiometricsAvailable && osAuthEnabled) {
- authStorageType = AUTHENTICATION_TYPE.BIOMETRIC;
- } else if (passcodeAvailable && osAuthEnabled) {
- authStorageType = AUTHENTICATION_TYPE.PASSCODE;
- } else {
- authStorageType = AUTHENTICATION_TYPE.PASSWORD;
- }
+ // The auth type used for keychain storage
+ const authType = getAuthType({
+ allowLoginWithRememberMe,
+ osAuthEnabled,
+ legacyUserChoseBiometrics,
+ legacyUserChosePasscode,
+ isBiometricsAvailable,
+ passcodeAvailable,
+ });
+
+ // Ex - "Face ID", "Device Passcode", "Password"
+ const authLabel = getAuthLabel({
+ allowLoginWithRememberMe,
+ legacyUserChoseBiometrics,
+ legacyUserChosePasscode,
+ isBiometricsAvailable,
+ passcodeAvailable,
+ supportedBiometricTypes,
+ });
+
+ const authDescription =
+ authLabel === 'Device Authentication'
+ ? strings('app_settings.enable_device_authentication_desc')
+ : undefined;
+
+ // Device auth cannot be used until user changes device settings
+ const deviceAuthRequiresSettings =
+ (legacyUserChoseBiometrics && !isBiometricsAvailable) ||
+ (legacyUserChosePasscode && !passcodeAvailable) ||
+ (!isBiometricsAvailable && !passcodeAvailable);
return {
isBiometricsAvailable,
- biometricsDisabledOnOS,
- isAuthToggleVisible,
- authToggleLabel: getAuthToggleLabel({
- isBiometricsAvailable,
- supportedOSAuthenticationTypes,
- passcodeAvailable,
- allowLoginWithRememberMe,
- }),
+ passcodeAvailable,
+ authLabel,
+ authDescription,
osAuthEnabled,
allowLoginWithRememberMe,
- authStorageType,
+ authType,
+ deviceAuthRequiresSettings,
};
} catch (error) {
// On error, default to no capabilities
return {
isBiometricsAvailable: false,
- biometricsDisabledOnOS: false,
- isAuthToggleVisible: false,
- authToggleLabel: '',
+ passcodeAvailable: false,
+ authLabel: '',
+ authDescription: '',
osAuthEnabled,
allowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.PASSWORD,
+ authType: AUTHENTICATION_TYPE.PASSWORD,
+ deviceAuthRequiresSettings: true,
};
}
};
@@ -782,7 +760,7 @@ class AuthenticationService {
// TODO: Refactor this orchestration to sagas.
// Navigate to optin metrics or home screen based on metrics consent and UI seen.
- const isMetricsEnabled = MetaMetrics.getInstance().isEnabled();
+ const isMetricsEnabled = analytics.isEnabled();
const isOptinMetaMetricsUISeen = await StorageWrapper.getItem(
OPTIN_META_METRICS_UI_SEEN,
);
diff --git a/app/core/Authentication/hooks/useAuthCapabilities.test.ts b/app/core/Authentication/hooks/useAuthCapabilities.test.ts
index df2e6d640e54..fc73657aa43c 100644
--- a/app/core/Authentication/hooks/useAuthCapabilities.test.ts
+++ b/app/core/Authentication/hooks/useAuthCapabilities.test.ts
@@ -4,26 +4,7 @@ import { Authentication } from '../Authentication';
import AUTHENTICATION_TYPE from '../../../constants/userProperties';
import { AuthCapabilities } from '../types';
-// Mock expo-local-authentication
-jest.mock('expo-local-authentication', () => ({
- AuthenticationType: {
- FINGERPRINT: 1,
- FACIAL_RECOGNITION: 2,
- IRIS: 3,
- },
- SecurityLevel: {
- NONE: 0,
- SECRET: 1,
- BIOMETRIC_WEAK: 2,
- BIOMETRIC_STRONG: 3,
- },
- isEnrolledAsync: jest.fn(),
- supportedAuthenticationTypesAsync: jest.fn(),
- getEnrolledLevelAsync: jest.fn(),
-}));
-
// Mock react-redux
-const mockDispatch = jest.fn();
let mockOsAuthEnabled = true;
let mockAllowLoginWithRememberMe = false;
@@ -36,13 +17,12 @@ jest.mock('react-redux', () => ({
},
}),
),
- useDispatch: () => mockDispatch,
}));
// Mock Authentication
const mockGetAuthCapabilities = jest.fn<
Promise,
- [boolean, boolean]
+ [{ osAuthEnabled: boolean; allowLoginWithRememberMe: boolean }]
>();
describe('useAuthCapabilities', () => {
@@ -50,12 +30,13 @@ describe('useAuthCapabilities', () => {
const mockCapabilities: AuthCapabilities = {
isBiometricsAvailable: true,
- biometricsDisabledOnOS: false,
- isAuthToggleVisible: true,
- authToggleLabel: 'Face ID',
+ passcodeAvailable: true,
+ authLabel: 'Face ID',
+ authDescription: '',
osAuthEnabled: true,
allowLoginWithRememberMe: false,
- authStorageType: AUTHENTICATION_TYPE.BIOMETRIC,
+ authType: AUTHENTICATION_TYPE.BIOMETRIC,
+ deviceAuthRequiresSettings: false,
};
beforeEach(() => {
@@ -80,8 +61,6 @@ describe('useAuthCapabilities', () => {
// Initially loading (synchronous check before async completes)
expect(result.current.isLoading).toBe(true);
expect(result.current.capabilities).toBeNull();
- expect(typeof result.current.refresh).toBe('function');
- expect(typeof result.current.updateOsAuthEnabled).toBe('function');
// Wait for async updates to complete to avoid act warnings
await act(async () => {
@@ -109,43 +88,14 @@ describe('useAuthCapabilities', () => {
expect(result.current.isLoading).toBe(false);
expect(getAuthCapabilitiesSpy).toHaveBeenCalledTimes(1);
- expect(getAuthCapabilitiesSpy).toHaveBeenCalledWith(
- mockOsAuthEnabled,
- mockAllowLoginWithRememberMe,
- );
- });
-
- it('returns default capabilities on error', async () => {
- mockGetAuthCapabilities.mockRejectedValue(new Error('Test error'));
- mockOsAuthEnabled = true;
-
- const { result, waitForNextUpdate } = renderHook(() =>
- useAuthCapabilities(),
- );
-
- await act(async () => {
- await waitForNextUpdate();
- });
-
- expect(result.current.capabilities).toEqual({
- isBiometricsAvailable: false,
- biometricsDisabledOnOS: false,
- isAuthToggleVisible: false,
- authToggleLabel: '',
+ expect(getAuthCapabilitiesSpy).toHaveBeenCalledWith({
osAuthEnabled: mockOsAuthEnabled,
allowLoginWithRememberMe: mockAllowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.PASSWORD,
});
-
- expect(getAuthCapabilitiesSpy).toHaveBeenCalledTimes(1);
- expect(getAuthCapabilitiesSpy).toHaveBeenCalledWith(
- mockOsAuthEnabled,
- mockAllowLoginWithRememberMe,
- );
});
- it('calls getAuthCapabilities again when refresh is called', async () => {
- const { result, waitForNextUpdate } = renderHook(() =>
+ it('calls getAuthCapabilities again when osAuthEnabled or allowLoginWithRememberMe change', async () => {
+ const { waitForNextUpdate, rerender } = renderHook(() =>
useAuthCapabilities(),
);
@@ -154,54 +104,17 @@ describe('useAuthCapabilities', () => {
});
expect(getAuthCapabilitiesSpy).toHaveBeenCalledTimes(1);
- await act(async () => {
- await result.current.refresh();
- });
-
- expect(getAuthCapabilitiesSpy).toHaveBeenCalledTimes(2);
- });
-
- it('updates capabilities after refresh', async () => {
- const { result, waitForNextUpdate } = renderHook(() =>
- useAuthCapabilities(),
- );
-
- await act(async () => {
- await waitForNextUpdate();
- });
-
- expect(result.current.capabilities).toEqual(mockCapabilities);
-
- const updatedCapabilities: AuthCapabilities = {
- ...mockCapabilities,
- authToggleLabel: 'Touch ID',
- };
- mockGetAuthCapabilities.mockResolvedValue(updatedCapabilities);
-
- await act(async () => {
- await result.current.refresh();
- });
-
- expect(result.current.capabilities).toEqual(updatedCapabilities);
- });
-
- it('dispatches setOsAuthEnabled with true when currently false', async () => {
mockOsAuthEnabled = false;
- const { result, waitForNextUpdate } = renderHook(() =>
- useAuthCapabilities(),
- );
+ rerender();
await act(async () => {
await waitForNextUpdate();
});
- act(() => {
- result.current.updateOsAuthEnabled();
- });
-
- expect(mockDispatch).toHaveBeenCalledWith({
- type: 'SET_OS_AUTH_ENABLED',
- enabled: !mockOsAuthEnabled, // Toggled from false to true
+ expect(getAuthCapabilitiesSpy).toHaveBeenCalledTimes(2);
+ expect(getAuthCapabilitiesSpy).toHaveBeenLastCalledWith({
+ osAuthEnabled: false,
+ allowLoginWithRememberMe: mockAllowLoginWithRememberMe,
});
});
});
diff --git a/app/core/Authentication/hooks/useAuthCapabilities.ts b/app/core/Authentication/hooks/useAuthCapabilities.ts
index 86e0f0619e90..aef8be8ba87c 100644
--- a/app/core/Authentication/hooks/useAuthCapabilities.ts
+++ b/app/core/Authentication/hooks/useAuthCapabilities.ts
@@ -1,10 +1,8 @@
import { useState, useEffect, useCallback } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import { useSelector } from 'react-redux';
import { UseAuthCapabilitiesResult } from './useAuthCapabilities.types';
import { RootState } from '../../../reducers';
-import AUTHENTICATION_TYPE from '../../../constants/userProperties';
-import { setOsAuthEnabled } from '../../../actions/security';
-import { Authentication } from '../Authentication';
+import useAuthentication from './useAuthentication';
/**
* Hook that detects device authentication capabilities using expo-local-authentication.
@@ -12,6 +10,7 @@ import { Authentication } from '../Authentication';
* Priority: REMEMBER_ME > BIOMETRIC > PASSCODE > PASSWORD
*/
const useAuthCapabilities = (): UseAuthCapabilitiesResult => {
+ const { getAuthCapabilities } = useAuthentication();
const [isLoading, setIsLoading] = useState(true);
const [capabilities, setCapabilities] = useState<
UseAuthCapabilitiesResult['capabilities'] | null
@@ -22,35 +21,20 @@ const useAuthCapabilities = (): UseAuthCapabilitiesResult => {
const allowLoginWithRememberMe = useSelector(
(state: RootState) => state.security.allowLoginWithRememberMe,
);
- const dispatch = useDispatch();
-
- const updateOsAuthEnabled = useCallback(() => {
- dispatch(setOsAuthEnabled(!osAuthEnabled));
- }, [dispatch, osAuthEnabled]);
const fetchAuthCapabilities = useCallback(async () => {
setIsLoading(true);
try {
- const result = await Authentication.getAuthCapabilities(
+ // No need to catch error as it will return default capabilities
+ const result = await getAuthCapabilities({
osAuthEnabled,
allowLoginWithRememberMe,
- );
- setCapabilities(result);
- } catch (error) {
- // On error, default to no capabilities
- setCapabilities({
- isBiometricsAvailable: false,
- biometricsDisabledOnOS: false,
- isAuthToggleVisible: false,
- authToggleLabel: '',
- osAuthEnabled,
- allowLoginWithRememberMe,
- authStorageType: AUTHENTICATION_TYPE.PASSWORD,
});
+ setCapabilities(result);
} finally {
setIsLoading(false);
}
- }, [osAuthEnabled, allowLoginWithRememberMe]);
+ }, [osAuthEnabled, allowLoginWithRememberMe, getAuthCapabilities]);
useEffect(() => {
fetchAuthCapabilities();
@@ -59,8 +43,6 @@ const useAuthCapabilities = (): UseAuthCapabilitiesResult => {
return {
isLoading,
capabilities,
- refresh: fetchAuthCapabilities,
- updateOsAuthEnabled,
};
};
diff --git a/app/core/Authentication/hooks/useAuthCapabilities.types.ts b/app/core/Authentication/hooks/useAuthCapabilities.types.ts
index 84842f1f1132..bbb8d5bc9e51 100644
--- a/app/core/Authentication/hooks/useAuthCapabilities.types.ts
+++ b/app/core/Authentication/hooks/useAuthCapabilities.types.ts
@@ -8,8 +8,4 @@ export interface UseAuthCapabilitiesResult {
isLoading: boolean;
/** Authentication capabilities (null while loading or on error) */
capabilities: AuthCapabilities | null;
- /** Refresh the capabilities (useful after user changes system settings) */
- refresh: () => Promise;
- /** Update the OS-level authentication enabled state */
- updateOsAuthEnabled: () => void;
}
diff --git a/app/core/Authentication/hooks/useAuthentication.test.ts b/app/core/Authentication/hooks/useAuthentication.test.ts
index 49931778be75..72d851bcb045 100644
--- a/app/core/Authentication/hooks/useAuthentication.test.ts
+++ b/app/core/Authentication/hooks/useAuthentication.test.ts
@@ -1,161 +1,44 @@
-import { act, renderHook } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react-hooks';
// Import useAuthentication
import useAuthentication from './useAuthentication';
-import { Authentication } from '../Authentication';
-
-// Create mock function for lockApp
-const mockLockApp = jest.fn<
- Promise,
- [
- {
- allowRememberMe?: boolean;
- reset?: boolean;
- locked?: boolean;
- navigateToLogin?: boolean;
- }?,
- ]
->();
describe('useAuthentication', () => {
- let lockAppSpy: jest.SpyInstance;
-
beforeEach(() => {
jest.clearAllMocks();
- // Spy on the actual lockApp method
- lockAppSpy = jest
- .spyOn(Authentication, 'lockApp')
- .mockImplementation(mockLockApp);
- mockLockApp.mockResolvedValue(undefined);
- });
-
- afterEach(() => {
- lockAppSpy.mockRestore();
});
describe('hook initialization', () => {
- it('returns lockApp function', () => {
+ it('returns all Authentication service methods', () => {
const { result } = renderHook(() => useAuthentication());
+ expect(result.current.unlockWallet).toBeDefined();
expect(result.current.lockApp).toBeDefined();
+ expect(result.current.reauthenticate).toBeDefined();
+ expect(result.current.revealSRP).toBeDefined();
+ expect(result.current.revealPrivateKey).toBeDefined();
+ expect(result.current.getAuthType).toBeDefined();
+ expect(result.current.componentAuthenticationType).toBeDefined();
+ expect(result.current.updateAuthPreference).toBeDefined();
+ expect(result.current.getAuthCapabilities).toBeDefined();
+ expect(result.current.updateOsAuthEnabled).toBeDefined();
+ expect(result.current.checkIsSeedlessPasswordOutdated).toBeDefined();
+
+ expect(typeof result.current.unlockWallet).toBe('function');
expect(typeof result.current.lockApp).toBe('function');
- });
-
- it('does not call Authentication.lockApp on initialization', () => {
- renderHook(() => useAuthentication());
-
- expect(lockAppSpy).not.toHaveBeenCalled();
- });
- });
-
- describe('lockApp', () => {
- it('calls Authentication.lockApp with provided arguments', async () => {
- const { result } = renderHook(() => useAuthentication());
-
- await act(async () => {
- await result.current.lockApp({ allowRememberMe: false });
- });
-
- expect(lockAppSpy).toHaveBeenCalledTimes(1);
- expect(lockAppSpy).toHaveBeenCalledWith({ allowRememberMe: false });
- });
-
- it('calls Authentication.lockApp with allowRememberMe true when provided', async () => {
- const { result } = renderHook(() => useAuthentication());
-
- await act(async () => {
- await result.current.lockApp({ allowRememberMe: true });
- });
-
- expect(lockAppSpy).toHaveBeenCalledTimes(1);
- expect(lockAppSpy).toHaveBeenCalledWith({ allowRememberMe: true });
- });
-
- it('calls Authentication.lockApp with empty object when no arguments provided', async () => {
- const { result } = renderHook(() => useAuthentication());
-
- await act(async () => {
- await result.current.lockApp({});
- });
-
- expect(lockAppSpy).toHaveBeenCalledTimes(1);
- expect(lockAppSpy).toHaveBeenCalledWith({});
- });
-
- it('returns promise that resolves when lockApp succeeds', async () => {
- const { result } = renderHook(() => useAuthentication());
-
- await act(async () => {
- const promise = result.current.lockApp({ allowRememberMe: false });
- await expect(promise).resolves.toBeUndefined();
- });
- });
-
- it('returns promise that rejects when lockApp fails', async () => {
- const testError = new Error('Lock app failed');
- mockLockApp.mockRejectedValue(testError);
- const { result } = renderHook(() => useAuthentication());
-
- await act(async () => {
- await expect(
- result.current.lockApp({ allowRememberMe: false }),
- ).rejects.toThrow('Lock app failed');
- });
-
- expect(lockAppSpy).toHaveBeenCalledWith({ allowRememberMe: false });
- });
-
- it('returns same function reference across renders', () => {
- const { result, rerender } = renderHook(() => useAuthentication());
- const firstReference = result.current.lockApp;
-
- rerender();
-
- expect(result.current.lockApp).toBe(firstReference);
- expect(typeof result.current.lockApp).toBe('function');
- });
- });
-
- describe('error handling', () => {
- it('calls lockApp with correct parameters even when it throws error', async () => {
- const testError = new Error('Lock app error');
- mockLockApp.mockRejectedValue(testError);
- const { result } = renderHook(() => useAuthentication());
-
- await act(async () => {
- try {
- await result.current.lockApp({ allowRememberMe: false });
- } catch {
- // Expected to throw
- }
- });
-
- expect(lockAppSpy).toHaveBeenCalledWith({ allowRememberMe: false });
- });
-
- it('propagates error from lockApp when it fails', async () => {
- const testError = new Error('Authentication service error');
- mockLockApp.mockRejectedValue(testError);
- const { result } = renderHook(() => useAuthentication());
-
- await act(async () => {
- await expect(
- result.current.lockApp({ allowRememberMe: false }),
- ).rejects.toThrow('Authentication service error');
- });
- });
- });
-
- describe('integration', () => {
- it('completes full flow: calls lockApp with allowRememberMe false', async () => {
- const { result } = renderHook(() => useAuthentication());
-
- await act(async () => {
- await result.current.lockApp({ allowRememberMe: false });
- });
-
- expect(lockAppSpy).toHaveBeenCalledTimes(1);
- expect(lockAppSpy).toHaveBeenCalledWith({ allowRememberMe: false });
+ expect(typeof result.current.reauthenticate).toBe('function');
+ expect(typeof result.current.revealSRP).toBe('function');
+ expect(typeof result.current.revealPrivateKey).toBe('function');
+ expect(typeof result.current.getAuthType).toBe('function');
+ expect(typeof result.current.componentAuthenticationType).toBe(
+ 'function',
+ );
+ expect(typeof result.current.updateAuthPreference).toBe('function');
+ expect(typeof result.current.getAuthCapabilities).toBe('function');
+ expect(typeof result.current.updateOsAuthEnabled).toBe('function');
+ expect(typeof result.current.checkIsSeedlessPasswordOutdated).toBe(
+ 'function',
+ );
});
});
});
diff --git a/app/core/Authentication/hooks/useAuthentication.ts b/app/core/Authentication/hooks/useAuthentication.ts
index 44bd97d671b2..91b38f8b3462 100644
--- a/app/core/Authentication/hooks/useAuthentication.ts
+++ b/app/core/Authentication/hooks/useAuthentication.ts
@@ -11,6 +11,9 @@ export default () => ({
revealPrivateKey: Authentication.revealPrivateKey,
getAuthType: Authentication.getType,
componentAuthenticationType: Authentication.componentAuthenticationType,
+ updateAuthPreference: Authentication.updateAuthPreference,
+ getAuthCapabilities: Authentication.getAuthCapabilities,
+ updateOsAuthEnabled: Authentication.updateOsAuthEnabled,
checkIsSeedlessPasswordOutdated:
Authentication.checkIsSeedlessPasswordOutdated,
});
diff --git a/app/core/Authentication/types.ts b/app/core/Authentication/types.ts
index 1b95c2251eda..f19b71722a5e 100644
--- a/app/core/Authentication/types.ts
+++ b/app/core/Authentication/types.ts
@@ -26,16 +26,18 @@ export enum ReauthenticateErrorType {
export interface AuthCapabilities {
/** Whether biometrics are enrolled and available to this app */
isBiometricsAvailable: boolean;
- /** If device supports biometrics but they're not available to the app (iOS: permission denied, Android: not enrolled) */
- biometricsDisabledOnOS: boolean;
- /** Whether the authentication toggle is visible (based on biometric-first priority) */
- isAuthToggleVisible: boolean;
- /** Human-readable label for the toggle (e.g., "Face ID", "Biometrics", "Device Passcode") */
- authToggleLabel: string;
+ /** Whether passcode is available to the app */
+ passcodeAvailable: boolean;
+ /** Human-readable label for the available device auth tier (e.g. "Face ID", "Device Passcode"). Reflects what the device supports, not the current authType, so the toggle can show the right label when osAuthEnabled is false. */
+ authLabel: string;
+ /** Description for the available authentication type */
+ authDescription: string;
/** Whether the OS-level authentication is enabled (from user preference in Redux) */
osAuthEnabled: boolean;
/** Whether Remember Me is enabled (from user preference in Redux) */
allowLoginWithRememberMe: boolean;
/** The derived AUTHENTICATION_TYPE for keychain storage based on capabilities + user preference. Priority: REMEMBER_ME > BIOMETRIC > PASSCODE > PASSWORD */
- authStorageType: AUTHENTICATION_TYPE;
+ authType: AUTHENTICATION_TYPE;
+ /** True when device auth cannot be used until the user changes device settings */
+ deviceAuthRequiresSettings: boolean;
}
diff --git a/app/core/Authentication/utils.test.ts b/app/core/Authentication/utils.test.ts
index 9b2c39ecc407..f7f61eeab229 100644
--- a/app/core/Authentication/utils.test.ts
+++ b/app/core/Authentication/utils.test.ts
@@ -5,10 +5,12 @@ import {
} from '../Engine/controllers/seedless-onboarding-controller/error';
import { UnlockWalletErrorType } from './types';
import { UNLOCK_WALLET_ERROR_MESSAGES } from './constants';
+import AUTHENTICATION_TYPE from '../../constants/userProperties';
import {
handlePasswordSubmissionError,
checkPasswordRequirement,
- getAuthToggleLabel,
+ getAuthLabel,
+ getAuthType,
} from './utils';
import { AuthenticationType } from 'expo-local-authentication';
@@ -95,6 +97,109 @@ describe('handlePasswordSubmissionError', () => {
});
});
+describe('getAuthType', () => {
+ const baseParams = {
+ allowLoginWithRememberMe: false,
+ osAuthEnabled: false,
+ legacyUserChoseBiometrics: false,
+ legacyUserChosePasscode: false,
+ isBiometricsAvailable: false,
+ passcodeAvailable: false,
+ };
+
+ it('returns REMEMBER_ME when allowLoginWithRememberMe is true', () => {
+ const result = getAuthType({
+ ...baseParams,
+ allowLoginWithRememberMe: true,
+ osAuthEnabled: true,
+ isBiometricsAvailable: true,
+ passcodeAvailable: true,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.REMEMBER_ME);
+ });
+
+ it('returns PASSWORD when osAuthEnabled is false', () => {
+ const result = getAuthType({
+ ...baseParams,
+ osAuthEnabled: false,
+ isBiometricsAvailable: true,
+ passcodeAvailable: true,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.PASSWORD);
+ });
+
+ it('returns BIOMETRIC when legacyUserChoseBiometrics and isBiometricsAvailable', () => {
+ const result = getAuthType({
+ ...baseParams,
+ osAuthEnabled: true,
+ legacyUserChoseBiometrics: true,
+ isBiometricsAvailable: true,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.BIOMETRIC);
+ });
+
+ it('returns PASSWORD when legacyUserChoseBiometrics but not isBiometricsAvailable', () => {
+ const result = getAuthType({
+ ...baseParams,
+ osAuthEnabled: true,
+ legacyUserChoseBiometrics: true,
+ isBiometricsAvailable: false,
+ passcodeAvailable: true,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.PASSWORD);
+ });
+
+ it('returns PASSCODE when legacyUserChosePasscode and passcodeAvailable', () => {
+ const result = getAuthType({
+ ...baseParams,
+ osAuthEnabled: true,
+ legacyUserChosePasscode: true,
+ passcodeAvailable: true,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.PASSCODE);
+ });
+
+ it('returns PASSWORD when legacyUserChosePasscode but not passcodeAvailable', () => {
+ const result = getAuthType({
+ ...baseParams,
+ osAuthEnabled: true,
+ legacyUserChosePasscode: true,
+ passcodeAvailable: false,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.PASSWORD);
+ });
+
+ it('returns BIOMETRIC when osAuthEnabled and isBiometricsAvailable (tiered fallback)', () => {
+ const result = getAuthType({
+ ...baseParams,
+ osAuthEnabled: true,
+ isBiometricsAvailable: true,
+ passcodeAvailable: true,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.BIOMETRIC);
+ });
+
+ it('returns PASSCODE when osAuthEnabled, no biometrics, passcodeAvailable (tiered fallback)', () => {
+ const result = getAuthType({
+ ...baseParams,
+ osAuthEnabled: true,
+ isBiometricsAvailable: false,
+ passcodeAvailable: true,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.PASSCODE);
+ });
+
+ it('returns PASSWORD when osAuthEnabled but neither biometrics nor passcode available', () => {
+ const result = getAuthType({
+ ...baseParams,
+ osAuthEnabled: true,
+ isBiometricsAvailable: false,
+ passcodeAvailable: false,
+ });
+ expect(result).toBe(AUTHENTICATION_TYPE.PASSWORD);
+ });
+});
+
describe('checkPasswordRequirement', () => {
it('return true if password equals the minimum length requirement', () => {
const password = 'password';
@@ -112,10 +217,15 @@ describe('checkPasswordRequirement', () => {
});
});
-describe('getAuthToggleLabel', () => {
- afterEach(() => {
- jest.restoreAllMocks();
- });
+describe('getAuthLabel', () => {
+ const baseParams = {
+ supportedBiometricTypes: [] as number[],
+ allowLoginWithRememberMe: false,
+ legacyUserChoseBiometrics: false,
+ legacyUserChosePasscode: false,
+ isBiometricsAvailable: false,
+ passcodeAvailable: false,
+ };
describe('iOS', () => {
beforeEach(() => {
@@ -123,79 +233,59 @@ describe('getAuthToggleLabel', () => {
});
it('returns "Remember Me" when allowLoginWithRememberMe is true', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [AuthenticationType.FACIAL_RECOGNITION],
- passcodeAvailable: true,
+ const result = getAuthLabel({
+ ...baseParams,
allowLoginWithRememberMe: true,
});
expect(result).toBe('Remember Me');
});
- it('returns "Face ID" when facial recognition is available', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [AuthenticationType.FACIAL_RECOGNITION],
- passcodeAvailable: true,
+ it('returns "Face ID" when legacyUserChoseBiometrics and Face ID supported', () => {
+ const result = getAuthLabel({
+ ...baseParams,
+ legacyUserChoseBiometrics: true,
+ supportedBiometricTypes: [AuthenticationType.FACIAL_RECOGNITION],
});
expect(result).toBe('Face ID');
});
- it('returns "Touch ID" when fingerprint is available', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [AuthenticationType.FINGERPRINT],
- passcodeAvailable: true,
+ it('returns "Touch ID" when legacyUserChoseBiometrics and Touch ID supported', () => {
+ const result = getAuthLabel({
+ ...baseParams,
+ legacyUserChoseBiometrics: true,
+ supportedBiometricTypes: [AuthenticationType.FINGERPRINT],
});
expect(result).toBe('Touch ID');
});
- it('returns "Face ID" when both facial recognition and fingerprint are available (Face ID priority)', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [
- AuthenticationType.FACIAL_RECOGNITION,
- AuthenticationType.FINGERPRINT,
- ],
- passcodeAvailable: true,
- });
- expect(result).toBe('Face ID');
- });
-
- it('returns "Device Passcode" when no biometrics but passcode is available', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: false,
- supportedOSAuthenticationTypes: [],
- passcodeAvailable: true,
+ it('returns "Device Passcode" when legacyUserChosePasscode is true', () => {
+ const result = getAuthLabel({
+ ...baseParams,
+ legacyUserChosePasscode: true,
});
expect(result).toBe('Device Passcode');
});
- it('returns empty string when nothing is available', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: false,
- supportedOSAuthenticationTypes: [],
- passcodeAvailable: false,
+ it('returns "Device Authentication" when isBiometricsAvailable (modern path)', () => {
+ const result = getAuthLabel({
+ ...baseParams,
+ isBiometricsAvailable: true,
+ supportedBiometricTypes: [AuthenticationType.FACIAL_RECOGNITION],
});
- expect(result).toBe('');
+ expect(result).toBe('Device Authentication');
});
- it('returns "Device Passcode" when biometrics hardware exists but is disabled', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: false,
- supportedOSAuthenticationTypes: [AuthenticationType.FACIAL_RECOGNITION],
+ it('returns "Device Authentication" when passcodeAvailable (modern path)', () => {
+ const result = getAuthLabel({
+ ...baseParams,
passcodeAvailable: true,
});
- expect(result).toBe('Device Passcode');
+ expect(result).toBe('Device Authentication');
});
- it('returns empty string when isBiometricsAvailable is true but supportedOSAuthenticationTypes is empty', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [],
- passcodeAvailable: false,
- });
- expect(result).toBe('');
+ it('returns "Password" when nothing is available', () => {
+ const result = getAuthLabel(baseParams);
+ expect(result).toBe('Password');
});
});
@@ -205,67 +295,50 @@ describe('getAuthToggleLabel', () => {
});
it('returns "Remember Me" when allowLoginWithRememberMe is true', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [AuthenticationType.FINGERPRINT],
- passcodeAvailable: true,
+ const result = getAuthLabel({
+ ...baseParams,
allowLoginWithRememberMe: true,
});
expect(result).toBe('Remember Me');
});
- it('returns "Biometrics" when fingerprint is available', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [AuthenticationType.FINGERPRINT],
- passcodeAvailable: true,
+ it('returns "Device Authentication" when legacyUserChoseBiometrics (Android)', () => {
+ const result = getAuthLabel({
+ ...baseParams,
+ legacyUserChoseBiometrics: true,
+ supportedBiometricTypes: [AuthenticationType.FINGERPRINT],
});
- expect(result).toBe('Biometrics');
+ expect(result).toBe('Device Authentication');
});
- it('returns "Biometrics" when facial recognition is available', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [AuthenticationType.FACIAL_RECOGNITION],
- passcodeAvailable: true,
+ it('returns "Device Authentication" when legacyUserChosePasscode (Android)', () => {
+ const result = getAuthLabel({
+ ...baseParams,
+ legacyUserChosePasscode: true,
});
- expect(result).toBe('Biometrics');
+ expect(result).toBe('Device Authentication');
});
- it('returns "Biometrics" when iris is available', () => {
- const result = getAuthToggleLabel({
+ it('returns "Device Authentication" when isBiometricsAvailable (Android)', () => {
+ const result = getAuthLabel({
+ ...baseParams,
isBiometricsAvailable: true,
- supportedOSAuthenticationTypes: [AuthenticationType.IRIS],
- passcodeAvailable: true,
+ supportedBiometricTypes: [AuthenticationType.FINGERPRINT],
});
- expect(result).toBe('Biometrics');
+ expect(result).toBe('Device Authentication');
});
- it('returns "Device PIN/Pattern" when no biometrics but passcode is available', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: false,
- supportedOSAuthenticationTypes: [],
+ it('returns "Device Authentication" when passcodeAvailable (Android)', () => {
+ const result = getAuthLabel({
+ ...baseParams,
passcodeAvailable: true,
});
- expect(result).toBe('Device PIN/Pattern');
+ expect(result).toBe('Device Authentication');
});
- it('returns empty string when nothing is available', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: false,
- supportedOSAuthenticationTypes: [],
- passcodeAvailable: false,
- });
- expect(result).toBe('');
- });
-
- it('returns "Device PIN/Pattern" when biometrics hardware exists but is not enrolled', () => {
- const result = getAuthToggleLabel({
- isBiometricsAvailable: false,
- supportedOSAuthenticationTypes: [AuthenticationType.FINGERPRINT],
- passcodeAvailable: true,
- });
- expect(result).toBe('Device PIN/Pattern');
+ it('returns "Password" when nothing is available', () => {
+ const result = getAuthLabel(baseParams);
+ expect(result).toBe('Password');
});
});
});
diff --git a/app/core/Authentication/utils.ts b/app/core/Authentication/utils.ts
index e8db69abf242..b1200f811cda 100644
--- a/app/core/Authentication/utils.ts
+++ b/app/core/Authentication/utils.ts
@@ -4,6 +4,7 @@ import { MIN_PASSWORD_LENGTH, UNLOCK_WALLET_ERROR_MESSAGES } from './constants';
import { SeedlessOnboardingControllerError } from '../Engine/controllers/seedless-onboarding-controller/error';
import { AuthenticationType } from 'expo-local-authentication';
import { Platform } from 'react-native';
+import AUTHENTICATION_TYPE from '../../constants/userProperties';
/**
* Handles password submission errors by throwing the appropriate error.
@@ -76,56 +77,116 @@ export const handlePasswordSubmissionError = (error: Error) => {
};
/**
- * Gets a human-readable label for the authentication toggle based on device capabilities.
- * Priority: Remember Me > Biometrics > Device Passcode > ""
+ * Derives the auth type for keychain storage (what the system actually uses).
+ * Order: Remember Me > osAuthEnabled > legacy explicit choice > new tiered fallback (biometrics → passcode → password).
*
- * iOS: "Remember Me" | "Face ID" | "Touch ID" | "Device Passcode" | ""
- * Android: "Remember Me" | "Biometrics" | "Device PIN/Pattern" | ""
+ * @param params.allowLoginWithRememberMe - Legacy - Whether the user has enabled remember me
+ * @param params.osAuthEnabled - Whether the user has enabled os auth
+ * @param params.legacyUserChoseBiometrics - Legacy - Whether the user has chosen biometrics
+ * @param params.legacyUserChosePasscode - Legacy - Whether the user has chosen passcode
+ * @param params.isBiometricsAvailable - Whether the device has biometrics available
+ * @param params.passcodeAvailable - Whether the device has passcode available
+ * @returns The AUTHENTICATION_TYPE to use for keychain/auth
*/
-export const getAuthToggleLabel = ({
+export const getAuthType = ({
+ allowLoginWithRememberMe,
+ osAuthEnabled,
+ legacyUserChoseBiometrics,
+ legacyUserChosePasscode,
isBiometricsAvailable,
- supportedOSAuthenticationTypes,
passcodeAvailable,
- allowLoginWithRememberMe = false,
}: {
+ allowLoginWithRememberMe: boolean;
+ osAuthEnabled: boolean;
+ legacyUserChoseBiometrics: boolean;
+ legacyUserChosePasscode: boolean;
+ isBiometricsAvailable: boolean;
+ passcodeAvailable: boolean;
+}): AUTHENTICATION_TYPE => {
+ // Legacy condition
+ if (allowLoginWithRememberMe) {
+ return AUTHENTICATION_TYPE.REMEMBER_ME;
+ }
+ if (!osAuthEnabled) {
+ return AUTHENTICATION_TYPE.PASSWORD;
+ }
+ // Legacy condition
+ if (legacyUserChoseBiometrics) {
+ return isBiometricsAvailable
+ ? AUTHENTICATION_TYPE.BIOMETRIC
+ : AUTHENTICATION_TYPE.PASSWORD;
+ }
+ // Legacy condition
+ if (legacyUserChosePasscode) {
+ return passcodeAvailable
+ ? AUTHENTICATION_TYPE.PASSCODE
+ : AUTHENTICATION_TYPE.PASSWORD;
+ }
+ if (isBiometricsAvailable) {
+ return AUTHENTICATION_TYPE.BIOMETRIC;
+ }
+ if (passcodeAvailable) {
+ return AUTHENTICATION_TYPE.PASSCODE;
+ }
+ return AUTHENTICATION_TYPE.PASSWORD;
+};
+
+/**
+ * Gets a human-readable label based on the authentication and supported biometric types.
+ *
+ * iOS: "Remember Me" | "Face ID" | "Touch ID" | "Device Passcode" | "Password"
+ * Android: "Remember Me" | "Device Authentication" | "Password"
+ *
+ * @param params.supportedBiometricTypes - The supported biometric types
+ * @param params.allowLoginWithRememberMe - Legacy - Whether the user has enabled remember me
+ * @param params.legacyUserChoseBiometrics - Legacy - Whether the user has chosen biometrics
+ * @param params.legacyUserChosePasscode - Legacy - Whether the user has chosen passcode
+ * @param params.isBiometricsAvailable - Whether the device has biometrics available
+ * @param params.passcodeAvailable - Whether the device has passcode available
+ * @returns The human-readable label for the authentication type
+ */
+export const getAuthLabel = ({
+ supportedBiometricTypes,
+ allowLoginWithRememberMe,
+ legacyUserChoseBiometrics,
+ legacyUserChosePasscode,
+ isBiometricsAvailable,
+ passcodeAvailable,
+}: {
+ supportedBiometricTypes: AuthenticationType[];
+ allowLoginWithRememberMe: boolean;
+ legacyUserChoseBiometrics: boolean;
+ legacyUserChosePasscode: boolean;
isBiometricsAvailable: boolean;
- supportedOSAuthenticationTypes: AuthenticationType[];
passcodeAvailable: boolean;
- allowLoginWithRememberMe?: boolean;
}): string => {
- // Priority 1: Remember Me (if enabled)
if (allowLoginWithRememberMe) {
return 'Remember Me';
}
-
- // Priority 2: Biometrics (if available)
- if (isBiometricsAvailable && supportedOSAuthenticationTypes.length > 0) {
+ if (legacyUserChoseBiometrics) {
+ // Show explicit authentication type for legacy biometrics
if (Platform.OS === 'ios') {
if (
- supportedOSAuthenticationTypes.includes(
- AuthenticationType.FACIAL_RECOGNITION,
- )
+ supportedBiometricTypes.includes(AuthenticationType.FACIAL_RECOGNITION)
) {
return 'Face ID';
}
- if (
- supportedOSAuthenticationTypes.includes(AuthenticationType.FINGERPRINT)
- ) {
+ if (supportedBiometricTypes.includes(AuthenticationType.FINGERPRINT)) {
return 'Touch ID';
}
- } else {
- // Android uses generic "Biometrics" label
- return 'Biometrics';
}
+ return 'Device Authentication';
}
-
- // Priority 3: Device passcode (if available)
- if (passcodeAvailable) {
- return Platform.OS === 'ios' ? 'Device Passcode' : 'Device PIN/Pattern';
+ if (legacyUserChosePasscode) {
+ // Show explicit authentication type for legacy passcode
+ return Platform.OS === 'ios' ? 'Device Passcode' : 'Device Authentication';
}
-
- // Priority 4: No OS authentication available
- return '';
+ if (isBiometricsAvailable || passcodeAvailable) {
+ // Modernized authentication access allows for both biometrics and passcode
+ // Here we return the generic "Device Authentication" label since the system will handle access control to use
+ return 'Device Authentication';
+ }
+ return 'Password';
};
/**
diff --git a/app/core/SecureKeychain.test.ts b/app/core/SecureKeychain.test.ts
index 64077363b453..ded505421c9f 100644
--- a/app/core/SecureKeychain.test.ts
+++ b/app/core/SecureKeychain.test.ts
@@ -22,6 +22,7 @@ jest.mock('react-native-keychain', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
},
ACCESS_CONTROL: {
+ BIOMETRY_ANY_OR_DEVICE_PASSCODE: 'BIOMETRY_ANY_OR_DEVICE_PASSCODE',
BIOMETRY_CURRENT_SET: 'BIOMETRY_CURRENT_SET',
DEVICE_PASSCODE: 'DEVICE_PASSCODE',
},
@@ -60,58 +61,84 @@ describe('SecureKeychain - setGenericPassword', () => {
SecureKeychain.init('test_salt');
});
- it('should set biometric authentication correctly', async () => {
+ it('should set device authentication correctly when type is BIOMETRIC', async () => {
await SecureKeychain.setGenericPassword(
mockPassword,
- SecureKeychain.TYPES.BIOMETRICS,
+ AUTHENTICATION_TYPE.BIOMETRIC,
);
expect(Keychain.setGenericPassword).toHaveBeenCalledWith(
'metamask-user',
expect.any(String),
expect.objectContaining({
- accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
- storage: Keychain.STORAGE_TYPE.AES_GCM,
+ accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
+ accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
}),
);
expect(mockAddTraitsToUser).toHaveBeenCalledWith(
expect.objectContaining({
[UserProfileProperty.AUTHENTICATION_TYPE]:
- AUTHENTICATION_TYPE.BIOMETRIC,
+ AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
}),
);
});
- it('should set passcode authentication correctly', async () => {
+ it('should set device authentication correctly when type is PASSCODE', async () => {
await SecureKeychain.setGenericPassword(
mockPassword,
- SecureKeychain.TYPES.PASSCODE,
+ AUTHENTICATION_TYPE.PASSCODE,
);
expect(Keychain.setGenericPassword).toHaveBeenCalledWith(
'metamask-user',
expect.any(String),
expect.objectContaining({
- accessControl: Keychain.ACCESS_CONTROL.DEVICE_PASSCODE,
- storage: Keychain.STORAGE_TYPE.AES_GCM,
+ accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
+ accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
+ }),
+ );
+
+ expect(mockAddTraitsToUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ [UserProfileProperty.AUTHENTICATION_TYPE]:
+ AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
}),
);
});
- it('should set remember me correctly', async () => {
+ it('should set device authentication correctly when type is DEVICE_AUTHENTICATION', async () => {
await SecureKeychain.setGenericPassword(
mockPassword,
- SecureKeychain.TYPES.REMEMBER_ME,
+ AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
);
expect(Keychain.setGenericPassword).toHaveBeenCalledWith(
'metamask-user',
expect.any(String),
- expect.not.objectContaining({
- accessControl: expect.anything(),
+ expect.objectContaining({
+ accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
+ accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
}),
);
+
+ expect(mockAddTraitsToUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ [UserProfileProperty.AUTHENTICATION_TYPE]:
+ AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
+ }),
+ );
+ });
+
+ it('should reset password when type is PASSWORD', async () => {
+ const resetSpy = jest.spyOn(SecureKeychain, 'resetGenericPassword');
+ await SecureKeychain.setGenericPassword(
+ mockPassword,
+ AUTHENTICATION_TYPE.PASSWORD,
+ );
+
+ expect(resetSpy).toHaveBeenCalled();
+ expect(Keychain.setGenericPassword).not.toHaveBeenCalled();
});
it('should reset password when no type is provided', async () => {
diff --git a/app/core/SecureKeychain.ts b/app/core/SecureKeychain.ts
index 0dcbb571cd38..05b4833e1835 100644
--- a/app/core/SecureKeychain.ts
+++ b/app/core/SecureKeychain.ts
@@ -1,25 +1,23 @@
+import { Platform } from 'react-native';
import * as Keychain from 'react-native-keychain'; // eslint-disable-line import/no-namespace
import { Encryptor, LEGACY_DERIVATION_OPTIONS } from './Encryptor';
import { strings } from '../../locales/i18n';
import { MetaMetricsEvents, MetaMetrics } from './Analytics';
import Device from '../util/device';
+import AUTHENTICATION_TYPE from '../constants/userProperties';
+import { UserProfileProperty } from '../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types';
+import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder';
const privates = new WeakMap();
const encryptor = new Encryptor({
keyDerivationOptions: LEGACY_DERIVATION_OPTIONS,
});
-const defaultOptions = {
+// Default options used for storing credentials in the keychain
+// Do not re-use for other scopes unless you know what you are doing
+const defaultCredentialsOptions: Keychain.SetOptions = {
service: 'com.metamask',
- authenticationPromptTitle: strings('authentication.auth_prompt_title'),
authenticationPrompt: { title: strings('authentication.auth_prompt_desc') },
- authenticationPromptDesc: strings('authentication.auth_prompt_desc'),
- fingerprintPromptTitle: strings('authentication.fingerprint_prompt_title'),
- fingerprintPromptDesc: strings('authentication.fingerprint_prompt_desc'),
- fingerprintPromptCancel: strings('authentication.fingerprint_prompt_cancel'),
};
-import AUTHENTICATION_TYPE from '../constants/userProperties';
-import { UserProfileProperty } from '../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types';
-import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder';
enum SecureKeychainTypes {
BIOMETRICS = 'BIOMETRICS',
@@ -139,7 +137,7 @@ const SecureKeychain = {
},
async resetGenericPassword() {
- const options = { service: defaultOptions.service };
+ const options = { service: defaultCredentialsOptions.service };
// This is called to remove other auth types and set the user back to the default password login
await MetaMetrics.getInstance().addTraitsToUser({
[UserProfileProperty.AUTHENTICATION_TYPE]: AUTHENTICATION_TYPE.PASSWORD,
@@ -151,8 +149,15 @@ const SecureKeychain = {
if (instance) {
try {
instance.isAuthenticating = true;
- const keychainObject =
- await Keychain.getGenericPassword(defaultOptions);
+ const keychainObject = await Keychain.getGenericPassword({
+ ...defaultCredentialsOptions,
+ // Access control is only used by Android when requesting device authentication
+ // For iOS, the access control is derived from the access control when the password was stored
+ accessControl:
+ Platform.OS === 'android'
+ ? Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE
+ : undefined,
+ });
if (keychainObject && keychainObject.password) {
const encryptedPassword = keychainObject.password;
const decrypted = await instance.decryptPassword(encryptedPassword);
@@ -169,45 +174,43 @@ const SecureKeychain = {
return null;
},
- async setGenericPassword(password: string, type?: SecureKeychainTypes) {
+ async setGenericPassword(password: string, type?: AUTHENTICATION_TYPE) {
const authOptions: Keychain.SetOptions = {
+ // Keychain is accessible only when device is unlocked
+ // Items with this accessible level will not migrate to a new device
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
};
const metrics = MetaMetrics.getInstance();
- if (type === this.TYPES.BIOMETRICS) {
- authOptions.accessControl = Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET;
- // Android requires this storage type so that the access control is enforced.
- authOptions.storage = Keychain.STORAGE_TYPE.AES_GCM;
+ // TODO: Remove biometric and passcode types once we have removed the legacy authentication types
+ if (
+ type === AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION ||
+ type === AUTHENTICATION_TYPE.BIOMETRIC ||
+ type === AUTHENTICATION_TYPE.PASSCODE
+ ) {
+ authOptions.accessControl =
+ Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE;
await metrics.addTraitsToUser({
[UserProfileProperty.AUTHENTICATION_TYPE]:
- AUTHENTICATION_TYPE.BIOMETRIC,
+ AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION,
});
- } else if (type === this.TYPES.PASSCODE) {
- authOptions.accessControl = Keychain.ACCESS_CONTROL.DEVICE_PASSCODE;
- // Android requires this storage type so that the access control is enforced.
- authOptions.storage = Keychain.STORAGE_TYPE.AES_GCM;
- await metrics.addTraitsToUser({
- [UserProfileProperty.AUTHENTICATION_TYPE]: AUTHENTICATION_TYPE.PASSCODE,
- });
- } else if (type === this.TYPES.REMEMBER_ME) {
- await metrics.addTraitsToUser({
- [UserProfileProperty.AUTHENTICATION_TYPE]:
- AUTHENTICATION_TYPE.REMEMBER_ME,
- });
- } else {
- // Setting a password without a type does not save it
- return await this.resetGenericPassword();
- }
+ const encryptedPassword = await instance.encryptPassword(password);
- const encryptedPassword = await instance.encryptPassword(password);
+ return await Keychain.setGenericPassword(
+ 'metamask-user',
+ encryptedPassword,
+ {
+ ...defaultCredentialsOptions,
+ ...authOptions,
+ },
+ );
+ }
- await Keychain.setGenericPassword('metamask-user', encryptedPassword, {
- ...defaultOptions,
- ...authOptions,
- });
+ // Reset password if no type is provided
+ // Ex. Password auth type does not store anything in the keychain
+ return await this.resetGenericPassword();
},
ACCESS_CONTROL: Keychain.ACCESS_CONTROL,
diff --git a/app/store/migrations/120.test.ts b/app/store/migrations/120.test.ts
new file mode 100644
index 000000000000..019ca117a9ec
--- /dev/null
+++ b/app/store/migrations/120.test.ts
@@ -0,0 +1,134 @@
+import { captureException } from '@sentry/react-native';
+import { ensureValidState } from './util';
+import StorageWrapper from '../storage-wrapper';
+import migrate, { migrationVersion } from './120';
+import {
+ BIOMETRY_CHOICE_DISABLED,
+ PASSCODE_DISABLED,
+ TRUE,
+} from '../../constants/storage';
+
+jest.mock('@sentry/react-native', () => ({
+ captureException: jest.fn(),
+}));
+
+jest.mock('./util', () => ({
+ ensureValidState: jest.fn(),
+}));
+
+jest.mock('../storage-wrapper', () => ({
+ getItem: jest.fn(),
+}));
+
+const mockedCaptureException = jest.mocked(captureException);
+const mockedEnsureValidState = jest.mocked(ensureValidState);
+const mockedStorageWrapper = jest.mocked(StorageWrapper);
+
+describe(`Migration ${migrationVersion}: Derive osAuthEnabled from existing auth preferences`, () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('sets osAuthEnabled to false when allowLoginWithRememberMe is true', async () => {
+ const state = { security: { allowLoginWithRememberMe: true } };
+ mockedEnsureValidState.mockReturnValue(true);
+
+ const migratedState = await migrate(state);
+
+ expect(migratedState).toStrictEqual({
+ security: { allowLoginWithRememberMe: true, osAuthEnabled: false },
+ });
+ expect(mockedStorageWrapper.getItem).not.toHaveBeenCalled();
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+
+ it('sets osAuthEnabled to true when biometrics is enabled (BIOMETRY_CHOICE_DISABLED is null)', async () => {
+ const state = { security: { allowLoginWithRememberMe: false } };
+ mockedEnsureValidState.mockReturnValue(true);
+ mockedStorageWrapper.getItem
+ .mockResolvedValueOnce(null) // BIOMETRY_CHOICE_DISABLED - not set
+ .mockResolvedValueOnce(TRUE); // PASSCODE_DISABLED - set
+
+ const migratedState = await migrate(state);
+
+ expect(migratedState).toStrictEqual({
+ security: { allowLoginWithRememberMe: false, osAuthEnabled: true },
+ });
+ expect(mockedStorageWrapper.getItem).toHaveBeenCalledWith(
+ BIOMETRY_CHOICE_DISABLED,
+ );
+ expect(mockedStorageWrapper.getItem).toHaveBeenCalledWith(
+ PASSCODE_DISABLED,
+ );
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+
+ it('sets osAuthEnabled to true when passcode is enabled (PASSCODE_DISABLED is null)', async () => {
+ const state = { security: {} }; // allowLoginWithRememberMe is undefined
+ mockedEnsureValidState.mockReturnValue(true);
+ mockedStorageWrapper.getItem
+ .mockResolvedValueOnce(TRUE) // BIOMETRY_CHOICE_DISABLED - set
+ .mockResolvedValueOnce(null); // PASSCODE_DISABLED - not set
+
+ const migratedState = await migrate(state);
+
+ expect(migratedState).toStrictEqual({
+ security: { osAuthEnabled: true },
+ });
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+
+ it('sets osAuthEnabled to false when both legacy flags are unset (no legacy preference)', async () => {
+ const state = { security: {} };
+ mockedEnsureValidState.mockReturnValue(true);
+ mockedStorageWrapper.getItem
+ .mockResolvedValueOnce(null) // BIOMETRY_CHOICE_DISABLED - not set
+ .mockResolvedValueOnce(null); // PASSCODE_DISABLED - not set
+
+ const migratedState = await migrate(state);
+
+ // Neither legacyUserChoseBiometrics nor legacyUserChosePasscode is true when both flags are null
+ expect(migratedState).toStrictEqual({
+ security: { osAuthEnabled: false },
+ });
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+
+ describe('error handling', () => {
+ it('returns state unchanged and captures exception when security object does not exist', async () => {
+ const state = { other: 'data' };
+ mockedEnsureValidState.mockReturnValue(true);
+
+ const migratedState = await migrate(state);
+
+ // State is unchanged; migration throws when accessing security.allowLoginWithRememberMe
+ expect(migratedState).toStrictEqual(state);
+ expect(mockedCaptureException).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringContaining(
+ `Migration ${migrationVersion}: Failed to migrate osAuthEnabled`,
+ ),
+ }),
+ );
+ expect(mockedStorageWrapper.getItem).not.toHaveBeenCalled();
+ });
+
+ it('captures exception and returns state unchanged when storage read fails', async () => {
+ const state = { security: {} };
+ const error = new Error('Storage error');
+ mockedEnsureValidState.mockReturnValue(true);
+ mockedStorageWrapper.getItem.mockRejectedValueOnce(error);
+
+ const migratedState = await migrate(state);
+
+ expect(migratedState).toStrictEqual(state);
+ expect(mockedCaptureException).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringContaining(
+ `Migration ${migrationVersion}: Failed to migrate osAuthEnabled`,
+ ),
+ }),
+ );
+ });
+ });
+});
diff --git a/app/store/migrations/120.ts b/app/store/migrations/120.ts
new file mode 100644
index 000000000000..f70dbd2e26d3
--- /dev/null
+++ b/app/store/migrations/120.ts
@@ -0,0 +1,70 @@
+import { ensureValidState } from './util';
+import { captureException } from '@sentry/react-native';
+import StorageWrapper from '../storage-wrapper';
+import {
+ BIOMETRY_CHOICE_DISABLED,
+ PASSCODE_DISABLED,
+ TRUE,
+} from '../../constants/storage';
+
+export const migrationVersion = 120;
+
+interface MigrationState {
+ security: {
+ allowLoginWithRememberMe: boolean;
+ osAuthEnabled: boolean;
+ };
+}
+
+/**
+ * Migration 120: Derive osAuthEnabled from existing auth preferences
+ *
+ * @param state - The persisted Redux state
+ * @returns The migrated Redux state
+ */
+const migration = async (state: unknown): Promise => {
+ if (!ensureValidState(state, migrationVersion)) {
+ return state;
+ }
+
+ const typedState = state as unknown as MigrationState;
+
+ try {
+ // 1. If Remember Me is on, osAuthEnabled should be false
+ if (typedState.security.allowLoginWithRememberMe) {
+ typedState.security.osAuthEnabled = false;
+ return state;
+ }
+
+ // 2. Derive from legacy storage flags
+ const biometryChoiceDisabled = await StorageWrapper.getItem(
+ BIOMETRY_CHOICE_DISABLED,
+ );
+ const passcodeDisabled = await StorageWrapper.getItem(PASSCODE_DISABLED);
+
+ // Legacy user preference selected device biometrics
+ const legacyUserChoseBiometrics =
+ passcodeDisabled === TRUE && !biometryChoiceDisabled;
+
+ // Legacy user preference selected device passcode
+ const legacyUserChosePasscode =
+ biometryChoiceDisabled === TRUE && !passcodeDisabled;
+
+ // If either flag doesn't exist (null/undefined), osAuthEnabled is true
+ // Only false if BOTH exist and are truthy (both disabled)
+ const osAuthEnabled = legacyUserChoseBiometrics || legacyUserChosePasscode;
+
+ typedState.security.osAuthEnabled = osAuthEnabled;
+ } catch (error) {
+ // Migration failures should not break the app
+ captureException(
+ new Error(
+ `Migration ${migrationVersion}: Failed to migrate osAuthEnabled: ${error}`,
+ ),
+ );
+ }
+
+ return state;
+};
+
+export default migration;
diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts
index 4e3546baeb00..28b879b4b661 100644
--- a/app/store/migrations/index.ts
+++ b/app/store/migrations/index.ts
@@ -120,6 +120,7 @@ import migration116 from './116';
import migration117 from './117';
import migration118 from './118';
import migration119 from './119';
+import migration120 from './120';
// Add migrations above this line
import { ControllerStorage } from '../persistConfig';
@@ -259,6 +260,7 @@ export const migrationList: MigrationsList = {
117: migration117,
118: migration118,
119: migration119,
+ 120: migration120,
};
// Enable both synchronous and asynchronous migrations
diff --git a/bitrise.yml b/bitrise.yml
index dbb2aa0b3bc0..fefd5a029f4c 100644
--- a/bitrise.yml
+++ b/bitrise.yml
@@ -3516,13 +3516,13 @@ app:
PROJECT_LOCATION_IOS: ios
- opts:
is_expand: false
- VERSION_NAME: 7.67.0
+ VERSION_NAME: 7.68.0
- opts:
is_expand: false
VERSION_NUMBER: 3607
- opts:
is_expand: false
- FLASK_VERSION_NAME: 7.67.0
+ FLASK_VERSION_NAME: 7.68.0
- opts:
is_expand: false
FLASK_VERSION_NUMBER: 3607
diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj
index 528c2c4320bf..de69b6709913 100644
--- a/ios/MetaMask.xcodeproj/project.pbxproj
+++ b/ios/MetaMask.xcodeproj/project.pbxproj
@@ -1319,7 +1319,7 @@
"${inherited}",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.67.0;
+ MARKETING_VERSION = 7.68.0;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = (
@@ -1385,7 +1385,7 @@
"${inherited}",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.67.0;
+ MARKETING_VERSION = 7.68.0;
ONLY_ACTIVE_ARCH = NO;
OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = (
@@ -1454,7 +1454,7 @@
"\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.67.0;
+ MARKETING_VERSION = 7.68.0;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = (
@@ -1518,7 +1518,7 @@
"\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.67.0;
+ MARKETING_VERSION = 7.68.0;
ONLY_ACTIVE_ARCH = NO;
OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = (
@@ -1684,7 +1684,7 @@
"\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.67.0;
+ MARKETING_VERSION = 7.68.0;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = (
"$(inherited)",
@@ -1751,7 +1751,7 @@
"\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.67.0;
+ MARKETING_VERSION = 7.68.0;
ONLY_ACTIVE_ARCH = NO;
OTHER_CFLAGS = (
"$(inherited)",
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 7e3b083411e0..57599fff53c4 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -2890,6 +2890,8 @@
"state_logs": "State logs",
"add_network_title": "Add a network",
"auto_lock": "Auto-lock",
+ "enable_device_authentication": "Enable Device Authentication",
+ "enable_device_authentication_desc": "Use your device’s biometrics or passcode to unlock MetaMask.",
"auto_lock_desc": "Choose the amount of time before the application automatically locks.",
"state_logs_desc": "This will help MetaMask debug any issue you might encounter. Please send it to MetaMask support via hamburger icon > Send Feedback, or reply to your existing ticket if you have one.",
"autolock_immediately": "Immediately",
@@ -4189,11 +4191,7 @@
"enable_device_passcode_android": "Unlock with device PIN?"
},
"authentication": {
- "auth_prompt_title": "Authentication required",
- "auth_prompt_desc": "Please authenticate in order to use MetaMask",
- "fingerprint_prompt_title": "Authentication required",
- "fingerprint_prompt_desc": "Use your fingerprint to unlock MetaMask",
- "fingerprint_prompt_cancel": "Cancel"
+ "auth_prompt_desc": "Please authenticate in order to use MetaMask"
},
"accountApproval": {
"title": "CONNECT REQUEST",
@@ -5538,10 +5536,10 @@
"enable_remember_me_description": "When Remember me is on, anyone with access to your phone can access your MetaMask account."
},
"turn_off_remember_me": {
- "title": "Enter your password to turn off Remember me",
- "placeholder": "Password",
- "description": "If you turn this option off, you'll need your password to unlock MetaMask from now on.",
- "action": "Turn off Remember me"
+ "title": "Turn off Remember Me",
+ "placeholder": "Confirm password",
+ "description": "Once turned off, Remember Me can't be used again. This feature has been discontinued, so you can unlock MetaMask with your password or biometrics instead.",
+ "action": "Turn off Remember Me"
},
"dapp_connect": {
"warning": "In order to use this feature, please update the app to the newest version"
diff --git a/package.json b/package.json
index eafb3ce84b6b..5d88a0f52112 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metamask",
- "version": "7.67.0",
+ "version": "7.68.0",
"private": true,
"scripts": {
"install:foundryup": "yarn mm-foundryup",