Add attachment upload option to mobile app (#1010)

This commit is contained in:
Leendert de Borst
2025-07-26 21:16:06 +02:00
committed by Leendert de Borst
parent a674baa6d6
commit 7da8189789
5 changed files with 362 additions and 46 deletions

View File

@@ -10,7 +10,7 @@ import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view
import Toast from 'react-native-toast-message';
import { CreateIdentityGenerator, IdentityHelperUtils, IdentityGenerator } from '@/utils/dist/shared/identity-generator';
import type { Credential } from '@/utils/dist/shared/models/vault';
import type { Attachment, Credential } from '@/utils/dist/shared/models/vault';
import type { FaviconExtractModel } from '@/utils/dist/shared/models/webapi';
import { CreatePasswordGenerator, PasswordGenerator } from '@/utils/dist/shared/password-generator';
import emitter from '@/utils/EventEmitter';
@@ -20,6 +20,7 @@ import { credentialSchema } from '@/utils/ValidationSchema';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { AttachmentUploader } from '@/components/credentials/details/AttachmentUploader';
import { ValidatedFormField, ValidatedFormFieldRef } from '@/components/form/ValidatedFormField';
import LoadingOverlay from '@/components/LoadingOverlay';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
@@ -48,6 +49,8 @@ export default function AddEditCredentialScreen() : React.ReactNode {
const serviceNameRef = useRef<ValidatedFormFieldRef>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
const { t } = useTranslation();
const { control, handleSubmit, setValue, watch } = useForm<Credential>({
@@ -89,6 +92,11 @@ export default function AddEditCredentialScreen() : React.ReactNode {
if (existingCredential.Alias?.FirstName || existingCredential.Alias?.LastName) {
setMode('manual');
}
// Load attachments for this credential
const credentialAttachments = await dbContext.sqliteClient!.getAttachmentsForCredential(id);
setAttachments(credentialAttachments);
setOriginalAttachmentIds(credentialAttachments.map(a => a.Id));
}
} catch (err) {
console.error('Error loading credential:', err);
@@ -282,9 +290,9 @@ export default function AddEditCredentialScreen() : React.ReactNode {
await executeVaultMutation(async () => {
if (isEditMode) {
await dbContext.sqliteClient!.updateCredentialById(credentialToSave);
await dbContext.sqliteClient!.updateCredentialById(credentialToSave, originalAttachmentIds, attachments);
} else {
const credentialId = await dbContext.sqliteClient!.createCredential(credentialToSave);
const credentialId = await dbContext.sqliteClient!.createCredential(credentialToSave, attachments);
credentialToSave.Id = credentialId;
}
@@ -315,7 +323,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
setIsSyncing(false);
}
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch, setIsSaveDisabled, setIsSyncing, isSaveDisabled, t]);
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch, setIsSaveDisabled, setIsSyncing, isSaveDisabled, t, originalAttachmentIds, attachments]);
/**
* Generate a random username.
@@ -710,6 +718,16 @@ export default function AddEditCredentialScreen() : React.ReactNode {
{/* TODO: Add TOTP management */}
</View>
<View style={styles.section}>
<ThemedText style={styles.sectionTitle}>{t('credentials.attachments')}</ThemedText>
<AttachmentUploader
attachments={attachments}
onAttachmentsChange={setAttachments}
originalAttachmentIds={originalAttachmentIds}
/>
</View>
{isEditMode && (
<TouchableOpacity
style={styles.deleteButton}

View File

@@ -1,10 +1,12 @@
import * as Sharing from 'expo-sharing';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TouchableOpacity, Platform, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import type { Credential, Attachment } from '@/utils/dist/shared/models/vault';
import emitter from '@/utils/EventEmitter';
import { useColors } from '@/hooks/useColorScheme';
@@ -66,31 +68,59 @@ export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ credential
}
};
useEffect(() => {
/**
* Load the attachments.
*/
const loadAttachments = async (): Promise<void> => {
if (!dbContext?.sqliteClient) {
return;
}
/**
* Load the attachments.
*/
const loadAttachments = useCallback(async (): Promise<void> => {
if (!dbContext?.sqliteClient) {
return;
}
try {
const attachmentList = await dbContext.sqliteClient.getAttachmentsForCredential(credential.Id);
setAttachments(attachmentList);
} catch (error) {
console.error('Error loading attachments:', error);
}
};
loadAttachments();
try {
const attachmentList = await dbContext.sqliteClient.getAttachmentsForCredential(credential.Id);
setAttachments(attachmentList);
} catch (error) {
console.error('Error loading attachments:', error);
}
}, [credential.Id, dbContext?.sqliteClient]);
useEffect((): (() => void) => {
loadAttachments();
// Add listener for credential changes to reload attachments
const credentialChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => {
if (changedId === credential.Id) {
await loadAttachments();
}
});
return () => {
credentialChangedSub.remove();
};
}, [credential.Id, dbContext?.sqliteClient, loadAttachments]);
if (attachments.length === 0) {
return null;
}
const styles = StyleSheet.create({
attachmentDate: {
fontSize: 12,
},
attachmentInfo: {
flex: 1,
},
attachmentItem: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
attachmentName: {
fontSize: 14,
fontWeight: '500',
marginBottom: 2,
},
container: {
paddingTop: 16,
},
@@ -100,23 +130,6 @@ export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ credential
marginTop: 8,
padding: 12,
},
attachmentItem: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
attachmentInfo: {
flex: 1,
},
attachmentName: {
fontSize: 14,
fontWeight: '500',
marginBottom: 2,
},
attachmentDate: {
fontSize: 12,
},
downloadIcon: {
marginLeft: 12,
},
@@ -143,7 +156,7 @@ export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ credential
</ThemedText>
</View>
<View style={styles.downloadIcon}>
<ThemedText type="subtitle">📎</ThemedText>
<Ionicons name="download-outline" size={24} color={colors.text} />
</View>
</View>
</TouchableOpacity>

View File

@@ -0,0 +1,210 @@
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import type { Attachment } from '@/utils/dist/shared/models/vault';
import { useColors } from '@/hooks/useColorScheme';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
type AttachmentUploaderProps = {
attachments: Attachment[];
onAttachmentsChange: (attachments: Attachment[]) => void;
}
/**
* This component allows uploading and managing attachments for a credential.
*/
export const AttachmentUploader: React.FC<AttachmentUploaderProps> = ({
attachments,
onAttachmentsChange,
}) => {
const { t } = useTranslation();
const colors = useColors();
const [statusMessage, setStatusMessage] = useState<string>('');
/**
* Handles file selection and upload.
*/
const handleFileSelection = async (): Promise<void> => {
try {
const result = await DocumentPicker.getDocumentAsync({
multiple: true,
copyToCacheDirectory: false,
});
if (result.canceled) {
return;
}
const newAttachments = [...attachments];
for (const file of result.assets) {
if (file.uri) {
// Read file as bytes
const response = await fetch(file.uri);
const arrayBuffer = await response.arrayBuffer();
const byteArray = new Uint8Array(arrayBuffer);
const attachment: Attachment = {
Id: crypto.randomUUID(),
Filename: file.name,
Blob: byteArray,
CredentialId: '', // Will be set when saving credential
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString(),
IsDeleted: false,
};
newAttachments.push(attachment);
}
}
onAttachmentsChange(newAttachments);
setStatusMessage('');
} catch (error) {
console.error('Error uploading files:', error);
setStatusMessage('Error uploading files.');
setTimeout(() => setStatusMessage(''), 3000);
}
};
/**
* Deletes an attachment.
*/
const deleteAttachment = (attachmentToDelete: Attachment): void => {
Alert.alert(
'Delete Attachment',
`Are you sure you want to delete ${attachmentToDelete.Filename}?`,
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
/**
* Deletes the attachment.
*/
onPress: (): void => {
try {
const updatedAttachments = [...attachments];
// Remove the attachment from the list
const index = updatedAttachments.findIndex(a => a.Id === attachmentToDelete.Id);
if (index !== -1) {
updatedAttachments.splice(index, 1);
}
onAttachmentsChange(updatedAttachments);
setStatusMessage('');
} catch (error) {
console.error('Error deleting attachment:', error);
setStatusMessage('Error deleting attachment.');
setTimeout(() => setStatusMessage(''), 3000);
}
},
},
]
);
};
const activeAttachments = attachments.filter(a => !a.IsDeleted);
const styles = StyleSheet.create({
addButton: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 8,
height: 35,
justifyContent: 'center',
marginTop: 12,
width: '100%',
},
attachmentDate: {
fontSize: 12,
},
attachmentInfo: {
flex: 1,
},
attachmentItem: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderRadius: 8,
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
padding: 12,
},
attachmentName: {
fontSize: 14,
fontWeight: '500',
marginBottom: 2,
},
container: {
backgroundColor: colors.accentBackground,
},
deleteButton: {
backgroundColor: colors.errorBackground,
borderRadius: 6,
marginLeft: 12,
padding: 8,
},
deleteButtonText: {
color: colors.text,
fontSize: 12,
fontWeight: '600',
},
statusMessage: {
fontSize: 14,
textAlign: 'center',
},
});
return (
<ThemedView style={styles.container}>
{statusMessage && (
<ThemedText style={styles.statusMessage} type="subtitle">
{statusMessage}
</ThemedText>
)}
{activeAttachments.length > 0 && (
<View>
{activeAttachments.map(attachment => (
<View key={attachment.Id} style={styles.attachmentItem}>
<View style={styles.attachmentInfo}>
<ThemedText style={styles.attachmentName}>
{attachment.Filename}
</ThemedText>
<ThemedText style={styles.attachmentDate} type="subtitle">
{new Date(attachment.CreatedAt).toLocaleDateString()}
</ThemedText>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => deleteAttachment(attachment)}
>
<ThemedText style={styles.deleteButtonText}>
{t('credentials.deleteAttachment')}
</ThemedText>
</TouchableOpacity>
</View>
))}
</View>
)}
<TouchableOpacity
style={styles.addButton}
onPress={handleFileSelection}
>
<Ionicons name="add" size={24} color={colors.background} />
</TouchableOpacity>
</ThemedView>
);
};

View File

@@ -101,6 +101,10 @@
"loadingAttachments": "Loading attachments...",
"addAttachments": "Add Attachments",
"deleteAttachment": "Delete",
"toasts": {
"credentialUpdated": "Credential updated successfully",
"credentialCreated": "Credential created successfully"
},
"errors": {
"loadFailed": "Failed to load credential",
"generateUsernameFailed": "Failed to generate username",

View File

@@ -1,5 +1,5 @@
import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/shared/models/metadata';
import type { Credential, EncryptionKey, PasswordSettings, TotpCode } from '@/utils/dist/shared/models/vault';
import type { Attachment, Credential, EncryptionKey, PasswordSettings, TotpCode } from '@/utils/dist/shared/models/vault';
import { VaultSqlGenerator, VaultVersion } from '@/utils/dist/shared/vault-sql';
import NativeVaultManager from '@/specs/NativeVaultManager';
@@ -497,9 +497,10 @@ class SqliteClient {
/**
* Create a new credential with associated entities
* @param credential The credential object to insert
* @param attachments The attachments to insert
* @returns The ID of the newly created credential
*/
public async createCredential(credential: Credential): Promise<string> {
public async createCredential(credential: Credential, attachments: Attachment[]): Promise<string> {
try {
await NativeVaultManager.beginTransaction();
@@ -589,6 +590,22 @@ class SqliteClient {
]);
}
// 5. Insert Attachments
for (const attachment of attachments) {
const attachmentQuery = `
INSERT INTO Attachments (Id, Filename, Blob, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
await this.executeUpdate(attachmentQuery, [
attachment.Id,
attachment.Filename,
attachment.Blob as Uint8Array,
credentialId,
currentDateTime,
currentDateTime,
0
]);
}
await NativeVaultManager.commitTransaction();
return credentialId;
@@ -795,9 +812,11 @@ class SqliteClient {
/**
* Update an existing credential with associated entities
* @param credential The credential object to update
* @param originalAttachmentIds The IDs of the original attachments
* @param attachments The attachments to update
* @returns The number of rows modified
*/
public async updateCredentialById(credential: Credential): Promise<number> {
public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[]): Promise<number> {
try {
await NativeVaultManager.beginTransaction();
const currentDateTime = new Date().toISOString()
@@ -939,6 +958,58 @@ class SqliteClient {
}
}
// 5. Handle Attachments
if (attachments) {
// Get current attachment IDs to track what needs to be deleted
const currentAttachmentIds = attachments.map(a => a.Id);
// Delete attachments that were removed (in originalAttachmentIds but not in current attachments)
const attachmentsToDelete = originalAttachmentIds.filter(id => !currentAttachmentIds.includes(id));
for (const attachmentId of attachmentsToDelete) {
const deleteQuery = `
UPDATE Attachments
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
await this.executeUpdate(deleteQuery, [currentDateTime, attachmentId]);
}
// Process each attachment
for (const attachment of attachments) {
const isExistingAttachment = originalAttachmentIds.includes(attachment.Id);
if (isExistingAttachment) {
// Update existing attachment
const updateQuery = `
UPDATE Attachments
SET Filename = ?,
Blob = ?,
UpdatedAt = ?
WHERE Id = ?`;
await this.executeUpdate(updateQuery, [
attachment.Filename,
attachment.Blob as Uint8Array,
currentDateTime,
attachment.Id
]);
} else {
// Insert new attachment
const insertQuery = `
INSERT INTO Attachments (Id, Filename, Blob, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
await this.executeUpdate(insertQuery, [
attachment.Id,
attachment.Filename,
attachment.Blob as Uint8Array,
credential.Id,
currentDateTime,
currentDateTime,
0
]);
}
}
}
await NativeVaultManager.commitTransaction();
return 1;