From c044a27a3fcccd6970bb47dbcdbb959a168278b8 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 14 Oct 2025 11:59:16 +0200 Subject: [PATCH] Add error code throw and detection to native vault sync logic implementation (#520) --- apps/mobile-app/app/initialize.tsx | 2 + apps/mobile-app/hooks/useVaultSync.ts | 94 +++++++++------ ...entialProviderViewController+Passkey.swift | 6 +- .../CredentialProviderViewController.swift | 61 ++++++++++ .../ios/Autofill/en.lproj/Localizable.strings | Bin 2548 -> 4032 bytes .../ios/NativeVaultManager/VaultManager.swift | 8 +- .../ios/VaultStoreKit/VaultStore+Sync.swift | 114 ++++++------------ .../ios/VaultStoreKit/VaultSyncError.swift | 94 +++++++++++++++ .../utils/types/errors/VaultSyncErrorCodes.ts | 54 +++++++++ 9 files changed, 321 insertions(+), 112 deletions(-) create mode 100644 apps/mobile-app/ios/VaultStoreKit/VaultSyncError.swift create mode 100644 apps/mobile-app/utils/types/errors/VaultSyncErrorCodes.ts diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index dca834dee..e9c694312 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -232,6 +232,8 @@ export default function Initialize() : React.ReactNode { * Show modal with error message for other errors */ Alert.alert(t('common.error'), error); + router.replace('/unlock'); + return; }, /** * On upgrade required. diff --git a/apps/mobile-app/hooks/useVaultSync.ts b/apps/mobile-app/hooks/useVaultSync.ts index 35d97792b..26b2753c0 100644 --- a/apps/mobile-app/hooks/useVaultSync.ts +++ b/apps/mobile-app/hooks/useVaultSync.ts @@ -11,6 +11,7 @@ import { useDb } from '@/context/DbContext'; import { useWebApi } from '@/context/WebApiContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; +import { VaultSyncErrorCode, getVaultSyncErrorCode } from '@/utils/types/errors/VaultSyncErrorCodes'; /** * Utility function to ensure a minimum time has elapsed for an operation @@ -98,37 +99,41 @@ export const useVaultSync = () : { await new Promise(resolve => setTimeout(resolve, 1000)); } } catch (err) { - const errorMessage = err instanceof Error ? err.message : t('common.errors.unknownError'); + console.error('VaultSync: syncVault error:', err); - // Check for authentication failure (401) - logout user - if (errorMessage.includes('Authentication failed') || errorMessage.includes('please login again')) { - await app.logout('Your session has expired. Please login again.'); - return false; + // Get the error code from the native layer + const errorCode = getVaultSyncErrorCode(err); + + console.log('VaultSync: errorCode:', errorCode); + + // Handle specific error codes + switch (errorCode) { + case VaultSyncErrorCode.SESSION_EXPIRED: + case VaultSyncErrorCode.AUTHENTICATION_FAILED: + await app.logout('Your session has expired. Please login again.'); + return false; + + case VaultSyncErrorCode.PASSWORD_CHANGED: + await app.logout(t('vault.errors.passwordChanged')); + return false; + + case VaultSyncErrorCode.CLIENT_VERSION_NOT_SUPPORTED: + await app.logout(t('vault.errors.versionNotSupported')); + return false; + + case VaultSyncErrorCode.SERVER_UNAVAILABLE: + onOffline?.(); + return false; + + case VaultSyncErrorCode.NETWORK_ERROR: + case VaultSyncErrorCode.TIMEOUT: + onOffline?.(); + return false; + + default: + // Unknown error or no error code - rethrow + throw err; } - - // Check for specific error conditions from native layer - if (errorMessage.includes('Server not available')) { - onOffline?.(); - return false; - } - - if (errorMessage.includes('Client version not supported')) { - onError?.(t('vault.errors.versionNotSupported')); - return false; - } - - if (errorMessage.includes('Password has changed')) { - await app.logout(t('vault.errors.passwordChanged')); - return false; - } - - // Network error - go offline but don't fail - if (errorMessage.includes('network') || errorMessage.includes('timeout')) { - await NativeVaultManager.setOfflineMode(true); - return true; - } - - throw err; } try { @@ -183,14 +188,35 @@ export const useVaultSync = () : { return false; } - const errorMessage = err instanceof Error ? err.message : t('common.errors.unknownError'); + // Check if it's a vault sync error with error code + const errorCode = getVaultSyncErrorCode(err); + if (errorCode) { + switch (errorCode) { + case VaultSyncErrorCode.SESSION_EXPIRED: + case VaultSyncErrorCode.AUTHENTICATION_FAILED: + await app.logout('Your session has expired. Please login again.'); + return false; - // Check if it's a network error - if (errorMessage.includes('network') || errorMessage.includes('timeout')) { - await NativeVaultManager.setOfflineMode(true); - return true; + case VaultSyncErrorCode.PASSWORD_CHANGED: + await app.logout(t('vault.errors.passwordChanged')); + return false; + + case VaultSyncErrorCode.NETWORK_ERROR: + case VaultSyncErrorCode.TIMEOUT: + await NativeVaultManager.setOfflineMode(true); + return true; + + case VaultSyncErrorCode.SERVER_UNAVAILABLE: + await NativeVaultManager.setOfflineMode(true); + return true; + + default: + // Let the error be handled below + break; + } } + const errorMessage = err instanceof Error ? err.message : t('common.errors.unknownError'); onError?.(errorMessage); return false; } diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift index a955ff8a8..1e46d3584 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController+Passkey.swift @@ -343,9 +343,9 @@ extension CredentialProviderViewController: PasskeyProviderDelegate { // Server connectivity check failed viewModel.setLoading(false) - // Show error dialog to user + // Show appropriate error dialog based on error type await MainActor.run { - self.showConnectivityErrorAlert(viewModel: viewModel) + self.showSyncErrorAlert(error: error) } return } @@ -623,7 +623,7 @@ extension CredentialProviderViewController: PasskeyProviderDelegate { } /** - * Show connectivity error alert dialog + * Show connectivity error alert dialog (kept for backward compatibility) */ private func showConnectivityErrorAlert(viewModel: PasskeyRegistrationViewModel) { let alert = UIAlertController( diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift index f8baa32cd..dbad39cce 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift @@ -279,4 +279,65 @@ public class CredentialProviderViewController: ASCredentialProviderViewControlle return true } + // MARK: - Error Handling + + /** + * Show sync error alert dialog with appropriate message based on error type + * This method is internal so it can be used by both passkey and credential extensions + */ + internal func showSyncErrorAlert(error: Error) { + var title = NSLocalizedString("connection_error_title", comment: "Connection Error") + var message = NSLocalizedString("connection_error_message", comment: "No connection to the server can be made.") + + // Check if it's a VaultSyncError and customize message accordingly + if let syncError = error as? VaultSyncError { + switch syncError { + case .sessionExpired, .authenticationFailed: + title = NSLocalizedString("session_expired_title", comment: "Session Expired") + message = NSLocalizedString("session_expired_message", comment: "Your session has expired. Please sign in again.") + + case .passwordChanged: + title = NSLocalizedString("password_changed_title", comment: "Password Changed") + message = NSLocalizedString("password_changed_message", comment: "Your password has been changed. Please sign in again.") + + case .clientVersionNotSupported: + title = NSLocalizedString("version_not_supported_title", comment: "Update Required") + message = NSLocalizedString("version_not_supported_message", comment: "Your app version is no longer supported. Please update to the latest version.") + + case .serverUnavailable: + title = NSLocalizedString("server_unavailable_title", comment: "Server Unavailable") + message = NSLocalizedString("server_unavailable_message", comment: "The server is currently unavailable. Please try again later.") + + case .networkError, .timeout: + title = NSLocalizedString("network_error_title", comment: "Network Error") + message = NSLocalizedString("network_error_message", comment: "A network error occurred. Please check your connection and try again.") + + default: + // Use default connectivity error message for other errors + break + } + } + + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction( + title: NSLocalizedString("ok", comment: "OK"), + style: .default, + handler: { _ in + // User acknowledged the error + } + )) + + // Present the alert + if let currentController = self.currentHostingController { + currentController.present(alert, animated: true) + } else { + self.present(alert, animated: true) + } + } + } diff --git a/apps/mobile-app/ios/Autofill/en.lproj/Localizable.strings b/apps/mobile-app/ios/Autofill/en.lproj/Localizable.strings index 6da9cfe859ec8e439c9cf4aa3b6e325f43d80c0f..11bb35d03da02633c042b3b4384dbff6180281e5 100644 GIT binary patch literal 4032 zcmbVPT~8Y^5Zq^eg%znUsDd9*RVt!Xs#;N0pi-YsAqgQOB=j!fqd&gwj5o{LIbRa0 zD7l<(z1|(qj`#llIh1_~aw0Ps%Ty-P$PMnNGM9n$Mv1pJ3J$BO`gj$Ox9r%j|tQls)N1|9u&*{8X^2@u8<4X1>R_Ctq>zHjL42 z-fi(x$sK$)`1BFyXoJ@Qyp9kF<7Af1nRz{~M);4BG2a7>pW*okRtj$@anZX_>aF3S}X>-+x}x!q@2dAx?hGdwI2G1*f_ zZ2**L!@UVH*WHkI{H`!-9@(J09mpoM-pB{6(}mM6zIMl}l<{^H$B|vSLR-kKDGJ?c?7Nm8g|F#?^LYHx{=&8n0O=RDf~ZSFS$6%A0pXYoom@ zyCaz^Pn&rq%vhl*W)DfVl5b?IRw$K(xXgjsYdF|O-f_6bYSc+8PX+ID{BlL=U4?Z^ zn&dV8DVc3MXS1m;<=I;6C8cdEE46op*}9 zbqs1>j`9F2_!?cEgYhP1zi~R{46#gmsFXw80rFP6LbB%x47Ki~%%JmZ4!M)gu^QRt zIgiN^`U(8Bm&iQ?D3NM?9%Gvy%w`;!M2yS(W_$aH)#{a2(?6@(w7gwAXEA~XoutkR zavY%w!dJk!Z-W}t6Ax-Zn0b~bIa($3vZW9y@)*m>>fsVw=rUa4fP=~)H* z*x~W?V25D6UUyB|%pjM=nZ~Z&R_Q|V0-UjK(pOaE2C?$kbx=#EQ6I}$w9zv?b(Q2E zC~9@0W~$CwLAN`cSEq<(v8p(}XPp3Hd&AVuWs}D)6pr92pO-5pdqziNi1^pv7+v|( S?z5!+R3P^0e&#qv#+`p}^1b;0 delta 7 OcmX>g|3!Gi7ft{V5(AU~ diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index c1516322c..8dcccc01e 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -644,7 +644,13 @@ public class VaultManager: NSObject { } catch { print("VaultManager: Vault sync failed: \(error)") await MainActor.run { - reject("SYNC_ERROR", "Failed to sync vault: \(error.localizedDescription)", error) + // Map VaultSyncError to proper error codes for React Native + if let syncError = error as? VaultSyncError { + reject(syncError.code, syncError.message, error) + } else { + // Fallback for unknown errors + reject("VAULT_SYNC_UNKNOWN_ERROR", "Failed to sync vault: \(error.localizedDescription)", error) + } } } } diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Sync.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Sync.swift index ec8104d3c..7daa4c17d 100644 --- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Sync.swift +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Sync.swift @@ -53,13 +53,19 @@ extension VaultStore { /// Fetch and validate server status private func fetchAndValidateStatus(using webApiService: WebApiService) async throws -> StatusResponse { - let statusResponse = try await webApiService.executeRequest( - method: "GET", - endpoint: "Auth/status", - body: nil, - headers: [:], - requiresAuth: true - ) + let statusResponse: WebApiResponse + do { + statusResponse = try await webApiService.executeRequest( + method: "GET", + endpoint: "Auth/status", + body: nil, + headers: [:], + requiresAuth: true + ) + } catch { + // Network error - convert to VaultSyncError + throw VaultSyncError.networkError(underlyingError: error) + } // Check response status // Note: WebApiService already handles 401 with automatic token refresh and retry @@ -68,30 +74,18 @@ extension VaultStore { if statusResponse.statusCode == 401 { // Authentication failed even after token refresh attempt print("VaultStore: Authentication failed (401) - token refresh also failed") - throw NSError( - domain: "VaultStore", - code: 401, - userInfo: [NSLocalizedDescriptionKey: "Authentication failed - please login again"] - ) + throw VaultSyncError.sessionExpired } // Other error (5xx, network, etc.) - go offline setOfflineMode(true) - throw NSError( - domain: "VaultStore", - code: statusResponse.statusCode, - userInfo: [NSLocalizedDescriptionKey: "Server returned status \(statusResponse.statusCode)"] - ) + throw VaultSyncError.serverUnavailable(statusCode: statusResponse.statusCode) } guard let statusData = statusResponse.body.data(using: .utf8) else { print("VaultStore: Failed to convert status response to data") print("VaultStore: Response body: '\(statusResponse.body)'") - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to convert status response to data"] - ) + throw VaultSyncError.parseError(message: "Failed to convert status response to data") } let decoder = JSONDecoder() @@ -101,19 +95,11 @@ extension VaultStore { } catch { print("VaultStore: Failed to decode status response: \(error)") print("VaultStore: Response body: '\(statusResponse.body)'") - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to parse status response: \(error.localizedDescription)"] - ) + throw VaultSyncError.parseError(message: "Failed to decode status response: \(error.localizedDescription)") } guard status.clientVersionSupported else { - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Client version not supported"] - ) + throw VaultSyncError.clientVersionNotSupported } try validateSrpSalt(status.srpSalt) @@ -129,30 +115,30 @@ extension VaultStore { } if !srpSalt.isEmpty && srpSalt != params.salt { - throw NSError( - domain: "VaultStore", - code: -2, - userInfo: [NSLocalizedDescriptionKey: "Password has changed, please login again"] - ) + throw VaultSyncError.passwordChanged } } /// Download vault from server and store it locally private func downloadAndStoreVault(using webApiService: WebApiService, newRevision: Int) async throws { - let vaultResponse = try await webApiService.executeRequest( - method: "GET", - endpoint: "Vault", - body: nil, - headers: [:], - requiresAuth: true - ) + let vaultResponse: WebApiResponse + do { + vaultResponse = try await webApiService.executeRequest( + method: "GET", + endpoint: "Vault", + body: nil, + headers: [:], + requiresAuth: true + ) + } catch { + throw VaultSyncError.networkError(underlyingError: error) + } guard vaultResponse.statusCode == 200 else { - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to download vault"] - ) + if vaultResponse.statusCode == 401 { + throw VaultSyncError.sessionExpired + } + throw VaultSyncError.serverUnavailable(statusCode: vaultResponse.statusCode) } let vault = try parseVaultResponse(vaultResponse.body) @@ -168,11 +154,7 @@ extension VaultStore { /// Parse vault response from JSON private func parseVaultResponse(_ body: String) throws -> VaultResponse { guard let vaultData = body.data(using: .utf8) else { - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to convert vault response to data"] - ) + throw VaultSyncError.parseError(message: "Failed to convert vault response to data") } do { @@ -180,11 +162,7 @@ extension VaultStore { } catch { print("VaultStore: Failed to decode vault response: \(error)") print("VaultStore: Response body: \(body)") - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to parse vault response: \(error.localizedDescription)"] - ) + throw VaultSyncError.parseError(message: "Failed to decode vault response: \(error.localizedDescription)") } } @@ -194,23 +172,11 @@ extension VaultStore { case 0: return case 1: - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Vault merge required"] - ) + throw VaultSyncError.vaultMergeRequired case 2: - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Vault outdated"] - ) + throw VaultSyncError.vaultOutdated default: - throw NSError( - domain: "VaultStore", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Unknown vault status: \(status)"] - ) + throw VaultSyncError.unknownError(message: "Unknown vault status: \(status)") } } } diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultSyncError.swift b/apps/mobile-app/ios/VaultStoreKit/VaultSyncError.swift new file mode 100644 index 000000000..ee5cd4924 --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultSyncError.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Error codes for vault sync operations +/// These error codes are language-independent and can be properly handled by the client +public enum VaultSyncError: Error { + // Authentication errors + case authenticationFailed + case sessionExpired + case passwordChanged + + // Network/connectivity errors + case serverUnavailable(statusCode: Int) + case networkError(underlyingError: Error) + case timeout + + // Version/compatibility errors + case clientVersionNotSupported + case vaultVersionIncompatible + + // Vault status errors + case vaultMergeRequired + case vaultOutdated + + // Decryption errors + case vaultDecryptFailed + + // Generic errors + case unknownError(message: String) + case parseError(message: String) + + /// Get the error code string for React Native bridge + public var code: String { + switch self { + case .authenticationFailed: + return "VAULT_SYNC_AUTH_FAILED" + case .sessionExpired: + return "VAULT_SYNC_SESSION_EXPIRED" + case .passwordChanged: + return "VAULT_SYNC_PASSWORD_CHANGED" + case .serverUnavailable: + return "VAULT_SYNC_SERVER_UNAVAILABLE" + case .networkError: + return "VAULT_SYNC_NETWORK_ERROR" + case .timeout: + return "VAULT_SYNC_TIMEOUT" + case .clientVersionNotSupported: + return "VAULT_SYNC_CLIENT_VERSION_NOT_SUPPORTED" + case .vaultVersionIncompatible: + return "VAULT_SYNC_VAULT_VERSION_INCOMPATIBLE" + case .vaultMergeRequired: + return "VAULT_SYNC_MERGE_REQUIRED" + case .vaultOutdated: + return "VAULT_SYNC_OUTDATED" + case .vaultDecryptFailed: + return "VAULT_SYNC_DECRYPT_FAILED" + case .unknownError: + return "VAULT_SYNC_UNKNOWN_ERROR" + case .parseError: + return "VAULT_SYNC_PARSE_ERROR" + } + } + + /// Get a user-friendly message (for logging/debugging) + public var message: String { + switch self { + case .authenticationFailed: + return "Authentication failed" + case .sessionExpired: + return "Session expired" + case .passwordChanged: + return "Password has changed" + case .serverUnavailable(let statusCode): + return "Server unavailable (status: \(statusCode))" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .timeout: + return "Request timeout" + case .clientVersionNotSupported: + return "Client version not supported" + case .vaultVersionIncompatible: + return "Vault version incompatible" + case .vaultMergeRequired: + return "Vault merge required" + case .vaultOutdated: + return "Vault outdated" + case .vaultDecryptFailed: + return "Failed to decrypt vault" + case .unknownError(let message): + return "Unknown error: \(message)" + case .parseError(let message): + return "Parse error: \(message)" + } + } +} diff --git a/apps/mobile-app/utils/types/errors/VaultSyncErrorCodes.ts b/apps/mobile-app/utils/types/errors/VaultSyncErrorCodes.ts new file mode 100644 index 000000000..8837cfa6f --- /dev/null +++ b/apps/mobile-app/utils/types/errors/VaultSyncErrorCodes.ts @@ -0,0 +1,54 @@ +/** + * Error codes for vault sync operations + * These codes are returned from native layer and should be used instead of parsing error messages + * This enables multi-language support and robust error handling + */ +export enum VaultSyncErrorCode { + // Authentication errors + AUTHENTICATION_FAILED = 'VAULT_SYNC_AUTH_FAILED', + SESSION_EXPIRED = 'VAULT_SYNC_SESSION_EXPIRED', + PASSWORD_CHANGED = 'VAULT_SYNC_PASSWORD_CHANGED', + + // Network/connectivity errors + SERVER_UNAVAILABLE = 'VAULT_SYNC_SERVER_UNAVAILABLE', + NETWORK_ERROR = 'VAULT_SYNC_NETWORK_ERROR', + TIMEOUT = 'VAULT_SYNC_TIMEOUT', + + // Version/compatibility errors + CLIENT_VERSION_NOT_SUPPORTED = 'VAULT_SYNC_CLIENT_VERSION_NOT_SUPPORTED', + VAULT_VERSION_INCOMPATIBLE = 'VAULT_SYNC_VAULT_VERSION_INCOMPATIBLE', + + // Vault status errors + VAULT_MERGE_REQUIRED = 'VAULT_SYNC_MERGE_REQUIRED', + VAULT_OUTDATED = 'VAULT_SYNC_OUTDATED', + + // Decryption errors + VAULT_DECRYPT_FAILED = 'VAULT_SYNC_DECRYPT_FAILED', + + // Generic errors + UNKNOWN_ERROR = 'VAULT_SYNC_UNKNOWN_ERROR', + PARSE_ERROR = 'VAULT_SYNC_PARSE_ERROR', +} + +/** + * Check if an error is a vault sync error + */ +export function isVaultSyncError(error: unknown): error is { code: string } { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' && + (error as { code: string }).code.startsWith('VAULT_SYNC_') + ); +} + +/** + * Get the vault sync error code from an error object + */ +export function getVaultSyncErrorCode(error: unknown): VaultSyncErrorCode | null { + if (isVaultSyncError(error)) { + return error.code as VaultSyncErrorCode; + } + return null; +}