mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-30 12:34:50 -04:00
Add onUpgradeRequired and executeRaw logic to iOS (#957)
This commit is contained in:
committed by
Leendert de Borst
parent
925455b5d6
commit
b4c4603868
@@ -175,6 +175,12 @@ export default function CredentialsScreen() : React.ReactNode {
|
||||
// Logout user
|
||||
await webApi.logout(error);
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: () : void => {
|
||||
router.replace('/upgrade');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error refreshing credentials:', err);
|
||||
@@ -186,7 +192,7 @@ export default function CredentialsScreen() : React.ReactNode {
|
||||
text2: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, webApi, authContext]);
|
||||
}, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, webApi, authContext, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !isDatabaseAvailable) {
|
||||
|
||||
@@ -160,7 +160,14 @@ function RootLayoutNav() : React.ReactNode {
|
||||
await webApi.logout(error);
|
||||
setRedirectTarget('/login');
|
||||
setBootComplete(true);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: () : void => {
|
||||
setRedirectTarget('/upgrade');
|
||||
setBootComplete(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -262,6 +269,7 @@ function RootLayoutNav() : React.ReactNode {
|
||||
<Stack.Screen name="login-settings" options={{ title: 'Login Settings' }} />
|
||||
<Stack.Screen name="reinitialize" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="unlock" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="upgrade" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" options={{ title: 'Not Found' }} />
|
||||
</Stack>
|
||||
|
||||
@@ -192,6 +192,7 @@ export default function LoginScreen() : React.ReactNode {
|
||||
await dbContext.storeEncryptionKeyDerivationParams(encryptionKeyDerivationParams);
|
||||
await dbContext.initializeDatabase(vaultResponseJson);
|
||||
|
||||
let checkSuccess = true;
|
||||
/**
|
||||
* After setting auth tokens, execute a server status check immediately
|
||||
* which takes care of certain sanity checks such as ensuring client/server
|
||||
@@ -203,12 +204,32 @@ export default function LoginScreen() : React.ReactNode {
|
||||
* Handle the status update.
|
||||
*/
|
||||
onError: (message) => {
|
||||
checkSuccess = false;
|
||||
|
||||
// Show modal with error message
|
||||
Alert.alert('Error', message);
|
||||
webApi.logout(message);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: async () : Promise<void> => {
|
||||
checkSuccess = false;
|
||||
|
||||
// Still login to ensure the user is logged in.
|
||||
await authContext.login();
|
||||
|
||||
// But after login, redirect to upgrade screen immediately.
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
},
|
||||
});
|
||||
|
||||
if (!checkSuccess) {
|
||||
// If the syncvault checks have failed, we can't continue with the login process.
|
||||
return;
|
||||
}
|
||||
|
||||
await authContext.login();
|
||||
|
||||
authContext.setOfflineMode(false);
|
||||
|
||||
@@ -163,7 +163,13 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: () : void => {
|
||||
router.replace('/upgrade');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
472
apps/mobile-app/app/upgrade.tsx
Normal file
472
apps/mobile-app/app/upgrade.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { StyleSheet, View, TouchableOpacity, Alert, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableWithoutFeedback, Keyboard, Text } from 'react-native';
|
||||
|
||||
import type { VaultVersion } from '@/utils/dist/shared/vault-sql';
|
||||
import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useVaultSync } from '@/hooks/useVaultSync';
|
||||
|
||||
import Logo from '@/assets/images/logo.svg';
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
import { ThemedText } from '@/components/themed/ThemedText';
|
||||
import { ThemedView } from '@/components/themed/ThemedView';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
|
||||
/**
|
||||
* Upgrade screen.
|
||||
*/
|
||||
export default function UpgradeScreen() : React.ReactNode {
|
||||
const { username } = useAuth();
|
||||
const { sqliteClient } = useDb();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<VaultVersion | null>(null);
|
||||
const [latestVersion, setLatestVersion] = useState<VaultVersion | null>(null);
|
||||
const [upgradeStatus, setUpgradeStatus] = useState('Preparing upgrade...');
|
||||
const colors = useColors();
|
||||
const webApi = useWebApi();
|
||||
const { syncVault } = useVaultSync();
|
||||
|
||||
/**
|
||||
* Load version information from the database.
|
||||
*/
|
||||
const loadVersionInfo = useCallback(async () => {
|
||||
try {
|
||||
if (sqliteClient) {
|
||||
const current = await sqliteClient.getDatabaseVersion();
|
||||
const latest = await sqliteClient.getLatestDatabaseVersion();
|
||||
setCurrentVersion(current);
|
||||
setLatestVersion(latest);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load version information:', error);
|
||||
}
|
||||
}, [sqliteClient]);
|
||||
|
||||
useEffect(() => {
|
||||
loadVersionInfo();
|
||||
}, [loadVersionInfo]);
|
||||
|
||||
/**
|
||||
* Handle the vault upgrade.
|
||||
*/
|
||||
const handleUpgrade = async () : Promise<void> => {
|
||||
if (!sqliteClient || !currentVersion || !latestVersion) {
|
||||
Alert.alert('Error', 'Unable to get version information. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setUpgradeStatus('Preparing upgrade...');
|
||||
|
||||
try {
|
||||
// Get upgrade SQL commands from vault-sql shared library
|
||||
setUpgradeStatus('Generating upgrade SQL...');
|
||||
const vaultSqlGenerator = new VaultSqlGenerator();
|
||||
const upgradeResult = vaultSqlGenerator.getUpgradeVaultSql(currentVersion.revision, latestVersion.revision);
|
||||
|
||||
if (!upgradeResult.success) {
|
||||
throw new Error(upgradeResult.error ?? 'Failed to generate upgrade SQL');
|
||||
}
|
||||
|
||||
if (upgradeResult.sqlCommands.length === 0) {
|
||||
// No upgrade needed, vault is already up to date
|
||||
setUpgradeStatus('Vault is already up to date');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await handleUpgradeSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
setUpgradeStatus('Starting database transaction...');
|
||||
await NativeVaultManager.beginTransaction();
|
||||
|
||||
// Execute each SQL command
|
||||
setUpgradeStatus('Applying database migrations...');
|
||||
for (let i = 0; i < upgradeResult.sqlCommands.length; i++) {
|
||||
const sqlCommand = upgradeResult.sqlCommands[i];
|
||||
console.log('update sql command', i);
|
||||
console.log('Executing SQL command:', sqlCommand);
|
||||
setUpgradeStatus(`Applying migration ${i + 1} of ${upgradeResult.sqlCommands.length}...`);
|
||||
|
||||
try {
|
||||
await NativeVaultManager.executeRaw(sqlCommand);
|
||||
} catch (error) {
|
||||
console.error(`Error executing SQL command ${i + 1}:`, sqlCommand, error);
|
||||
await NativeVaultManager.rollbackTransaction();
|
||||
throw new Error(`Failed to apply migration ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
setUpgradeStatus('Committing changes...');
|
||||
await NativeVaultManager.commitTransaction();
|
||||
|
||||
// Upload to server
|
||||
setUpgradeStatus('Uploading vault to server...');
|
||||
await uploadVaultToServer();
|
||||
|
||||
// Sync and navigate to credentials
|
||||
setUpgradeStatus('Finalizing upgrade...');
|
||||
await handleUpgradeSuccess();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upgrade failed:', error);
|
||||
Alert.alert('Upgrade Failed', error instanceof Error ? error.message : 'An unknown error occurred during the upgrade. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setUpgradeStatus('Preparing upgrade...');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload the upgraded vault to the server.
|
||||
*/
|
||||
const uploadVaultToServer = async () : Promise<void> => {
|
||||
try {
|
||||
// Get the current vault revision number
|
||||
const currentRevision = await NativeVaultManager.getCurrentVaultRevisionNumber();
|
||||
|
||||
// Get the encrypted database
|
||||
const encryptedDb = await NativeVaultManager.getEncryptedDatabase();
|
||||
if (!encryptedDb) {
|
||||
throw new Error('Failed to get encrypted database');
|
||||
}
|
||||
|
||||
// Get all private email domains from credentials in order to claim them on server
|
||||
const privateEmailDomains = await sqliteClient!.getPrivateEmailDomains();
|
||||
|
||||
const credentials = await sqliteClient!.getAllCredentials();
|
||||
const privateEmailAddresses = credentials
|
||||
.filter(cred => cred.Alias?.Email != null)
|
||||
.map(cred => cred.Alias!.Email!)
|
||||
.filter((email, index, self) => self.indexOf(email) === index)
|
||||
.filter(email => {
|
||||
return privateEmailDomains.some(domain => email.toLowerCase().endsWith(`@${domain.toLowerCase()}`));
|
||||
});
|
||||
|
||||
// Get username from the auth context
|
||||
if (!username) {
|
||||
throw new Error('Username not found');
|
||||
}
|
||||
|
||||
// Create vault object for upload
|
||||
const newVault = {
|
||||
blob: encryptedDb,
|
||||
createdAt: new Date().toISOString(),
|
||||
credentialsCount: credentials.length,
|
||||
currentRevisionNumber: currentRevision,
|
||||
emailAddressList: privateEmailAddresses,
|
||||
privateEmailDomainList: [], // Empty on purpose, API will not use this for vault updates
|
||||
publicEmailDomainList: [], // Empty on purpose, API will not use this for vault updates
|
||||
encryptionPublicKey: '', // Empty on purpose, only required if new public/private key pair is generated
|
||||
client: '', // Empty on purpose, API will not use this for vault updates
|
||||
updatedAt: new Date().toISOString(),
|
||||
username: username,
|
||||
version: (await sqliteClient!.getDatabaseVersion())?.version ?? '0.0.0'
|
||||
};
|
||||
|
||||
// Upload to server
|
||||
const response = await webApi.post<typeof newVault, { status: number; newRevisionNumber: number }>('Vault', newVault);
|
||||
|
||||
if (response.status === 0) {
|
||||
await NativeVaultManager.setCurrentVaultRevisionNumber(response.newRevisionNumber);
|
||||
} else if (response.status === 1) {
|
||||
throw new Error('Vault merge required. Please login via the web app to merge the multiple pending updates to your vault.');
|
||||
} else {
|
||||
throw new Error('Failed to upload vault to server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading vault:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle successful upgrade completion.
|
||||
*/
|
||||
const handleUpgradeSuccess = async () : Promise<void> => {
|
||||
try {
|
||||
// Sync vault to ensure we have the latest data
|
||||
await syncVault({
|
||||
/**
|
||||
* Handle the status update.
|
||||
*/
|
||||
onStatus: (message) => setUpgradeStatus(message),
|
||||
/**
|
||||
* Handle successful vault sync and navigate to credentials.
|
||||
*/
|
||||
onSuccess: () => {
|
||||
// Navigate to credentials index
|
||||
router.replace('/(tabs)/credentials');
|
||||
},
|
||||
/**
|
||||
* Handle sync error and still navigate to credentials.
|
||||
*/
|
||||
onError: (error) => {
|
||||
console.error('Sync error after upgrade:', error);
|
||||
// Still navigate to credentials even if sync fails
|
||||
router.replace('/(tabs)/credentials');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during post-upgrade sync:', error);
|
||||
// Navigate to credentials even if sync fails
|
||||
router.replace('/(tabs)/credentials');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the logout.
|
||||
*/
|
||||
const handleLogout = async () : Promise<void> => {
|
||||
/*
|
||||
* Clear any stored tokens or session data
|
||||
* This will be handled by the auth context
|
||||
*/
|
||||
await webApi.logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
appName: {
|
||||
color: colors.text,
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
width: '100%',
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
width: '100%',
|
||||
},
|
||||
faceIdButton: {
|
||||
alignItems: 'center',
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
faceIdButtonText: {
|
||||
color: colors.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
gradientContainer: {
|
||||
height: Dimensions.get('window').height * 0.4,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
},
|
||||
headerSection: {
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 24,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 8,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
height: 50,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
},
|
||||
inputIcon: {
|
||||
padding: 12,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
logoutButton: {
|
||||
alignSelf: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
logoutButtonText: {
|
||||
color: colors.red,
|
||||
fontSize: 16,
|
||||
},
|
||||
mainContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 40,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
currentVersionValue: {
|
||||
color: colors.primary,
|
||||
},
|
||||
latestVersionValue: {
|
||||
color: '#10B981',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
opacity: 0.7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
username: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
opacity: 0.8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
versionContainer: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
},
|
||||
versionLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
opacity: 0.7,
|
||||
},
|
||||
versionRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
versionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
versionValue: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<LoadingIndicator status={upgradeStatus} />
|
||||
</View>
|
||||
) : (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardAvoidingView}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
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}>Upgrade Vault</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Avatar />
|
||||
<ThemedText style={styles.username}>{username}</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.subtitle}>AliasVault has updated and your vault needs to be upgraded.</ThemedText>
|
||||
|
||||
<View style={styles.versionContainer}>
|
||||
<ThemedText style={styles.versionTitle}>Version Information</ThemedText>
|
||||
<View style={styles.versionRow}>
|
||||
<ThemedText style={styles.versionLabel}>Your vault:</ThemedText>
|
||||
<ThemedText style={[styles.versionValue, styles.currentVersionValue]}>
|
||||
{currentVersion?.releaseVersion ?? '...'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.versionRow}>
|
||||
<ThemedText style={styles.versionLabel}>New version:</ThemedText>
|
||||
<ThemedText style={[styles.versionValue, styles.latestVersionValue]}>
|
||||
{latestVersion?.releaseVersion ?? '...'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleUpgrade}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>
|
||||
{isLoading ? 'Upgrading...' : 'Upgrade'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.logoutButton}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<ThemedText style={styles.logoutButtonText}>Logout</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type DbContextType = {
|
||||
storeEncryptionKey: (derivedKey: string) => Promise<void>;
|
||||
storeEncryptionKeyDerivationParams: (keyDerivationParams: EncryptionKeyDerivationParams) => Promise<void>;
|
||||
initializeDatabase: (vaultResponse: VaultResponse) => Promise<void>;
|
||||
hasPendingMigrations: () => Promise<boolean>;
|
||||
clearDatabase: () => void;
|
||||
getVaultMetadata: () => Promise<VaultMetadata | null>;
|
||||
testDatabaseConnection: (derivedKey: string) => Promise<boolean>;
|
||||
@@ -97,6 +98,16 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
setDbAvailable(true);
|
||||
}, [sqliteClient, unlockVault]);
|
||||
|
||||
/**
|
||||
* Check if there are any pending migrations.
|
||||
*/
|
||||
const hasPendingMigrations = useCallback(async () => {
|
||||
const currentVersion = await sqliteClient.getDatabaseVersion();
|
||||
const latestVersion = await sqliteClient.getLatestDatabaseVersion();
|
||||
|
||||
return currentVersion.revision < latestVersion.revision;
|
||||
}, [sqliteClient]);
|
||||
|
||||
const checkStoredVault = useCallback(async () => {
|
||||
try {
|
||||
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
|
||||
@@ -166,7 +177,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
|
||||
// Try to get the database version as a simple test query
|
||||
const version = await sqliteClient.getDatabaseVersion();
|
||||
if (version && version.length > 0) {
|
||||
if (version && version.version && version.version.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -182,13 +193,14 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
dbInitialized,
|
||||
dbAvailable,
|
||||
initializeDatabase,
|
||||
hasPendingMigrations,
|
||||
clearDatabase,
|
||||
getVaultMetadata,
|
||||
testDatabaseConnection,
|
||||
unlockVault,
|
||||
storeEncryptionKey,
|
||||
storeEncryptionKeyDerivationParams,
|
||||
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, clearDatabase, getVaultMetadata, testDatabaseConnection, unlockVault, storeEncryptionKey, storeEncryptionKeyDerivationParams]);
|
||||
}), [sqliteClient, dbInitialized, dbAvailable, initializeDatabase, hasPendingMigrations, clearDatabase, getVaultMetadata, testDatabaseConnection, unlockVault, storeEncryptionKey, storeEncryptionKeyDerivationParams]);
|
||||
|
||||
return (
|
||||
<DbContext.Provider value={contextValue}>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useVaultMutate() : {
|
||||
client: '', // Empty on purpose, API will not use this for vault updates
|
||||
updatedAt: new Date().toISOString(),
|
||||
username: username,
|
||||
version: await dbContext.sqliteClient!.getDatabaseVersion() ?? '0.0.0'
|
||||
version: (await dbContext.sqliteClient!.getDatabaseVersion())?.version ?? '0.0.0'
|
||||
};
|
||||
}, [dbContext, authContext]);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
@@ -37,6 +38,7 @@ type VaultSyncOptions = {
|
||||
onError?: (error: string) => void;
|
||||
onStatus?: (message: string) => void;
|
||||
onOffline?: () => void;
|
||||
onUpgradeRequired?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +52,7 @@ export const useVaultSync = () : {
|
||||
const webApi = useWebApi();
|
||||
|
||||
const syncVault = useCallback(async (options: VaultSyncOptions = {}) => {
|
||||
const { initialSync = false, onSuccess, onError, onStatus, onOffline } = options;
|
||||
const { initialSync = false, onSuccess, onError, onStatus, onOffline, onUpgradeRequired } = options;
|
||||
|
||||
// For the initial sync, we add an artifical delay to various steps which makes it feel more fluid.
|
||||
const enableDelay = initialSync;
|
||||
@@ -112,6 +114,15 @@ export const useVaultSync = () : {
|
||||
|
||||
try {
|
||||
await dbContext.initializeDatabase(vaultResponseJson as VaultResponse);
|
||||
|
||||
// Check if the vault is up to date, if not, redirect to the upgrade page.
|
||||
console.log('Checking for pending migrations');
|
||||
if (await dbContext.hasPendingMigrations()) {
|
||||
console.log('Pending migrations, redirecting to upgrade page');
|
||||
onUpgradeRequired?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
onSuccess?.(true);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -120,6 +131,14 @@ export const useVaultSync = () : {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the vault is up to date, if not, redirect to the upgrade page.
|
||||
console.log('Checking for pending migrations');
|
||||
if (await dbContext.hasPendingMigrations()) {
|
||||
console.log('Pending migrations, redirecting to upgrade page');
|
||||
onUpgradeRequired?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
await withMinimumDelay(() => Promise.resolve(onSuccess?.(false)), 300, enableDelay);
|
||||
return false;
|
||||
} catch (err) {
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
[vaultManager executeUpdate:query params:params resolver:resolve rejecter:reject];
|
||||
}
|
||||
|
||||
- (void)executeRaw:(NSString *)query resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
||||
[vaultManager executeRaw:query resolver:resolve rejecter:reject];
|
||||
}
|
||||
|
||||
- (void)beginTransaction:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
||||
[vaultManager beginTransaction:resolve rejecter:reject];
|
||||
}
|
||||
|
||||
@@ -163,6 +163,19 @@ public class VaultManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func executeRaw(_ query: String,
|
||||
resolver resolve: @escaping RCTPromiseResolveBlock,
|
||||
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
||||
do {
|
||||
// Execute the raw query through the vault store
|
||||
try vaultStore.executeRaw(query)
|
||||
resolve(nil)
|
||||
} catch {
|
||||
reject("RAW_ERROR", "Failed to execute raw query: \(error.localizedDescription)", error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func clearVault() {
|
||||
do {
|
||||
|
||||
@@ -74,6 +74,30 @@ extension VaultStore {
|
||||
return dbConnection.changes
|
||||
}
|
||||
|
||||
/// Execute a raw SQL command on the database without parameters (for DDL operations like CREATE TABLE).
|
||||
public func executeRaw(_ query: String) throws {
|
||||
guard let dbConnection = self.dbConnection else {
|
||||
throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"])
|
||||
}
|
||||
|
||||
// Split the query by semicolons to handle multiple statements
|
||||
let statements = query.components(separatedBy: ";")
|
||||
|
||||
for statement in statements {
|
||||
let trimmedStatement = statement.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Skip empty statements and transaction control statements (handled externally)
|
||||
if trimmedStatement.isEmpty ||
|
||||
trimmedStatement.uppercased().hasPrefix("BEGIN TRANSACTION") ||
|
||||
trimmedStatement.uppercased().hasPrefix("COMMIT") ||
|
||||
trimmedStatement.uppercased().hasPrefix("ROLLBACK") {
|
||||
continue
|
||||
}
|
||||
|
||||
try dbConnection.execute(trimmedStatement)
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin a transaction on the database. This is required for all database operations that modify the database.
|
||||
public func beginTransaction() throws {
|
||||
guard let dbConnection = self.dbConnection else {
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Spec extends TurboModule {
|
||||
// SQL operations
|
||||
executeQuery(query: string, params: (string | number | null)[]): Promise<string[]>;
|
||||
executeUpdate(query: string, params:(string | number | null)[]): Promise<number>;
|
||||
executeRaw(query: string): Promise<void>;
|
||||
beginTransaction(): Promise<void>;
|
||||
commitTransaction(): Promise<void>;
|
||||
rollbackTransaction(): Promise<void>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/shared/models/metadata';
|
||||
import type { Credential, EncryptionKey, PasswordSettings, TotpCode } from '@/utils/dist/shared/models/vault';
|
||||
import { VaultSqlGenerator, VaultVersion } from '@/utils/dist/shared/vault-sql';
|
||||
|
||||
import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
|
||||
@@ -560,8 +561,10 @@ class SqliteClient {
|
||||
* Returns the semantic version (e.g., "1.4.1") from the latest migration.
|
||||
* Returns null if no migrations are found.
|
||||
*/
|
||||
public async getDatabaseVersion(): Promise<string | null> {
|
||||
public async getDatabaseVersion(): Promise<VaultVersion> {
|
||||
try {
|
||||
let currentVersion = '';
|
||||
|
||||
// Query the migrations history table for the latest migration
|
||||
const results = await this.executeQuery<{ MigrationId: string }>(`
|
||||
SELECT MigrationId
|
||||
@@ -570,7 +573,7 @@ class SqliteClient {
|
||||
LIMIT 1`);
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
throw new Error('No migrations found');
|
||||
}
|
||||
|
||||
// Extract version using regex - matches patterns like "20240917191243_1.4.1-RenameAttachmentsPlural"
|
||||
@@ -579,16 +582,34 @@ class SqliteClient {
|
||||
const versionMatch = versionRegex.exec(migrationId);
|
||||
|
||||
if (versionMatch?.[1]) {
|
||||
return versionMatch[1];
|
||||
currentVersion = versionMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
// Get all available vault versions to get the revision number of the current version.
|
||||
const vaultSqlGenerator = new VaultSqlGenerator();
|
||||
const allVersions = vaultSqlGenerator.getAllVersions();
|
||||
const currentVersionRevision = allVersions.find(v => v.version === currentVersion);
|
||||
|
||||
if (!currentVersionRevision) {
|
||||
throw new Error(`Current version ${currentVersion} not found in available vault versions.`);
|
||||
}
|
||||
|
||||
return currentVersionRevision;
|
||||
} catch (error) {
|
||||
console.error('Error getting database version:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the version info of the latest available vault migration.
|
||||
*/
|
||||
public async getLatestDatabaseVersion(): Promise<VaultVersion> {
|
||||
const vaultSqlGenerator = new VaultSqlGenerator();
|
||||
const allVersions = vaultSqlGenerator.getAllVersions();
|
||||
return allVersions[allVersions.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TOTP codes for a credential
|
||||
* @param credentialId - The ID of the credential to get TOTP codes for
|
||||
|
||||
Reference in New Issue
Block a user