Add error code throw and detection to native vault sync logic implementation (#520)

This commit is contained in:
Leendert de Borst
2025-10-14 11:59:16 +02:00
parent 95753e3fa9
commit c044a27a3f
9 changed files with 321 additions and 112 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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)
}
}
}

View File

Binary file not shown.

View File

@@ -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)
}
}
}
}

View File

@@ -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)")
}
}
}

View File

@@ -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)"
}
}
}

View File

@@ -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;
}