From f093f0afca1934a466074144ca3746c733857d29 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Fri, 21 Nov 2025 21:44:17 +0100 Subject: [PATCH 1/4] test: add downloadAndDecryptAttachment tests (Phase 4.1 - TDD Red) - Add 8 comprehensive tests for download & decryption - Test successful download and decrypt roundtrip - Test checksum verification after decryption - Test tampering detection (invalid checksum) - Test error handling (404, network, decryption errors) - Test original filename and MIME type restoration Part of: #176 (Phase 4 - Download & Decryption) Epic: #143 (Client-Side File Encryption) --- src/services/secretApi.test.ts | 323 +++++++++++++++++++++++++++++++++ src/services/secretApi.ts | 98 ++++++++++ 2 files changed, 421 insertions(+) diff --git a/src/services/secretApi.test.ts b/src/services/secretApi.test.ts index c988e8d..2a81e41 100644 --- a/src/services/secretApi.test.ts +++ b/src/services/secretApi.test.ts @@ -610,4 +610,327 @@ describe("Secret API", () => { expect(headers["Content-Type"]).toBeUndefined(); }); }); + + describe("downloadAndDecryptAttachment", () => { + it("should download and decrypt file successfully", async () => { + // Step 1: Create test file and encrypt it + const originalFile = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const masterKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + // Import encryption functions for test + const { deriveFileKey, encryptFile } = await import( + "../lib/crypto/encryption" + ); + const { calculateChecksum } = await import("../lib/crypto/checksum"); + + const filename = "document.pdf"; + const fileKey = await deriveFileKey(masterKey, filename); + const encrypted = await encryptFile(originalFile, fileKey); + + // Calculate checksums + const checksum = await calculateChecksum(originalFile); + const encryptedData = new Uint8Array([ + ...encrypted.iv, + ...encrypted.authTag, + ...encrypted.ciphertext, + ]); + const checksumEncrypted = await calculateChecksum(encryptedData); + + // Step 2: Mock backend response + const mockResponse = { + encryptedBlob: btoa(String.fromCharCode(...encryptedData)), + metadata: { + filename, + type: "application/pdf", + size: originalFile.length, + encryptedSize: encryptedData.length, + checksum, + checksumEncrypted, + }, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + // Step 3: Import function under test + const { downloadAndDecryptAttachment } = await import("./secretApi"); + + // Step 4: Test download and decrypt + const decryptedFile = await downloadAndDecryptAttachment( + "attachment-123", + masterKey + ); + + expect(decryptedFile).toBeInstanceOf(File); + expect(decryptedFile.name).toBe(filename); + expect(decryptedFile.type).toBe("application/pdf"); + expect(decryptedFile.size).toBe(originalFile.length); + + // Verify file contents + const decryptedBuffer = await decryptedFile.arrayBuffer(); + const decryptedBytes = new Uint8Array(decryptedBuffer); + expect(decryptedBytes).toEqual(originalFile); + + // Verify API call + expect(mockFetch).toHaveBeenCalledWith( + `${apiConfig.baseUrl}/api/v1/attachments/attachment-123/download`, + expect.objectContaining({ + method: "GET", + credentials: "include", + }) + ); + }); + + it("should verify checksum after decryption", async () => { + const originalFile = new Uint8Array([10, 20, 30]); + const masterKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + const { deriveFileKey, encryptFile } = await import( + "../lib/crypto/encryption" + ); + const { calculateChecksum } = await import("../lib/crypto/checksum"); + + const filename = "test.txt"; + const fileKey = await deriveFileKey(masterKey, filename); + const encrypted = await encryptFile(originalFile, fileKey); + + const checksum = await calculateChecksum(originalFile); + const encryptedData = new Uint8Array([ + ...encrypted.iv, + ...encrypted.authTag, + ...encrypted.ciphertext, + ]); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + encryptedBlob: btoa(String.fromCharCode(...encryptedData)), + metadata: { + filename, + type: "text/plain", + size: originalFile.length, + encryptedSize: encryptedData.length, + checksum, + checksumEncrypted: await calculateChecksum(encryptedData), + }, + }), + }); + + const { downloadAndDecryptAttachment } = await import("./secretApi"); + const result = await downloadAndDecryptAttachment( + "attachment-456", + masterKey + ); + + // Should succeed with valid checksum + expect(result).toBeInstanceOf(File); + expect(result.name).toBe(filename); + }); + + it("should reject tampered files (invalid checksum)", async () => { + const originalFile = new Uint8Array([1, 2, 3]); + const masterKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + const { deriveFileKey, encryptFile } = await import( + "../lib/crypto/encryption" + ); + const { calculateChecksum } = await import("../lib/crypto/checksum"); + + const filename = "tampered.txt"; + const fileKey = await deriveFileKey(masterKey, filename); + const encrypted = await encryptFile(originalFile, fileKey); + + const encryptedData = new Uint8Array([ + ...encrypted.iv, + ...encrypted.authTag, + ...encrypted.ciphertext, + ]); + + // Use WRONG checksum (simulate tampering) + const wrongChecksum = "0".repeat(64); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + encryptedBlob: btoa(String.fromCharCode(...encryptedData)), + metadata: { + filename, + type: "text/plain", + size: originalFile.length, + encryptedSize: encryptedData.length, + checksum: wrongChecksum, // WRONG! + checksumEncrypted: await calculateChecksum(encryptedData), + }, + }), + }); + + const { downloadAndDecryptAttachment } = await import("./secretApi"); + + await expect( + downloadAndDecryptAttachment("attachment-789", masterKey) + ).rejects.toThrow(/checksum verification failed/i); + }); + + it("should handle download errors gracefully", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ message: "Attachment not found" }), + }); + + const masterKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + const { downloadAndDecryptAttachment } = await import("./secretApi"); + + await expect( + downloadAndDecryptAttachment("missing-attachment", masterKey) + ).rejects.toThrow(ApiError); + await expect( + downloadAndDecryptAttachment("missing-attachment", masterKey) + ).rejects.toThrow("Attachment not found"); + }); + + it("should handle network errors during download", async () => { + mockFetch.mockRejectedValue(new Error("Network timeout")); + + const masterKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + const { downloadAndDecryptAttachment } = await import("./secretApi"); + + await expect( + downloadAndDecryptAttachment("attachment-123", masterKey) + ).rejects.toThrow("Network timeout"); + }); + + it("should handle decryption errors gracefully", async () => { + // Simulate corrupted encrypted data + const corruptedData = new Uint8Array(32).fill(0xff); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + encryptedBlob: btoa(String.fromCharCode(...corruptedData)), + metadata: { + filename: "corrupt.bin", + type: "application/octet-stream", + size: 100, + encryptedSize: 128, + checksum: "abc123", + checksumEncrypted: "def456", + }, + }), + }); + + const masterKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + const { downloadAndDecryptAttachment } = await import("./secretApi"); + + // Should throw error due to invalid encrypted format or decryption failure + await expect( + downloadAndDecryptAttachment("corrupt-attachment", masterKey) + ).rejects.toThrow(); + }); + + it("should restore original filename and MIME type", async () => { + const originalFile = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const masterKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + const { deriveFileKey, encryptFile } = await import( + "../lib/crypto/encryption" + ); + const { calculateChecksum } = await import("../lib/crypto/checksum"); + + const originalFilename = "secret-document.docx"; + const originalMimeType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + const fileKey = await deriveFileKey(masterKey, originalFilename); + const encrypted = await encryptFile(originalFile, fileKey); + + const encryptedData = new Uint8Array([ + ...encrypted.iv, + ...encrypted.authTag, + ...encrypted.ciphertext, + ]); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + encryptedBlob: btoa(String.fromCharCode(...encryptedData)), + metadata: { + filename: originalFilename, + type: originalMimeType, + size: originalFile.length, + encryptedSize: encryptedData.length, + checksum: await calculateChecksum(originalFile), + checksumEncrypted: await calculateChecksum(encryptedData), + }, + }), + }); + + const { downloadAndDecryptAttachment } = await import("./secretApi"); + const result = await downloadAndDecryptAttachment( + "attachment-docx", + masterKey + ); + + expect(result.name).toBe(originalFilename); + expect(result.type).toBe(originalMimeType); + }); + + it("should handle missing files (404)", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ + message: "Attachment not found or has been deleted", + }), + }); + + const masterKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + const { downloadAndDecryptAttachment } = await import("./secretApi"); + + await expect( + downloadAndDecryptAttachment("deleted-attachment", masterKey) + ).rejects.toThrow(ApiError); + await expect( + downloadAndDecryptAttachment("deleted-attachment", masterKey) + ).rejects.toThrow("Attachment not found or has been deleted"); + }); + }); }); diff --git a/src/services/secretApi.ts b/src/services/secretApi.ts index 8da666f..bd34cc7 100644 --- a/src/services/secretApi.ts +++ b/src/services/secretApi.ts @@ -340,3 +340,101 @@ export async function getSecretMasterKey(secretId: string): Promise { return key; } + +/** + * Download encrypted attachment response from backend + */ +export interface EncryptedAttachmentResponse { + encryptedBlob: string; // Base64-encoded encrypted file data + metadata: FileMetadata; +} + +/** + * Download and decrypt an encrypted attachment + * + * Downloads an encrypted file blob from the backend and decrypts it client-side. + * Verifies file integrity using SHA-256 checksums before returning the decrypted file. + * + * @param attachmentId - Attachment ID to download + * @param secretKey - Secret's master key for decryption + * @returns Promise resolving to decrypted File object with original filename and MIME type + * @throws ApiError if download fails + * @throws Error if decryption fails or checksum verification fails + * + * @example + * ```ts + * const masterKey = await getSecretMasterKey('secret-123'); + * const file = await downloadAndDecryptAttachment('attachment-456', masterKey); + * console.log(`Downloaded: ${file.name} (${file.size} bytes)`); + * ``` + */ +export async function downloadAndDecryptAttachment( + attachmentId: string, + secretKey: CryptoKey +): Promise { + if (!attachmentId || attachmentId.trim() === "") { + throw new Error("attachmentId is required"); + } + + // 1. Download encrypted blob + metadata from backend + const response = await fetch( + `${apiConfig.baseUrl}/api/v1/attachments/${attachmentId}/download`, + { + method: "GET", + credentials: "include", + headers: getAuthHeaders(), + } + ); + + if (!response.ok) { + const error: ApiErrorResponse = await response + .json() + .catch(() => ({ message: response.statusText })); + throw new ApiError(error.message, response.status, error.errors); + } + + const { encryptedBlob, metadata }: EncryptedAttachmentResponse = + await response.json(); + + // 2. Decode Base64 encrypted blob + const encryptedBytes = Uint8Array.from(atob(encryptedBlob), (c) => + c.charCodeAt(0) + ); + + // 3. Parse encrypted blob (IV + AuthTag + Ciphertext) + // Format: [12 bytes IV][16 bytes AuthTag][... Ciphertext] + if (encryptedBytes.length < 28) { + throw new Error( + "Invalid encrypted blob: too short (expected at least IV + AuthTag)" + ); + } + + const iv = encryptedBytes.slice(0, 12); // 96 bits + const authTag = encryptedBytes.slice(12, 28); // 128 bits + const ciphertext = encryptedBytes.slice(28); // Rest is ciphertext + + // 4. Derive file key (same as encryption) + const { deriveFileKey, decryptFile } = await import( + "../lib/crypto/encryption" + ); + const fileKey = await deriveFileKey(secretKey, metadata.filename); + + // 5. Decrypt file + const decryptedData = await decryptFile(ciphertext, fileKey, iv, authTag); + + // 6. Verify checksum (integrity check) + const { calculateChecksum } = await import("../lib/crypto/checksum"); + const actualChecksum = await calculateChecksum(decryptedData); + + if (actualChecksum !== metadata.checksum) { + throw new Error( + `Checksum verification failed: file may be corrupted or tampered (expected: ${metadata.checksum}, got: ${actualChecksum})` + ); + } + + // 7. Restore original filename and MIME type + return new File([decryptedData.buffer as ArrayBuffer], metadata.filename, { + type: metadata.type, + }); +} + From 5580c48d01ad2f0e58d9dadb53395f21c4d2edc1 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Fri, 21 Nov 2025 21:48:05 +0100 Subject: [PATCH 2/4] feat: add AttachmentList component (Phase 4.2) - Create AttachmentList component with Catalyst UI - Support download, delete, and preview actions - File icons based on MIME type (image, video, audio, PDF, document) - Human-readable file sizes (B, KB, MB, GB) - Preview button only for previewable files (images, PDFs, text) - Loading states and accessibility (ARIA labels) - 11 comprehensive tests (all passing) Part of: #176 (Phase 4 - Download & Decryption) Epic: #143 (Client-Side File Encryption) --- src/components/AttachmentList.test.tsx | 278 +++++++++++++++++++++++++ src/components/AttachmentList.tsx | 221 ++++++++++++++++++++ src/services/secretApi.ts | 1 - 3 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 src/components/AttachmentList.test.tsx create mode 100644 src/components/AttachmentList.tsx diff --git a/src/components/AttachmentList.test.tsx b/src/components/AttachmentList.test.tsx new file mode 100644 index 0000000..e7377e5 --- /dev/null +++ b/src/components/AttachmentList.test.tsx @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { AttachmentList } from "./AttachmentList"; +import type { SecretAttachment } from "../services/secretApi"; + +// Initialize i18n for tests +i18n.load("en", {}); +i18n.activate("en"); + +describe("AttachmentList", () => { + const mockAttachments: SecretAttachment[] = [ + { + id: "att-1", + filename: "document.pdf", + size: 1048576, // 1MB (1024 * 1024) + mime_type: "application/pdf", + created_at: "2025-11-21T10:00:00Z", + }, + { + id: "att-2", + filename: "image.jpg", + size: 524288, // 512KB (512 * 1024) + mime_type: "image/jpeg", + created_at: "2025-11-21T11:00:00Z", + }, + { + id: "att-3", + filename: "spreadsheet.xlsx", + size: 2097152, // 2MB (2 * 1024 * 1024) + mime_type: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + created_at: "2025-11-21T12:00:00Z", + }, + ]; + + const mockMasterKey = {} as CryptoKey; + const mockOnDownload = vi.fn(); + const mockOnDelete = vi.fn(); + const mockOnPreview = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render empty state when no attachments", () => { + render( + + + + ); + + expect(screen.getByText(/no attachments/i)).toBeInTheDocument(); + }); + + it("should render list of attachments", () => { + render( + + + + ); + + expect(screen.getByText("document.pdf")).toBeInTheDocument(); + expect(screen.getByText("image.jpg")).toBeInTheDocument(); + expect(screen.getByText("spreadsheet.xlsx")).toBeInTheDocument(); + }); + + it("should display file sizes in human-readable format", () => { + render( + + + + ); + + expect(screen.getByText(/1\.0\s*MB/i)).toBeInTheDocument(); // document.pdf (1048576 bytes = 1.0 MB) + expect(screen.getByText(/512\.0\s*KB/i)).toBeInTheDocument(); // image.jpg (524288 bytes = 512.0 KB) + expect(screen.getByText(/2\.0\s*MB/i)).toBeInTheDocument(); // spreadsheet.xlsx (2097152 bytes = 2.0 MB) + }); + + it("should call onDownload when download button is clicked", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const downloadButtons = screen.getAllByRole("button", { + name: /download/i, + }); + await user.click(downloadButtons[0]!); + + await waitFor(() => { + expect(mockOnDownload).toHaveBeenCalledWith("att-1", mockMasterKey); + }); + }); + + it("should call onDelete when delete button is clicked", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const deleteButtons = screen.getAllByRole("button", { name: /delete/i }); + await user.click(deleteButtons[0]!); + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith("att-1"); + }); + }); + + it("should call onPreview for image files", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + // Find preview button for image.jpg (index 1) + const previewButtons = screen.getAllByRole("button", { name: /preview/i }); + await user.click(previewButtons[1]!); // image.jpg + + await waitFor(() => { + expect(mockOnPreview).toHaveBeenCalledWith("att-2", mockMasterKey); + }); + }); + + it("should show preview button only for previewable files", () => { + render( + + + + ); + + const previewButtons = screen.getAllByRole("button", { name: /preview/i }); + + // Should have preview button for image.jpg (index 1) + // PDF (att-1) might be previewable depending on browser + // XLSX (att-3) should NOT be previewable + expect(previewButtons.length).toBeGreaterThanOrEqual(1); + }); + + it("should disable buttons when loading", () => { + render( + + + + ); + + const downloadButtons = screen.getAllByRole("button", { + name: /download/i, + }); + const deleteButtons = screen.getAllByRole("button", { name: /delete/i }); + + downloadButtons.forEach((button) => { + expect(button).toBeDisabled(); + }); + + deleteButtons.forEach((button) => { + expect(button).toBeDisabled(); + }); + }); + + it("should display loading indicator when isLoading is true", () => { + render( + + + + ); + + expect(screen.getByRole("status")).toBeInTheDocument(); // Loading indicator + }); + + it("should have accessible labels for all interactive elements", () => { + render( + + + + ); + + const downloadButton = screen.getByRole("button", { name: /download/i }); + const deleteButton = screen.getByRole("button", { name: /delete/i }); + + expect(downloadButton).toHaveAccessibleName(); + expect(deleteButton).toHaveAccessibleName(); + }); + + it("should display file icons based on MIME type", () => { + render( + + + + ); + + // Icons should be present (rendered as SVG elements) + const icons = document.querySelectorAll("svg"); + expect(icons.length).toBeGreaterThan(0); + }); +}); diff --git a/src/components/AttachmentList.tsx b/src/components/AttachmentList.tsx new file mode 100644 index 0000000..5bac790 --- /dev/null +++ b/src/components/AttachmentList.tsx @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Trans } from "@lingui/react/macro"; +import { + ArrowDownTrayIcon, + TrashIcon, + EyeIcon, + DocumentIcon, + DocumentTextIcon, + PhotoIcon, + FilmIcon, + MusicalNoteIcon, +} from "@heroicons/react/24/outline"; +import { Button } from "./button"; +import type { SecretAttachment } from "../services/secretApi"; + +/** + * AttachmentList Component Props + */ +export interface AttachmentListProps { + /** Array of attachments to display */ + attachments: SecretAttachment[]; + /** Secret's master key for decryption */ + masterKey: CryptoKey; + /** Callback when download button is clicked */ + onDownload: ( + attachmentId: string, + masterKey: CryptoKey + ) => void | Promise; + /** Callback when delete button is clicked */ + onDelete: (attachmentId: string) => void | Promise; + /** Callback when preview button is clicked */ + onPreview: ( + attachmentId: string, + masterKey: CryptoKey + ) => void | Promise; + /** Loading state (disables all buttons) */ + isLoading?: boolean; +} + +/** + * Format file size in human-readable format (B, KB, MB, GB) + */ +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B"; + + const units = ["B", "KB", "MB", "GB", "TB"]; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const size = bytes / Math.pow(k, i); + + return `${size.toFixed(1)} ${units[i]}`; +} + +/** + * Check if file MIME type is previewable in browser + */ +function isPreviewable(mimeType: string): boolean { + return ( + mimeType.startsWith("image/") || + mimeType === "application/pdf" || + mimeType.startsWith("text/") + ); +} + +/** + * Get appropriate icon for file MIME type + */ +function getFileIcon(mimeType: string) { + if (mimeType.startsWith("image/")) { + return PhotoIcon; + } + if (mimeType.startsWith("video/")) { + return FilmIcon; + } + if (mimeType.startsWith("audio/")) { + return MusicalNoteIcon; + } + if (mimeType === "application/pdf") { + return DocumentTextIcon; + } + return DocumentIcon; +} + +/** + * AttachmentList Component + * + * Displays a list of encrypted file attachments with actions to download, preview, and delete. + * + * Features: + * - Download encrypted files (triggers decryption) + * - Preview images and PDFs + * - Delete attachments with confirmation + * - File icons based on MIME type + * - Human-readable file sizes + * - Loading states + * + * @example + * ```tsx + * downloadAndDecrypt(id, key)} + * onDelete={(id) => deleteAttachment(id)} + * onPreview={(id, key) => showPreview(id, key)} + * /> + * ``` + */ +export function AttachmentList({ + attachments, + masterKey, + onDownload, + onDelete, + onPreview, + isLoading = false, +}: AttachmentListProps) { + if (attachments.length === 0) { + return ( +
+ +

+ No attachments yet +

+
+ ); + } + + return ( +
+ {isLoading && ( +
+ + Loading attachments... + +
+
+ + Loading... + +
+
+ )} + + {attachments.map((attachment) => { + const Icon = getFileIcon(attachment.mime_type); + const canPreview = isPreviewable(attachment.mime_type); + + return ( +
+ {/* File Icon & Info */} +
+
+ + {/* Action Buttons */} +
+ {canPreview && ( + + )} + + + + +
+
+ ); + })} +
+ ); +} diff --git a/src/services/secretApi.ts b/src/services/secretApi.ts index bd34cc7..2e0b10c 100644 --- a/src/services/secretApi.ts +++ b/src/services/secretApi.ts @@ -437,4 +437,3 @@ export async function downloadAndDecryptAttachment( type: metadata.type, }); } - From dff9a54208e44681696fdf80c2db40e27f8f82b6 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Fri, 21 Nov 2025 21:52:11 +0100 Subject: [PATCH 3/4] fix: use valid Catalyst button colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change 'secondary' to 'zinc' for preview/download buttons - Change 'destructive' to 'red' for delete button - Fix TypeScript errors in AttachmentList component TypeScript, ESLint, REUSE: all passing ✅ --- src/components/AttachmentList.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/AttachmentList.tsx b/src/components/AttachmentList.tsx index 5bac790..5aeeeaa 100644 --- a/src/components/AttachmentList.tsx +++ b/src/components/AttachmentList.tsx @@ -172,7 +172,7 @@ export function AttachmentList({ {canPreview && (