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');