diff --git a/apps/mobile-app/app/(tabs)/settings/index.tsx b/apps/mobile-app/app/(tabs)/settings/index.tsx index a89d7e237..8a9c6c4f9 100644 --- a/apps/mobile-app/app/(tabs)/settings/index.tsx +++ b/apps/mobile-app/app/(tabs)/settings/index.tsx @@ -8,6 +8,7 @@ import { useApiUrl } from '@/utils/ApiUrlUtility'; import { AppInfo } from '@/utils/AppInfo'; import { useColors } from '@/hooks/useColorScheme'; +import { useLogout } from '@/hooks/useLogout'; import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; import { useTranslation } from '@/hooks/useTranslation'; @@ -18,9 +19,6 @@ import { InlineSkeletonLoader } from '@/components/ui/InlineSkeletonLoader'; import { TitleContainer } from '@/components/ui/TitleContainer'; import { UsernameDisplay } from '@/components/ui/UsernameDisplay'; import { useApp } from '@/context/AppContext'; -import { useAuth } from '@/context/AuthContext'; -import { useDb } from '@/context/DbContext'; -import { useWebApi } from '@/context/WebApiContext'; /** * Settings screen. @@ -31,9 +29,7 @@ export default function SettingsScreen() : React.ReactNode { const insets = useSafeAreaInsets(); const { getAuthMethodDisplayKey, shouldShowAutofillReminder } = useApp(); const { getAutoLockTimeout, getClipboardClearTimeout } = useApp(); - const { clearAuthUserInitiated } = useAuth(); - const { isDirty } = useDb(); - const webApi = useWebApi(); + const { logoutUserInitiated } = useLogout(); const { loadApiUrl, getDisplayUrl } = useApiUrl(); const scrollY = useRef(new Animated.Value(0)).current; const scrollViewRef = useRef(null); @@ -112,56 +108,6 @@ export default function SettingsScreen() : React.ReactNode { }, [getAutoLockTimeout, getAuthMethodDisplayKey, setIsFirstLoad, loadApiUrl, getClipboardClearTimeout, t]) ); - /** - * Perform the actual logout - revokes tokens and clears auth. - */ - const performLogout = async () : Promise => { - try { - await webApi.revokeTokens(); - } catch (error) { - console.error('Error revoking tokens:', error); - // Continue with logout even if revoke fails - } - await clearAuthUserInitiated(); - router.replace('/login'); - }; - - /** - * Handle the logout button press. - * Shows warning if there are unsynced changes, otherwise shows normal confirmation. - */ - const handleLogout = async () : Promise => { - if (isDirty) { - // Show warning about unsynced changes - Alert.alert( - t('logout.unsyncedChangesTitle'), - t('logout.unsyncedChangesWarning'), - [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: t('logout.logoutAnyway'), - style: 'destructive', - onPress: performLogout, - }, - ] - ); - } else { - // Show normal confirmation dialog - Alert.alert( - t('auth.logout'), - t('auth.confirmLogout'), - [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: t('auth.logout'), - style: 'destructive', - onPress: performLogout, - }, - ] - ); - } - }; - /** * Handle the vault unlock press. */ @@ -547,7 +493,7 @@ export default function SettingsScreen() : React.ReactNode { diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 1f3738bfa..da69a589c 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -10,6 +10,7 @@ import EncryptionUtility from '@/utils/EncryptionUtility'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; import { useColors } from '@/hooks/useColorScheme'; +import { useLogout } from '@/hooks/useLogout'; import { useTranslation } from '@/hooks/useTranslation'; import Logo from '@/assets/images/logo.svg'; @@ -25,7 +26,8 @@ import NativeVaultManager from '@/specs/NativeVaultManager'; * Unlock screen. */ export default function UnlockScreen() : React.ReactNode { - const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams, logout } = useApp(); + const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams } = useApp(); + const { logoutUserInitiated, logoutForced } = useLogout(); const dbContext = useDb(); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(true); @@ -48,12 +50,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 logout(); - router.replace('/login'); + // No params means corrupted state - force logout without confirmation + await logoutForced(); return null; } return params; - }, [logout, getEncryptionKeyDerivationParams]); + }, [logoutForced, getEncryptionKeyDerivationParams]); useEffect(() => { getKeyDerivationParams(); @@ -140,7 +142,8 @@ export default function UnlockScreen() : React.ReactNode { } } catch (error) { if (error instanceof VaultVersionIncompatibleError) { - await logout(t(error.message)); + // Vault version incompatible - force logout without confirmation + await logoutForced(); return; } @@ -155,18 +158,6 @@ export default function UnlockScreen() : React.ReactNode { } }; - /** - * Handle the logout. - */ - const handleLogout = async () : Promise => { - /* - * Clear any stored tokens or session data - * This will be handled by the auth context - */ - await logout(); - router.replace('/login'); - }; - /** * Handle the biometrics retry. */ @@ -415,7 +406,7 @@ export default function UnlockScreen() : React.ReactNode { {t('auth.logout')} diff --git a/apps/mobile-app/hooks/useLogout.ts b/apps/mobile-app/hooks/useLogout.ts new file mode 100644 index 000000000..ce236a43b --- /dev/null +++ b/apps/mobile-app/hooks/useLogout.ts @@ -0,0 +1,114 @@ +import { useCallback } from 'react'; +import { Alert } from 'react-native'; +import { router } from 'expo-router'; + +import { useTranslation } from '@/hooks/useTranslation'; +import { useAuth } from '@/context/AuthContext'; +import { useDb } from '@/context/DbContext'; +import { useWebApi } from '@/context/WebApiContext'; + +type UseLogoutReturn = { + /** + * User-initiated logout (e.g., user clicks logout button). + * Shows confirmation dialog and warns about unsynced changes. + * Clears ALL data including vault. + */ + logoutUserInitiated: () => Promise; + + /** + * Forced logout (e.g., corrupted state, incompatible vault version). + * Logs out immediately without confirmation dialog. + * Clears ALL data including vault. + */ + logoutForced: () => Promise; +}; + +/** + * Hook for handling logout across the app. + * Provides consistent logout behavior with: + * - Warning about unsynced changes (isDirty check) + * - Confirmation dialog for user-initiated logout + * - Token revocation + * - Complete auth and vault data clearance + * + * Usage: + * ```ts + * const { logoutUserInitiated, logoutForced } = useLogout(); + * + * // For user clicking logout button: + * await logoutUserInitiated(); + * + * // For error scenarios requiring immediate logout: + * await logoutForced(); + * ``` + */ +export function useLogout(): UseLogoutReturn { + const { t } = useTranslation(); + const { clearAuthUserInitiated } = useAuth(); + const { isDirty } = useDb(); + const webApi = useWebApi(); + + /** + * Perform the actual logout - revokes tokens and clears auth. + * Internal function used by both logout methods. + */ + const performLogout = useCallback(async (): Promise => { + try { + await webApi.revokeTokens(); + } catch (error) { + console.error('Error revoking tokens:', error); + // Continue with logout even if revoke fails + } + await clearAuthUserInitiated(); + router.replace('/login'); + }, [webApi, clearAuthUserInitiated]); + + /** + * Forced logout - logs out immediately without confirmation. + * Use for error scenarios like corrupted state or incompatible vault version. + */ + const logoutForced = useCallback(async (): Promise => { + await performLogout(); + }, [performLogout]); + + /** + * User-initiated logout - shows confirmation dialog. + * Shows warning if there are unsynced changes, otherwise shows normal confirmation. + */ + const logoutUserInitiated = useCallback(async (): Promise => { + if (isDirty) { + // Show warning about unsynced changes + Alert.alert( + t('logout.unsyncedChangesTitle'), + t('logout.unsyncedChangesWarning'), + [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('logout.logoutAnyway'), + style: 'destructive', + onPress: performLogout, + }, + ] + ); + } else { + // Show normal confirmation dialog + Alert.alert( + t('auth.logout'), + t('auth.confirmLogout'), + [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('auth.logout'), + style: 'destructive', + onPress: performLogout, + }, + ] + ); + } + }, [isDirty, t, performLogout]); + + return { + logoutUserInitiated, + logoutForced, + }; +} diff --git a/apps/mobile-app/ios/AliasVaultUITests/AliasVaultUITests.swift b/apps/mobile-app/ios/AliasVaultUITests/AliasVaultUITests.swift index 17a22112c..a8b32b30a 100644 --- a/apps/mobile-app/ios/AliasVaultUITests/AliasVaultUITests.swift +++ b/apps/mobile-app/ios/AliasVaultUITests/AliasVaultUITests.swift @@ -178,12 +178,11 @@ final class AliasVaultUITests: XCTestCase { ) let usernameInput = app.findTextField(testID: "username-input") - usernameInput.tapNoIdle() - usernameInput.typeText(testUser.username) + usernameInput.clearAndTypeTextNoIdle(testUser.username) let passwordInput = app.findTextField(testID: "password-input") passwordInput.tapNoIdle() - passwordInput.typeText(testUser.password) + passwordInput.typeTextNoIdle(testUser.password) app.hideKeyboardIfVisible() @@ -267,11 +266,11 @@ final class AliasVaultUITests: XCTestCase { XCTContext.runActivity(named: "Fill item details") { _ in let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input") itemNameInput.tapNoIdle() - itemNameInput.typeText(uniqueName) + itemNameInput.typeTextNoIdle(uniqueName) let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input") serviceUrlInput.tapNoIdle() - serviceUrlInput.typeText("https://example.com") + serviceUrlInput.typeTextNoIdle("https://example.com") let addEmailButton = app.findElement(testID: "add-email-button") app.scrollToElement(addEmailButton) @@ -279,13 +278,13 @@ final class AliasVaultUITests: XCTestCase { let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input") loginEmailInput.tapNoIdle() - loginEmailInput.typeText("e2e-test@example.com") + loginEmailInput.typeTextNoIdle("e2e-test@example.com") let loginUsernameInput = app.findAndScrollToTextField(testID: "login-username-input") if loginUsernameInput.exists { app.scrollToElement(loginUsernameInput) loginUsernameInput.tapNoIdle() - loginUsernameInput.typeText("e2euser") + loginUsernameInput.typeTextNoIdle("e2euser") } app.hideKeyboardIfVisible() @@ -456,11 +455,11 @@ final class AliasVaultUITests: XCTestCase { let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input") itemNameInput.tapNoIdle() - itemNameInput.typeText(uniqueName) + itemNameInput.typeTextNoIdle(uniqueName) let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input") serviceUrlInput.tapNoIdle() - serviceUrlInput.typeText("https://offline-test.example.com") + serviceUrlInput.typeTextNoIdle("https://offline-test.example.com") let addEmailButton = app.findElement(testID: "add-email-button") app.scrollToElement(addEmailButton) @@ -468,7 +467,7 @@ final class AliasVaultUITests: XCTestCase { let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input") loginEmailInput.tapNoIdle() - loginEmailInput.typeText("offline-test@example.com") + loginEmailInput.typeTextNoIdle("offline-test@example.com") app.hideKeyboardIfVisible() @@ -675,11 +674,11 @@ final class AliasVaultUITests: XCTestCase { let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input") itemNameInput.tapNoIdle() - itemNameInput.typeText(uniqueName) + itemNameInput.typeTextNoIdle(uniqueName) let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input") serviceUrlInput.tapNoIdle() - serviceUrlInput.typeText("https://rpo-test.example.com") + serviceUrlInput.typeTextNoIdle("https://rpo-test.example.com") let addEmailButton = app.findElement(testID: "add-email-button") app.scrollToElement(addEmailButton) @@ -687,7 +686,7 @@ final class AliasVaultUITests: XCTestCase { let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input") loginEmailInput.tapNoIdle() - loginEmailInput.typeText("rpo-test@example.com") + loginEmailInput.typeTextNoIdle("rpo-test@example.com") app.hideKeyboardIfVisible() @@ -885,11 +884,11 @@ final class AliasVaultUITests: XCTestCase { let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input") itemNameInput.tapNoIdle() - itemNameInput.typeText(uniqueName) + itemNameInput.typeTextNoIdle(uniqueName) let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input") serviceUrlInput.tapNoIdle() - serviceUrlInput.typeText("https://forced-logout-test.example.com") + serviceUrlInput.typeTextNoIdle("https://forced-logout-test.example.com") let addEmailButton = app.findElement(testID: "add-email-button") app.scrollToElement(addEmailButton) @@ -897,7 +896,7 @@ final class AliasVaultUITests: XCTestCase { let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input") loginEmailInput.tapNoIdle() - loginEmailInput.typeText("forced-logout-test@example.com") + loginEmailInput.typeTextNoIdle("forced-logout-test@example.com") app.hideKeyboardIfVisible() @@ -1020,12 +1019,11 @@ final class AliasVaultUITests: XCTestCase { XCTContext.runActivity(named: "Step 6: Re-login with same credentials") { _ in // Clear username field and enter credentials let usernameInput = app.findTextField(testID: "username-input") - usernameInput.tapNoIdle() usernameInput.clearAndTypeTextNoIdle(testUser.username) let passwordInput = app.findTextField(testID: "password-input") passwordInput.tapNoIdle() - passwordInput.typeText(testUser.password) + passwordInput.typeTextNoIdle(testUser.password) app.hideKeyboardIfVisible() @@ -1121,11 +1119,31 @@ final class AliasVaultUITests: XCTestCase { /// - Parameter testUser: The test user to login with @MainActor private func loginWithTestUser(_ testUser: TestUser) { - sleep(1) // Allow app to settle - + // Wait for app to settle and reach a known state (unlock, login, or items screen) + // Use longer initial timeout since app may still be loading after launch let unlockScreen = app.findElement(testID: "unlock-screen") - if unlockScreen.waitForExistenceNoIdle(timeout: 3) { - // We're on unlock screen - logout to start fresh with test user + let loginScreen = app.findElement(testID: "login-screen") + let itemsScreen = app.findElement(testID: "items-screen") + + // Wait up to 15 seconds for any of the expected screens to appear + var screenFound = false + let startTime = Date() + let maxWaitTime: TimeInterval = 15 + + while !screenFound && Date().timeIntervalSince(startTime) < maxWaitTime { + if unlockScreen.exists || loginScreen.exists || itemsScreen.exists { + screenFound = true + } else { + Thread.sleep(forTimeInterval: 0.5) + } + } + + if !screenFound { + print("[Helper] Warning: No expected screen found after \(maxWaitTime)s, proceeding anyway") + } + + // Handle unlock screen - logout to start fresh with test user + if unlockScreen.exists { print("[Helper] Unlock screen detected - logging out to login fresh with test user") let logoutButton = app.findElement(testID: "logout-button") @@ -1140,26 +1158,23 @@ final class AliasVaultUITests: XCTestCase { } // Wait for login screen - let loginScreen = app.findElement(testID: "login-screen") _ = loginScreen.waitForExistenceNoIdle(timeout: 10) } // Check if we're on login screen - let loginScreen = app.findElement(testID: "login-screen") - if loginScreen.waitForExistenceNoIdle(timeout: 3) { + if loginScreen.waitForExistenceNoIdle(timeout: 2) { performLogin(with: testUser) return } // Check if we're already on items screen (already logged in as correct user) - let itemsScreen = app.findElement(testID: "items-screen") - if itemsScreen.waitForExistenceNoIdle(timeout: 3) { + if itemsScreen.waitForExistenceNoIdle(timeout: 2) { print("[Helper] Already on items screen, assuming correct user is logged in") return } - // Unknown state - try to navigate to login - print("[Helper] Unknown app state, waiting for login or items screen") + // Unknown state - log warning but continue (test will fail with appropriate error if needed) + print("[Helper] Unknown app state after waiting, test may fail") } /// Unlocks the vault if the unlock screen is displayed. @@ -1169,7 +1184,7 @@ final class AliasVaultUITests: XCTestCase { @MainActor private func unlockVaultIfNeeded(with testUser: TestUser) { let unlockScreen = app.findElement(testID: "unlock-screen") - guard unlockScreen.waitForExistenceNoIdle(timeout: 3) else { + guard unlockScreen.waitForExistenceNoIdle(timeout: 1) else { // Not on unlock screen, nothing to do return } @@ -1178,16 +1193,16 @@ final class AliasVaultUITests: XCTestCase { // Enter password in unlock screen let passwordInput = app.findTextField(testID: "unlock-password-input") - if passwordInput.waitForExistenceNoIdle(timeout: 5) { + if passwordInput.waitForExistenceNoIdle(timeout: 1) { passwordInput.tapNoIdle() - passwordInput.typeText(testUser.password) + passwordInput.typeTextNoIdle(testUser.password) } app.hideKeyboardIfVisible() // Tap unlock button let unlockButton = app.findElement(testID: "unlock-button") - if unlockButton.waitForExistenceNoIdle(timeout: 3) { + if unlockButton.waitForExistenceNoIdle(timeout: 1) { unlockButton.tapNoIdle() } @@ -1259,7 +1274,7 @@ final class AliasVaultUITests: XCTestCase { let passwordInput = app.findTextField(testID: "password-input") if passwordInput.waitForExistenceNoIdle(timeout: 3) { passwordInput.tapNoIdle() - passwordInput.typeText(testUser.password) + passwordInput.typeTextNoIdle(testUser.password) } app.hideKeyboardIfVisible() diff --git a/apps/mobile-app/ios/AliasVaultUITests/XCUIElementHelpers.swift b/apps/mobile-app/ios/AliasVaultUITests/XCUIElementHelpers.swift index e86f1d523..a267f4873 100644 --- a/apps/mobile-app/ios/AliasVaultUITests/XCUIElementHelpers.swift +++ b/apps/mobile-app/ios/AliasVaultUITests/XCUIElementHelpers.swift @@ -22,17 +22,52 @@ extension XCUIElement { } /// Clear text field and enter new text without waiting for idle. + /// Waits for keyboard to be ready before typing to prevent missing characters. @MainActor - func clearAndTypeTextNoIdle(_ text: String) { - guard let currentValue = self.value as? String, !currentValue.isEmpty else { + func clearAndTypeTextNoIdle(_ text: String, app: XCUIApplication? = nil) { + // Tap to focus + self.tapNoIdle() + + // Wait for keyboard to appear (critical for preventing missing characters) + let application = app ?? XCUIApplication() + let keyboardReady = application.keyboards.firstMatch.waitForExistenceNoIdle(timeout: 3) + if !keyboardReady { + // Retry tap if keyboard didn't appear + Thread.sleep(forTimeInterval: 0.2) self.tapNoIdle() - self.typeText(text) - return + _ = application.keyboards.firstMatch.waitForExistenceNoIdle(timeout: 2) } - self.tapNoIdle() - let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) - self.typeText(deleteString) + // Small delay to ensure keyboard is fully ready + Thread.sleep(forTimeInterval: 0.1) + + // Clear existing text if any + if let currentValue = self.value as? String, !currentValue.isEmpty { + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) + self.typeText(deleteString) + } + + // Type the new text + self.typeText(text) + } + + /// Type text with keyboard wait to prevent missing characters. + /// Use this instead of raw typeText() for more reliable input. + @MainActor + func typeTextNoIdle(_ text: String, app: XCUIApplication? = nil) { + // Wait for keyboard to appear + let application = app ?? XCUIApplication() + let keyboardReady = application.keyboards.firstMatch.waitForExistenceNoIdle(timeout: 3) + if !keyboardReady { + // Tap to focus if keyboard not visible + self.tapNoIdle() + _ = application.keyboards.firstMatch.waitForExistenceNoIdle(timeout: 2) + } + + // Small delay to ensure keyboard is fully ready + Thread.sleep(forTimeInterval: 0.1) + + // Type the text self.typeText(text) } }