Add shared logout flow component, update tests (#1404)

This commit is contained in:
Leendert de Borst
2026-01-16 11:39:15 +01:00
parent 620798930c
commit 91cc754dee
5 changed files with 217 additions and 116 deletions

View File

@@ -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<ScrollView>(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<void> => {
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<void> => {
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 {
<View style={styles.section}>
<TouchableOpacity
style={styles.settingItem}
onPress={handleLogout}
onPress={logoutUserInitiated}
>
<View style={styles.settingItemIcon}>
<Ionicons name="log-out" size={20} color={colors.primary} />

View File

@@ -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<void> => {
/*
* 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 {
<RobustPressable
style={styles.logoutButton}
onPress={handleLogout}
onPress={logoutUserInitiated}
testID="logout-button"
>
<ThemedText style={styles.logoutButtonText}>{t('auth.logout')}</ThemedText>

View File

@@ -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<void>;
/**
* Forced logout (e.g., corrupted state, incompatible vault version).
* Logs out immediately without confirmation dialog.
* Clears ALL data including vault.
*/
logoutForced: () => Promise<void>;
};
/**
* 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<void> => {
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<void> => {
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<void> => {
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,
};
}

View File

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

View File

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