diff --git a/mobile-app/app/(tabs)/credentials/[id].tsx b/mobile-app/app/(tabs)/credentials/[id].tsx index fb18c288e..cc323da1f 100644 --- a/mobile-app/app/(tabs)/credentials/[id].tsx +++ b/mobile-app/app/(tabs)/credentials/[id].tsx @@ -1,6 +1,6 @@ -import { useLocalSearchParams } from 'expo-router'; +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; -import { ActivityIndicator, View, Text, useColorScheme, StyleSheet, TouchableOpacity, Linking } from 'react-native'; +import { ActivityIndicator, View, Text, StyleSheet, TouchableOpacity, Linking } from 'react-native'; import Toast from 'react-native-toast-message'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; @@ -13,14 +13,42 @@ import { AliasDetails } from '@/components/credentialDetails/AliasDetails'; import { NotesSection } from '@/components/credentialDetails/NotesSection'; import { EmailPreview } from '@/components/credentialDetails/EmailPreview'; import { TotpSection } from '@/components/credentialDetails/TotpSection'; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useColors } from '@/hooks/useColorScheme'; +import emitter from '@/utils/EventEmitter'; export default function CredentialDetailsScreen() { const { id } = useLocalSearchParams(); const [credential, setCredential] = useState(null); const [isLoading, setIsLoading] = useState(true); const dbContext = useDb(); - const colorScheme = useColorScheme(); - const isDarkMode = colorScheme === 'dark'; + const navigation = useNavigation(); + const colors = useColors(); + const router = useRouter(); + + // Set header buttons + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + + + + + ), + }); + }, [navigation, credential]); + + const handleEdit = () => { + router.push(`/(tabs)/credentials/add-edit?id=${id}`); + } useEffect(() => { const loadCredential = async () => { @@ -38,7 +66,16 @@ export default function CredentialDetailsScreen() { loadCredential(); + // Add listener for credential changes + const credentialChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => { + if (changedId === id) { + console.log('This credential was changed, refreshing details'); + await loadCredential(); + } + }); + return () => { + credentialChangedSub.remove(); Toast.hide(); }; }, [id, dbContext.dbAvailable]); @@ -68,7 +105,7 @@ export default function CredentialDetailsScreen() { {credential.ServiceUrl && ( Linking.openURL(credential.ServiceUrl!)}> - + {credential.ServiceUrl} diff --git a/mobile-app/app/(tabs)/credentials/add-edit.tsx b/mobile-app/app/(tabs)/credentials/add-edit.tsx index 6a44b4303..fec11f08e 100644 --- a/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -1,5 +1,5 @@ import { StyleSheet, View, TextInput, TouchableOpacity, ScrollView, Platform, Animated } from 'react-native'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; @@ -10,6 +10,7 @@ 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'; type CredentialMode = 'random' | 'manual'; @@ -21,6 +22,7 @@ export default function AddEditCredentialScreen() { const [mode, setMode] = useState('random'); const [isLoading, setIsLoading] = useState(false); const navigation = useNavigation(); + const serviceNameInputRef = useRef(null); const [credential, setCredential] = useState>({ Id: "", Username: "", @@ -64,10 +66,10 @@ export default function AddEditCredentialScreen() { } } - - // Set navigation options + // Set header buttons useEffect(() => { navigation.setOptions({ + title: isEditMode ? 'Edit Credential' : 'Add Credential', headerLeft: () => ( router.back()} @@ -118,9 +120,31 @@ export default function AddEditCredentialScreen() { ServiceName: serviceName, // ... other form fields })); + + // In create mode, autofocus the service name field and select all default text + // so user can start renaming the service immediately if they want. + if (!isEditMode) { + setTimeout(() => { + serviceNameInputRef.current?.focus(); + if (serviceUrl) { + // If serviceUrl is provided, select all text + serviceNameInputRef.current?.setSelection(0, serviceName.length || 0); + } + }, 200); + } } }, [serviceUrl]); + useEffect(() => { + // Focus and select text logic + if (!isEditMode) { + // In create mode, always focus the service name field + setTimeout(() => { + serviceNameInputRef.current?.focus(); + }, 100); + } + }, [isEditMode, serviceUrl]); + const loadExistingCredential = async () => { try { setIsLoading(true); @@ -183,19 +207,33 @@ export default function AddEditCredentialScreen() { credentialToSave = generateRandomValues(); } - // Always use createCredential since updateCredentialById doesn't exist - await dbContext.sqliteClient!.createCredential(credentialToSave); - Toast.show({ - type: 'success', - text1: 'Credential saved successfully', - position: 'bottom' - }); + 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(); } catch (error) { console.error('Error saving credential:', error); Toast.show({ type: 'error', - text1: 'Failed to save credential', + text1: isEditMode ? 'Failed to update credential' : 'Failed to create credential', text2: error instanceof Error ? error.message : 'Unknown error', position: 'bottom' }); @@ -291,8 +329,6 @@ export default function AddEditCredentialScreen() { }, }); - console.log('credential serviceName: ', credential.ServiceName); - return ( @@ -321,6 +357,7 @@ export default function AddEditCredentialScreen() { Service Information { + const tabPressSub = emitter.addListener('tabPress', (routeName: string) => { if (routeName === 'credentials' && isTabFocused) { console.log('Credentials tab re-pressed while focused: reset screen'); setSearchQuery(''); // Reset search @@ -53,8 +53,15 @@ export default function CredentialsScreen() { } }); + // Add listener for credential changes + const credentialChangedSub = emitter.addListener('credentialChanged', async () => { + console.log('Credential changed, refreshing list'); + await loadCredentials(); + }); + return () => { - sub.remove(); + tabPressSub.remove(); + credentialChangedSub.remove(); unsubscribeFocus(); unsubscribeBlur(); }; diff --git a/mobile-app/utils/SqliteClient.tsx b/mobile-app/utils/SqliteClient.tsx index 9f04f8d39..f8aa28960 100644 --- a/mobile-app/utils/SqliteClient.tsx +++ b/mobile-app/utils/SqliteClient.tsx @@ -542,6 +542,140 @@ class SqliteClient { } }; } + + /** + * Update an existing credential with associated entities + * @param credential The credential object to update + * @returns The number of rows modified + */ + public async updateCredentialById(credential: Credential): Promise { + try { + await NativeVaultManager.beginTransaction(); + const currentDateTime = new Date().toISOString() + .replace('T', ' ') + .replace('Z', '') + .substring(0, 23); + + // Get existing credential to compare changes + const existingCredential = await this.getCredentialById(credential.Id); + if (!existingCredential) { + throw new Error('Credential not found'); + } + + // 1. Update Service + // TODO: make Logo update optional, currently not supported as its becoming null. + // Logo = ?, + + const serviceQuery = ` + UPDATE Services + SET Name = ?, + Url = ?, + UpdatedAt = ? + WHERE Id = ( + SELECT ServiceId + FROM Credentials + WHERE Id = ? + )`; + + /*let logoData = null; + try { + if (credential.Logo) { + if (typeof credential.Logo === 'object' && !ArrayBuffer.isView(credential.Logo)) { + const values = Object.values(credential.Logo); + logoData = new Uint8Array(values); + } else if (Array.isArray(credential.Logo) || credential.Logo instanceof ArrayBuffer || credential.Logo instanceof Uint8Array) { + logoData = new Uint8Array(credential.Logo); + } + } + } catch (error) { + console.warn('Failed to convert logo to Uint8Array:', error); + logoData = null; + }*/ + + await this.executeUpdate(serviceQuery, [ + credential.ServiceName, + credential.ServiceUrl ?? null, + //logoData, + currentDateTime, + credential.Id + ]); + + // 2. Update Alias + const aliasQuery = ` + UPDATE Aliases + SET FirstName = ?, + LastName = ?, + NickName = ?, + BirthDate = ?, + Gender = ?, + Email = ?, + UpdatedAt = ? + WHERE Id = ( + SELECT AliasId + FROM Credentials + WHERE Id = ? + )`; + + // Only update BirthDate if it's actually different (accounting for format differences) + let birthDate = credential.Alias.BirthDate; + if (birthDate && existingCredential.Alias.BirthDate) { + const newDate = new Date(birthDate); + const existingDate = new Date(existingCredential.Alias.BirthDate); + if (newDate.getTime() === existingDate.getTime()) { + birthDate = existingCredential.Alias.BirthDate; + } + } + + await this.executeUpdate(aliasQuery, [ + credential.Alias.FirstName ?? null, + credential.Alias.LastName ?? null, + credential.Alias.NickName ?? null, + birthDate ?? null, + credential.Alias.Gender ?? null, + credential.Alias.Email ?? null, + currentDateTime, + credential.Id + ]); + + // 3. Update Credential + const credentialQuery = ` + UPDATE Credentials + SET Username = ?, + Notes = ?, + UpdatedAt = ? + WHERE Id = ?`; + + await this.executeUpdate(credentialQuery, [ + credential.Username ?? null, + credential.Notes ?? null, + currentDateTime, + credential.Id + ]); + + // 4. Update Password if changed + if (credential.Password !== existingCredential.Password) { + const passwordQuery = ` + UPDATE Passwords + SET Value = ?, + UpdatedAt = ? + WHERE CredentialId = ?`; + + await this.executeUpdate(passwordQuery, [ + credential.Password, + currentDateTime, + credential.Id + ]); + } + + await NativeVaultManager.commitTransaction(); + return 1; + + } catch (error) { + await NativeVaultManager.rollbackTransaction(); + console.error('Error updating credential:', error); + throw error; + } + } } export default SqliteClient; \ No newline at end of file