diff --git a/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx b/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx index 6d215e7a4..f284754b7 100644 --- a/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx +++ b/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx @@ -133,16 +133,10 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { return; } - // If enabling biometrics and PIN is enabled, disable PIN first - if (value && pinEnabled) { - try { - await NativeVaultManager.removeAndDisablePin(); - setPinEnabled(false); - } catch (error) { - console.error('Failed to disable PIN:', error); - } - } - + /* + * Biometrics and PIN can now both be enabled simultaneously. + * Biometrics takes priority during unlock, PIN serves as fallback. + */ setIsBiometricsEnabled(value); setAuthMethods(value ? ['faceid', 'password'] : ['password']); @@ -155,7 +149,7 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { visibilityTime: 1200, }); } - }, [hasBiometrics, pinEnabled, setAuthMethods, biometricDisplayName, showDialog, t]); + }, [hasBiometrics, setAuthMethods, biometricDisplayName, showDialog, t]); /** * Handle enable PIN - launches native PIN setup UI. @@ -165,12 +159,10 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { // Launch native PIN setup UI await NativeVaultManager.showPinSetup(); - // PIN setup successful - now disable biometrics if it was enabled - if (isBiometricsEnabled) { - setIsBiometricsEnabled(false); - await setAuthMethods(['password']); - } - + /* + * PIN and biometrics can now both be enabled simultaneously. + * Biometrics takes priority during unlock, PIN serves as fallback. + */ setPinEnabled(true); Toast.show({ type: 'success', @@ -188,7 +180,7 @@ export default function VaultUnlockSettingsScreen() : React.ReactNode { console.error('Failed to enable PIN:', error); showAlert(t('common.error'), t('common.errors.unknownErrorTryAgain')); } - }, [isBiometricsEnabled, setAuthMethods, showAlert, t]); + }, [showAlert, t]); /** * Handle disable PIN. diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 352a65889..094c7d227 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -168,10 +168,78 @@ export default function UnlockScreen() : React.ReactNode { }; /** - * Handle the biometrics retry. + * Internal PIN unlock handler - performs PIN unlock and navigates on success. + * Returns true if unlock succeeded, false otherwise. */ - const handleUnlockRetry = async () : Promise => { - router.replace('/reinitialize'); + const performPinUnlock = async () : Promise => { + try { + await NativeVaultManager.showPinUnlock(); + + // Check if vault is now unlocked + const isNowUnlocked = await NativeVaultManager.isVaultUnlocked(); + if (isNowUnlocked) { + // Check if the vault is up to date + if (await dbContext.hasPendingMigrations()) { + router.replace('/upgrade'); + return true; + } + router.replace('/reinitialize'); + return true; + } + // Not unlocked means user cancelled - return false but don't show error + return false; + } catch (err) { + // User cancelled or PIN unlock failed + const errorMessage = err instanceof Error ? err.message : ''; + if (!errorMessage.includes('cancelled') && !errorMessage.includes('canceled')) { + console.error('PIN unlock error:', err); + setError(t('auth.errors.pinFailed')); + } + return false; + } + }; + + /** + * Handle the biometrics retry - directly triggers biometric unlock. + * If biometric fails and PIN is available, automatically falls back to PIN. + */ + const handleBiometricRetry = async () : Promise => { + setIsLoading(true); + setError(null); + try { + const unlocked = await dbContext.unlockVault(); + if (unlocked) { + // Check if the vault is up to date + if (await dbContext.hasPendingMigrations()) { + router.replace('/upgrade'); + return; + } + router.replace('/reinitialize'); + } else { + // Biometric failed - try PIN fallback if available + if (pinAvailable) { + await performPinUnlock(); + } + } + } catch (err) { + console.error('Biometric retry error:', err); + // Biometric failed - try PIN fallback if available + if (pinAvailable) { + await performPinUnlock(); + } + } finally { + setIsLoading(false); + } + }; + + /** + * Handle the PIN retry button - directly triggers PIN unlock flow. + */ + const handlePinRetry = async () : Promise => { + setIsLoading(true); + setError(null); + await performPinUnlock(); + setIsLoading(false); }; const styles = StyleSheet.create({ @@ -396,7 +464,7 @@ export default function UnlockScreen() : React.ReactNode { {isBiometricsAvailable && ( {t('auth.tryBiometricAgain', { biometric: biometricDisplayName })} @@ -406,7 +474,7 @@ export default function UnlockScreen() : React.ReactNode { {pinAvailable && ( {t('auth.tryPinAgain')} diff --git a/apps/mobile-app/utils/VaultUnlockHelper.ts b/apps/mobile-app/utils/VaultUnlockHelper.ts index 46e38a56f..489be787e 100644 --- a/apps/mobile-app/utils/VaultUnlockHelper.ts +++ b/apps/mobile-app/utils/VaultUnlockHelper.ts @@ -15,7 +15,8 @@ export type UnlockResult = { export class VaultUnlockHelper { /** * Attempt to unlock the vault using available authentication methods. - * Tries biometric first (if available), then PIN (if enabled), otherwise indicates manual unlock needed. + * Priority: Biometric -> PIN -> Manual unlock + * If biometric fails/is cancelled and PIN is enabled, automatically falls back to PIN. * * @param params Configuration for unlock attempt * @returns Promise indicating success/failure and any actions needed @@ -34,52 +35,32 @@ export class VaultUnlockHelper { if (isFaceIDEnabled) { try { const isUnlocked = await unlockVault(); - if (!isUnlocked) { - return { - success: false, - error: 'Biometric unlock failed', - redirectToUnlock: true, - }; + if (isUnlocked) { + return { success: true }; } - return { success: true }; + // Biometric failed - fall through to PIN fallback below + console.log('Biometric unlock returned false, trying PIN fallback if available'); } catch (error) { + // Biometric error - fall through to PIN fallback below console.error('Biometric unlock error:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Biometric unlock failed', - redirectToUnlock: true, - }; } + + // Biometric failed or was cancelled - try PIN fallback if available + if (isPinEnabled) { + return this.attemptPinUnlock(); + } + + // No PIN fallback available + return { + success: false, + error: 'Biometric unlock failed', + redirectToUnlock: true, + }; } - // Try PIN unlock if biometric is not available + // Biometric not enabled - try PIN unlock directly if (isPinEnabled) { - try { - await NativeVaultManager.showPinUnlock(); - - // Verify vault is now unlocked - const isNowUnlocked = await NativeVaultManager.isVaultUnlocked(); - if (!isNowUnlocked) { - return { - success: false, - error: 'PIN unlock failed', - redirectToUnlock: true, - }; - } - return { success: true }; - } catch (error) { - // User cancelled or PIN unlock failed - // Only log non-cancellation errors to reduce noise - const errorMessage = error instanceof Error ? error.message : 'PIN unlock failed or cancelled'; - if (!errorMessage.includes('cancelled')) { - console.error('PIN unlock error:', error); - } - return { - success: false, - error: errorMessage, - redirectToUnlock: true, - }; - } + return this.attemptPinUnlock(); } // No automatic unlock method available - manual unlock required @@ -90,6 +71,38 @@ export class VaultUnlockHelper { }; } + /** + * Attempt PIN unlock. + * @returns Promise indicating success/failure + */ + private static async attemptPinUnlock(): Promise { + try { + await NativeVaultManager.showPinUnlock(); + + // Verify vault is now unlocked + const isNowUnlocked = await NativeVaultManager.isVaultUnlocked(); + if (!isNowUnlocked) { + return { + success: false, + error: 'PIN unlock failed', + redirectToUnlock: true, + }; + } + return { success: true }; + } catch (error) { + // User cancelled or PIN unlock failed + const errorMessage = error instanceof Error ? error.message : 'PIN unlock failed or cancelled'; + if (!errorMessage.includes('cancelled') && !errorMessage.includes('canceled')) { + console.error('PIN unlock error:', error); + } + return { + success: false, + error: errorMessage, + redirectToUnlock: true, + }; + } + } + /** * Authenticate user for a specific action (e.g., mobile unlock confirmation). * Uses the native authenticateUser which automatically detects and uses the appropriate method.