Tweak re-authenticate flow with custom title/subtitle (#1347)

This commit is contained in:
Leendert de Borst
2025-11-17 10:54:54 +01:00
parent 5367c5eb34
commit 63cc511a9f
9 changed files with 124 additions and 70 deletions

View File

@@ -143,7 +143,7 @@ export default function SettingsLayout(): React.ReactNode {
<Stack.Screen
name="qr-result"
options={{
title: t('settings.qrScanner.mobileUnlock.successTitle'),
title: t('common.success'),
...defaultHeaderOptions,
}}
/>

View File

@@ -103,56 +103,43 @@ export default function QRConfirmScreen() : React.ReactNode {
setIsProcessing(true);
try {
let authenticated = false;
// Check if biometric or PIN is enabled
const authMethods = await NativeVaultManager.getAuthMethods();
const isPinEnabled = await NativeVaultManager.isPinEnabled();
const isBiometricEnabled = authMethods.includes('faceid');
// Check which authentication method is available
const pinEnabled = await NativeVaultManager.isPinEnabled();
if (pinEnabled) {
// PIN is enabled, use PIN unlock
try {
await NativeVaultManager.showPinUnlock();
authenticated = true;
} catch (pinError: unknown) {
// User cancelled PIN or PIN failed
console.error('PIN unlock cancelled or failed:', pinError);
Alert.alert(
t('common.error'),
t('settings.qrScanner.mobileUnlock.authenticationFailed')
);
setIsProcessing(false);
return;
}
} else {
// Try biometric authentication
try {
authenticated = await NativeVaultManager.authenticateUser(
t('settings.qrScanner.mobileUnlock.authenticationRequired')
);
} catch (authError: unknown) {
console.error('Biometric authentication error:', authError);
Alert.alert(
t('common.error'),
t('settings.qrScanner.mobileUnlock.authenticationFailed')
);
setIsProcessing(false);
return;
}
}
if (!authenticated) {
if (!isBiometricEnabled && !isPinEnabled) {
Alert.alert(
t('common.error'),
t('settings.qrScanner.mobileUnlock.authenticationFailed')
t('settings.qrScanner.mobileUnlock.noAuthMethodEnabled'),
[
{
text: t('common.ok'),
onPress: (): void => {
router.back();
},
},
]
);
setIsProcessing(false);
return;
}
// Authenticate user with either biometric or PIN (automatically detected)
const authenticated = await NativeVaultManager.authenticateUser(
t('settings.qrScanner.mobileUnlock.confirmTitle'),
t('settings.qrScanner.mobileUnlock.confirmSubtitle')
);
if (!authenticated) {
setIsProcessing(false);
return;
}
// Process the mobile unlock
await handleMobileUnlock(requestId);
} catch (error) {
console.error('QR code processing error:', error);
console.error('Authentication or QR code processing error:', error);
Alert.alert(
t('common.error'),
error instanceof Error ? error.message : t('common.errors.unknownError')

View File

@@ -88,7 +88,7 @@ export default function QRResultScreen() : React.ReactNode {
/>
<ThemedText style={styles.title}>
{isSuccess
? t('settings.qrScanner.mobileUnlock.successTitle')
? t('common.success')
: t('common.error')}
</ThemedText>
<ThemedText style={styles.message}>

View File

@@ -404,13 +404,13 @@
"cameraPermissionTitle": "Camera Permission Required",
"cameraPermissionMessage": "Please allow camera access to scan QR codes.",
"mobileUnlock": {
"confirmTitle": "Confirm Login",
"confirmTitle": "Confirm Login Request",
"confirmSubtitle": "Re-authenticate to approve login on another device.",
"confirmMessage": "You are about to log in on a remote device with your account. This other device will have full access to your vault. Only proceed if you trust this device.",
"successTitle": "Device Unlocked",
"successDescription": "The remote device has been successfully unlocked.",
"requestExpired": "This unlock request has expired. Please generate a new QR code.",
"successDescription": "The remote device has been successfully logged in.",
"requestExpired": "This login request has expired. Please generate a new QR code.",
"authenticationFailed": "Authentication failed. Please try again.",
"authenticationRequired": "Authentication required to confirm this action."
"noAuthMethodEnabled": "Biometric or PIN unlock needs to be enabled to unlock with mobile"
}
}
},

View File

@@ -281,8 +281,8 @@
// MARK: - Re-authentication
- (void)authenticateUser:(NSString *)reason resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager authenticateUser:reason resolver:resolve rejecter:reject];
- (void)authenticateUser:(NSString *)title subtitle:(NSString *)subtitle resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager authenticateUser:title subtitle:subtitle resolver:resolve rejecter:reject];
}
@end

View File

@@ -904,14 +904,69 @@ public class VaultManager: NSObject {
}
@objc
func authenticateUser(_ reason: String,
func authenticateUser(_ title: String?,
subtitle: String?,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
do {
let authenticated = try vaultStore.authenticateUser(reason: reason)
// Check if PIN is enabled first
let pinEnabled = vaultStore.isPinEnabled()
if pinEnabled {
// PIN is enabled, show PIN unlock UI
DispatchQueue.main.async { [weak self] in
guard let self = self else {
reject("INTERNAL_ERROR", "VaultManager instance deallocated", nil)
return
}
// Get the root view controller from React Native
guard let rootVC = RCTPresentedViewController() else {
reject("NO_VIEW_CONTROLLER", "No view controller available", nil)
return
}
// Create PIN unlock view with ViewModel
// Use custom title/subtitle if provided, otherwise use defaults
let customTitle = (title?.isEmpty == false) ? title : nil
let customSubtitle = (subtitle?.isEmpty == false) ? subtitle : nil
let viewModel = PinUnlockViewModel(
pinLength: self.vaultStore.getPinLength(),
customTitle: customTitle,
customSubtitle: customSubtitle,
unlockHandler: { [weak self] pin in
guard let self = self else {
throw NSError(domain: "VaultManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "VaultManager instance deallocated"])
}
// Unlock vault with PIN (just validates, doesn't store in memory)
_ = try self.vaultStore.unlockWithPin(pin)
// Success - dismiss and resolve
await MainActor.run {
rootVC.dismiss(animated: true) {
resolve(true)
}
}
},
cancelHandler: {
// User cancelled - dismiss and resolve with false
rootVC.dismiss(animated: true) {
resolve(false)
}
}
)
let pinView = PinUnlockView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: pinView)
// Present modally as full screen
hostingController.modalPresentationStyle = .fullScreen
rootVC.present(hostingController, animated: true)
}
} else {
// Use biometric authentication
let authenticated = vaultStore.authenticateUser(title: title, subtitle: subtitle)
resolve(authenticated)
} catch {
reject("AUTH_ERROR", "Authentication failed: \(error.localizedDescription)", error)
}
}

View File

@@ -42,17 +42,26 @@ extension VaultStore {
return self.enabledAuthMethods
}
/// Authenticate the user using biometric authentication
/// Authenticate the user using biometric authentication only
/// Note: This method only handles biometric authentication. If PIN is enabled,
/// this will return false and the caller should use showPinUnlock instead.
/// Returns true if authentication succeeded, false otherwise
public func authenticateUser(reason: String) throws -> Bool {
/// - Parameter title: The title for authentication. Optional, defaults to "Unlock Vault" context.
/// - Parameter subtitle: The subtitle for authentication. Optional, defaults to title or "Unlock Vault" context.
public func authenticateUser(title: String?, subtitle: String?) -> Bool {
// Use title if provided, otherwise default
let authReason = (title?.isEmpty == false) ? title! : "Unlock Vault"
// Check if PIN is enabled - if so, return false (caller should use PIN UI)
if isPinEnabled() {
print("PIN authentication is enabled, returning false")
return false
}
// Check if biometric authentication is enabled
guard self.enabledAuthMethods.contains(.faceID) else {
print("Biometric authentication not enabled")
throw NSError(
domain: "VaultStore",
code: 100,
userInfo: [NSLocalizedDescriptionKey: "Biometric authentication not enabled"]
)
print("No authentication method enabled")
return false
}
let context = LAContext()
@@ -61,11 +70,7 @@ extension VaultStore {
// Check if biometric authentication is available
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
print("Biometric authentication not available: \(error?.localizedDescription ?? "unknown error")")
throw NSError(
domain: "VaultStore",
code: 100,
userInfo: [NSLocalizedDescriptionKey: "Biometric authentication not available"]
)
return false
}
// Perform biometric authentication synchronously
@@ -74,7 +79,7 @@ extension VaultStore {
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
localizedReason: authReason
) { success, authError in
authenticated = success
if let authError = authError {

View File

@@ -46,13 +46,13 @@ public struct PinUnlockView: View {
.padding(.bottom, 12)
// Title
Text(String(localized: "unlock_vault", bundle: locBundle))
Text(viewModel.customTitle ?? String(localized: "unlock_vault", bundle: locBundle))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(colors.text)
.padding(.bottom, 6)
// Subtitle
Text(String(format: String(localized: "enter_pin_to_unlock_vault", bundle: locBundle)))
Text(viewModel.customSubtitle ?? String(format: String(localized: "enter_pin_to_unlock_vault", bundle: locBundle)))
.font(.system(size: 15))
.foregroundColor(colors.text.opacity(0.7))
.multilineTextAlignment(.center)
@@ -219,15 +219,21 @@ public class PinUnlockViewModel: ObservableObject {
@Published public var isUnlocking: Bool = false
public let pinLength: Int?
public let customTitle: String?
public let customSubtitle: String?
private let unlockHandler: (String) async throws -> Void
private let cancelHandler: () -> Void
public init(
pinLength: Int?,
customTitle: String? = nil,
customSubtitle: String? = nil,
unlockHandler: @escaping (String) async throws -> Void,
cancelHandler: @escaping () -> Void
) {
self.pinLength = pinLength
self.customTitle = customTitle
self.customSubtitle = customSubtitle
self.unlockHandler = unlockHandler
self.cancelHandler = cancelHandler
}

View File

@@ -100,7 +100,8 @@ export interface Spec extends TurboModule {
encryptDecryptionKeyForMobileUnlock(publicKeyJWK: string): Promise<string>;
// Re-authentication methods
authenticateUser(reason: string): Promise<boolean>;
// Authenticate user with biometric or PIN. If title/subtitle are null/empty, defaults to "Unlock Vault" context.
authenticateUser(title: string | null, subtitle: string | null): Promise<boolean>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeVaultManager');