diff --git a/mobile-app/app/index.tsx b/mobile-app/app/index.tsx index 82c43ba10..80fdfeae9 100644 --- a/mobile-app/app/index.tsx +++ b/mobile-app/app/index.tsx @@ -6,14 +6,20 @@ import { useVaultSync } from '@/hooks/useVaultSync'; import { ThemedView } from '@/components/ThemedView'; import LoadingIndicator from '@/components/LoadingIndicator'; import { install } from 'react-native-quick-crypto'; +import { NativeModules } from 'react-native'; export default function InitialLoadingScreen() { const { isInitialized: isAuthInitialized, isLoggedIn } = useAuth(); const { dbInitialized, dbAvailable } = useDb(); const { syncVault } = useVaultSync(); - const hasInitialized = useRef(false); + const authContext = useAuth(); const [status, setStatus] = useState(''); + const hasInitialized = useRef(false); + // TODO: isauthinitialized and dbinitialize do not have to be checekd anymore separately. + // Refactor this and check usages of isAuthInitialized and dbInitialized in other places. + // Doing the check in this file once should be enough. Especially after adding the server + // status check later which should fire periodically.. const isFullyInitialized = isAuthInitialized && dbInitialized; const requireLoginOrUnlock = isFullyInitialized && (!isLoggedIn || !dbAvailable); @@ -22,13 +28,22 @@ export default function InitialLoadingScreen() { install(); useEffect(() => { + if (hasInitialized.current) { + return; + } + + hasInitialized.current = true; + async function initialize() { - if (hasInitialized.current) { + const isLoggedIn = await authContext.initializeAuth(); + + // If user is not logged in, navigate to login immediately + if (!isLoggedIn) { + console.log('User not logged in, navigating to login'); + router.replace('/login'); return; } - hasInitialized.current = true; - // Perform initial vault sync console.log('Initial vault sync'); await syncVault({ @@ -38,22 +53,56 @@ export default function InitialLoadingScreen() { } }); - // Navigate to appropriate screen - if (requireLoginOrUnlock) { - console.log('Navigating to login'); - router.replace('/login'); - } else { - console.log('Navigating to credentials'); - router.replace('/(tabs)/(credentials)'); + // Try to unlock with FaceID + try { + // This will check if the encrypted database file exists locally. + // TODO: this isvaultinitialized should be renamed to "isVaultExists" or something similar + // as we're just checking if the file exists. + const isInitialized = await NativeModules.CredentialManager.isVaultInitialized(); + if (isInitialized) { + // Attempt to unlock the vault with FaceID. + setStatus('Unlocking vault|'); + const isUnlocked = await NativeModules.CredentialManager.unlockVault(); + if (isUnlocked) { + await new Promise(resolve => setTimeout(resolve, 1000)); + setStatus('Decrypting vault'); + await new Promise(resolve => setTimeout(resolve, 1000)); + console.log('FaceID unlock successful, navigating to credentials'); + router.replace('/(tabs)/(credentials)'); + return; + } + else { + console.log('FaceID unlock failed, navigating to unlock screen'); + router.replace('/unlock'); + } + } + else { + // Vault is not initialized which means the database does not exist or decryption key is missing + // from device's keychain. Navigate to the unlock screen. + console.log('Vault is not initialized (db file does not exist), navigating to unlock screen'); + router.replace('/unlock'); + return; + } + } catch (error) { + console.log('FaceID unlock failed:', error); + // If FaceID fails (too many attempts, manual cancel, etc.) + // navigate to unlock screen + router.replace('/unlock'); + return; } + + // If we get here, something went wrong with the FaceID unlock + // Navigate to unlock screen as a fallback + console.log('FaceID unlock failed, navigating to unlock screen'); + router.replace('/unlock'); } initialize(); - }, [isFullyInitialized, requireLoginOrUnlock, syncVault]); // Keep all dependencies to satisfy ESLint + }, [isFullyInitialized, requireLoginOrUnlock, syncVault]); return ( - {isLoggedIn && status ? : null} + {(isLoggedIn && status) ? : null} ); } \ No newline at end of file diff --git a/mobile-app/app/unlock.tsx b/mobile-app/app/unlock.tsx new file mode 100644 index 000000000..f8707a38e --- /dev/null +++ b/mobile-app/app/unlock.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import { StyleSheet, View, TextInput, TouchableOpacity, Alert } from 'react-native'; +import { router } from 'expo-router'; +import { useAuth } from '@/context/AuthContext'; +import { useDb } from '@/context/DbContext'; +import { ThemedView } from '@/components/ThemedView'; +import { ThemedText } from '@/components/ThemedText'; +import LoadingIndicator from '@/components/LoadingIndicator'; +import { NativeModules } from 'react-native'; +import { VaultResponse } from '@/utils/types/webapi/VaultResponse'; + +export default function UnlockScreen() { + const { isLoggedIn } = useAuth(); + const { initializeDatabase } = useDb(); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleUnlock = async () => { + if (!password) { + Alert.alert('Error', 'Please enter your password'); + return; + } + + setIsLoading(true); + try { + // Initialize the database with the provided password + // We need to get the vault response from the API first + const response = await fetch('https://api.aliasvault.net/v1/vault', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch vault'); + } + + const vaultResponse: VaultResponse = await response.json(); + + // Initialize the database with the vault response and password + await initializeDatabase(vaultResponse, password); + + // If successful, navigate to credentials + router.replace('/(tabs)/(credentials)'); + } catch (error) { + Alert.alert('Error', 'Incorrect password. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleLogout = async () => { + // Clear any stored tokens or session data + // This will be handled by the auth context + router.replace('/login'); + }; + + return ( + + + Unlock Vault + Enter your password to unlock your vault + + + + + + {isLoading ? 'Unlocking...' : 'Unlock'} + + + + + Logout + + + + {isLoading && } + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + content: { + width: '100%', + maxWidth: 400, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 8, + textAlign: 'center', + }, + subtitle: { + fontSize: 16, + marginBottom: 24, + textAlign: 'center', + opacity: 0.7, + }, + input: { + width: '100%', + height: 50, + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + paddingHorizontal: 16, + marginBottom: 16, + fontSize: 16, + }, + button: { + width: '100%', + height: 50, + backgroundColor: '#007AFF', + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + buttonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + logoutButton: { + width: '100%', + height: 50, + justifyContent: 'center', + alignItems: 'center', + }, + logoutButtonText: { + color: '#007AFF', + fontSize: 16, + }, +}); \ No newline at end of file diff --git a/mobile-app/components/LoadingIndicator.tsx b/mobile-app/components/LoadingIndicator.tsx index 03b39f653..ae5fe17e9 100644 --- a/mobile-app/components/LoadingIndicator.tsx +++ b/mobile-app/components/LoadingIndicator.tsx @@ -48,6 +48,9 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps) { ]) ); + // Reset dots when status changes + setDots(''); + const dotsInterval = setInterval(() => { setDots(prevDots => { if (prevDots.length >= 3) return ''; @@ -61,7 +64,12 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps) { animation.stop(); clearInterval(dotsInterval); }; - }, []); + }, [status]); + + // If the status ends with a pipe character (|), don't show any dots + // This provides an explicit way to disable the loading dots animation + const statusTrimmed = status.endsWith('|') ? status.slice(0, -1) : status; + const shouldShowDots = !status.endsWith('|'); const styles = StyleSheet.create({ container: { @@ -150,7 +158,10 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps) { ]} /> - {status}{dots} + + {statusTrimmed} + {shouldShowDots && dots} + ); } \ No newline at end of file diff --git a/mobile-app/context/DbContext.tsx b/mobile-app/context/DbContext.tsx index 9af63efab..f99eac843 100644 --- a/mobile-app/context/DbContext.tsx +++ b/mobile-app/context/DbContext.tsx @@ -66,13 +66,16 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } // Get metadata from SQLite client const metadata = await sqliteClient.getVaultMetadata(); if (metadata) { + console.log('Vault metadata found, setting dbInitialized and dbAvailable to true'); setDbInitialized(true); setDbAvailable(true); } else { + console.log('Vault metadata not found, setting dbInitialized and dbAvailable to false'); setDbInitialized(true); setDbAvailable(false); } } else { + console.log('Vault not initialized, setting dbInitialized and dbAvailable to false'); setDbInitialized(true); setDbAvailable(false); } diff --git a/mobile-app/hooks/useVaultSync.ts b/mobile-app/hooks/useVaultSync.ts index 67687fc30..236cbdacf 100644 --- a/mobile-app/hooks/useVaultSync.ts +++ b/mobile-app/hooks/useVaultSync.ts @@ -59,7 +59,7 @@ export const useVaultSync = () => { onStatus?.('Checking vault updates'); const statusResponse = await withMinimumDelay( () => webApi.getStatus(), - 700, + 300, initialSync ); const statusError = webApi.validateStatusResponse(statusResponse); @@ -99,9 +99,11 @@ export const useVaultSync = () => { } console.log('Vault sync finished: No updates needed'); - onStatus?.('Decrypting vault'); - await new Promise(resolve => setTimeout(resolve, 300)); - onSuccess?.(false); + await withMinimumDelay( + () => Promise.resolve(onSuccess?.(false)), + 300, + initialSync + ); return false; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync'; diff --git a/mobile-app/ios/CredentialManager/CredentialManager.m b/mobile-app/ios/CredentialManager/CredentialManager.m index 5a2ed6433..5b59012f3 100644 --- a/mobile-app/ios/CredentialManager/CredentialManager.m +++ b/mobile-app/ios/CredentialManager/CredentialManager.m @@ -6,8 +6,11 @@ RCT_EXTERN_METHOD(requiresMainQueueSetup) RCT_EXTERN_METHOD(addCredential:(NSString *)username password:(NSString *)password service:(NSString *)service) RCT_EXTERN_METHOD(getCredentials) RCT_EXTERN_METHOD(clearVault) +// TODO: isvaultinitialized should be renamed to "isVaultExists" or something similar as we're just checking if the file exists. +// The initializevault actually initializes the vault by decrypting it and loading the DB into memory. RCT_EXTERN_METHOD(isVaultInitialized:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(getVaultMetadata:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(unlockVault:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) // New methods for SQLite database operations RCT_EXTERN_METHOD(storeDatabase:(NSString *)base64EncryptedDb diff --git a/mobile-app/ios/CredentialManager/CredentialManager.swift b/mobile-app/ios/CredentialManager/CredentialManager.swift index f7fb152c2..bc50c76d2 100644 --- a/mobile-app/ios/CredentialManager/CredentialManager.swift +++ b/mobile-app/ios/CredentialManager/CredentialManager.swift @@ -148,6 +148,30 @@ class CredentialManager: NSObject { } } + @objc + func unlockVault(_ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + do { + // TODO: rename this to unlockvault? so meaning is: initialized means it exists, and unlock means + // we're decrypting the encrypted database (that exists) and try to load it into memory. If unlocking + // fails, user is redirected to the unlock screen in the react native app. + try credentialStore.initializeDatabase() + resolve(true) + } catch { + // Check if the error is related to Face ID or decryption + if let nsError = error as NSError? { + if nsError.domain == "SharedCredentialStore" { + // These are our known error codes for initialization failures + if nsError.code == 1 || nsError.code == 2 || nsError.code == 10 { + resolve(false) + return + } + } + } + reject("INIT_ERROR", "Failed to unlock vault: \(error.localizedDescription)", error) + } + } + @objc func requiresMainQueueSetup() -> Bool { return false diff --git a/mobile-app/ios/CredentialManager/SharedCredentialStore.swift b/mobile-app/ios/CredentialManager/SharedCredentialStore.swift index 7a303ca78..4cad24629 100644 --- a/mobile-app/ios/CredentialManager/SharedCredentialStore.swift +++ b/mobile-app/ios/CredentialManager/SharedCredentialStore.swift @@ -93,7 +93,7 @@ class SharedCredentialStore { } // Get the encrypted database as a base64 encoded string - func getEncryptedDatabase() -> String? { + private func getEncryptedDatabase() -> String? { do { // Return the base64 encoded string return try String(contentsOf: getEncryptedDbPath(), encoding: .utf8) @@ -103,7 +103,7 @@ class SharedCredentialStore { } // Get the vault metadata from UserDefaults - func getVaultMetadata() -> String? { + public func getVaultMetadata() -> String? { return UserDefaults.standard.string(forKey: "vault_metadata") }