Make vault save to server flow work in app (#771)

This commit is contained in:
Leendert de Borst
2025-04-29 17:16:52 +02:00
parent 6c5247a4b0
commit 2fd117ae96
9 changed files with 293 additions and 110 deletions

View File

@@ -1,4 +1,4 @@
import { StyleSheet, View, TextInput, TouchableOpacity, ScrollView, Platform, Animated } from 'react-native';
import { StyleSheet, View, TextInput, TouchableOpacity, ScrollView, Platform, Animated, ActivityIndicator } from 'react-native';
import { useState, useEffect, useRef } from 'react';
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
import { ThemedText } from '@/components/ThemedText';
@@ -6,19 +6,29 @@ import { ThemedView } from '@/components/ThemedView';
import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView';
import { useColors } from '@/hooks/useColorScheme';
import { useDb } from '@/context/DbContext';
import { useWebApi } from '@/context/WebApiContext';
import { useVaultSync } from '@/hooks/useVaultSync';
import { Credential } from '@/utils/types/Credential';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import Toast from 'react-native-toast-message';
import { Gender } from "@/utils/generators/Identity/types/Gender";
import emitter from '@/utils/EventEmitter';
import NativeVaultManager from '@/specs/NativeVaultManager';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAuth } from '@/context/AuthContext';
type CredentialMode = 'random' | 'manual';
interface VaultPostResponse {
status: number;
newRevisionNumber: number;
}
export default function AddEditCredentialScreen() {
const { id, serviceUrl } = useLocalSearchParams<{ id: string, serviceUrl?: string }>();
const router = useRouter();
const colors = useColors();
const dbContext = useDb();
const authContext = useAuth();
const [mode, setMode] = useState<CredentialMode>('random');
const [isLoading, setIsLoading] = useState(false);
const navigation = useNavigation();
@@ -39,6 +49,9 @@ export default function AddEditCredentialScreen() {
Email: ""
},
});
const [syncStatus, setSyncStatus] = useState<string>('');
const webApi = useWebApi();
const { syncVault } = useVaultSync();
function extractServiceNameFromUrl(url: string): string {
try {
@@ -197,38 +210,26 @@ export default function AddEditCredentialScreen() {
const handleSave = async () => {
try {
setIsLoading(true);
setSyncStatus('Checking for vault updates...');
let credentialToSave = credential as Credential;
// If mode is random, generate random values for all fields before saving.
// TODO: replace this with actual identity generator logic.
if (mode === 'random') {
console.log('Generating random values');
credentialToSave = generateRandomValues();
}
if (isEditMode) {
// Update existing credential
await dbContext.sqliteClient!.updateCredentialById(credentialToSave);
Toast.show({
type: 'success',
text1: 'Credential updated successfully',
position: 'bottom'
});
} else {
// Create new credential
await dbContext.sqliteClient!.createCredential(credentialToSave);
Toast.show({
type: 'success',
text1: 'Credential created successfully',
position: 'bottom'
});
}
// Emit an event to notify list and detail views to refresh
emitter.emit('credentialChanged', credentialToSave.Id);
router.back();
// First check if there are any vault updates
await syncVault({
onStatus: (message) => setSyncStatus(message),
onSuccess: async (hasNewVault) => {
if (hasNewVault) {
console.log('Vault was changed, but has now been reloaded so we can continue with the save.');
}
await handleSaveInner();
},
onError: (error) => {
Toast.show({
type: 'error',
text1: 'Failed to sync vault',
text2: error,
position: 'bottom'
});
}
});
} catch (error) {
console.error('Error saving credential:', error);
Toast.show({
@@ -239,6 +240,96 @@ export default function AddEditCredentialScreen() {
});
} finally {
setIsLoading(false);
setSyncStatus('');
}
};
const handleSaveInner = async () => {
let credentialToSave = credential as Credential;
// If mode is random, generate random values for all fields before saving.
if (mode === 'random') {
console.log('Generating random values');
credentialToSave = generateRandomValues();
}
setSyncStatus('Saving changes to vault...');
if (isEditMode) {
// Update existing credential
await dbContext.sqliteClient!.updateCredentialById(credentialToSave);
} else {
// Create new credential
await dbContext.sqliteClient!.createCredential(credentialToSave);
}
// 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');
}
setSyncStatus('Uploading vault to server...');
// Get email addresses from credentials
const credentials = await dbContext.sqliteClient!.getAllCredentials();
const emailAddresses = credentials
.filter(cred => cred.Alias?.Email != null)
.map(cred => cred.Alias!.Email!)
.filter((email, index, self) => self.indexOf(email) === index);
// Get username from the auth context
const username = authContext.username;
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: emailAddresses,
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
// TODO: can client be null? double check this.
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'
};
console.log('New vault current revision number:', currentRevision);
console.log('Trying to upload vault to server...');
// Upload to server
const response = await webApi.post<typeof newVault, VaultPostResponse>('Vault', newVault);
console.log('Vault upload response:', response);
// Check if response is successful
if (response.status === 0) {
await NativeVaultManager.setCurrentVaultRevisionNumber(response.newRevisionNumber);
Toast.show({
type: 'success',
text1: isEditMode ? 'Credential updated successfully' : 'Credential created successfully',
position: 'bottom'
});
// Emit an event to notify list and detail views to refresh
emitter.emit('credentialChanged', credentialToSave.Id);
router.back();
} 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');
}
};
@@ -327,11 +418,36 @@ export default function AddEditCredentialScreen() {
height: 100,
textAlignVertical: 'top',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
syncStatus: {
marginTop: 16,
textAlign: 'center',
color: '#fff',
fontSize: 16,
},
});
return (
<ThemedSafeAreaView style={styles.container}>
<ThemedView style={styles.content}>
{(isLoading || syncStatus) && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={colors.primary} />
{syncStatus && (
<ThemedText style={styles.syncStatus}>{syncStatus}</ThemedText>
)}
</View>
)}
<ScrollView>
{!isEditMode && (
<View style={styles.modeSelector}>

View File

@@ -12,6 +12,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface RCTNativeVaultManager : NSObject <NativeVaultManagerSpec>
- (void)getEncryptedDatabase:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject;
- (void)getCurrentVaultRevisionNumber:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject;
- (void)setCurrentVaultRevisionNumber:(double)revisionNumber resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject;
@end
NS_ASSUME_NONNULL_END

View File

@@ -116,4 +116,16 @@
[vaultManager clearVault];
}
- (void)getEncryptedDatabase:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager getEncryptedDatabase:resolve rejecter:reject];
}
- (void)getCurrentVaultRevisionNumber:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager getCurrentVaultRevisionNumber:resolve rejecter:reject];
}
- (void)setCurrentVaultRevisionNumber:(double)revisionNumber resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager setCurrentVaultRevisionNumber:revisionNumber resolver:resolve rejecter:reject];
}
@end

View File

@@ -168,6 +168,31 @@ public class VaultManager: NSObject {
}
}
@objc
func getEncryptedDatabase(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
if let encryptedDb = vaultStore.getEncryptedDatabase() {
resolve(encryptedDb)
} else {
reject("DB_ERROR", "Failed to get encrypted database", nil)
}
}
@objc
func getCurrentVaultRevisionNumber(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
let revisionNumber = vaultStore.getCurrentVaultRevisionNumber()
resolve(revisionNumber)
}
@objc
func setCurrentVaultRevisionNumber(_ revisionNumber: Int,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
vaultStore.setCurrentVaultRevisionNumber(revisionNumber)
resolve(nil)
}
@objc
func isVaultInitialized(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {

View File

@@ -0,0 +1,13 @@
import Foundation
public struct VaultMetadata: Codable {
public var publicEmailDomains: [String]?
public var privateEmailDomains: [String]?
public var vaultRevisionNumber: Int
public init(publicEmailDomains: [String]? = nil, privateEmailDomains: [String]? = nil, vaultRevisionNumber: Int) {
self.publicEmailDomains = publicEmailDomains
self.privateEmailDomains = privateEmailDomains
self.vaultRevisionNumber = vaultRevisionNumber
}
}

View File

@@ -248,7 +248,7 @@ public class VaultStore {
}
// Get the encrypted database as a base64 encoded string
private func getEncryptedDatabase() -> String? {
public func getEncryptedDatabase() -> String? {
do {
// Return the base64 encoded string
return try String(contentsOf: getEncryptedDbPath(), encoding: .utf8)
@@ -257,11 +257,51 @@ public class VaultStore {
}
}
// Get the current vault revision number
public func getCurrentVaultRevisionNumber() -> Int {
guard let metadata = getVaultMetadataObject() else {
return 0
}
return metadata.vaultRevisionNumber
}
// Set the current vault revision number
public func setCurrentVaultRevisionNumber(_ revisionNumber: Int) {
var metadata: VaultMetadata
if let existingMetadata = getVaultMetadataObject() {
metadata = existingMetadata
} else {
metadata = VaultMetadata(
publicEmailDomains: [],
privateEmailDomains: [],
vaultRevisionNumber: revisionNumber
)
}
metadata.vaultRevisionNumber = revisionNumber
if let data = try? JSONEncoder().encode(metadata),
let jsonString = String(data: data, encoding: .utf8) {
UserDefaults.standard.set(jsonString, forKey: vaultMetadataKey)
UserDefaults.standard.synchronize()
}
}
// Get the vault metadata from UserDefaults
public func getVaultMetadata() -> String? {
return UserDefaults.standard.string(forKey: vaultMetadataKey)
}
// Helper to decode the JSON metadata into VaultMetadata object
private func getVaultMetadataObject() -> VaultMetadata? {
guard let jsonString = getVaultMetadata(),
let data = jsonString.data(using: .utf8),
let metadata = try? JSONDecoder().decode(VaultMetadata.self, from: data) else {
return nil
}
return metadata
}
/**
* Initialize the database.
*

View File

@@ -208,7 +208,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */ = {
CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -218,73 +218,12 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKit;
sourceTree = "<group>";
};
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKitTests;
sourceTree = "<group>";
};
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUI;
sourceTree = "<group>";
};
CEE4817A2DBE8AC800F4A367 /* VaultUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUITests;
sourceTree = "<group>";
};
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultModels;
sourceTree = "<group>";
};
CEE909812DA548C7008D568F /* Autofill */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = Autofill;
sourceTree = "<group>";
};
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKit; sourceTree = "<group>"; };
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = "<group>"; };
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = "<group>"; };
CEE4817A2DBE8AC800F4A367 /* VaultUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUITests; sourceTree = "<group>"; };
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = "<group>"; };
CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -1270,10 +1209,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -1328,10 +1264,7 @@
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;

View File

@@ -0,0 +1,37 @@
import { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
// Basic credential operations
addCredential(username: string, password: string, service: string): Promise<void>;
getCredentials(): Promise<{ credentials: Array<{ username: string; password: string; service: string }> }>;
clearVault(): Promise<void>;
// Vault state management
isVaultInitialized(): Promise<boolean>;
isVaultUnlocked(): Promise<boolean>;
getVaultMetadata(): Promise<any>;
unlockVault(): Promise<boolean>;
// Database operations
storeDatabase(base64EncryptedDb: string, metadata: string): Promise<void>;
setAuthMethods(authMethods: string[]): Promise<void>;
storeEncryptionKey(base64EncryptionKey: string): Promise<void>;
getEncryptedDatabase(): Promise<string | null>;
getCurrentVaultRevisionNumber(): Promise<number>;
setCurrentVaultRevisionNumber(revisionNumber: number): Promise<void>;
// SQL operations
executeQuery(query: string, params: any[]): Promise<any[]>;
executeUpdate(query: string, params: any[]): Promise<number>;
beginTransaction(): Promise<void>;
commitTransaction(): Promise<void>;
rollbackTransaction(): Promise<void>;
// Auto-lock settings
setAutoLockTimeout(timeout: number): Promise<void>;
getAutoLockTimeout(): Promise<number>;
getAuthMethods(): Promise<string[]>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeVaultManager');

View File

@@ -17,6 +17,9 @@ export interface Spec extends TurboModule {
storeDatabase(base64EncryptedDb: string, metadata: string): Promise<void>;
setAuthMethods(authMethods: string[]): Promise<void>;
storeEncryptionKey(base64EncryptionKey: string): Promise<void>;
getEncryptedDatabase(): Promise<string | null>;
getCurrentVaultRevisionNumber(): Promise<number>;
setCurrentVaultRevisionNumber(revisionNumber: number): Promise<void>;
// SQL operations
executeQuery(query: string, params: any[]): Promise<any[]>;