mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-21 08:02:45 -04:00
Tweak index.tsx app startup and vault unlock flow (#771)
This commit is contained in:
@@ -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
155
mobile-app/app/unlock.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user