Tweak index.tsx app startup and vault unlock flow (#771)

This commit is contained in:
Leendert de Borst
2025-04-22 17:33:06 +02:00
parent 470ffec21a
commit ecb2d29dcf
8 changed files with 268 additions and 21 deletions

View File

@@ -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 (
<ThemedView style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
{isLoggedIn && status ? <LoadingIndicator status={status} /> : null}
{(isLoggedIn && status) ? <LoadingIndicator status={status} /> : null}
</ThemedView>
);
}

155
mobile-app/app/unlock.tsx Normal file
View File

@@ -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 (
<ThemedView style={styles.container}>
<View style={styles.content}>
<ThemedText style={styles.title}>Unlock Vault</ThemedText>
<ThemedText style={styles.subtitle}>Enter your password to unlock your vault</ThemedText>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={styles.button}
onPress={handleUnlock}
disabled={isLoading}
>
<ThemedText style={styles.buttonText}>
{isLoading ? 'Unlocking...' : 'Unlock'}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={styles.logoutButton}
onPress={handleLogout}
>
<ThemedText style={styles.logoutButtonText}>Logout</ThemedText>
</TouchableOpacity>
</View>
{isLoading && <LoadingIndicator status="Unlocking vault..." />}
</ThemedView>
);
}
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,
},
});

View File

@@ -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) {
]}
/>
</View>
<Text style={styles.statusText}>{status}{dots}</Text>
<Text style={styles.statusText}>
{statusTrimmed}
{shouldShowDots && dots}
</Text>
</View>
);
}

View File

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

View File

@@ -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';

View File

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

View File

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

View File

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