From ab66d7d81347b35ff84e48ec4f0eb00b8996872f Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Tue, 23 Jun 2026 12:19:56 -0600 Subject: [PATCH] Add drag-and-drop document upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old prompt()-based file upload flow with an inline drop zone. Before: Click 'Upload Doc' → native file picker → prompt() for type → prompt() for notes → upload. Three modal interruptions. After: Drop a file (or click to browse) → inline form shows with type dropdown and notes field → click Upload. Zero browser dialogs. New component: DocumentDropZone - Drag-and-drop with visual feedback (border color change on dragover) - Click-to-browse fallback - Inline type selector (advisory, email, screenshot, patch, other) - Inline notes field - Cancel button to dismiss without uploading - Shows filename and size before upload - Uses toast notifications for success/error Removed from CVECard: - handleFileUpload function (createElement('input') + prompt() pattern) - uploadingFile state variable - Upload (lucide) icon import --- frontend/src/components/CVECard.js | 60 ++----- frontend/src/components/DocumentDropZone.js | 169 ++++++++++++++++++++ 2 files changed, 180 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/DocumentDropZone.js 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' }} + /> +
+
+ + +
+ ); +}