From 7b6170e927ea84b4746569cbb78277bb1f3ca3d8 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 11 Nov 2025 22:54:39 +0100 Subject: [PATCH] Tweak iOS native pin unlock view flow (#1340) --- ...entialProviderViewController+Passkey.swift | 1 + .../CredentialProviderViewController.swift | 36 ++++++++++++++++-- .../ios/Autofill/UnlockCoordinator.swift | 20 +++++++++- .../ios/Autofill/en.lproj/Localizable.strings | Bin 4406 -> 4072 bytes .../ios/NativeVaultManager/VaultManager.swift | 12 ++++-- .../ios/VaultUI/Auth/PinUnlockView.swift | 27 +++++++++++-- .../ios/VaultUI/en.lproj/Localizable.strings | Bin 6414 -> 6004 bytes 7 files changed, 83 insertions(+), 13 deletions(-) diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift index 79900e0eb..ed1f2840d 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift @@ -268,6 +268,7 @@ extension CredentialProviderViewController: PasskeyProviderDelegate { ) }, cancelHandler: { [weak self] in + // Passkey registration - just cancel on any dismissal self?.extensionContext.cancelRequest(withError: NSError( domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift index cb72af014..dc35236f2 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift @@ -235,8 +235,36 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle } } }, - cancelHandler: { [weak self] in - self?.handleCancel() + cancelHandler: { [weak self] pinWasDisabled in + guard let self = self else { return } + + if pinWasDisabled { + // PIN was disabled due to max attempts + // Try biometric as fallback if available + if vaultStore.isBiometricAuthEnabled() { + do { + // Try to unlock with biometric + try vaultStore.unlockVault() + + // Success - process the credential request + if let passkeyRequest = self.quickReturnPasskeyRequest { + self.handleQuickReturnPasskeyCredential(vaultStore: vaultStore, request: passkeyRequest) + } else if let passwordRequest = self.quickReturnPasswordRequest { + self.handleQuickReturnPasswordCredential(vaultStore: vaultStore, request: passwordRequest) + } + } catch { + // Biometric failed - cancel + print("Biometric fallback failed after PIN lockout: \(error)") + self.handleCancel() + } + } else { + // No other auth method - cancel + self.handleCancel() + } + } else { + // User manually cancelled + self.handleCancel() + } } ) @@ -525,8 +553,8 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle // Check if PIN is available as fallback if !pinEnabled { let alert = UIAlertController( - title: String(format: NSLocalizedString("biometric_required", comment: ""), authMethod), - message: String(format: NSLocalizedString("biometric_required_message", comment: ""), authMethod), + title: String(format: NSLocalizedString("auth_required", comment: ""), authMethod), + message: String(format: NSLocalizedString("auth_required_message", comment: ""), authMethod), preferredStyle: .alert ) alert.addAction(UIAlertAction(title: NSLocalizedString("ok", comment: ""), style: .default) { [weak self] _ in diff --git a/apps/mobile-app/ios/Autofill/UnlockCoordinator.swift b/apps/mobile-app/ios/Autofill/UnlockCoordinator.swift index 6cf1f7e59..1f307e585 100644 --- a/apps/mobile-app/ios/Autofill/UnlockCoordinator.swift +++ b/apps/mobile-app/ios/Autofill/UnlockCoordinator.swift @@ -100,8 +100,24 @@ class UnlockCoordinator: ObservableObject { self.onUnlocked() } }, - cancelHandler: { [weak self] in - self?.cancel() + cancelHandler: { [weak self] pinWasDisabled in + guard let self = self else { return } + + if pinWasDisabled { + // PIN was disabled due to max attempts + // Try biometric as fallback if available + if self.vaultStore.isBiometricAuthEnabled() { + Task { + await self.attemptBiometricUnlock() + } + } else { + // No other auth method available - cancel + self.cancel() + } + } else { + // User manually cancelled - just cancel + self.cancel() + } } ) } diff --git a/apps/mobile-app/ios/Autofill/en.lproj/Localizable.strings b/apps/mobile-app/ios/Autofill/en.lproj/Localizable.strings index b346207028b2a80d30c57553e95bc7332f3d51fb..9eca1401a93926af4e70a66f2fa46be5518bebd5 100644 GIT binary patch delta 172 zcmdm{^g@2aBPRPqhEj$Sh75*yh9ZVkhC(1O6U<6sP-0MEumxfz21lrxRE9jD+DwLI zhD0!(&yY8{o=2J;YQ|)KCSAsW$v0V~6`g>p@)>f0CYAtA1L-IRnv@S@D=-8wcry4+ o7G~9D&jp&32{gl>QC++gs3H|83o$F7Aq^;#!;rH%m-Qq!01N~r>Hq)$ delta 310 zcmaDMzfEbwBPPKlhD?ThhFpeJh7yJ%AUk>TOD0)nRR)L2ip?^Q4@g!5O)3SNqQH;>G_MR~8jx0CCv00wwBsTya>p|H7 diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index 6e7851f3d..1d847038c 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -859,10 +859,16 @@ public class VaultManager: NSObject { } } }, - cancelHandler: { - // User cancelled + cancelHandler: { pinWasDisabled in + // Dismiss the view rootVC.dismiss(animated: true) { - reject("USER_CANCELLED", "User cancelled PIN unlock", nil) + if pinWasDisabled { + // PIN was disabled due to max attempts + reject("PIN_DISABLED", "PIN was disabled after too many failed attempts", nil) + } else { + // User manually cancelled + reject("USER_CANCELLED", "User cancelled PIN unlock", nil) + } } } ) diff --git a/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift b/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift index a9edd39cd..673a5e94f 100644 --- a/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift +++ b/apps/mobile-app/ios/VaultUI/Auth/PinUnlockView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit private let locBundle = Bundle.vaultUI @@ -288,12 +289,12 @@ public class PinUnlockViewModel: ObservableObject { public let pinLength: Int? private let unlockHandler: (String) async throws -> Void - private let cancelHandler: () -> Void + private let cancelHandler: (Bool) -> Void // Bool indicates if PIN was disabled/locked public init( pinLength: Int?, unlockHandler: @escaping (String) async throws -> Void, - cancelHandler: @escaping () -> Void + cancelHandler: @escaping (Bool) -> Void ) { self.pinLength = pinLength self.unlockHandler = unlockHandler @@ -328,7 +329,7 @@ public class PinUnlockViewModel: ObservableObject { } public func cancel() { - cancelHandler() + cancelHandler(false) // User manually cancelled } private func attemptUnlock() async { @@ -341,18 +342,36 @@ public class PinUnlockViewModel: ObservableObject { // Success - the handler will navigate away or complete the flow // Keep loading state active since we're navigating } catch let nsError as NSError { - // Handle unlock errors + // Check for PIN disabled errors (error code 25) + // This occurs when PIN was disabled due to max attempts or configuration issue + if nsError.code == 25 { + // PIN is no longer available - auto-dismiss the view + // This happens when user enters wrong PIN too many times + isUnlocking = false + cancelHandler(true) + return + } + + // Handle other unlock errors (incorrect PIN, etc.) isUnlocking = false self.error = nsError.localizedDescription + triggerErrorFeedback() shakeAndClear() } catch let genericError { // Generic error isUnlocking = false self.error = String(localized: "unlock_failed", bundle: locBundle) + triggerErrorFeedback() shakeAndClear() } } + private func triggerErrorFeedback() { + // Trigger haptic feedback for error + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.error) + } + private func shakeAndClear() { // Clear the PIN after a short delay to show error Task { diff --git a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings index 5a0d6c72d5bf2c22e3b217e341e02b844b11deab..751c856835446d9cd39d1e3f46179d86ed6f777d 100644 GIT binary patch delta 31 ncmeA(`l7eNOmwn^=#j};qGFSK#0)0aiHJ?!BlcwS6$vWfR!h(pPFouN@eT0xUMgX78#YS#= zEDbX!YvX=&Sc$NNjXgZ<7_AaELy@WuQAGFM<8emBx(g@qA!CKfkFJd@RsSd3*iv)g zbL5=X24imKvHw7;h=6>W