From 346dc03fb544090d70731cddeb04bc333c58a005 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 17 Apr 2026 16:19:20 +0200 Subject: [PATCH] Add vault-error.tsx page and scaffolding (#1926) --- apps/mobile-app/app/(tabs)/items/index.tsx | 12 +- apps/mobile-app/app/_layout.tsx | 1 + apps/mobile-app/app/initialize.tsx | 203 ++++++++------- apps/mobile-app/app/reinitialize.tsx | 205 ++++++++------- apps/mobile-app/app/vault-error.tsx | 286 +++++++++++++++++++++ apps/mobile-app/i18n/locales/en.json | 8 + apps/mobile-app/utils/SqliteClient.ts | 16 +- 7 files changed, 532 insertions(+), 199 deletions(-) create mode 100644 apps/mobile-app/app/vault-error.tsx diff --git a/apps/mobile-app/app/(tabs)/items/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx index 2a02d2905..baffc3e66 100644 --- a/apps/mobile-app/app/(tabs)/items/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -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', () => { diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx index 93587c92f..49b2be943 100644 --- a/apps/mobile-app/app/_layout.tsx +++ b/apps/mobile-app/app/_layout.tsx @@ -145,6 +145,7 @@ function RootLayoutNav() : React.ReactNode { + diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index a85f2ed97..b7bb52cd9 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -50,107 +50,122 @@ export default function Initialize() : React.ReactNode { * Initialize the app. */ const initialize = async () : Promise => { - 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 => { + 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 => { - 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(); diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx index 0e8b4eb8f..c71aaf997 100644 --- a/apps/mobile-app/app/reinitialize.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -48,115 +48,130 @@ export default function ReinitializeScreen() : React.ReactNode { * Initialize the app. */ const initialize = async () : Promise => { - 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 => { + 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 => { - 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(); diff --git a/apps/mobile-app/app/vault-error.tsx b/apps/mobile-app/app/vault-error.tsx new file mode 100644 index 000000000..f6795f4b3 --- /dev/null +++ b/apps/mobile-app/app/vault-error.tsx @@ -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 => { + 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 ( + + + + + + + + {t('app.vaultError.title')} + + + + + + + + + {t('app.vaultError.description')} + + + + {errorMessage ?? t('common.errors.unknownError')} + + + {/* Show/hide details toggle */} + setShowDetails(!showDetails)} + > + + + {showDetails ? t('app.vaultError.hideDetails') : t('app.vaultError.showDetails')} + + + + {/* Stack trace details */} + {showDetails && ( + + + + {`Source: ${errorSource ?? 'unknown'}\nPlatform: ${Platform.OS} ${Platform.Version}\n\n${errorStack ?? 'No stack trace available'}`} + + + + + + {copied ? t('common.copied') : t('app.vaultError.copyErrorDetails')} + + + + )} + + {/* Action buttons */} + + + + {t('common.retry')} + + + + + + + {t('auth.logout')} + + + + + + + ); +} diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index e194147ad..aede6f3c2 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -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", diff --git a/apps/mobile-app/utils/SqliteClient.ts b/apps/mobile-app/utils/SqliteClient.ts index d12d580b4..1750e0b34 100644 --- a/apps/mobile-app/utils/SqliteClient.ts +++ b/apps/mobile-app/utils/SqliteClient.ts @@ -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; } }