Add vault-error.tsx page and scaffolding (#1926)

This commit is contained in:
Leendert de Borst
2026-04-17 16:19:20 +02:00
committed by Leendert de Borst
parent 172613fab3
commit 346dc03fb5
7 changed files with 532 additions and 199 deletions

View File

@@ -338,14 +338,14 @@ export default function ItemsScreen(): React.ReactNode {
setIsLoadingItems(false);
} catch (err) {
console.error('Error loading items:', err);
Toast.show({
type: 'error',
text1: t('items.errorLoadingItems'),
text2: t('common.errors.unknownError'),
const errorMessage = err instanceof Error ? err.message : String(err);
const errorStack = err instanceof Error ? err.stack ?? '' : '';
router.replace({
pathname: '/vault-error',
params: { errorMessage, errorStack, errorSource: 'items' },
});
setIsLoadingItems(false);
}
}, [dbContext.sqliteClient, setIsLoadingItems, t]);
}, [dbContext.sqliteClient, setIsLoadingItems, router]);
useEffect(() => {
const unsubscribeFocus = navigation.addListener('focus', () => {

View File

@@ -145,6 +145,7 @@ function RootLayoutNav() : React.ReactNode {
<Stack.Screen name="reinitialize" options={{ headerShown: false }} />
<Stack.Screen name="unlock" options={{ headerShown: false }} />
<Stack.Screen name="upgrade" options={{ headerShown: false }} />
<Stack.Screen name="vault-error" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>

View File

@@ -50,107 +50,122 @@ export default function Initialize() : React.ReactNode {
* Initialize the app.
*/
const initialize = async () : Promise<void> => {
const { isLoggedIn, enabledAuthMethods } = await app.initializeAuth();
try {
const { isLoggedIn, enabledAuthMethods } = await app.initializeAuth();
if (!isLoggedIn) {
router.replace('/login');
return;
}
/**
* If we already have an unlocked vault, we can skip the biometric unlock
* but still need to perform vault sync to check for updates.
*/
const isAlreadyUnlocked = await NativeVaultManager.isVaultUnlocked();
if (!isAlreadyUnlocked) {
// Check if we have an encrypted database and if FaceID is enabled
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
if (hasEncryptedDatabase) {
// Attempt automatic unlock using centralized helper
updateStatus(t('app.status.unlockingVault'));
const unlockResult = await VaultUnlockHelper.attemptAutomaticUnlock({ enabledAuthMethods, unlockVault: dbContext.unlockVault });
if (!unlockResult.success) {
/*
* Unlock failed or cancelled, redirect to unlock screen.
* Only log non-cancellation errors to avoid noise.
*/
if (!unlockResult.error?.includes('cancelled')) {
console.error('Automatic unlock failed:', unlockResult.error);
}
router.replace('/unlock');
return;
}
// Check if the vault needs migration before syncing
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
}
} catch (err) {
console.error('Error during initial vault unlock:', err);
router.replace('/unlock');
if (!isLoggedIn) {
router.replace('/login');
return;
}
} else {
/**
* Vault already unlocked (e.g., from password unlock)
* Check if migrations are needed.
* If we already have an unlocked vault, we can skip the biometric unlock
* but still need to perform vault sync to check for updates.
*/
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
const isAlreadyUnlocked = await NativeVaultManager.isVaultUnlocked();
if (!isAlreadyUnlocked) {
// Check if we have an encrypted database and if FaceID is enabled
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
if (hasEncryptedDatabase) {
// Attempt automatic unlock using centralized helper
updateStatus(t('app.status.unlockingVault'));
const unlockResult = await VaultUnlockHelper.attemptAutomaticUnlock({ enabledAuthMethods, unlockVault: dbContext.unlockVault });
if (!unlockResult.success) {
/*
* Unlock failed or cancelled, redirect to unlock screen.
* Only log non-cancellation errors to avoid noise.
*/
if (!unlockResult.error?.includes('cancelled')) {
console.error('Automatic unlock failed:', unlockResult.error);
}
router.replace('/unlock');
return;
}
// Check if the vault needs migration before syncing
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
}
} catch (err) {
console.error('Error during initial vault unlock:', err);
router.replace('/unlock');
return;
}
} else {
/**
* Vault already unlocked (e.g., from password unlock)
* Check if migrations are needed.
*/
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
}
/*
* Perform vault sync in background - don't block app access.
* The ServerSyncIndicator will show sync progress/offline status.
* This also handles uploading pending local changes (isDirty) from previous sessions.
*/
void (async (): Promise<void> => {
try {
await syncVault({
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
// Sync completed - ServerSyncIndicator will update
await dbContext.refreshSyncState();
},
/**
* Handle offline state - just set offline mode and continue.
* The ServerSyncIndicator will show offline status.
*/
onOffline: async () => {
await dbContext.setIsOffline(true);
await dbContext.refreshSyncState();
},
/**
* Handle error during vault sync.
*/
onError: async (error: string) => {
console.error('Vault sync error during initialize:', error);
await dbContext.refreshSyncState();
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
} finally {
await dbContext.refreshSyncState();
}
})();
// Navigate immediately - don't wait for sync
navigation.navigateAfterUnlock();
} catch (err) {
/*
* Catch any unhandled errors during vault initialization to prevent hard crashes.
* Redirect to vault error screen where users can view and copy the error details
* for remote debugging purposes.
*/
console.error('Fatal error during vault initialization:', err);
const errorMessage = err instanceof Error ? err.message : String(err);
const errorStack = err instanceof Error ? err.stack ?? '' : '';
router.replace({
pathname: '/vault-error',
params: { errorMessage, errorStack, errorSource: 'initialize' },
});
}
/*
* Perform vault sync in background - don't block app access.
* The ServerSyncIndicator will show sync progress/offline status.
* This also handles uploading pending local changes (isDirty) from previous sessions.
*/
void (async (): Promise<void> => {
try {
await syncVault({
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
// Sync completed - ServerSyncIndicator will update
await dbContext.refreshSyncState();
},
/**
* Handle offline state - just set offline mode and continue.
* The ServerSyncIndicator will show offline status.
*/
onOffline: async () => {
await dbContext.setIsOffline(true);
await dbContext.refreshSyncState();
},
/**
* Handle error during vault sync.
*/
onError: async (error: string) => {
console.error('Vault sync error during initialize:', error);
await dbContext.refreshSyncState();
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
} finally {
await dbContext.refreshSyncState();
}
})();
// Navigate immediately - don't wait for sync
navigation.navigateAfterUnlock();
};
initialize();

View File

@@ -48,115 +48,130 @@ export default function ReinitializeScreen() : React.ReactNode {
* Initialize the app.
*/
const initialize = async () : Promise<void> => {
const { isLoggedIn, enabledAuthMethods } = await app.initializeAuth();
try {
const { isLoggedIn, enabledAuthMethods } = await app.initializeAuth();
// If user is not logged in, navigate to login immediately
if (!isLoggedIn) {
router.replace('/login');
return;
}
// If user is not logged in, navigate to login immediately
if (!isLoggedIn) {
router.replace('/login');
return;
}
/**
* If we already have an unlocked vault, we can skip the biometric unlock
* but still need to perform vault sync to check for updates.
*/
const isAlreadyUnlocked = await NativeVaultManager.isVaultUnlocked();
/**
* If we already have an unlocked vault, we can skip the biometric unlock
* but still need to perform vault sync to check for updates.
*/
const isAlreadyUnlocked = await NativeVaultManager.isVaultUnlocked();
if (!isAlreadyUnlocked) {
// Check if we have an encrypted database
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
if (!isAlreadyUnlocked) {
// Check if we have an encrypted database
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
if (hasEncryptedDatabase) {
// Attempt automatic unlock using centralized helper
updateStatus(t('app.status.unlockingVault'));
// Small delay to ensure loading screen is fully rendered before showing native unlock dialog
await new Promise(resolve => setTimeout(resolve, 150));
const unlockResult = await VaultUnlockHelper.attemptAutomaticUnlock({ enabledAuthMethods, unlockVault: dbContext.unlockVault });
if (hasEncryptedDatabase) {
// Attempt automatic unlock using centralized helper
updateStatus(t('app.status.unlockingVault'));
// Small delay to ensure loading screen is fully rendered before showing native unlock dialog
await new Promise(resolve => setTimeout(resolve, 150));
const unlockResult = await VaultUnlockHelper.attemptAutomaticUnlock({ enabledAuthMethods, unlockVault: dbContext.unlockVault });
if (!unlockResult.success) {
// Unlock failed, redirect to unlock screen
console.error('Automatic unlock failed:', unlockResult.error);
if (!unlockResult.success) {
// Unlock failed, redirect to unlock screen
console.error('Automatic unlock failed:', unlockResult.error);
router.replace('/unlock');
return;
}
// Add small delay for UX
await new Promise(resolve => setTimeout(resolve, 150));
updateStatus(t('app.status.decryptingVault'));
await new Promise(resolve => setTimeout(resolve, 300));
// Check if the vault needs migration before syncing
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
} else {
// No encrypted database, redirect to unlock screen
router.replace('/unlock');
return;
}
// Add small delay for UX
await new Promise(resolve => setTimeout(resolve, 150));
updateStatus(t('app.status.decryptingVault'));
await new Promise(resolve => setTimeout(resolve, 300));
// Check if the vault needs migration before syncing
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
} else {
// No encrypted database, redirect to unlock screen
} catch (err) {
console.error('Error during initial vault unlock:', err);
router.replace('/unlock');
return;
}
} catch (err) {
console.error('Error during initial vault unlock:', err);
router.replace('/unlock');
return;
} else {
/**
* Vault already unlocked (e.g., from password unlock)
* Check if migrations are needed.
*/
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
}
} else {
/**
* Vault already unlocked (e.g., from password unlock)
* Check if migrations are needed.
/*
* Perform vault sync in background - don't block app access.
* The ServerSyncIndicator will show sync progress/offline status.
* This also handles uploading pending local changes (isDirty) from previous sessions.
*/
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
void (async (): Promise<void> => {
try {
await syncVault({
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
// Sync completed - ServerSyncIndicator will update
await dbContext.refreshSyncState();
},
/**
* Handle error during vault sync.
* Authentication errors are already handled in useVaultSync.
*/
onError: async (error: string) => {
console.error('Vault sync error during reinitialize:', error);
await dbContext.refreshSyncState();
},
/**
* Handle offline state - just set offline mode and continue.
* The ServerSyncIndicator will show offline status.
*/
onOffline: async () => {
await dbContext.setIsOffline(true);
await dbContext.refreshSyncState();
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
} finally {
await dbContext.refreshSyncState();
}
})();
// Navigate immediately
navigation.navigateAfterUnlock();
} catch (err) {
/*
* Catch any unhandled errors during vault re-initialization to prevent hard crashes.
* Redirect to vault error screen where users can view and copy the error details
* for remote debugging purposes.
*/
console.error('Fatal error during vault re-initialization:', err);
const errorMessage = err instanceof Error ? err.message : String(err);
const errorStack = err instanceof Error ? err.stack ?? '' : '';
router.replace({
pathname: '/vault-error',
params: { errorMessage, errorStack, errorSource: 'reinitialize' },
});
}
/*
* Perform vault sync in background - don't block app access.
* The ServerSyncIndicator will show sync progress/offline status.
* This also handles uploading pending local changes (isDirty) from previous sessions.
*/
void (async (): Promise<void> => {
try {
await syncVault({
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
// Sync completed - ServerSyncIndicator will update
await dbContext.refreshSyncState();
},
/**
* Handle error during vault sync.
* Authentication errors are already handled in useVaultSync.
*/
onError: async (error: string) => {
console.error('Vault sync error during reinitialize:', error);
await dbContext.refreshSyncState();
},
/**
* Handle offline state - just set offline mode and continue.
* The ServerSyncIndicator will show offline status.
*/
onOffline: async () => {
await dbContext.setIsOffline(true);
await dbContext.refreshSyncState();
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
} finally {
await dbContext.refreshSyncState();
}
})();
// Navigate immediately
navigation.navigateAfterUnlock();
};
initialize();

View File

@@ -0,0 +1,286 @@
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
import { useState, useCallback } from 'react';
import { StyleSheet, View, ScrollView, Dimensions, Text, Platform } from 'react-native';
import { copyToClipboard } from '@/utils/ClipboardUtility';
import { useColors } from '@/hooks/useColorScheme';
import { useLogout } from '@/hooks/useLogout';
import { useTranslation } from '@/hooks/useTranslation';
import Logo from '@/assets/images/logo.svg';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { RobustPressable } from '@/components/ui/RobustPressable';
/**
* Vault error screen displayed when the app encounters an unrecoverable error
* during vault initialization or bootstrap.
*/
export default function VaultErrorScreen() : React.ReactNode {
const { errorMessage, errorStack, errorSource } = useLocalSearchParams<{
errorMessage: string;
errorStack: string;
errorSource: string;
}>();
const colors = useColors();
const { t } = useTranslation();
const { logoutUserInitiated } = useLogout();
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false);
/**
* Copy the full error details to clipboard for sharing with support.
*/
const handleCopyError = useCallback(async (): Promise<void> => {
const errorReport = [
`Source: ${errorSource ?? 'unknown'}`,
`Platform: ${Platform.OS} ${Platform.Version}`,
`Error: ${errorMessage ?? 'Unknown error'}`,
'',
'Stack trace:',
errorStack ?? 'No stack trace available',
].join('\n');
await copyToClipboard(errorReport);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [errorMessage, errorStack, errorSource]);
/**
* Retry by navigating back to the initialize screen.
*/
const handleRetry = useCallback((): void => {
router.replace('/initialize');
}, []);
const styles = StyleSheet.create({
appName: {
color: colors.text,
fontSize: 28,
fontWeight: 'bold',
textAlign: 'center',
},
button: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 8,
justifyContent: 'center',
marginBottom: 12,
minHeight: 50,
paddingVertical: 8,
width: '100%',
},
buttonText: {
color: colors.primarySurfaceText,
fontSize: 16,
fontWeight: '600',
paddingHorizontal: 16,
paddingVertical: 4,
textAlign: 'center',
},
container: {
flex: 1,
},
content: {
backgroundColor: colors.accentBackground,
borderRadius: 10,
padding: 20,
width: '100%',
},
copyButton: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
flexDirection: 'row',
justifyContent: 'center',
marginTop: 8,
minHeight: 40,
paddingHorizontal: 16,
paddingVertical: 8,
},
copyButtonText: {
color: colors.primary,
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
detailsContainer: {
backgroundColor: colors.background,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
marginTop: 12,
maxHeight: 250,
padding: 12,
},
detailsText: {
color: colors.textMuted,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 11,
},
errorIcon: {
alignItems: 'center',
marginBottom: 12,
},
errorMessageText: {
color: colors.errorText,
fontSize: 14,
textAlign: 'center',
},
gradientContainer: {
height: Dimensions.get('window').height * 0.4,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
headerSection: {
paddingBottom: 24,
paddingHorizontal: 16,
paddingTop: 24,
},
logoContainer: {
alignItems: 'center',
marginBottom: 8,
},
logoutButton: {
alignSelf: 'center',
justifyContent: 'center',
marginTop: 8,
},
logoutButtonText: {
color: colors.red,
fontSize: 16,
},
mainContent: {
flex: 1,
justifyContent: 'center',
paddingBottom: 40,
paddingHorizontal: 20,
},
scrollContent: {
flexGrow: 1,
},
showDetailsButton: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
marginTop: 12,
},
showDetailsText: {
color: colors.primary,
fontSize: 14,
marginLeft: 4,
},
subtitle: {
color: colors.text,
fontSize: 14,
marginBottom: 16,
opacity: 0.7,
textAlign: 'center',
},
});
return (
<ThemedView style={styles.container} testID="vault-error-screen">
<ScrollView
style={styles.container}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<LinearGradient
colors={[colors.loginHeader, colors.background]}
style={styles.gradientContainer}
/>
<View style={styles.mainContent}>
<View style={styles.headerSection}>
<View style={styles.logoContainer}>
<Logo width={80} height={80} />
<Text style={styles.appName}>{t('app.vaultError.title')}</Text>
</View>
</View>
<View style={styles.content}>
<View style={styles.errorIcon}>
<MaterialIcons name="error-outline" size={48} color={colors.red} />
</View>
<ThemedText style={styles.subtitle}>
{t('app.vaultError.description')}
</ThemedText>
<ThemedText style={styles.errorMessageText}>
{errorMessage ?? t('common.errors.unknownError')}
</ThemedText>
{/* Show/hide details toggle */}
<RobustPressable
style={styles.showDetailsButton}
onPress={() => setShowDetails(!showDetails)}
>
<MaterialIcons
name={showDetails ? 'expand-less' : 'expand-more'}
size={20}
color={colors.primary}
/>
<ThemedText style={styles.showDetailsText}>
{showDetails ? t('app.vaultError.hideDetails') : t('app.vaultError.showDetails')}
</ThemedText>
</RobustPressable>
{/* Stack trace details */}
{showDetails && (
<View>
<ScrollView style={styles.detailsContainer} nestedScrollEnabled>
<Text style={styles.detailsText} selectable>
{`Source: ${errorSource ?? 'unknown'}\nPlatform: ${Platform.OS} ${Platform.Version}\n\n${errorStack ?? 'No stack trace available'}`}
</Text>
</ScrollView>
<RobustPressable
style={styles.copyButton}
onPress={handleCopyError}
>
<MaterialIcons
name={copied ? 'check' : 'content-copy'}
size={16}
color={colors.primary}
/>
<ThemedText style={styles.copyButtonText}>
{copied ? t('common.copied') : t('app.vaultError.copyErrorDetails')}
</ThemedText>
</RobustPressable>
</View>
)}
{/* Action buttons */}
<View style={{ marginTop: 20 }}>
<RobustPressable
style={styles.button}
onPress={handleRetry}
testID="retry-button"
>
<ThemedText style={styles.buttonText}>
{t('common.retry')}
</ThemedText>
</RobustPressable>
</View>
<RobustPressable
style={styles.logoutButton}
onPress={logoutUserInitiated}
testID="logout-button"
>
<ThemedText style={styles.logoutButtonText}>
{t('auth.logout')}
</ThemedText>
</RobustPressable>
</View>
</View>
</ScrollView>
</ThemedView>
);
}

View File

@@ -26,6 +26,7 @@
"generate": "Generate",
"attachments": "Attachments",
"or": "or",
"retry": "Retry",
"deleteItemConfirmTitle": "Delete Item",
"deleteItemConfirmDescription": "Are you sure you want to delete this item?",
"errors": {
@@ -618,6 +619,13 @@
"vaultAutoLockedMessage": "Vault auto-locked after timeout.",
"attemptingToUnlockMessage": "Attempting to unlock."
},
"vaultError": {
"title": "Vault Error",
"description": "An error occurred while loading your vault. You can retry or copy the error details to share with support.",
"showDetails": "Show error details",
"hideDetails": "Hide error details",
"copyErrorDetails": "Copy error details"
},
"loginSettings": {
"title": "API Connection",
"aliasvaultNet": "Aliasvault.net",

View File

@@ -250,8 +250,12 @@ class SqliteClient implements IDatabaseClient {
const results = await NativeVaultManager.executeQuery(query, convertedParams);
return results as T[];
} catch (error) {
console.error('Error executing query:', error);
throw error;
const originalMessage = error instanceof Error ? error.message : String(error);
const queryPreview = query.trim().substring(0, 200);
const enrichedError = new Error(originalMessage);
enrichedError.stack = `SQL: ${queryPreview}\n\n${error instanceof Error && error.stack ? error.stack : ''}`;
console.error('Error executing query:', enrichedError.message);
throw enrichedError;
}
}
@@ -278,8 +282,12 @@ class SqliteClient implements IDatabaseClient {
const result = await NativeVaultManager.executeUpdate(query, convertedParams);
return result as number;
} catch (error) {
console.error('Error executing update:', error);
throw error;
const originalMessage = error instanceof Error ? error.message : String(error);
const queryPreview = query.trim().substring(0, 200);
const enrichedError = new Error(originalMessage);
enrichedError.stack = `SQL: ${queryPreview}\n\n${error instanceof Error && error.stack ? error.stack : ''}`;
console.error('Error executing update:', enrichedError.message);
throw enrichedError;
}
}