Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,6 @@ const ONYXKEYS = {
/** A unique ID for the device */
DEVICE_ID: 'deviceID',

/** Holds information about device-specific biometrics which:
* - does need to be persisted
* - does not need to be kept in secure storage
* - does not persist across uninstallations
* (secure storage persists across uninstallation)
*/
DEVICE_BIOMETRICS: 'deviceBiometrics',

/** Boolean flag set whenever the sidebar has loaded */
IS_SIDEBAR_LOADED: 'isSidebarLoaded',

Expand Down Expand Up @@ -811,6 +803,13 @@ const ONYXKEYS = {
* Key format: passkey_${userId}
*/
PASSKEY_CREDENTIALS: 'passkeyCredentials_',

/** Holds information about device-specific biometrics which:
* - does need to be persisted across logout (but not uninstallation)
* - does not need to be kept in secure storage
* Key format: deviceBiometrics_${accountID}
*/
DEVICE_BIOMETRICS: 'deviceBiometrics_',
},

/** List of Form ids */
Expand Down Expand Up @@ -1247,6 +1246,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.DOMAIN_ERRORS]: OnyxTypes.DomainErrors;
[ONYXKEYS.COLLECTION.CODING_RULE_MATCHING_TRANSACTION]: OnyxTypes.CodingRuleMatchingTransaction;
[ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS]: OnyxTypes.LocalPasskeyCredentialsEntry;
[ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS]: OnyxTypes.DeviceBiometrics;
};

type OnyxValuesMapping = {
Expand Down Expand Up @@ -1475,7 +1475,6 @@ type OnyxValuesMapping = {
[ONYXKEYS.IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN]: boolean;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
[ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record<string, Record<string, boolean>>;
[ONYXKEYS.DEVICE_BIOMETRICS]: OnyxTypes.DeviceBiometrics;
};

type OnyxDerivedValuesMapping = {
Expand Down
29 changes: 16 additions & 13 deletions src/components/MultifactorAuthentication/Context/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,21 @@ import useBiometrics from '@components/MultifactorAuthentication/biometrics/useB
import type {MultifactorAuthenticationScenario, MultifactorAuthenticationScenarioParams} from '@components/MultifactorAuthentication/config/types';
import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs';
import trackMFAFlowOutcome from '@components/MultifactorAuthentication/observability/trackMFAFlowOutcome';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useNetwork from '@hooks/useNetwork';
import {requestValidateCodeAction} from '@libs/actions/User';
import getPlatform from '@libs/getPlatform';
import type {ChallengeType, MultifactorAuthenticationCallbackInput} from '@libs/MultifactorAuthentication/shared/types';
import Navigation from '@navigation/Navigation';
import {clearLocalMFAPublicKeyList, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
import {clearLocalMFAPublicKeyList, getDeviceBiometricsOnyxKey, requestAuthorizationChallenge, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication';
import {processRegistration, processScenarioAction} from '@userActions/MultifactorAuthentication/processing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {DeviceBiometrics} from '@src/types/onyx';
import {useMultifactorAuthenticationActions, useMultifactorAuthenticationState} from './State';

let deviceBiometricsState: OnyxEntry<DeviceBiometrics>;

// Use Onyx.connectWithoutView instead of useOnyx hook to access the device biometrics state.
// This is a non-reactive read that allows us to check the current value (hasAcceptedSoftPrompt)
// from within the process() callback without triggering calling it too many times during the
// fresh registration flow
Onyx.connectWithoutView({
key: ONYXKEYS.DEVICE_BIOMETRICS,
callback: (data) => {
deviceBiometricsState = data;
},
});

type ExecuteScenarioParams<T extends MultifactorAuthenticationScenario> = MultifactorAuthenticationScenarioParams<T>;

type MultifactorAuthenticationContextValue = {
Expand Down Expand Up @@ -71,10 +60,24 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent
const {dispatch} = useMultifactorAuthenticationActions();

const biometrics = useBiometrics();
const {accountID} = useCurrentUserPersonalDetails();
const {isOffline} = useNetwork();
const platform = getPlatform();
const promptType = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[biometrics.deviceVerificationType];

useEffect(() => {
// Non-reactive read of deviceBiometrics. Using Onyx.connectWithoutView (set up in a useEffect
// inside the provider) instead of useOnyx to avoid triggering process() too many times during
// the fresh registration flow.
const connection = Onyx.connectWithoutView({
key: getDeviceBiometricsOnyxKey(accountID),
callback: (data) => {
deviceBiometricsState = data;
},
});
return () => Onyx.disconnect(connection);
}, [accountID]);

Comment on lines +68 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Onyx.connectWithoutView in components is a massive anti-pattern, and if it’s possible for this code to work with useOnyx, it would definitely be better to use that instead.

In useEffect, the deps array isn’t fully populated anyway, so omitting one additional dependency shouldn’t be a problem.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I don’t think useOnyx would cause process() to trigger too frequently. Could you clarify in more detail why we shouldn’t use useOnyx?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Experimentally, it did though 🙈. The process function relies on being called precisely when it should be and never more, and adding a subscription to this flag caused one extra call when we set the flag.

@chuckdries chuckdries Mar 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it feels icky, but we've already talked about refactoring this file and agreed it's not a short term priority (connectWithoutView was the pattern already established in this file)

/**
* Handles the completion of a multifactor authentication scenario.
* Invokes the scenario's callback function and navigates to the appropriate outcome screen.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import type DotLottieAnimation from '@components/LottieAnimations/types';
import useBiometrics from '@components/MultifactorAuthentication/biometrics/useBiometrics';
import {MULTIFACTOR_AUTHENTICATION_PROMPT_UI} from '@components/MultifactorAuthentication/config';
import type {MultifactorAuthenticationPromptType} from '@components/MultifactorAuthentication/config/types';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useOnyx from '@hooks/useOnyx';
import {getDeviceBiometricsOnyxKey} from '@libs/actions/MultifactorAuthentication';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type IconAsset from '@src/types/utils/IconAsset';
import {useMultifactorAuthenticationState} from './State';

Expand All @@ -29,8 +30,9 @@ type PromptContent = {
function usePromptContent(promptType: MultifactorAuthenticationPromptType): PromptContent {
const state = useMultifactorAuthenticationState();
const {areLocalCredentialsKnownToServer} = useBiometrics();
const {accountID} = useCurrentUserPersonalDetails();
const [serverHasCredentials, setServerHasCredentials] = useState(false);
const [deviceBiometricsState] = useOnyx(ONYXKEYS.DEVICE_BIOMETRICS);
const [deviceBiometricsState] = useOnyx(getDeviceBiometricsOnyxKey(accountID));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Migrate legacy soft-prompt state to account-scoped key

This read now only checks deviceBiometrics_${accountID}, but the previous value was stored under the legacy non-collection key, so existing users who already accepted the soft prompt will be treated as first-time until they re-approve. In practice, the first MFA authorization after this upgrade can show an unnecessary soft prompt even without reinstall/logout. Add a migration or fallback read from the legacy key during the transition.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is known and acceptable - this is not a commonly used feature, as the main use for it is behind a beta, and the UX cost of re-registration is low. I don't think it's worth the complexity to add a migration.

const hasEverAcceptedSoftPrompt = deviceBiometricsState?.hasAcceptedSoftPrompt ?? false;

// We need to know if server has this device's credentials specifically
Expand Down
1 change: 1 addition & 0 deletions src/libs/actions/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [
ONYXKEYS.SHOULD_USE_STAGING_SERVER,
ONYXKEYS.IS_DEBUG_MODE_ENABLED,
ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS,
ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS,

// Preserve IS_USING_IMPORTED_STATE so that when the app restarts (especially in HybridApp mode),
// we know if we're in imported state mode and should skip API calls that would cause infinite loading
Expand Down
1 change: 1 addition & 0 deletions src/libs/actions/Delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [
ONYXKEYS.SHOULD_USE_STAGING_SERVER,
ONYXKEYS.IS_DEBUG_MODE_ENABLED,
ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS,
ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS,
];

type WithDelegatedAccess = {
Expand Down
9 changes: 7 additions & 2 deletions src/libs/actions/MultifactorAuthentication/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,12 @@ async function fireAndForgetDenyTransaction({transactionID}: DenyTransactionPara
);
}

function markHasAcceptedSoftPrompt() {
Onyx.merge(ONYXKEYS.DEVICE_BIOMETRICS, {
function getDeviceBiometricsOnyxKey(accountID: number): `${typeof ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS}${string}` {
return `${ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS}${accountID}`;
}

function markHasAcceptedSoftPrompt(accountID: number) {
Onyx.merge(getDeviceBiometricsOnyxKey(accountID), {
hasAcceptedSoftPrompt: true,
});
}
Expand All @@ -370,6 +374,7 @@ export {
requestAuthorizationChallenge,
troubleshootMultifactorAuthentication,
revokeMultifactorAuthenticationCredentials,
getDeviceBiometricsOnyxKey,
markHasAcceptedSoftPrompt,
clearLocalMFAPublicKeyList,
setPersonalDetailsAndShipExpensifyCardsWithPIN,
Expand Down
1 change: 1 addition & 0 deletions src/libs/actions/Session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
import type Session from '@src/types/onyx/Session';
import type {AutoAuthState} from '@src/types/onyx/Session';
import pkg from '../../../../package.json';
import {clearCachedAttachments} from '../Attachment';

Check warning on line 72 in src/libs/actions/Session/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unexpected parent import '../Attachment'. Use '@userActions/Attachment' instead
import clearCache from './clearCache';
import updateSessionAuthTokens from './updateSessionAuthTokens';

Expand All @@ -81,7 +81,7 @@
let isHybridAppSetupFinished = false;
let hasSwitchedAccountInHybridMode = false;

Onyx.connect({

Check warning on line 84 in src/libs/actions/Session/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
session = value ?? {};
Expand All @@ -106,19 +106,19 @@
});

let stashedSession: Session = {};
Onyx.connect({

Check warning on line 109 in src/libs/actions/Session/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.STASHED_SESSION,
callback: (value) => (stashedSession = value ?? {}),
});

let credentials: Credentials = {};
Onyx.connect({

Check warning on line 115 in src/libs/actions/Session/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.CREDENTIALS,
callback: (value) => (credentials = value ?? {}),
});

let stashedCredentials: Credentials = {};
Onyx.connect({

Check warning on line 121 in src/libs/actions/Session/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.STASHED_CREDENTIALS,
callback: (value) => (stashedCredentials = value ?? {}),
});
Expand Down Expand Up @@ -318,6 +318,7 @@
// we know if we're in imported state mode and should skip API calls that would cause infinite loading
ONYXKEYS.IS_USING_IMPORTED_STATE,
ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS,
ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS,
];

function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, shouldSignOutFromOldDot = true, shouldForceUseStashedSession?: boolean) {
Expand Down
1 change: 1 addition & 0 deletions src/libs/actions/SignInRedirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function clearStorageAndRedirect(errorMessage?: string): Promise<void> {
keysToPreserve.push(ONYXKEYS.SHOULD_USE_STAGING_SERVER);
keysToPreserve.push(ONYXKEYS.IS_DEBUG_MODE_ENABLED);
keysToPreserve.push(ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS);
keysToPreserve.push(ONYXKEYS.COLLECTION.DEVICE_BIOMETRICS);

// After signing out, set ourselves as offline if we were offline before logging out and we are not forcing it.
// If we are forcing offline, ignore it while signed out, otherwise it would require a refresh because there's no way to toggle the switch to go back online while signed out.
Expand Down
4 changes: 3 additions & 1 deletion src/pages/MultifactorAuthentication/PromptPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {DefaultCancelConfirmModal} from '@components/MultifactorAuthentication/c
import {useMultifactorAuthentication, useMultifactorAuthenticationActions, useMultifactorAuthenticationState, usePromptContent} from '@components/MultifactorAuthentication/Context';
import MultifactorAuthenticationPromptContent from '@components/MultifactorAuthentication/PromptContent';
import ScreenWrapper from '@components/ScreenWrapper';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -27,14 +28,15 @@ function MultifactorAuthenticationPromptPage({route}: MultifactorAuthenticationP
const {cancel} = useMultifactorAuthentication();
const state = useMultifactorAuthenticationState();
const {dispatch} = useMultifactorAuthenticationActions();
const {accountID} = useCurrentUserPersonalDetails();
const {isOffline} = useNetwork();

const {illustration, title, subtitle, shouldDisplayConfirmButton} = usePromptContent(route.params.promptType);

const [isCancelModalVisible, setCancelModalVisibility] = useState(false);

const onConfirm = () => {
markHasAcceptedSoftPrompt();
markHasAcceptedSoftPrompt(accountID);
dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true});
};

Expand Down
2 changes: 1 addition & 1 deletion src/types/onyx/DeviceBiometrics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** Holds information about device-specific biometrics which:
* - does need to be persisted
* - persists across logout/login (stored as a collection keyed by accountID)
* - does not need to be kept in secure storage
* - does not persist across uninstallations
* (secure storage persists across uninstallation)
Expand Down
Loading