diff --git a/frontend/src/components/CVECard.js b/frontend/src/components/CVECard.js
index 2157221..1490319 100644
--- a/frontend/src/components/CVECard.js
+++ b/frontend/src/components/CVECard.js
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
-import { ChevronDown, FileText, Eye, Edit2, Trash2, Upload, Plus, AlertCircle } from 'lucide-react';
+import { ChevronDown, FileText, Eye, Edit2, Trash2, Plus, AlertCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
+import DocumentDropZone from './DocumentDropZone';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -50,7 +51,6 @@ export default function CVECard({
const [expanded, setExpanded] = useState(false);
const [docExpanded, setDocExpanded] = useState(null);
const [documents, setDocuments] = useState({});
- const [uploadingFile, setUploadingFile] = useState(false);
const [selectedDocuments, setSelectedDocuments] = useState([]);
const severityOrder = { Critical: 0, High: 1, Medium: 2, Low: 3 };
@@ -87,45 +87,6 @@ export default function CVECard({
}
};
- const handleFileUpload = async (cveId, vendor) => {
- const fileInput = document.createElement('input');
- fileInput.type = 'file';
- fileInput.accept = '.pdf,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.txt,.csv,.log,.msg,.eml,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.html,.htm,.xml,.json,.yaml,.yml,.zip,.gz,.tar,.7z';
-
- fileInput.onchange = async (e) => {
- const file = e.target.files[0];
- if (!file) return;
-
- const docType = prompt('Document type (advisory, email, screenshot, patch, other):', 'advisory');
- if (!docType) return;
- const notes = prompt('Notes (optional):');
-
- setUploadingFile(true);
- const formData = new FormData();
- formData.append('file', file);
- formData.append('cveId', cveId);
- formData.append('vendor', vendor);
- formData.append('type', docType);
- if (notes) formData.append('notes', notes);
-
- try {
- const response = await fetch(`${API_BASE}/cves/${cveId}/documents`, {
- method: 'POST', credentials: 'include', body: formData,
- });
- if (!response.ok) throw new Error('Failed to upload document');
- toast.success('Document uploaded successfully');
- const key = `${cveId}-${vendor}`;
- setDocuments(prev => { const next = { ...prev }; delete next[key]; return next; });
- await fetchDocuments(cveId, vendor);
- } catch (err) {
- toast.error(err.message);
- } finally {
- setUploadingFile(false);
- }
- };
- fileInput.click();
- };
-
const handleDeleteDocument = (docId, cveId, vendor) => {
onRequestConfirm({
title: 'Delete Document',
@@ -328,14 +289,15 @@ export default function CVECard({
No documents attached
)}
{canWrite() && (
-
+ {
+ const key = `${cve.cve_id}-${cve.vendor}`;
+ setDocuments(prev => { const next = { ...prev }; delete next[key]; return next; });
+ await fetchDocuments(cve.cve_id, cve.vendor);
+ }}
+ />
)}
)}
diff --git a/frontend/src/components/DocumentDropZone.js b/frontend/src/components/DocumentDropZone.js
new file mode 100644
index 0000000..408466c
--- /dev/null
+++ b/frontend/src/components/DocumentDropZone.js
@@ -0,0 +1,169 @@
+import React, { useState, useRef } from 'react';
+import { Upload, X } from 'lucide-react';
+import { useToast } from '../contexts/ToastContext';
+
+// ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only — avoid hardcoded absolute URL fallback
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+const ACCEPTED_EXTENSIONS = '.pdf,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.txt,.csv,.log,.msg,.eml,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.html,.htm,.xml,.json,.yaml,.yml,.zip,.gz,.tar,.7z';
+
+const DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
+
+export default function DocumentDropZone({ cveId, vendor, onUploadComplete }) {
+ const toast = useToast();
+ const fileInputRef = useRef(null);
+ const [dragOver, setDragOver] = useState(false);
+ const [file, setFile] = useState(null);
+ const [docType, setDocType] = useState('advisory');
+ const [notes, setNotes] = useState('');
+ const [uploading, setUploading] = useState(false);
+
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragOver(true);
+ };
+
+ const handleDragLeave = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragOver(false);
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragOver(false);
+ const droppedFile = e.dataTransfer.files[0];
+ if (droppedFile) setFile(droppedFile);
+ };
+
+ const handleFileSelect = (e) => {
+ const selected = e.target.files[0];
+ if (selected) setFile(selected);
+ };
+
+ const handleUpload = async () => {
+ if (!file) return;
+
+ setUploading(true);
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('cveId', cveId);
+ formData.append('vendor', vendor);
+ formData.append('type', docType);
+ if (notes.trim()) formData.append('notes', notes.trim());
+
+ try {
+ const response = await fetch(`${API_BASE}/cves/${cveId}/documents`, {
+ method: 'POST',
+ credentials: 'include',
+ body: formData,
+ });
+ if (!response.ok) throw new Error('Failed to upload document');
+ toast.success(`Uploaded ${file.name}`);
+ setFile(null);
+ setNotes('');
+ setDocType('advisory');
+ onUploadComplete();
+ } catch (err) {
+ toast.error(err.message);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleCancel = () => {
+ setFile(null);
+ setNotes('');
+ setDocType('advisory');
+ };
+
+ // No file selected — show drop zone
+ if (!file) {
+ return (
+ fileInputRef.current?.click()}
+ className="mt-3 cursor-pointer transition-all"
+ style={{
+ border: `2px dashed ${dragOver ? '#0EA5E9' : 'rgba(100, 116, 139, 0.4)'}`,
+ borderRadius: '0.5rem',
+ padding: '1rem',
+ textAlign: 'center',
+ background: dragOver ? 'rgba(14, 165, 233, 0.05)' : 'transparent',
+ }}
+ role="button"
+ aria-label="Drop a file here or click to browse"
+ >
+
+
+ Drop file here or click to browse
+
+
+
+ );
+ }
+
+ // File selected — show upload form
+ return (
+
+
+
+
+ {file.name}
+
+ ({(file.size / 1024).toFixed(0)} KB)
+
+
+
+
+
+
+
+
+
+
+
+
+ setNotes(e.target.value)}
+ className="intel-input w-full text-xs"
+ style={{ padding: '0.375rem 0.5rem' }}
+ />
+
+
+
+
+
+ );
+}