mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 13:28:12 -04:00
Add attachment upload option to mobile app (#1010)
This commit is contained in:
committed by
Leendert de Borst
parent
a674baa6d6
commit
7da8189789
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user