From e5d924a094fa33cea36839c3b4cfd3225ae4373c Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 27 Sep 2025 12:25:24 +0200 Subject: [PATCH] Mobile app logout flow and AppContext refactor (#1274) --- .../app/(tabs)/credentials/index.tsx | 31 ++-- apps/mobile-app/app/(tabs)/settings/index.tsx | 10 +- .../settings/security/delete-account.tsx | 4 +- apps/mobile-app/app/_layout.tsx | 17 +- apps/mobile-app/app/initialize.tsx | 40 +++-- apps/mobile-app/app/login.tsx | 6 +- apps/mobile-app/app/reinitialize.tsx | 35 +++- apps/mobile-app/app/unlock.tsx | 18 +- apps/mobile-app/app/upgrade.tsx | 8 +- apps/mobile-app/context/AppContext.tsx | 162 ++++++++++++++++++ apps/mobile-app/context/AuthContext.tsx | 14 +- apps/mobile-app/context/DbContext.tsx | 31 ++-- apps/mobile-app/context/WebApiContext.tsx | 15 +- apps/mobile-app/events/LogoutEventEmitter.ts | 38 ++++ apps/mobile-app/hooks/useVaultMutate.ts | 6 +- apps/mobile-app/hooks/useVaultSync.ts | 51 +++--- apps/mobile-app/utils/SqliteClient.tsx | 64 ++++--- apps/mobile-app/utils/WebApiService.ts | 36 ++-- .../types/errors/VaultAuthenticationError.ts | 20 +++ .../utils/types/errors/VaultVersionError.ts | 19 ++ 20 files changed, 443 insertions(+), 182 deletions(-) create mode 100644 apps/mobile-app/context/AppContext.tsx create mode 100644 apps/mobile-app/events/LogoutEventEmitter.ts create mode 100644 apps/mobile-app/utils/types/errors/VaultAuthenticationError.ts create mode 100644 apps/mobile-app/utils/types/errors/VaultVersionError.ts diff --git a/apps/mobile-app/app/(tabs)/credentials/index.tsx b/apps/mobile-app/app/(tabs)/credentials/index.tsx index 2721612aa..8234335a1 100644 --- a/apps/mobile-app/app/(tabs)/credentials/index.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/index.tsx @@ -10,6 +10,7 @@ import Toast from 'react-native-toast-message'; import type { Credential } from '@/utils/dist/shared/models/vault'; import emitter from '@/utils/EventEmitter'; +import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; import { useColors } from '@/hooks/useColorScheme'; import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; @@ -27,9 +28,8 @@ import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader'; import { RobustPressable } from '@/components/ui/RobustPressable'; import { SkeletonLoader } from '@/components/ui/SkeletonLoader'; import { TitleContainer } from '@/components/ui/TitleContainer'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; -import { useWebApi } from '@/context/WebApiContext'; /** * Credentials screen. @@ -37,7 +37,6 @@ import { useWebApi } from '@/context/WebApiContext'; export default function CredentialsScreen() : React.ReactNode { const [searchQuery, setSearchQuery] = useState(''); const { syncVault } = useVaultSync(); - const webApi = useWebApi(); const colors = useColors(); const { t } = useTranslation(); const flatListRef = useRef(null); @@ -54,7 +53,7 @@ export default function CredentialsScreen() : React.ReactNode { const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate(); const [isSyncing, setIsSyncing] = useState(false); - const authContext = useAuth(); + const authContext = useApp(); const dbContext = useDb(); const isAuthenticated = authContext.isLoggedIn; @@ -172,11 +171,11 @@ export default function CredentialsScreen() : React.ReactNode { setRefreshing(false); setIsLoadingCredentials(false); - // Show modal with error message + /** + * Authentication errors are handled in useVaultSync + * For other errors, show alert + */ Alert.alert(t('common.error'), error); - - // Logout user - await webApi.logout(error); }, /** * On upgrade required. @@ -189,13 +188,17 @@ export default function CredentialsScreen() : React.ReactNode { console.error('Error refreshing credentials:', err); setRefreshing(false); setIsLoadingCredentials(false); - Toast.show({ - type: 'error', - text1: t('credentials.vaultSyncFailed'), - text2: err instanceof Error ? err.message : 'Unknown error', - }); + + // Authentication errors are already handled in useVaultSync + if (!(err instanceof VaultAuthenticationError)) { + Toast.show({ + type: 'error', + text1: t('credentials.vaultSyncFailed'), + text2: err instanceof Error ? err.message : 'Unknown error', + }); + } } - }, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, webApi, authContext, router, t]); + }, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, authContext, router, t]); useEffect(() => { if (!isAuthenticated || !isDatabaseAvailable) { diff --git a/apps/mobile-app/app/(tabs)/settings/index.tsx b/apps/mobile-app/app/(tabs)/settings/index.tsx index 7c7777704..1c23a591d 100644 --- a/apps/mobile-app/app/(tabs)/settings/index.tsx +++ b/apps/mobile-app/app/(tabs)/settings/index.tsx @@ -16,18 +16,16 @@ import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader'; import { InlineSkeletonLoader } from '@/components/ui/InlineSkeletonLoader'; import { TitleContainer } from '@/components/ui/TitleContainer'; import { UsernameDisplay } from '@/components/ui/UsernameDisplay'; -import { useAuth } from '@/context/AuthContext'; -import { useWebApi } from '@/context/WebApiContext'; +import { useApp } from '@/context/AppContext'; /** * Settings screen. */ export default function SettingsScreen() : React.ReactNode { - const webApi = useWebApi(); const colors = useColors(); const { t } = useTranslation(); - const { getAuthMethodDisplayKey, shouldShowAutofillReminder } = useAuth(); - const { getAutoLockTimeout, getClipboardClearTimeout } = useAuth(); + const { getAuthMethodDisplayKey, shouldShowAutofillReminder, logout } = useApp(); + const { getAutoLockTimeout, getClipboardClearTimeout } = useApp(); const { loadApiUrl, getDisplayUrl } = useApiUrl(); const scrollY = useRef(new Animated.Value(0)).current; const scrollViewRef = useRef(null); @@ -121,7 +119,7 @@ export default function SettingsScreen() : React.ReactNode { * Handle the logout. */ onPress: async () : Promise => { - await webApi.logout(); + await logout(); router.replace('/login'); } }, diff --git a/apps/mobile-app/app/(tabs)/settings/security/delete-account.tsx b/apps/mobile-app/app/(tabs)/settings/security/delete-account.tsx index e3bbef6de..318041cd0 100644 --- a/apps/mobile-app/app/(tabs)/settings/security/delete-account.tsx +++ b/apps/mobile-app/app/(tabs)/settings/security/delete-account.tsx @@ -17,7 +17,7 @@ import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; import { ThemedText } from '@/components/themed/ThemedText'; import { ThemedTextInput } from '@/components/themed/ThemedTextInput'; import { UsernameDisplay } from '@/components/ui/UsernameDisplay'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useWebApi } from '@/context/WebApiContext'; /** @@ -26,7 +26,7 @@ import { useWebApi } from '@/context/WebApiContext'; export default function DeleteAccountScreen(): React.ReactNode { const colors = useColors(); const webApi = useWebApi(); - const { username, verifyPassword, logout } = useAuth(); + const { username, verifyPassword, logout } = useApp(); const { t } = useTranslation(); const [confirmUsername, setConfirmUsername] = useState(''); diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx index 0f7689ca5..1c186b15b 100644 --- a/apps/mobile-app/app/_layout.tsx +++ b/apps/mobile-app/app/_layout.tsx @@ -15,6 +15,7 @@ import { useColors, useColorScheme } from '@/hooks/useColorScheme'; import SpaceMono from '@/assets/fonts/SpaceMono-Regular.ttf'; import { ThemedView } from '@/components/themed/ThemedView'; import { AliasVaultToast } from '@/components/Toast'; +import { AppProvider } from '@/context/AppContext'; import { AuthProvider } from '@/context/AuthContext'; import { ClipboardCountdownProvider } from '@/context/ClipboardCountdownContext'; import { DbProvider } from '@/context/DbContext'; @@ -185,13 +186,15 @@ export default function RootLayout() : React.ReactNode { - - - - - - - + + + + + + + + + diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index a5b2d91fe..14d78a593 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -7,10 +7,10 @@ import { useVaultSync } from '@/hooks/useVaultSync'; import LoadingIndicator from '@/components/LoadingIndicator'; import { ThemedView } from '@/components/themed/ThemedView'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; -import { useWebApi } from '@/context/WebApiContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; +import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionError'; /** * Initialize page that handles all boot logic. @@ -22,10 +22,9 @@ export default function Initialize() : React.ReactNode { const hasInitialized = useRef(false); const offlineButtonTimeoutRef = useRef(null); const { t } = useTranslation(); - const { initializeAuth, setOfflineMode } = useAuth(); + const app = useApp(); const { syncVault } = useVaultSync(); const dbContext = useDb(); - const webApi = useWebApi(); /** * Handle offline scenario - show alert with options to open local vault or retry sync. @@ -42,7 +41,7 @@ export default function Initialize() : React.ReactNode { */ onPress: async () : Promise => { setStatus(t('app.status.openingVaultReadOnly')); - const { enabledAuthMethods } = await initializeAuth(); + const { enabledAuthMethods } = await app.initializeAuth(); try { const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); @@ -54,7 +53,7 @@ export default function Initialize() : React.ReactNode { } // Set offline mode - setOfflineMode(true); + app.setOfflineMode(true); // FaceID not enabled const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); @@ -86,7 +85,11 @@ export default function Initialize() : React.ReactNode { // Success - navigate to credentials router.replace('/(tabs)/credentials'); - } catch { + } catch (err) { + if (err instanceof VaultVersionIncompatibleError) { + await app.logout(t(err.message)); + return; + } router.replace('/unlock'); } } @@ -116,7 +119,7 @@ export default function Initialize() : React.ReactNode { } ] ); - }, [dbContext, router, initializeAuth, t, setOfflineMode]); + }, [dbContext, router, app, t]); useEffect(() => { // Ensure this only runs once. @@ -134,7 +137,7 @@ export default function Initialize() : React.ReactNode { * Handle vault unlocking process. */ async function handleVaultUnlock() : Promise { - const { enabledAuthMethods } = await initializeAuth(); + const { enabledAuthMethods } = await app.initializeAuth(); try { const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); @@ -168,7 +171,12 @@ export default function Initialize() : React.ReactNode { router.replace('/unlock'); return; } - } catch { + } catch (err) { + if (err instanceof VaultVersionIncompatibleError) { + await app.logout(t(err.message)); + return; + } + router.replace('/unlock'); return; } @@ -178,7 +186,7 @@ export default function Initialize() : React.ReactNode { * Initialize the app. */ const initialize = async () : Promise => { - const { isLoggedIn } = await initializeAuth(); + const { isLoggedIn } = await app.initializeAuth(); if (!isLoggedIn) { router.replace('/login'); @@ -225,11 +233,11 @@ export default function Initialize() : React.ReactNode { * Handle error during vault sync. */ onError: async (error: string) => { - // Show modal with error message + /** + * Authentication errors are already handled in useVaultSync + * Show modal with error message for other errors + */ Alert.alert(t('common.error'), error); - - // The logout user and navigate to the login screen. - await webApi.logout(error); router.replace('/login'); }, /** @@ -252,7 +260,7 @@ export default function Initialize() : React.ReactNode { clearTimeout(offlineButtonTimeoutRef.current); } }; - }, [dbContext, syncVault, initializeAuth, webApi, router, t, handleOfflineFlow]); + }, [dbContext, syncVault, app, router, t, handleOfflineFlow]); /** * Handle offline button press by calling the stored offline handler. diff --git a/apps/mobile-app/app/login.tsx b/apps/mobile-app/app/login.tsx index 6aa6d9c88..80d3005eb 100644 --- a/apps/mobile-app/app/login.tsx +++ b/apps/mobile-app/app/login.tsx @@ -25,7 +25,7 @@ import LoadingIndicator from '@/components/LoadingIndicator'; import { ThemedView } from '@/components/themed/ThemedView'; import { InAppBrowserView } from '@/components/ui/InAppBrowserView'; import { RobustPressable } from '@/components/ui/RobustPressable'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import { useWebApi } from '@/context/WebApiContext'; @@ -66,7 +66,7 @@ export default function LoginScreen() : React.ReactNode { const [loginStatus, setLoginStatus] = useState(null); const [showPassword, setShowPassword] = useState(false); - const authContext = useAuth(); + const authContext = useApp(); const dbContext = useDb(); const webApi = useWebApi(); const { syncVault } = useVaultSync(); @@ -192,7 +192,7 @@ export default function LoginScreen() : React.ReactNode { // Show modal with error message Alert.alert(t('common.error'), message); - webApi.logout(message); + // Error will trigger logout through the sync process setIsLoading(false); }, /** diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx index 631c19057..09b762d93 100644 --- a/apps/mobile-app/app/reinitialize.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -9,16 +9,17 @@ import { useVaultSync } from '@/hooks/useVaultSync'; import LoadingIndicator from '@/components/LoadingIndicator'; import { ThemedText } from '@/components/themed/ThemedText'; import { ThemedView } from '@/components/themed/ThemedView'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; +import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionError'; /** * Reinitialize screen which is triggered when the app was still open but the database in memory * was cleared because of a time-out. When this happens, we need to re-initialize and unlock the vault. */ export default function ReinitializeScreen() : React.ReactNode { - const authContext = useAuth(); + const authContext = useApp(); const dbContext = useDb(); const { syncVault } = useVaultSync(); const [status, setStatus] = useState(''); @@ -27,6 +28,7 @@ export default function ReinitializeScreen() : React.ReactNode { const offlineButtonTimeoutRef = useRef(null); const colors = useColors(); const { t } = useTranslation(); + const app = useApp(); /** * Handle offline scenario - show alert with options to open local vault or retry sync. @@ -43,7 +45,7 @@ export default function ReinitializeScreen() : React.ReactNode { */ onPress: async () : Promise => { setStatus(t('app.status.openingVaultReadOnly')); - const { enabledAuthMethods } = await authContext.initializeAuth(); + const { enabledAuthMethods } = await app.initializeAuth(); try { const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); @@ -117,7 +119,12 @@ export default function ReinitializeScreen() : React.ReactNode { } }, 0); authContext.setReturnUrl(null); - } catch { + } catch (err) { + if (err instanceof VaultVersionIncompatibleError) { + await authContext.logout(t(err.message)); + return; + } + router.replace('/unlock'); } } @@ -203,7 +210,7 @@ export default function ReinitializeScreen() : React.ReactNode { * Handle vault unlocking process. */ async function handleVaultUnlock() : Promise { - const { enabledAuthMethods } = await authContext.initializeAuth(); + const { enabledAuthMethods } = await app.initializeAuth(); try { const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); @@ -233,7 +240,12 @@ export default function ReinitializeScreen() : React.ReactNode { } router.replace('/unlock'); - } catch { + } catch (err) { + if (err instanceof VaultVersionIncompatibleError) { + await authContext.logout(t(err.message)); + return; + } + router.replace('/unlock'); } } @@ -242,7 +254,7 @@ export default function ReinitializeScreen() : React.ReactNode { * Initialize the app. */ const initialize = async () : Promise => { - const { isLoggedIn } = await authContext.initializeAuth(); + const { isLoggedIn } = await app.initializeAuth(); // If user is not logged in, navigate to login immediately if (!isLoggedIn) { @@ -285,6 +297,15 @@ export default function ReinitializeScreen() : React.ReactNode { onSuccess: async () => { await handleVaultUnlock(); }, + /** + * Handle error during vault sync. + * Authentication errors are already handled in useVaultSync. + */ + onError: (error: string) => { + console.error('Vault sync error during reinitialize:', error); + // Even if sync fails, try to continue with local vault if available + handleVaultUnlock(); + }, /** * Handle offline state and prompt user for action. */ diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 336417670..2f1a88cc9 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -17,22 +17,21 @@ import { ThemedText } from '@/components/themed/ThemedText'; import { ThemedView } from '@/components/themed/ThemedView'; import { Avatar } from '@/components/ui/Avatar'; import { RobustPressable } from '@/components/ui/RobustPressable'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; -import { useWebApi } from '@/context/WebApiContext'; +import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionError'; /** * Unlock screen. */ export default function UnlockScreen() : React.ReactNode { - const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayNameKey, getEncryptionKeyDerivationParams } = useAuth(); + const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayNameKey, getEncryptionKeyDerivationParams, logout } = useApp(); const dbContext = useDb(); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isBiometricsAvailable, setIsBiometricsAvailable] = useState(false); const colors = useColors(); const { t } = useTranslation(); - const webApi = useWebApi(); const [biometricDisplayName, setBiometricDisplayName] = useState(''); const [showPassword, setShowPassword] = useState(false); @@ -43,12 +42,12 @@ export default function UnlockScreen() : React.ReactNode { const getKeyDerivationParams = useCallback(async () : Promise<{ salt: string; encryptionType: string; encryptionSettings: string } | null> => { const params = await getEncryptionKeyDerivationParams(); if (!params) { - await webApi.logout(); + await logout(); router.replace('/login'); return null; } return params; - }, [webApi, getEncryptionKeyDerivationParams]); + }, [logout, getEncryptionKeyDerivationParams]); useEffect(() => { getKeyDerivationParams(); @@ -114,6 +113,11 @@ export default function UnlockScreen() : React.ReactNode { Alert.alert(t('common.error'), t('auth.errors.incorrectPassword')); } } catch (error) { + if (error instanceof VaultVersionIncompatibleError) { + await logout(t(error.message)); + return; + } + console.error('Unlock error:', error); Alert.alert(t('common.error'), t('auth.errors.incorrectPassword')); } finally { @@ -129,7 +133,7 @@ export default function UnlockScreen() : React.ReactNode { * Clear any stored tokens or session data * This will be handled by the auth context */ - await webApi.logout(); + await logout(); router.replace('/login'); }; diff --git a/apps/mobile-app/app/upgrade.tsx b/apps/mobile-app/app/upgrade.tsx index 5d3b3cdd9..0a9ba3539 100644 --- a/apps/mobile-app/app/upgrade.tsx +++ b/apps/mobile-app/app/upgrade.tsx @@ -17,7 +17,7 @@ import { ThemedText } from '@/components/themed/ThemedText'; import { ThemedView } from '@/components/themed/ThemedView'; import { Avatar } from '@/components/ui/Avatar'; import { RobustPressable } from '@/components/ui/RobustPressable'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import { useWebApi } from '@/context/WebApiContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; @@ -26,7 +26,8 @@ import NativeVaultManager from '@/specs/NativeVaultManager'; * Upgrade screen. */ export default function UpgradeScreen() : React.ReactNode { - const { username } = useAuth(); + const { username, logout } = useApp(); + const webApi = useWebApi(); const { sqliteClient } = useDb(); const [isLoading, setIsLoading] = useState(false); const [currentVersion, setCurrentVersion] = useState(null); @@ -34,7 +35,6 @@ export default function UpgradeScreen() : React.ReactNode { const [upgradeStatus, setUpgradeStatus] = useState(''); const colors = useColors(); const { t } = useTranslation(); - const webApi = useWebApi(); const { executeVaultMutation, isLoading: isVaultMutationLoading, syncStatus } = useVaultMutate(); const { syncVault } = useVaultSync(); @@ -217,7 +217,7 @@ export default function UpgradeScreen() : React.ReactNode { * Clear any stored tokens or session data * This will be handled by the auth context */ - await webApi.logout(); + await logout(); router.replace('/login'); }; diff --git a/apps/mobile-app/context/AppContext.tsx b/apps/mobile-app/context/AppContext.tsx new file mode 100644 index 000000000..d7cd7eaac --- /dev/null +++ b/apps/mobile-app/context/AppContext.tsx @@ -0,0 +1,162 @@ +import React, { createContext, useContext, useMemo, useCallback, useEffect, useState } from 'react'; + +import { useAuth } from '@/context/AuthContext'; +import { useWebApi } from '@/context/WebApiContext'; + +import { logoutEventEmitter } from '@/events/LogoutEventEmitter'; + +import i18n from '@/i18n'; + +type AppContextType = { + isLoggedIn: boolean; + isInitialized: boolean; + username: string | null; + isOffline: boolean; + logout: (errorMessage?: string) => Promise; + initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>; + setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise; + login: () => Promise; + isLoggingOut: boolean; + // Auth methods from AuthContext + getEnabledAuthMethods: () => Promise; + isBiometricsEnabled: () => Promise; + setAuthMethods: (methods: AuthMethod[]) => Promise; + getAuthMethodDisplayKey: () => Promise; + getAutoLockTimeout: () => Promise; + setAutoLockTimeout: (timeout: number) => Promise; + getClipboardClearTimeout: () => Promise; + setClipboardClearTimeout: (timeout: number) => Promise; + getBiometricDisplayNameKey: () => Promise; + isBiometricsEnabledOnDevice: () => Promise; + setOfflineMode: (isOffline: boolean) => void; + verifyPassword: (password: string) => Promise; + getEncryptionKeyDerivationParams: () => Promise<{ salt: string; encryptionType: string; encryptionSettings: string } | null>; + // Autofill methods + shouldShowAutofillReminder: boolean; + markAutofillConfigured: () => Promise; + // Return URL methods + returnUrl: { path: string; params?: object } | null; + setReturnUrl: (url: { path: string; params?: object } | null) => void; +} + +export type AuthMethod = 'faceid' | 'password'; + +const AppContext = createContext(undefined); + +/** + * AppProvider that coordinates between auth, db, and webApi contexts. + */ +export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const auth = useAuth(); + const webApi = useWebApi(); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + /** + * Logout the user by revoking tokens and clearing the auth tokens from storage. + * Prevents recursive logout calls by tracking logout state. + */ + const logout = useCallback(async (errorMessage?: string): Promise => { + // Prevent recursive logout calls + if (isLoggingOut) { + console.debug('Logout already in progress, ignoring duplicate call'); + return; + } + + try { + setIsLoggingOut(true); + await webApi.revokeTokens(); + await auth.clearAuth(errorMessage); + } catch (error) { + console.error('Error during logout:', error); + } finally { + setIsLoggingOut(false); + } + }, [auth, webApi, isLoggingOut]); + + /** + * Subscribe to logout events from WebApiService. + */ + useEffect(() => { + const unsubscribe = logoutEventEmitter.subscribe(async (errorKey: string) => { + await logout(i18n.t(errorKey)); + }); + + return unsubscribe; + }, [logout]); + + const contextValue = useMemo(() => ({ + // Pass through auth state + isInitialized: auth.isInitialized, + isLoggedIn: auth.isLoggedIn, + username: auth.username, + isOffline: auth.isOffline, + shouldShowAutofillReminder: auth.shouldShowAutofillReminder, + returnUrl: auth.returnUrl, + // Wrap auth methods + logout, + initializeAuth: auth.initializeAuth, + setAuthTokens: auth.setAuthTokens, + login: auth.login, + isLoggingOut, + // Pass through other auth methods + getEnabledAuthMethods: auth.getEnabledAuthMethods, + isBiometricsEnabled: auth.isBiometricsEnabled, + setAuthMethods: auth.setAuthMethods, + getAuthMethodDisplayKey: auth.getAuthMethodDisplayKey, + getAutoLockTimeout: auth.getAutoLockTimeout, + setAutoLockTimeout: auth.setAutoLockTimeout, + getClipboardClearTimeout: auth.getClipboardClearTimeout, + setClipboardClearTimeout: auth.setClipboardClearTimeout, + getBiometricDisplayNameKey: auth.getBiometricDisplayNameKey, + isBiometricsEnabledOnDevice: auth.isBiometricsEnabledOnDevice, + setOfflineMode: auth.setOfflineMode, + verifyPassword: auth.verifyPassword, + getEncryptionKeyDerivationParams: auth.getEncryptionKeyDerivationParams, + markAutofillConfigured: auth.markAutofillConfigured, + setReturnUrl: auth.setReturnUrl, + }), [ + auth.isInitialized, + auth.isLoggedIn, + auth.username, + auth.isOffline, + auth.shouldShowAutofillReminder, + auth.returnUrl, + auth.initializeAuth, + auth.setAuthTokens, + auth.login, + auth.getEnabledAuthMethods, + auth.isBiometricsEnabled, + auth.setAuthMethods, + auth.getAuthMethodDisplayKey, + auth.getAutoLockTimeout, + auth.setAutoLockTimeout, + auth.getClipboardClearTimeout, + auth.setClipboardClearTimeout, + auth.getBiometricDisplayNameKey, + auth.isBiometricsEnabledOnDevice, + auth.setOfflineMode, + auth.verifyPassword, + auth.getEncryptionKeyDerivationParams, + auth.markAutofillConfigured, + auth.setReturnUrl, + logout, + isLoggingOut, + ]); + + return ( + + {children} + + ); +}; + +/** + * Hook to use the AppContext. + */ +export const useApp = (): AppContextType => { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error('useApp must be used within an AppProvider'); + } + return context; +}; diff --git a/apps/mobile-app/context/AuthContext.tsx b/apps/mobile-app/context/AuthContext.tsx index 9cb6d722b..09fc41b72 100644 --- a/apps/mobile-app/context/AuthContext.tsx +++ b/apps/mobile-app/context/AuthContext.tsx @@ -11,6 +11,7 @@ import EncryptionUtility from '@/utils/EncryptionUtility'; import { useDb } from '@/context/DbContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; +import i18n from '@/i18n'; // Create a navigation reference export const navigationRef = React.createRef>(); @@ -27,7 +28,7 @@ type AuthContextType = { setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise; initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>; login: () => Promise; - logout: (errorMessage?: string) => Promise; + clearAuth: (errorMessage?: string) => Promise; setAuthMethods: (methods: AuthMethod[]) => Promise; getAuthMethodDisplayKey: () => Promise; getAutoLockTimeout: () => Promise; @@ -161,9 +162,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, []); /** - * Logout the user and clear the auth tokens from chrome storage. + * Clear authentication data and tokens from storage. + * This is called by AppContext after revoking tokens on the server. */ - const logout = useCallback(async (errorMessage?: string): Promise => { + const clearAuth = useCallback(async (errorMessage?: string): Promise => { await AsyncStorage.removeItem('username'); await AsyncStorage.removeItem('accessToken'); await AsyncStorage.removeItem('refreshToken'); @@ -172,7 +174,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children if (errorMessage) { // Show alert - Alert.alert(errorMessage); + Alert.alert(i18n.t('common.error'), errorMessage); } setUsername(null); @@ -429,7 +431,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setAuthTokens, initializeAuth, login, - logout, + clearAuth, setAuthMethods, getAuthMethodDisplayKey, isBiometricsEnabledOnDevice, @@ -455,7 +457,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setAuthTokens, initializeAuth, login, - logout, + clearAuth, setAuthMethods, getAuthMethodDisplayKey, isBiometricsEnabledOnDevice, diff --git a/apps/mobile-app/context/DbContext.tsx b/apps/mobile-app/context/DbContext.tsx index 608658ce2..1dae0c3a1 100644 --- a/apps/mobile-app/context/DbContext.tsx +++ b/apps/mobile-app/context/DbContext.tsx @@ -166,27 +166,22 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } * @returns true if the database is working, false otherwise */ const testDatabaseConnection = useCallback(async (derivedKey: string): Promise => { - try { - // Store the encryption key - await sqliteClient.storeEncryptionKey(derivedKey); + // Store the encryption key + await sqliteClient.storeEncryptionKey(derivedKey); - // Initialize the database - const unlocked = await unlockVault(); - if (!unlocked) { - return false; - } - - // Try to get the database version as a simple test query - const version = await sqliteClient.getDatabaseVersion(); - if (version && version.version && version.version.length > 0) { - return true; - } - - return false; - } catch { - // Error testing database connection, return false + // Initialize the database + const unlocked = await unlockVault(); + if (!unlocked) { return false; } + + // Try to get the database version as a simple test query + const version = await sqliteClient.getDatabaseVersion(); + if (version && version.version && version.version.length > 0) { + return true; + } + + return false; }, [sqliteClient, unlockVault]); const contextValue = useMemo(() => ({ diff --git a/apps/mobile-app/context/WebApiContext.tsx b/apps/mobile-app/context/WebApiContext.tsx index 12b4f67f8..fc2cc41bf 100644 --- a/apps/mobile-app/context/WebApiContext.tsx +++ b/apps/mobile-app/context/WebApiContext.tsx @@ -2,32 +2,21 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { WebApiService } from '@/utils/WebApiService'; -import { useAuth } from '@/context/AuthContext'; - const WebApiContext = createContext(null); /** * WebApiProvider to provide the WebApiService to the app that components can use. */ export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { logout } = useAuth(); const [webApiService, setWebApiService] = useState(null); /** * Initialize WebApiService */ useEffect(() : void => { - const service = new WebApiService( - (statusError: string | null) => { - if (statusError) { - logout(statusError); - } else { - logout(); - } - } - ); + const service = new WebApiService(); setWebApiService(service); - }, [logout]); + }, []); if (!webApiService) { return null; diff --git a/apps/mobile-app/events/LogoutEventEmitter.ts b/apps/mobile-app/events/LogoutEventEmitter.ts new file mode 100644 index 000000000..b18d3adc2 --- /dev/null +++ b/apps/mobile-app/events/LogoutEventEmitter.ts @@ -0,0 +1,38 @@ +type LogoutListener = (errorMessage: string) => void | Promise; + +/** + * Simple event emitter for logout events to avoid circular dependencies + * between WebApiService and Auth contexts. + */ +class LogoutEventEmitter { + private listeners: Set = new Set(); + + /** + * Subscribe to logout events. + * Returns an unsubscribe function. + */ + public subscribe(listener: LogoutListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Emit a logout event to all listeners. + * + * @param errorTranslationKey - The translation key of the error message to emit. + */ + public emit(errorTranslationKey: string): void { + this.listeners.forEach(listener => { + try { + listener(errorTranslationKey); + } catch (error) { + console.error('Error in logout listener:', error); + } + }); + } +} + +// Export singleton instance +export const logoutEventEmitter = new LogoutEventEmitter(); diff --git a/apps/mobile-app/hooks/useVaultMutate.ts b/apps/mobile-app/hooks/useVaultMutate.ts index d12e24123..036b304d5 100644 --- a/apps/mobile-app/hooks/useVaultMutate.ts +++ b/apps/mobile-app/hooks/useVaultMutate.ts @@ -11,7 +11,7 @@ import EncryptionUtility from '@/utils/EncryptionUtility'; import { useVaultSync } from '@/hooks/useVaultSync'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import { useWebApi } from '@/context/WebApiContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; @@ -39,7 +39,7 @@ export function useVaultMutate() : { const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const [syncStatus, setSyncStatus] = useState(t('vault.syncingVault')); - const authContext = useAuth(); + const authContext = useApp(); const dbContext = useDb(); const webApi = useWebApi(); const { syncVault } = useVaultSync(); @@ -201,7 +201,7 @@ export function useVaultMutate() : { await NativeVaultManager.unlockVault(); } catch { // If any part of this fails, we need logout the user as the local vault and stored encryption key are now potentially corrupt. - authContext.logout(t('vault.errors.errorDuringPasswordChange')); + await authContext.logout(t('vault.errors.errorDuringPasswordChange')); } // Generate SRP password change data diff --git a/apps/mobile-app/hooks/useVaultSync.ts b/apps/mobile-app/hooks/useVaultSync.ts index f7d05189c..27d6e0f50 100644 --- a/apps/mobile-app/hooks/useVaultSync.ts +++ b/apps/mobile-app/hooks/useVaultSync.ts @@ -2,13 +2,15 @@ import { useCallback } from 'react'; import { AppInfo } from '@/utils/AppInfo'; import type { VaultResponse } from '@/utils/dist/shared/models/webapi'; +import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; import { useTranslation } from '@/hooks/useTranslation'; -import { useAuth } from '@/context/AuthContext'; +import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import { useWebApi } from '@/context/WebApiContext'; import NativeVaultManager from '@/specs/NativeVaultManager'; +import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionError'; /** * Utility function to ensure a minimum time has elapsed for an operation @@ -50,7 +52,7 @@ export const useVaultSync = () : { syncVault: (options?: VaultSyncOptions) => Promise; } => { const { t } = useTranslation(); - const authContext = useAuth(); + const app = useApp(); const dbContext = useDb(); const webApi = useWebApi(); @@ -61,7 +63,7 @@ export const useVaultSync = () : { const enableDelay = initialSync; try { - const { isLoggedIn } = await authContext.initializeAuth(); + const { isLoggedIn } = await app.initializeAuth(); if (!isLoggedIn) { // Not authenticated, return false immediately @@ -91,7 +93,7 @@ export const useVaultSync = () : { } // Check if the SRP salt has changed compared to locally stored encryption key derivation params - const keyDerivationParams = await authContext.getEncryptionKeyDerivationParams(); + const keyDerivationParams = await app.getEncryptionKeyDerivationParams(); if (keyDerivationParams && statusResponse.srpSalt && statusResponse.srpSalt !== keyDerivationParams.salt) { /** * Server SRP salt has changed compared to locally stored value, which means the user has changed @@ -99,12 +101,12 @@ export const useVaultSync = () : { * longer valid and the user needs to re-authenticate. We trigger a logout but do not revoke tokens * as these were already revoked by the server upon password change. */ - await webApi.logout(t('vault.errors.passwordChanged'), false); + await app.logout(t('vault.errors.passwordChanged')); return false; } // If we get here, it means we have a valid connection to the server. - authContext.setOfflineMode(false); + app.setOfflineMode(false); // Compare vault revisions const vaultMetadata = await dbContext.getVaultMetadata(); @@ -116,16 +118,8 @@ export const useVaultSync = () : { const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse); if (vaultError) { - // Only logout if it's an authentication error, not a network error - if (vaultError.includes('authentication') || vaultError.includes('unauthorized')) { - await webApi.logout(vaultError); - onError?.(vaultError); - return false; - } - - // For other errors, go into offline mode - authContext.setOfflineMode(true); - return true; + // Throw authentication error which will be caught and handled + throw new VaultAuthenticationError(vaultError); } try { @@ -139,7 +133,12 @@ export const useVaultSync = () : { onSuccess?.(true); return true; - } catch { + } catch (err) { + if (err instanceof VaultVersionIncompatibleError) { + await app.logout(t(err.message)); + return false; + } + // Vault could not be decrypted, throw an error throw new Error(t('vault.errors.vaultDecryptFailed')); } @@ -154,19 +153,31 @@ export const useVaultSync = () : { await withMinimumDelay(() => Promise.resolve(onSuccess?.(false)), 300, enableDelay); return false; } catch (err) { - const errorMessage = err instanceof Error ? err.message : t('common.errors.unknownError'); console.error('Vault sync error:', err); + // Handle authentication errors + if (err instanceof VaultAuthenticationError) { + await app.logout(err.message); + return false; + } + + if (err instanceof VaultVersionIncompatibleError) { + await app.logout(t(err.message)); + return false; + } + + const errorMessage = err instanceof Error ? err.message : t('common.errors.unknownError'); + // Check if it's a network error if (errorMessage.includes('network') || errorMessage.includes('timeout')) { - authContext.setOfflineMode(true); + app.setOfflineMode(true); return true; } onError?.(errorMessage); return false; } - }, [authContext, dbContext, webApi, t]); + }, [app, dbContext, webApi, t]); return { syncVault }; }; \ No newline at end of file diff --git a/apps/mobile-app/utils/SqliteClient.tsx b/apps/mobile-app/utils/SqliteClient.tsx index 53f2879da..773b1674e 100644 --- a/apps/mobile-app/utils/SqliteClient.tsx +++ b/apps/mobile-app/utils/SqliteClient.tsx @@ -3,6 +3,7 @@ import { Buffer } from 'buffer'; import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/shared/models/metadata'; import type { Attachment, Credential, EncryptionKey, PasswordSettings, TotpCode } from '@/utils/dist/shared/models/vault'; import { VaultSqlGenerator, VaultVersion } from '@/utils/dist/shared/vault-sql'; +import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionError'; import NativeVaultManager from '@/specs/NativeVaultManager'; @@ -625,43 +626,38 @@ class SqliteClient { * Returns null if no matching version is found. */ public async getDatabaseVersion(): Promise { - try { - let currentVersion = ''; + let currentVersion = ''; - // Query the migrations history table for the latest migration - const results = await this.executeQuery<{ MigrationId: string }>(` - SELECT MigrationId - FROM __EFMigrationsHistory - ORDER BY MigrationId DESC - LIMIT 1`); + // Query the migrations history table for the latest migration + const results = await this.executeQuery<{ MigrationId: string }>(` + SELECT MigrationId + FROM __EFMigrationsHistory + ORDER BY MigrationId DESC + LIMIT 1`); - if (results.length === 0) { - throw new Error('No migrations found'); - } - - // Extract version using regex - matches patterns like "20240917191243_1.4.1-RenameAttachmentsPlural" - const migrationId = results[0].MigrationId; - const versionRegex = /_(\d+\.\d+\.\d+)-/; - const versionMatch = versionRegex.exec(migrationId); - - if (versionMatch?.[1]) { - currentVersion = versionMatch[1]; - } - - // Get all available vault versions to get the revision number of the current version. - const vaultSqlGenerator = new VaultSqlGenerator(); - const allVersions = vaultSqlGenerator.getAllVersions(); - const currentVersionRevision = allVersions.find(v => v.version === currentVersion); - - if (!currentVersionRevision) { - throw new Error(`This app is outdated and cannot be used to access this vault. Please update this app to continue.`); - } - - return currentVersionRevision; - } catch (error) { - console.error('Error getting database version:', error); - throw error; + if (results.length === 0) { + throw new Error('No migrations found'); } + + // Extract version using regex - matches patterns like "20240917191243_1.4.1-RenameAttachmentsPlural" + const migrationId = results[0].MigrationId; + const versionRegex = /_(\d+\.\d+\.\d+)-/; + const versionMatch = versionRegex.exec(migrationId); + + if (versionMatch?.[1]) { + currentVersion = versionMatch[1]; + } + + // Get all available vault versions to get the revision number of the current version. + const vaultSqlGenerator = new VaultSqlGenerator(); + const allVersions = vaultSqlGenerator.getAllVersions(); + const currentVersionRevision = allVersions.find(v => v.version === currentVersion); + + if (!currentVersionRevision) { + throw new VaultVersionIncompatibleError('vault.errors.appOutdated'); + } + + return currentVersionRevision; } /** diff --git a/apps/mobile-app/utils/WebApiService.ts b/apps/mobile-app/utils/WebApiService.ts index e960af87d..d98dc1998 100644 --- a/apps/mobile-app/utils/WebApiService.ts +++ b/apps/mobile-app/utils/WebApiService.ts @@ -6,6 +6,7 @@ import type { StatusResponse, VaultResponse, AuthLogModel, RefreshToken } from ' import i18n from '@/i18n'; import { LocalAuthError } from './types/errors/LocalAuthError'; +import { logoutEventEmitter } from '@/events/LogoutEventEmitter'; type RequestInit = globalThis.RequestInit; @@ -23,10 +24,8 @@ type TokenResponse = { export class WebApiService { /** * Constructor for the WebApiService class. - * - * @param {Function} authContextLogout - Function to handle logout. */ - public constructor(private readonly authContextLogout: (statusError: string | null) => void) { } + public constructor() { } /** * Get the base URL for the API from settings. @@ -86,7 +85,7 @@ export class WebApiService { return parseJson ? retryResponse.json() : retryResponse as unknown as T; } else { - this.authContextLogout(null); + logoutEventEmitter.emit('auth.errors.sessionExpired'); throw new Error(i18n.t('auth.errors.sessionExpired')); } } @@ -188,7 +187,7 @@ export class WebApiService { this.updateTokens(tokenResponse.token, tokenResponse.refreshToken); return tokenResponse.token; } catch { - this.authContextLogout(i18n.t('auth.errors.sessionExpired')); + logoutEventEmitter.emit('auth.errors.sessionExpired'); return null; } } @@ -259,29 +258,21 @@ export class WebApiService { } /** - * Logout and revoke tokens via WebApi and remove local storage tokens via AuthContext. + * Revoke tokens via WebApi called when logging out. */ - public async logout(statusError: string | null = null, revokeTokens: boolean = true): Promise { - // Logout and revoke tokens via WebApi. + public async revokeTokens(): Promise { + // Revoke tokens via WebApi. try { - if (revokeTokens) { - const refreshToken = await this.getRefreshToken(); - if (!refreshToken) { - return; - } - - // We do not await this as we want to continue with the logout even if the revoke fails or takes a long time. - this.post('Auth/revoke', { + const refreshToken = await this.getRefreshToken(); + if (refreshToken) { + await this.post('Auth/revoke', { token: await this.getAccessToken(), refreshToken: refreshToken, }, false); } } catch (err) { - console.error('WebApi logout error:', err); + console.error('WebApi revoke tokens error:', err); } - - // Logout and remove tokens from local storage via AuthContext. - this.authContextLogout(statusError); } /** @@ -296,7 +287,7 @@ export class WebApiService { * If session expired, logout the user immediately as otherwise this would * trigger a server offline banner. */ - this.authContextLogout(error.message); + logoutEventEmitter.emit('auth.errors.sessionExpired'); throw error; } @@ -307,7 +298,8 @@ export class WebApiService { return { clientVersionSupported: true, serverVersion: '0.0.0', - vaultRevision: 0 + vaultRevision: 0, + srpSalt: '' }; } } diff --git a/apps/mobile-app/utils/types/errors/VaultAuthenticationError.ts b/apps/mobile-app/utils/types/errors/VaultAuthenticationError.ts new file mode 100644 index 000000000..add11c020 --- /dev/null +++ b/apps/mobile-app/utils/types/errors/VaultAuthenticationError.ts @@ -0,0 +1,20 @@ +/** + * Custom error class for vault authentication errors. + * Thrown when the vault sync fails due to authentication issues. + */ +export class VaultAuthenticationError extends Error { + /** + * Creates a new instance of VaultAuthenticationError. + * + * @param message - The error message (translation key or translated message). + */ + public constructor(message: string) { + super(message); + this.name = 'VaultAuthenticationError'; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, VaultAuthenticationError); + } + } +} diff --git a/apps/mobile-app/utils/types/errors/VaultVersionError.ts b/apps/mobile-app/utils/types/errors/VaultVersionError.ts new file mode 100644 index 000000000..08b7a986c --- /dev/null +++ b/apps/mobile-app/utils/types/errors/VaultVersionError.ts @@ -0,0 +1,19 @@ +/** + * Custom error class for vault version incompatibility issues. + * Thrown when the mobile app is outdated and cannot handle the vault version. + */ +export class VaultVersionIncompatibleError extends Error { + /** + * Creates a new instance of the VaultVersionIncompatibleError class. + * @param message - The error message. + */ + public constructor(message: string) { + super(message); + this.name = 'VaultVersionIncompatibleError'; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, VaultVersionIncompatibleError); + } + } +}