From 7da818978986c85a577a8f5cc0d90b92ea99b5f8 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 26 Jul 2025 21:16:06 +0200 Subject: [PATCH] Add attachment upload option to mobile app (#1010) --- .../app/(tabs)/credentials/add-edit.tsx | 26 ++- .../credentials/details/AttachmentSection.tsx | 91 ++++---- .../details/AttachmentUploader.tsx | 210 ++++++++++++++++++ apps/mobile-app/i18n/locales/en.json | 4 + apps/mobile-app/utils/SqliteClient.tsx | 77 ++++++- 5 files changed, 362 insertions(+), 46 deletions(-) create mode 100644 apps/mobile-app/components/credentials/details/AttachmentUploader.tsx diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx index 2638c66b5..0b38cf656 100644 --- a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -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(null); const [isSyncing, setIsSyncing] = useState(false); const [isSaveDisabled, setIsSaveDisabled] = useState(false); + const [attachments, setAttachments] = useState([]); + const [originalAttachmentIds, setOriginalAttachmentIds] = useState([]); const { t } = useTranslation(); const { control, handleSubmit, setValue, watch } = useForm({ @@ -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 */} + + {t('credentials.attachments')} + + + + {isEditMode && ( = ({ credential } }; - useEffect(() => { - /** - * Load the attachments. - */ - const loadAttachments = async (): Promise => { - if (!dbContext?.sqliteClient) { - return; - } + /** + * Load the attachments. + */ + const loadAttachments = useCallback(async (): Promise => { + 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 = ({ 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 = ({ credential - 📎 + diff --git a/apps/mobile-app/components/credentials/details/AttachmentUploader.tsx b/apps/mobile-app/components/credentials/details/AttachmentUploader.tsx new file mode 100644 index 000000000..0eee95fa8 --- /dev/null +++ b/apps/mobile-app/components/credentials/details/AttachmentUploader.tsx @@ -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 = ({ + attachments, + onAttachmentsChange, +}) => { + const { t } = useTranslation(); + const colors = useColors(); + const [statusMessage, setStatusMessage] = useState(''); + + /** + * Handles file selection and upload. + */ + const handleFileSelection = async (): Promise => { + 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 ( + + {statusMessage && ( + + {statusMessage} + + )} + + {activeAttachments.length > 0 && ( + + {activeAttachments.map(attachment => ( + + + + {attachment.Filename} + + + {new Date(attachment.CreatedAt).toLocaleDateString()} + + + deleteAttachment(attachment)} + > + + {t('credentials.deleteAttachment')} + + + + ))} + + )} + + + + + + ); +}; \ No newline at end of file diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index f8bab724a..8d62aa300 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -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", diff --git a/apps/mobile-app/utils/SqliteClient.tsx b/apps/mobile-app/utils/SqliteClient.tsx index a0203caee..08e954be7 100644 --- a/apps/mobile-app/utils/SqliteClient.tsx +++ b/apps/mobile-app/utils/SqliteClient.tsx @@ -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 { + public async createCredential(credential: Credential, attachments: Attachment[]): Promise { 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 { + public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[]): Promise { 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;