Add attachment viewer to browser extension (#808)

This commit is contained in:
Leendert de Borst
2025-07-25 16:42:08 +02:00
committed by Leendert de Borst
parent 25acce3ae0
commit ccb84780eb
6 changed files with 176 additions and 4 deletions

View File

@@ -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<AttachmentBlockProps> = ({ credentialId }) => {
const { t } = useTranslation();
const [attachments, setAttachments] = useState<Attachment[]>([]);
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<void> => {
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 (
<div className="text-gray-500 dark:text-gray-400 mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('common.attachments')}</h2>
{t('common.loadingAttachments')}
</div>
);
}
if (attachments.length === 0) {
return null;
}
return (
<div className="mb-4">
<div className="space-y-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('common.attachments')}</h2>
<div className="grid grid-cols-1 gap-2">
{attachments.map(attachment => (
<button
key={attachment.Id}
className="w-full text-left p-2 ps-3 pe-3 rounded bg-white dark:bg-gray-800 shadow hover:shadow-md transition-all border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => downloadAttachment(attachment)}
aria-label={`Download ${attachment.Filename}`}
>
<div className="flex justify-between items-center gap-2">
<div className="flex items-center flex-1">
<div className="flex flex-col">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{attachment.Filename}</h4>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(attachment.CreatedAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex items-center">
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4" />
</svg>
</div>
</div>
</button>
))}
</div>
</div>
</div>
);
};
export default AttachmentBlock;

View File

@@ -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
};

View File

@@ -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 => {
<LoginCredentialsBlock credential={credential} />
<AliasBlock credential={credential} />
<NotesBlock notes={credential.Notes} />
<AttachmentBlock credentialId={credential.Id} />
</div>
);
};

View File

@@ -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",

View File

@@ -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<Attachment>(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

View File

@@ -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 };