From ccb84780ebfb0f5bb995568342d7a65b9d402942 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 25 Jul 2025 16:42:08 +0200 Subject: [PATCH] Add attachment viewer to browser extension (#808) --- .../CredentialDetails/AttachmentBlock.tsx | 120 ++++++++++++++++++ .../components/CredentialDetails/index.tsx | 4 +- .../popup/pages/CredentialDetails.tsx | 4 +- .../src/i18n/locales/en.json | 2 + .../src/utils/SqliteClient.ts | 35 ++++- .../utils/dist/shared/models/vault/index.d.ts | 15 ++- 6 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 apps/browser-extension/src/entrypoints/popup/components/CredentialDetails/AttachmentBlock.tsx diff --git a/apps/browser-extension/src/entrypoints/popup/components/CredentialDetails/AttachmentBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/CredentialDetails/AttachmentBlock.tsx new file mode 100644 index 000000000..f438a9710 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/CredentialDetails/AttachmentBlock.tsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useDb } from '@/entrypoints/popup/context/DbContext'; + +import type { Attachment } from '@/utils/dist/shared/models/vault'; + +type AttachmentBlockProps = { + credentialId: string; +} + +/** + * This component shows attachments for a credential. + */ +const AttachmentBlock: React.FC = ({ credentialId }) => { + const { t } = useTranslation(); + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const dbContext = useDb(); + + /** + * Downloads an attachment file. + */ + const downloadAttachment = (attachment: Attachment): void => { + try { + // Convert Uint8Array or number[] to Uint8Array + const byteArray = attachment.Blob instanceof Uint8Array + ? attachment.Blob + : new Uint8Array(attachment.Blob); + + // Create blob and download + const blob = new Blob([byteArray as BlobPart]); + const url = URL.createObjectURL(blob); + + // Create temporary download link + const a = document.createElement('a'); + a.href = url; + a.download = attachment.Filename; + document.body.appendChild(a); + a.click(); + + // Cleanup + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Error downloading attachment:', error); + } + }; + + useEffect(() => { + /** + * Loads the attachments for the credential. + */ + const loadAttachments = async (): Promise => { + if (!dbContext?.sqliteClient) { + return; + } + + try { + const attachmentList = dbContext.sqliteClient.getAttachmentsForCredential(credentialId); + setAttachments(attachmentList); + } catch (error) { + console.error('Error loading attachments:', error); + } finally { + setLoading(false); + } + }; + + loadAttachments(); + }, [credentialId, dbContext?.sqliteClient]); + + if (loading) { + return ( +
+

{t('common.attachments')}

+ {t('common.loadingAttachments')} +
+ ); + } + + if (attachments.length === 0) { + return null; + } + + return ( +
+
+

{t('common.attachments')}

+
+ {attachments.map(attachment => ( + + ))} +
+
+
+ ); +}; + +export default AttachmentBlock; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/components/CredentialDetails/index.tsx b/apps/browser-extension/src/entrypoints/popup/components/CredentialDetails/index.tsx index 1ec32a6fd..45926b2b2 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/CredentialDetails/index.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/CredentialDetails/index.tsx @@ -1,4 +1,5 @@ import AliasBlock from './AliasBlock'; +import AttachmentBlock from './AttachmentBlock'; import EmailBlock from './EmailBlock'; import HeaderBlock from './HeaderBlock'; import LoginCredentialsBlock from './LoginCredentialsBlock'; @@ -11,5 +12,6 @@ export { TotpBlock, LoginCredentialsBlock, AliasBlock, - NotesBlock + NotesBlock, + AttachmentBlock }; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx index cdce7eb35..b64620a3c 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/CredentialDetails.tsx @@ -8,7 +8,8 @@ import { TotpBlock, LoginCredentialsBlock, AliasBlock, - NotesBlock + NotesBlock, + AttachmentBlock } from '@/entrypoints/popup/components/CredentialDetails'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; @@ -114,6 +115,7 @@ const CredentialDetails: React.FC = (): React.ReactElement => { + ); }; diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index 424f98574..38c22c9e8 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -64,6 +64,8 @@ "copyToClipboard": "Copy to clipboard", "loadingEmails": "Loading emails...", "loadingTotpCodes": "Loading TOTP codes...", + "attachments": "Attachments", + "loadingAttachments": "Loading attachments...", "settings": "Settings", "recentEmails": "Recent emails", "loginCredentials": "Login credentials", diff --git a/apps/browser-extension/src/utils/SqliteClient.ts b/apps/browser-extension/src/utils/SqliteClient.ts index 45ab7a5eb..a13238269 100644 --- a/apps/browser-extension/src/utils/SqliteClient.ts +++ b/apps/browser-extension/src/utils/SqliteClient.ts @@ -1,6 +1,6 @@ import initSqlJs, { Database } from 'sql.js'; -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 type { VaultVersion } from '@/utils/dist/shared/vault-sql'; import { VaultSqlGenerator } from '@/utils/dist/shared/vault-sql'; @@ -641,6 +641,39 @@ export class SqliteClient { } } + /** + * Get attachments for a specific credential + * @param credentialId - The ID of the credential + * @returns Array of attachments for the credential + */ + public getAttachmentsForCredential(credentialId: string): Attachment[] { + if (!this.db) { + throw new Error('Database not initialized'); + } + + try { + if (!this.tableExists('Attachments')) { + return []; + } + + const query = ` + SELECT + Id, + Filename, + Blob, + CredentialId, + CreatedAt, + UpdatedAt, + IsDeleted + FROM Attachments + WHERE CredentialId = ? AND IsDeleted = 0`; + return this.executeQuery(query, [credentialId]); + } catch (error) { + console.error('Error getting attachments:', error); + return []; + } + } + /** * Delete a credential by ID * @param credentialId - The ID of the credential to delete diff --git a/apps/browser-extension/src/utils/dist/shared/models/vault/index.d.ts b/apps/browser-extension/src/utils/dist/shared/models/vault/index.d.ts index 5693b65b4..2cb31d3ef 100644 --- a/apps/browser-extension/src/utils/dist/shared/models/vault/index.d.ts +++ b/apps/browser-extension/src/utils/dist/shared/models/vault/index.d.ts @@ -77,4 +77,17 @@ type Alias = { Email?: string; }; -export type { Alias, Credential, EncryptionKey, PasswordSettings, TotpCode }; +/** + * Attachment SQLite database type. + */ +type Attachment = { + Id: string; + Filename: string; + Blob: Uint8Array | number[]; + CredentialId: string; + CreatedAt: string; + UpdatedAt: string; + IsDeleted?: boolean; +}; + +export type { Alias, Attachment, Credential, EncryptionKey, PasswordSettings, TotpCode };