Add credential update scaffolding (#771)

This commit is contained in:
Leendert de Borst
2025-04-29 14:19:50 +02:00
parent caef74477b
commit a46d1ca39e
4 changed files with 235 additions and 20 deletions

View File

@@ -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<Credential | null>(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: () => (
<View style={{ flexDirection: 'row' }}>
<TouchableOpacity
onPress={handleEdit}
style={{ padding: 10, paddingRight: 0 }}
>
<MaterialIcons
name="edit"
size={24}
color={colors.primary}
/>
</TouchableOpacity>
</View>
),
});
}, [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() {
</ThemedText>
{credential.ServiceUrl && (
<TouchableOpacity onPress={() => Linking.openURL(credential.ServiceUrl!)}>
<Text style={[styles.serviceUrl, { color: isDarkMode ? '#60a5fa' : '#2563eb' }]}>
<Text style={[styles.serviceUrl, { color: colors.primary }]}>
{credential.ServiceUrl}
</Text>
</TouchableOpacity>

View File

@@ -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<CredentialMode>('random');
const [isLoading, setIsLoading] = useState(false);
const navigation = useNavigation();
const serviceNameInputRef = useRef<TextInput>(null);
const [credential, setCredential] = useState<Partial<Credential>>({
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: () => (
<TouchableOpacity
onPress={() => 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 (
<ThemedSafeAreaView style={styles.container}>
<ThemedView style={styles.content}>
@@ -321,6 +357,7 @@ export default function AddEditCredentialScreen() {
<View style={styles.section}>
<ThemedText style={styles.sectionTitle}>Service Information</ThemedText>
<TextInput
ref={serviceNameInputRef}
style={styles.input}
placeholder="Service Name"
placeholderTextColor={colors.textMuted}

View File

@@ -43,7 +43,7 @@ export default function CredentialsScreen() {
setIsTabFocused(false);
});
const sub = emitter.addListener('tabPress', (routeName: string) => {
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();
};

View File

@@ -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<number> {
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;