Mobile app logout flow and AppContext refactor (#1274)

This commit is contained in:
Leendert de Borst
2025-09-27 12:25:24 +02:00
committed by Leendert de Borst
parent 46c364bbb4
commit e5d924a094
20 changed files with 443 additions and 182 deletions

View File

@@ -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<FlatList>(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) {

View File

@@ -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<ScrollView>(null);
@@ -121,7 +119,7 @@ export default function SettingsScreen() : React.ReactNode {
* Handle the logout.
*/
onPress: async () : Promise<void> => {
await webApi.logout();
await logout();
router.replace('/login');
}
},

View File

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

View File

@@ -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 {
<DbProvider>
<AuthProvider>
<WebApiProvider>
<ClipboardCountdownProvider>
<KeyboardProvider>
<GestureHandlerRootView>
<RootLayoutNav />
</GestureHandlerRootView>
</KeyboardProvider>
</ClipboardCountdownProvider>
<AppProvider>
<ClipboardCountdownProvider>
<KeyboardProvider>
<GestureHandlerRootView>
<RootLayoutNav />
</GestureHandlerRootView>
</KeyboardProvider>
</ClipboardCountdownProvider>
</AppProvider>
</WebApiProvider>
</AuthProvider>
</DbProvider>

View File

@@ -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<NodeJS.Timeout | null>(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<void> => {
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<void> {
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<void> => {
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.

View File

@@ -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<string | null>(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);
},
/**

View File

@@ -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<NodeJS.Timeout | null>(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<void> => {
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<void> {
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<void> => {
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.
*/

View File

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

View File

@@ -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<VaultVersion | null>(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');
};

View File

@@ -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<void>;
initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>;
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
login: () => Promise<void>;
isLoggingOut: boolean;
// Auth methods from AuthContext
getEnabledAuthMethods: () => Promise<AuthMethod[]>;
isBiometricsEnabled: () => Promise<boolean>;
setAuthMethods: (methods: AuthMethod[]) => Promise<void>;
getAuthMethodDisplayKey: () => Promise<string>;
getAutoLockTimeout: () => Promise<number>;
setAutoLockTimeout: (timeout: number) => Promise<void>;
getClipboardClearTimeout: () => Promise<number>;
setClipboardClearTimeout: (timeout: number) => Promise<void>;
getBiometricDisplayNameKey: () => Promise<string>;
isBiometricsEnabledOnDevice: () => Promise<boolean>;
setOfflineMode: (isOffline: boolean) => void;
verifyPassword: (password: string) => Promise<string | null>;
getEncryptionKeyDerivationParams: () => Promise<{ salt: string; encryptionType: string; encryptionSettings: string } | null>;
// Autofill methods
shouldShowAutofillReminder: boolean;
markAutofillConfigured: () => Promise<void>;
// 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<AppContextType | undefined>(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<void> => {
// 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 (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
);
};
/**
* 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;
};

View File

@@ -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<NavigationContainerRef<ParamListBase>>();
@@ -27,7 +28,7 @@ type AuthContextType = {
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>;
login: () => Promise<void>;
logout: (errorMessage?: string) => Promise<void>;
clearAuth: (errorMessage?: string) => Promise<void>;
setAuthMethods: (methods: AuthMethod[]) => Promise<void>;
getAuthMethodDisplayKey: () => Promise<string>;
getAutoLockTimeout: () => Promise<number>;
@@ -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<void> => {
const clearAuth = useCallback(async (errorMessage?: string): Promise<void> => {
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,

View File

@@ -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<boolean> => {
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(() => ({

View File

@@ -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<WebApiService | null>(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<WebApiService | null>(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;

View File

@@ -0,0 +1,38 @@
type LogoutListener = (errorMessage: string) => void | Promise<void>;
/**
* Simple event emitter for logout events to avoid circular dependencies
* between WebApiService and Auth contexts.
*/
class LogoutEventEmitter {
private listeners: Set<LogoutListener> = 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();

View File

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

View File

@@ -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<boolean>;
} => {
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 };
};

View File

@@ -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<VaultVersion> {
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;
}
/**

View File

@@ -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<void> {
// Logout and revoke tokens via WebApi.
public async revokeTokens(): Promise<void> {
// 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: ''
};
}
}

View File

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

View File

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