diff --git a/apps/mobile-app/app/(tabs)/settings/_layout.tsx b/apps/mobile-app/app/(tabs)/settings/_layout.tsx index 98150d83e..f21665ff5 100644 --- a/apps/mobile-app/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/settings/_layout.tsx @@ -143,7 +143,7 @@ export default function SettingsLayout(): React.ReactNode { diff --git a/apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx b/apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx index f855dca27..b6ebb109b 100644 --- a/apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx +++ b/apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx @@ -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') diff --git a/apps/mobile-app/app/(tabs)/settings/qr-result.tsx b/apps/mobile-app/app/(tabs)/settings/qr-result.tsx index c2c1eafe0..4b4c14785 100644 --- a/apps/mobile-app/app/(tabs)/settings/qr-result.tsx +++ b/apps/mobile-app/app/(tabs)/settings/qr-result.tsx @@ -88,7 +88,7 @@ export default function QRResultScreen() : React.ReactNode { /> {isSuccess - ? t('settings.qrScanner.mobileUnlock.successTitle') + ? t('common.success') : t('common.error')} diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index e48868374..c6fb9b634 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -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" } } }, diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm index cf90d24d0..47f2978ef 100644 --- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm +++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm @@ -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 diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index 7ef396121..5a4062db2 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -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) } } diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift index e500974eb..10c5ef2db 100644 --- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift @@ -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 { diff --git a/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift b/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift index 9cfcf1426..30be4ae30 100644 --- a/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift +++ b/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift @@ -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 } diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts index cfa4846e4..88dfc0929 100644 --- a/apps/mobile-app/specs/NativeVaultManager.ts +++ b/apps/mobile-app/specs/NativeVaultManager.ts @@ -100,7 +100,8 @@ export interface Spec extends TurboModule { encryptDecryptionKeyForMobileUnlock(publicKeyJWK: string): Promise; // Re-authentication methods - authenticateUser(reason: string): Promise; + // 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; } export default TurboModuleRegistry.getEnforcing('NativeVaultManager');