Allow biometric and PIN unlock options to be enabled at the same time (#1562)

This commit is contained in:
Leendert de Borst
2026-01-31 20:09:34 +01:00
committed by Leendert de Borst
parent b9791a0563
commit d2e7e29b50
3 changed files with 136 additions and 63 deletions

View File

@@ -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.

View File

@@ -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<void> => {
router.replace('/reinitialize');
const performPinUnlock = async () : Promise<boolean> => {
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<void> => {
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<void> => {
setIsLoading(true);
setError(null);
await performPinUnlock();
setIsLoading(false);
};
const styles = StyleSheet.create({
@@ -396,7 +464,7 @@ export default function UnlockScreen() : React.ReactNode {
{isBiometricsAvailable && (
<RobustPressable
style={styles.faceIdButton}
onPress={handleUnlockRetry}
onPress={handleBiometricRetry}
>
<ThemedText style={styles.faceIdButtonText}>{t('auth.tryBiometricAgain', { biometric: biometricDisplayName })}</ThemedText>
</RobustPressable>
@@ -406,7 +474,7 @@ export default function UnlockScreen() : React.ReactNode {
{pinAvailable && (
<RobustPressable
style={styles.faceIdButton}
onPress={handleUnlockRetry}
onPress={handlePinRetry}
>
<ThemedText style={styles.faceIdButtonText}>{t('auth.tryPinAgain')}</ThemedText>
</RobustPressable>

View File

@@ -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<UnlockResult> 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<UnlockResult> indicating success/failure
*/
private static async attemptPinUnlock(): Promise<UnlockResult> {
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.