diff --git a/frontend/src/App.css b/frontend/src/App.css
index b76c0eb..e410620 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -17,6 +17,18 @@
}
}
+/* Toast notification slide-in */
+@keyframes toast-slide-in {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
:root {
/* Base Colors - Modern Slate Foundation */
--intel-darkest: #0F172A;
diff --git a/frontend/src/App.js b/frontend/src/App.js
index 66bc9bb..79b8af6 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -1,5 +1,5 @@
-import React, { useState, useEffect } from 'react';
-import { Search, FileText, AlertCircle, AlertTriangle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
+import React, { useState } from 'react';
+import { Plus, RefreshCw, Menu, Loader } from 'lucide-react';
import { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu';
@@ -8,8 +8,6 @@ import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal';
import NavDrawer from './components/NavDrawer';
import AdminScopeToggle from './components/AdminScopeToggle';
-import CalendarWidget from './components/CalendarWidget';
-import ConfirmModal from './components/ConfirmModal';
import VulnerabilityTriagePage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
@@ -17,190 +15,17 @@ import CompliancePage from './components/pages/CompliancePage';
import CCPMetricsPage from './components/pages/CCPMetricsPage';
import JiraPage from './components/pages/JiraPage';
import AdminPage from './components/pages/AdminPage';
-import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
-import ArcherPage from './components/pages/ArcherPage';
import ArcherTemplatePage from './components/pages/ArcherTemplatePage';
+import HomePage from './components/pages/HomePage';
import FeedbackModal from './components/FeedbackModal';
import NotificationBell from './components/NotificationBell';
import './App.css';
-const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
-
-// Determine if a Jira status represents a "closed/done" state
-function isClosedStatus(status) {
- if (!status) return false;
- const lower = status.toLowerCase();
- return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
-}
-
-function getTicketStatusColor(status) {
- if (!status) return '#F59E0B';
- if (isClosedStatus(status)) return '#10B981';
- const lower = status.toLowerCase();
- if (['open', 'to do', 'backlog', 'new'].some(s => lower === s)) return '#F59E0B';
- // Everything else (in progress, approval, prioritizing, etc.) gets blue/purple
- return '#0EA5E9';
-}
-
-// ============================================
-// INLINE STYLES - NUCLEAR OPTION FOR VISIBILITY
-// ============================================
-const STYLES = {
- // Main container with visible background
- mainContainer: {
- minHeight: '100vh',
- background: 'linear-gradient(135deg, #0F172A 0%, #1E293B 50%, #0F172A 100%)',
- padding: '1.5rem',
- position: 'relative',
- overflow: 'hidden',
- },
- // Stat cards with refined borders
- statCard: {
- background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%)',
- border: '2px solid #0EA5E9',
- borderRadius: '0.5rem',
- padding: '1rem',
- boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.15)',
- position: 'relative',
- overflow: 'hidden',
- },
- // Intel card with refined glowing border
- intelCard: {
- background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
- border: '2px solid rgba(14, 165, 233, 0.4)',
- borderRadius: '0.5rem',
- boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12)',
- position: 'relative',
- overflow: 'hidden',
- },
- // Vendor card with depth
- vendorCard: {
- background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%)',
- border: '1.5px solid rgba(14, 165, 233, 0.3)',
- borderRadius: '0.5rem',
- padding: '1rem',
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(14, 165, 233, 0.08)',
- marginBottom: '0.75rem',
- },
- // CRITICAL severity badge - Modern red with refined glow
- badgeCritical: {
- display: 'inline-flex',
- alignItems: 'center',
- gap: '0.5rem',
- background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.25) 0%, rgba(239, 68, 68, 0.2) 100%)',
- border: '2px solid #EF4444',
- borderRadius: '0.375rem',
- padding: '0.375rem 0.875rem',
- color: '#FCA5A5',
- fontWeight: '700',
- fontSize: '0.75rem',
- textTransform: 'uppercase',
- letterSpacing: '0.5px',
- textShadow: '0 0 8px rgba(239, 68, 68, 0.5)',
- boxShadow: '0 0 16px rgba(239, 68, 68, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4)',
- },
- // HIGH severity badge - Amber with refined glow
- badgeHigh: {
- display: 'inline-flex',
- alignItems: 'center',
- gap: '0.5rem',
- background: 'linear-gradient(135deg, rgba(245, 158, 11, 0.25) 0%, rgba(245, 158, 11, 0.2) 100%)',
- border: '2px solid #F59E0B',
- borderRadius: '0.375rem',
- padding: '0.375rem 0.875rem',
- color: '#FCD34D',
- fontWeight: '700',
- fontSize: '0.75rem',
- textTransform: 'uppercase',
- letterSpacing: '0.5px',
- textShadow: '0 0 8px rgba(245, 158, 11, 0.5)',
- boxShadow: '0 0 16px rgba(245, 158, 11, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4)',
- },
- // MEDIUM severity badge - Sky blue with refined glow
- badgeMedium: {
- display: 'inline-flex',
- alignItems: 'center',
- gap: '0.5rem',
- background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%)',
- border: '2px solid #0EA5E9',
- borderRadius: '0.375rem',
- padding: '0.375rem 0.875rem',
- color: '#7DD3FC',
- fontWeight: '700',
- fontSize: '0.75rem',
- textTransform: 'uppercase',
- letterSpacing: '0.5px',
- textShadow: '0 0 8px rgba(14, 165, 233, 0.5)',
- boxShadow: '0 0 16px rgba(14, 165, 233, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4)',
- },
- // LOW severity badge - Emerald with refined glow
- badgeLow: {
- display: 'inline-flex',
- alignItems: 'center',
- gap: '0.5rem',
- background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.25) 0%, rgba(16, 185, 129, 0.2) 100%)',
- border: '2px solid #10B981',
- borderRadius: '0.375rem',
- padding: '0.375rem 0.875rem',
- color: '#6EE7B7',
- fontWeight: '700',
- fontSize: '0.75rem',
- textTransform: 'uppercase',
- letterSpacing: '0.5px',
- textShadow: '0 0 8px rgba(16, 185, 129, 0.5)',
- boxShadow: '0 0 16px rgba(16, 185, 129, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4)',
- },
- // Glowing dot for badges
- glowDot: (color) => ({
- width: '8px',
- height: '8px',
- borderRadius: '50%',
- background: color,
- boxShadow: `0 0 12px ${color}, 0 0 6px ${color}`,
- animation: 'pulse 2s ease-in-out infinite',
- }),
-};
-
-// Helper function to get severity badge style
-const getSeverityBadgeStyle = (severity) => {
- switch (severity?.toLowerCase()) {
- case 'critical': return STYLES.badgeCritical;
- case 'high': return STYLES.badgeHigh;
- case 'medium': return STYLES.badgeMedium;
- case 'low': return STYLES.badgeLow;
- default: return STYLES.badgeMedium;
- }
-};
-
-// Helper function to get severity dot color
-const getSeverityDotColor = (severity) => {
- switch (severity?.toLowerCase()) {
- case 'critical': return '#EF4444';
- case 'high': return '#F59E0B';
- case 'medium': return '#0EA5E9';
- case 'low': return '#10B981';
- default: return '#0EA5E9';
- }
-};
-
-const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
+const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin', 'archer-templates']);
export default function App() {
- const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, isInGroup, getActiveTeamsParam, adminScope } = useAuth();
- const [searchQuery, setSearchQuery] = useState('');
- const [selectedVendor, setSelectedVendor] = useState('All Vendors');
- const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
- const [selectedCVE, setSelectedCVE] = useState(null);
- const [selectedVendorView, setSelectedVendorView] = useState(null);
- const [selectedDocuments, setSelectedDocuments] = useState([]);
- const [cves, setCves] = useState([]);
- const [vendors, setVendors] = useState(['All Vendors']);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [cveDocuments, setCveDocuments] = useState({});
- const [quickCheckCVE, setQuickCheckCVE] = useState('');
- const [quickCheckResult, setQuickCheckResult] = useState(null);
- const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin', 'archer-templates']);
+ const { isAuthenticated, loading: authLoading, canWrite, isAdmin, isInGroup } = useAuth();
+
const [currentPage, setCurrentPageRaw] = useState(() => {
try {
const saved = localStorage.getItem('cve-dashboard-page');
@@ -211,6 +36,7 @@ export default function App() {
setCurrentPageRaw(page);
try { localStorage.setItem('cve-dashboard-page', page); } catch {}
};
+
const [navOpen, setNavOpen] = useState(false);
const [calendarFilter, setCalendarFilter] = useState(null);
const [reportingExcFilter, setReportingExcFilter] = useState(null);
@@ -220,750 +46,16 @@ export default function App() {
const [showFeedback, setShowFeedback] = useState(false);
const [feedbackType, setFeedbackType] = useState('bug');
const [showNvdSync, setShowNvdSync] = useState(false);
- const [newCVE, setNewCVE] = useState({
- cve_id: '',
- vendor: '',
- severity: 'Medium',
- description: '',
- published_date: new Date().toISOString().split('T')[0]
- });
- const [uploadingFile, setUploadingFile] = useState(false);
- const [nvdLoading, setNvdLoading] = useState(false);
- const [nvdError, setNvdError] = useState(null);
- const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
- const [showEditCVE, setShowEditCVE] = useState(false);
- const [editingCVE, setEditingCVE] = useState(null);
- const [editForm, setEditForm] = useState({
- cve_id: '', vendor: '', severity: 'Medium', description: '', published_date: '', status: 'Open'
- });
- const [editNvdLoading, setEditNvdLoading] = useState(false);
- const [editNvdError, setEditNvdError] = useState(null);
- const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
- const [expandedCVEs, setExpandedCVEs] = useState({});
- const [visibleCount, setVisibleCount] = useState(5);
- const [jiraTickets, setJiraTickets] = useState([]);
- const [showAddTicket, setShowAddTicket] = useState(false);
- const [showEditTicket, setShowEditTicket] = useState(false);
- const [editingTicket, setEditingTicket] = useState(null);
- const [ticketForm, setTicketForm] = useState({
- cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open'
- });
- // For adding ticket from within a CVE card
- const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor }
- // Archer tickets state
- const [archerTickets, setArcherTickets] = useState([]);
- const [showAddArcherTicket, setShowAddArcherTicket] = useState(false);
- const [showEditArcherTicket, setShowEditArcherTicket] = useState(false);
- const [editingArcherTicket, setEditingArcherTicket] = useState(null);
- const [archerTicketForm, setArcherTicketForm] = useState({
- exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: ''
- });
- const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
-
- // Ivanti workflows state
- const [ivantiTotal, setIvantiTotal] = useState(null);
- const [ivantiWorkflows, setIvantiWorkflows] = useState([]);
- const [ivantiSyncedAt, setIvantiSyncedAt] = useState(null);
- const [ivantiSyncStatus, setIvantiSyncStatus] = useState(null);
- const [ivantiSyncError, setIvantiSyncError] = useState(null);
- const [ivantiLoading, setIvantiLoading] = useState(false);
- const [ivantiSyncing, setIvantiSyncing] = useState(false);
-
- // Archive filter state
- const [archiveFilter, setArchiveFilter] = useState(null);
- const [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
- const [archiveList, setArchiveList] = useState([]);
- const [archiveListLoading, setArchiveListLoading] = useState(false);
-
- // Confirmation modal state — replaces window.confirm()
- const [pendingConfirm, setPendingConfirm] = useState(null);
-
- const toggleCVEExpand = (cveId) => {
- setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
- };
-
- const lookupNVD = async (cveId) => {
- const trimmed = cveId.trim();
- if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
-
- setNvdLoading(true);
- setNvdError(null);
- setNvdAutoFilled(false);
-
- try {
- const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, {
- credentials: 'include'
- });
-
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'NVD lookup failed');
- }
-
- const data = await response.json();
- setNewCVE(prev => ({
- ...prev,
- description: prev.description || data.description,
- severity: data.severity,
- published_date: data.published_date || prev.published_date
- }));
- setNvdAutoFilled(true);
- } catch (err) {
- setNvdError(err.message);
- } finally {
- setNvdLoading(false);
+ // Navigation handler that accepts optional context (filters)
+ const handleNavigate = (page, context) => {
+ if (page === 'triage') {
+ setCalendarFilter(context?.calendarFilter || null);
+ setReportingExcFilter(context?.reportingExcFilter || null);
}
+ setCurrentPage(page);
};
- const fetchCVEs = async () => {
- setLoading(true);
- setError(null);
- try {
- const params = new URLSearchParams();
- if (searchQuery) params.append('search', searchQuery);
- if (selectedVendor !== 'All Vendors') params.append('vendor', selectedVendor);
- if (selectedSeverity !== 'All Severities') params.append('severity', selectedSeverity);
-
- const response = await fetch(`${API_BASE}/cves?${params}`, {
- credentials: 'include'
- });
- if (!response.ok) throw new Error('Failed to fetch CVEs');
- const data = await response.json();
- setCves(data);
- } catch (err) {
- setError(err.message);
- console.error('Error fetching CVEs:', err);
- } finally {
- setLoading(false);
- }
- };
-
- const fetchVendors = async () => {
- try {
- const response = await fetch(`${API_BASE}/vendors`, {
- credentials: 'include'
- });
- if (!response.ok) throw new Error('Failed to fetch vendors');
- const data = await response.json();
- setVendors(['All Vendors', ...data]);
- } catch (err) {
- console.error('Error fetching vendors:', err);
- }
- };
-
- const fetchJiraTickets = async () => {
- try {
- const response = await fetch(`${API_BASE}/jira-tickets`, {
- credentials: 'include'
- });
- if (!response.ok) throw new Error('Failed to fetch JIRA tickets');
- const data = await response.json();
- setJiraTickets(data);
- } catch (err) {
- console.error('Error fetching JIRA tickets:', err);
- }
- };
-
- const fetchArcherTickets = async () => {
- try {
- const response = await fetch(`${API_BASE}/archer-tickets`, {
- credentials: 'include'
- });
- if (!response.ok) throw new Error('Failed to fetch Archer tickets');
- const data = await response.json();
- setArcherTickets(data);
- } catch (err) {
- console.error('Error fetching Archer tickets:', err);
- }
- };
-
- const applyIvantiState = (data) => {
- setIvantiTotal(data.total ?? 0);
- setIvantiWorkflows(data.workflows || []);
- setIvantiSyncedAt(data.synced_at || null);
- setIvantiSyncStatus(data.sync_status || null);
- setIvantiSyncError(data.error_message || null);
- };
-
- const fetchIvantiWorkflows = async () => {
- setIvantiLoading(true);
- try {
- const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' });
- const data = await response.json();
- if (response.ok) applyIvantiState(data);
- } catch (err) {
- console.error('Error loading Ivanti workflows:', err);
- } finally {
- setIvantiLoading(false);
- }
- };
-
- const syncIvantiWorkflows = async () => {
- setIvantiSyncing(true);
- try {
- const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, {
- method: 'POST',
- credentials: 'include'
- });
- const data = await response.json();
- if (response.ok) applyIvantiState(data);
- } catch (err) {
- console.error('Error syncing Ivanti workflows:', err);
- } finally {
- setIvantiSyncing(false);
- setArchiveRefreshKey(k => k + 1);
- }
- };
-
- const handleArchiveStateClick = (state) => {
- const newFilter = archiveFilter === state ? null : state;
- setArchiveFilter(newFilter);
- if (newFilter) {
- setArchiveListLoading(true);
- const teamsParam = getActiveTeamsParam();
- const url = teamsParam
- ? `${API_BASE}/ivanti/archive?state=${newFilter}&teams=${encodeURIComponent(teamsParam)}`
- : `${API_BASE}/ivanti/archive?state=${newFilter}`;
- fetch(url, { credentials: 'include' })
- .then(res => res.ok ? res.json() : Promise.reject())
- .then(data => setArchiveList(data.archives || []))
- .catch(() => setArchiveList([]))
- .finally(() => setArchiveListLoading(false));
- } else {
- setArchiveList([]);
- }
- };
-
- const fetchDocuments = async (cveId, vendor) => {
- const key = `${cveId}-${vendor}`;
- if (cveDocuments[key]) return;
-
- try {
- const response = await fetch(`${API_BASE}/cves/${cveId}/documents?vendor=${vendor}`, {
- credentials: 'include'
- });
- if (!response.ok) throw new Error('Failed to fetch documents');
- const data = await response.json();
- setCveDocuments(prev => ({ ...prev, [key]: data }));
- } catch (err) {
- console.error('Error fetching documents:', err);
- }
- };
-
- const quickCheckCVEStatus = async () => {
- if (!quickCheckCVE.trim()) return;
-
- try {
- const response = await fetch(`${API_BASE}/cves/check/${quickCheckCVE.trim()}`, {
- credentials: 'include'
- });
- if (!response.ok) throw new Error('Failed to check CVE');
- const data = await response.json();
- setQuickCheckResult(data);
- } catch (err) {
- console.error('Error checking CVE:', err);
- setQuickCheckResult({ error: err.message });
- }
- };
-
- const handleViewDocuments = async (cveId, vendor) => {
- if (selectedCVE === cveId && selectedVendorView === vendor) {
- setSelectedCVE(null);
- setSelectedVendorView(null);
- } else {
- setSelectedCVE(cveId);
- setSelectedVendorView(vendor);
- await fetchDocuments(cveId, vendor);
- }
- };
-
- const toggleDocumentSelection = (docId) => {
- setSelectedDocuments(prev =>
- prev.includes(docId)
- ? prev.filter(id => id !== docId)
- : [...prev, docId]
- );
- };
-
- const exportSelectedDocuments = () => {
- alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
- };
-
- const handleAddCVE = async (e) => {
- e.preventDefault();
- try {
- const response = await fetch(`${API_BASE}/cves`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify(newCVE)
- });
-
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'Failed to add CVE');
- }
-
- alert(`CVE ${newCVE.cve_id} added successfully for vendor: ${newCVE.vendor}!`);
- setShowAddCVE(false);
- setNewCVE({
- cve_id: '',
- vendor: '',
- severity: 'Medium',
- description: '',
- published_date: new Date().toISOString().split('T')[0]
- });
- setNvdLoading(false);
- setNvdError(null);
- setNvdAutoFilled(false);
- fetchCVEs();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- };
-
- 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');
-
- alert(`Document uploaded successfully!`);
- const key = `${cveId}-${vendor}`;
- delete cveDocuments[key];
- await fetchDocuments(cveId, vendor);
- fetchCVEs();
- } catch (err) {
- alert(`Error: ${err.message}`);
- } finally {
- setUploadingFile(false);
- }
- };
-
- fileInput.click();
- };
-
- const handleDeleteDocument = async (docId, cveId, vendor) => {
- setPendingConfirm({
- title: 'Delete Document',
- message: 'Are you sure you want to delete this document?',
- confirmText: 'Delete',
- onConfirm: async () => {
- setPendingConfirm(null);
- try {
- const response = await fetch(`${API_BASE}/documents/${docId}`, {
- method: 'DELETE',
- credentials: 'include'
- });
-
- if (!response.ok) throw new Error('Failed to delete document');
-
- alert('Document deleted successfully!');
- const key = `${cveId}-${vendor}`;
- delete cveDocuments[key];
- await fetchDocuments(cveId, vendor);
- fetchCVEs();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- },
- });
- };
-
- const handleEditCVE = (cve) => {
- setEditingCVE(cve);
- setEditForm({
- cve_id: cve.cve_id,
- vendor: cve.vendor,
- severity: cve.severity,
- description: cve.description || '',
- published_date: cve.published_date || '',
- status: cve.status || 'Open'
- });
- setEditNvdLoading(false);
- setEditNvdError(null);
- setEditNvdAutoFilled(false);
- setShowEditCVE(true);
- };
-
- const handleEditCVESubmit = async (e) => {
- e.preventDefault();
- if (!editingCVE) return;
-
- try {
- const body = {};
- if (editForm.cve_id !== editingCVE.cve_id) body.cve_id = editForm.cve_id;
- if (editForm.vendor !== editingCVE.vendor) body.vendor = editForm.vendor;
- if (editForm.severity !== editingCVE.severity) body.severity = editForm.severity;
- if (editForm.description !== (editingCVE.description || '')) body.description = editForm.description;
- if (editForm.published_date !== (editingCVE.published_date || '')) body.published_date = editForm.published_date;
- if (editForm.status !== (editingCVE.status || 'Open')) body.status = editForm.status;
-
- if (Object.keys(body).length === 0) {
- alert('No changes detected.');
- return;
- }
-
- const response = await fetch(`${API_BASE}/cves/${editingCVE.id}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify(body)
- });
-
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'Failed to update CVE');
- }
-
- alert('CVE updated successfully!');
- setShowEditCVE(false);
- setEditingCVE(null);
- fetchCVEs();
- fetchVendors();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- };
-
- const lookupNVDForEdit = async (cveId) => {
- const trimmed = cveId.trim();
- if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
-
- setEditNvdLoading(true);
- setEditNvdError(null);
- setEditNvdAutoFilled(false);
-
- try {
- const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, {
- credentials: 'include'
- });
-
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'NVD lookup failed');
- }
-
- const data = await response.json();
- setEditForm(prev => ({
- ...prev,
- description: data.description || prev.description,
- severity: data.severity || prev.severity,
- published_date: data.published_date || prev.published_date
- }));
- setEditNvdAutoFilled(true);
- } catch (err) {
- setEditNvdError(err.message);
- } finally {
- setEditNvdLoading(false);
- }
- };
-
- const handleDeleteCVEEntry = async (cve) => {
- setPendingConfirm({
- title: 'Delete Vendor Entry',
- message: `Are you sure you want to delete the "${cve.vendor}" entry for ${cve.cve_id}? This will also delete all associated documents.`,
- confirmText: 'Delete',
- onConfirm: async () => {
- setPendingConfirm(null);
- try {
- const url = `${API_BASE}/cves/${cve.id}`;
- console.log('DELETE request to:', url);
- const response = await fetch(url, {
- method: 'DELETE',
- credentials: 'include'
- });
-
- if (!response.ok) {
- const contentType = response.headers.get('content-type');
- if (contentType && contentType.includes('application/json')) {
- const data = await response.json();
- throw new Error(data.error || 'Failed to delete CVE entry');
- } else {
- throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`);
- }
- }
-
- alert(`Deleted ${cve.vendor} entry for ${cve.cve_id}`);
- fetchCVEs();
- fetchVendors();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- },
- });
- };
-
- const handleDeleteEntireCVE = async (cveId, vendorCount) => {
- setPendingConfirm({
- title: 'Delete Entire CVE',
- message: `Are you sure you want to delete ALL ${vendorCount} vendor entries for ${cveId}? This will permanently remove all associated documents and files.`,
- confirmText: 'Delete All',
- onConfirm: async () => {
- setPendingConfirm(null);
- try {
- const url = `${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`;
- console.log('DELETE request to:', url);
- const response = await fetch(url, {
- method: 'DELETE',
- credentials: 'include'
- });
-
- if (!response.ok) {
- const contentType = response.headers.get('content-type');
- if (contentType && contentType.includes('application/json')) {
- const data = await response.json();
- throw new Error(data.error || 'Failed to delete CVE');
- } else {
- throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`);
- }
- }
-
- alert(`Deleted all entries for ${cveId}`);
- fetchCVEs();
- fetchVendors();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- },
- });
- };
-
- const handleAddTicket = async (e) => {
- e.preventDefault();
- try {
- const response = await fetch(`${API_BASE}/jira-tickets`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify(ticketForm)
- });
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'Failed to create ticket');
- }
- alert('JIRA ticket added successfully!');
- setShowAddTicket(false);
- setAddTicketContext(null);
- setTicketForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' });
- fetchJiraTickets();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- };
-
- const handleEditTicket = (ticket) => {
- setEditingTicket(ticket);
- setTicketForm({
- cve_id: ticket.cve_id,
- vendor: ticket.vendor,
- ticket_key: ticket.ticket_key,
- url: ticket.url || '',
- summary: ticket.summary || '',
- status: ticket.status
- });
- setShowEditTicket(true);
- };
-
- const handleUpdateTicket = async (e) => {
- e.preventDefault();
- try {
- const response = await fetch(`${API_BASE}/jira-tickets/${editingTicket.id}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify({
- ticket_key: ticketForm.ticket_key,
- url: ticketForm.url,
- summary: ticketForm.summary,
- status: ticketForm.status
- })
- });
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'Failed to update ticket');
- }
- alert('JIRA ticket updated!');
- setShowEditTicket(false);
- setEditingTicket(null);
- setTicketForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' });
- fetchJiraTickets();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- };
-
- const handleDeleteTicket = async (ticket) => {
- setPendingConfirm({
- title: 'Delete Ticket',
- message: `Delete ticket ${ticket.ticket_key}?`,
- confirmText: 'Delete',
- onConfirm: async () => {
- setPendingConfirm(null);
- try {
- const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, {
- method: 'DELETE',
- credentials: 'include'
- });
- if (!response.ok) throw new Error('Failed to delete ticket');
- alert('Ticket deleted');
- fetchJiraTickets();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- },
- });
- };
-
- const openAddTicketForCVE = (cve_id, vendor) => {
- setAddTicketContext({ cve_id, vendor });
- setTicketForm({ cve_id, vendor, ticket_key: '', url: '', summary: '', status: 'Open' });
- setShowAddTicket(true);
- };
-
- // ========== ARCHER TICKET HANDLERS ==========
-
- const handleAddArcherTicket = async (e) => {
- e.preventDefault();
- try {
- const response = await fetch(`${API_BASE}/archer-tickets`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify(archerTicketForm)
- });
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'Failed to create Archer ticket');
- }
- alert('Archer ticket added successfully!');
- setShowAddArcherTicket(false);
- setAddArcherTicketContext(null);
- setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
- fetchArcherTickets();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- };
-
- const handleEditArcherTicket = (ticket) => {
- setEditingArcherTicket(ticket);
- setArcherTicketForm({
- exc_number: ticket.exc_number,
- archer_url: ticket.archer_url || '',
- status: ticket.status,
- cve_id: ticket.cve_id,
- vendor: ticket.vendor
- });
- setShowEditArcherTicket(true);
- };
-
- const handleUpdateArcherTicket = async (e) => {
- e.preventDefault();
- try {
- const response = await fetch(`${API_BASE}/archer-tickets/${editingArcherTicket.id}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify({
- exc_number: archerTicketForm.exc_number,
- archer_url: archerTicketForm.archer_url,
- status: archerTicketForm.status
- })
- });
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.error || 'Failed to update Archer ticket');
- }
- alert('Archer ticket updated!');
- setShowEditArcherTicket(false);
- setEditingArcherTicket(null);
- setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
- fetchArcherTickets();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- };
-
- const handleDeleteArcherTicket = async (ticket) => {
- setPendingConfirm({
- title: 'Delete Archer Ticket',
- message: `Delete Archer ticket ${ticket.exc_number}?`,
- confirmText: 'Delete',
- onConfirm: async () => {
- setPendingConfirm(null);
- try {
- const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
- method: 'DELETE',
- credentials: 'include'
- });
- if (!response.ok) throw new Error('Failed to delete Archer ticket');
- alert('Archer ticket deleted');
- fetchArcherTickets();
- } catch (err) {
- alert(`Error: ${err.message}`);
- }
- },
- });
- };
-
- const _openAddArcherTicketForCVE = (cve_id, vendor) => {
- setAddArcherTicketContext({ cve_id, vendor });
- setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id, vendor });
- setShowAddArcherTicket(true);
- };
-
- // Fetch CVEs from API when authenticated
- useEffect(() => {
- if (isAuthenticated) {
- fetchCVEs();
- fetchVendors();
- fetchJiraTickets();
- fetchArcherTickets();
- fetchIvantiWorkflows();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isAuthenticated]);
-
- // Refetch when filters change
- useEffect(() => {
- if (isAuthenticated) {
- fetchCVEs();
- setVisibleCount(5);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchQuery, selectedVendor, selectedSeverity]);
-
// Show loading while checking auth
if (authLoading) {
return (
@@ -981,17 +73,6 @@ export default function App() {
return ;
}
- // Group CVEs by CVE ID
- const groupedCVEs = cves.reduce((acc, cve) => {
- if (!acc[cve.cve_id]) {
- acc[cve.cve_id] = [];
- }
- acc[cve.cve_id].push(cve);
- return acc;
- }, {});
-
- const filteredGroupedCVEs = groupedCVEs;
-
return (
setNavOpen(false)}
currentPage={currentPage}
onNavigate={(page) => {
- // Clear contextual filters when navigating directly via the nav drawer
if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); }
setCurrentPage(page);
}}
@@ -1043,7 +123,7 @@ export default function App() {
NVD Sync
)}
- {canWrite() && (
+ {canWrite() && currentPage === 'home' && (
-
- {/* Stats Bar - only shown on Home page */}
- {currentPage === 'home' &&
-
-
-
Total CVEs
-
{Object.keys(filteredGroupedCVEs).length}
-
-
-
-
Vendor Entries
-
{cves.length}
-
-
-
-
Open Tickets
-
{jiraTickets.filter(t => !isClosedStatus(t.status)).length}
-
-
-
-
Critical
-
{cves.filter(c => c.severity === 'Critical').length}
-
-
}
{/* Page content */}
- {currentPage === 'triage' && }
- {currentPage === 'compliance' && }
- {currentPage === 'ccp-metrics' && isInGroup('Admin', 'Leadership') && }
- {currentPage === 'ccp-metrics' && !isInGroup('Admin', 'Leadership') && (() => { setCurrentPage('home'); return null; })()}
- {currentPage === 'knowledge-base' && }
- {currentPage === 'exports' && }
- {currentPage === 'jira' && }
+ {currentPage === 'home' && }
+ {currentPage === 'triage' && }
+ {currentPage === 'compliance' && }
+ {currentPage === 'ccp-metrics' && isInGroup('Admin', 'Leadership') && }
+ {currentPage === 'ccp-metrics' && !isInGroup('Admin', 'Leadership') && (() => { setCurrentPage('home'); return null; })()}
+ {currentPage === 'knowledge-base' && }
+ {currentPage === 'exports' && }
+ {currentPage === 'jira' && }
{currentPage === 'archer-templates' && }
{currentPage === 'admin' && isAdmin() && }
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
- {/* User Management Modal */}
- {showUserManagement && (
- setShowUserManagement(false)} />
- )}
-
- {/* Audit Log Modal */}
- {showAuditLog && (
- setShowAuditLog(false)} />
- )}
-
- {/* NVD Sync Modal */}
- {showNvdSync && (
- setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
- )}
-
- {/* Feedback Modal (Bug Report / Feature Request) */}
+ {/* Global Modals */}
+ {showUserManagement && setShowUserManagement(false)} />}
+ {showAuditLog && setShowAuditLog(false)} />}
+ {showNvdSync && setShowNvdSync(false)} onSyncComplete={() => {}} />}
setShowFeedback(false)}
defaultType={feedbackType}
currentPage={currentPage}
/>
-
- {/* Add CVE Modal */}
- {showAddCVE && (
-
-
-
-
-
Add CVE Entry
-
-
-
-
-
- Tip: You can add the same CVE-ID multiple times with different vendors.
- Each vendor will have its own documents folder.
-
-
-
-
-
-
-
- )}
-
- {/* Edit CVE Modal */}
- {showEditCVE && editingCVE && (
-
-
-
-
-
Edit CVE Entry
-
-
-
-
-
- Note: Changing CVE ID or Vendor will move associated documents to the new path.
-
-
-
-
-
-
-
- )}
-
- {/* Add JIRA Ticket Modal */}
- {showAddTicket && (
-
- )}
-
- {/* Edit JIRA Ticket Modal */}
- {showEditTicket && editingTicket && (
-
-
-
-
-
Edit JIRA Ticket
-
-
-
- {editingTicket.cve_id} / {editingTicket.vendor}
-
-
-
-
-
- )}
-
- {/* Add Archer Ticket Modal */}
- {showAddArcherTicket && (
-
- )}
-
- {/* Edit Archer Ticket Modal */}
- {showEditArcherTicket && editingArcherTicket && (
-
-
-
-
-
Edit Archer Risk Ticket
-
-
-
- {editingArcherTicket.cve_id} / {editingArcherTicket.vendor}
-
-
-
-
-
- )}
-
- {/* Two Column Layout - Home page only */}
- {currentPage === 'home' &&
- {/* CENTER PANEL - Main Content */}
-
- <>
- {/* Quick Check */}
-
-
-
Quick CVE Lookup
-
- setQuickCheckCVE(e.target.value)}
- onKeyPress={(e) => e.key === 'Enter' && quickCheckCVEStatus()}
- className="flex-1 intel-input"
- />
-
-
-
- {quickCheckResult && (
-
- {quickCheckResult.error ? (
-
-
-
-
Error
-
{quickCheckResult.error}
-
-
- ) : quickCheckResult.exists ? (
-
-
-
-
✓ CVE Addressed ({quickCheckResult.vendors.length} vendor{quickCheckResult.vendors.length > 1 ? 's' : ''})
-
- {quickCheckResult.vendors.map((vendorInfo, idx) => (
-
-
{vendorInfo.vendor}
-
-
Severity: {vendorInfo.severity}
-
Status: {vendorInfo.status}
-
Documents: {vendorInfo.total_documents} attached
-
-
- ))}
-
-
-
- ) : (
-
-
-
-
Not Found
-
This CVE has not been addressed yet. No entry exists in the database.
-
-
- )}
-
- )}
-
-
- {/* Search and Filters */}
-
-
-
-
- setSearchQuery(e.target.value)}
- className="intel-input w-full"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Results Summary */}
-
-
- {Object.keys(filteredGroupedCVEs).length} CVE{Object.keys(filteredGroupedCVEs).length !== 1 ? 's' : ''}
- •
- {cves.length} vendor entr{cves.length !== 1 ? 'ies' : 'y'}
-
- {selectedDocuments.length > 0 && canExport() && (
-
- )}
-
-
- {/* CVE List - Grouped by CVE ID */}
- {loading ? (
-
-
-
Scanning Vulnerabilities...
-
- ) : error ? (
-
-
-
Error Loading CVEs
-
{error}
-
-
- ) : (
-
- {Object.entries(filteredGroupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => {
- const isCVEExpanded = expandedCVEs[cveId];
- const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
- const highestSeverity = vendorEntries.reduce((highest, entry) => {
- const currentOrder = severityOrder[entry.severity] ?? 4;
- const highestOrder = severityOrder[highest] ?? 4;
- return currentOrder < highestOrder ? entry.severity : highest;
- }, vendorEntries[0].severity);
- const totalDocCount = vendorEntries.reduce((sum, entry) => sum + (entry.document_count || 0), 0);
- const overallStatuses = [...new Set(vendorEntries.map(e => e.status))];
-
- return (
-
- {/* Clickable CVE Header */}
-
toggleCVEExpand(cveId)}
- >
-
-
-
-
-
{cveId}
-
-
- {/* Collapsed: truncated description + summary row */}
- {!isCVEExpanded && (
-
-
{vendorEntries[0].description}
-
-
-
- {highestSeverity}
-
- {vendorEntries.length} vendor{vendorEntries.length > 1 ? 's' : ''}
-
-
- {totalDocCount} doc{totalDocCount !== 1 ? 's' : ''}
-
-
- {overallStatuses.join(', ')}
-
-
-
- )}
-
- {/* Expanded: full description + metadata */}
- {isCVEExpanded && (
-
-
{vendorEntries[0].description}
-
- Published: {vendorEntries[0].published_date}
- •
- {vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}
- {isAdmin() && vendorEntries.length >= 2 && (
-
- )}
-
-
- )}
-
-
-
-
- {/* Expanded: Vendor Entries */}
- {isCVEExpanded && (
-
-
- {vendorEntries.map((cve) => {
- const key = `${cve.cve_id}-${cve.vendor}`;
- const documents = cveDocuments[key] || [];
- const isDocExpanded = selectedCVE === cve.cve_id && selectedVendorView === cve.vendor;
-
- return (
-
-
-
-
-
{cve.vendor}
-
-
- {cve.severity}
-
-
-
- Status: {cve.status}
-
-
- {cve.document_count} doc{cve.document_count !== 1 ? 's' : ''}
-
-
-
-
-
- {canWrite() && (
-
- )}
- {canDelete(cve) && (
-
- )}
-
-
-
- {/* Documents Section */}
- {isDocExpanded && (
-
-
-
- Documents ({documents.length})
-
- {documents.length > 0 ? (
-
- {documents.map(doc => (
-
-
-
toggleDocumentSelection(doc.id)}
- className="w-4 h-4 text-intel-accent rounded focus:ring-2 focus:ring-intel-accent bg-intel-dark border-intel-accent/50"
- />
-
-
-
{doc.name}
-
- {doc.type} • {doc.file_size}
- {doc.notes && • {doc.notes}}
-
-
-
-
-
- View
-
- {isAdmin() && (
-
- )}
-
-
- ))}
-
- ) : (
-
No documents attached
- )}
- {canWrite() && (
-
- )}
-
- )}
-
- {/* JIRA Tickets for this vendor */}
- {(() => {
- const vendorTickets = jiraTickets.filter(t => t.cve_id === cve.cve_id && t.vendor === cve.vendor);
- return vendorTickets.length > 0 || canWrite() ? (
-
-
-
-
- JIRA Tickets ({vendorTickets.length})
-
- {canWrite() && (
-
- )}
-
- {vendorTickets.length > 0 ? (
-
- {vendorTickets.map(ticket => (
-
-
- {canWrite() && (
-
-
- {canDelete(ticket) && (
-
- )}
-
- )}
-
- ))}
-
- ) : (
-
No JIRA tickets linked
- )}
-
- ) : null;
- })()}
-
- );
- })}
-
-
- )}
-
- );
- })}
- {/* Show more / pagination footer */}
- {Object.keys(filteredGroupedCVEs).length > visibleCount && (
-
-
- Showing {visibleCount} of {Object.keys(filteredGroupedCVEs).length} CVEs
-
-
-
-
-
-
- )}
- {visibleCount > 5 && Object.keys(filteredGroupedCVEs).length <= visibleCount && Object.keys(filteredGroupedCVEs).length > 5 && (
-
-
-
- )}
-
- )}
-
- {Object.keys(filteredGroupedCVEs).length === 0 && !loading && (
-
-
-
No CVEs Found
-
Try adjusting your search criteria or filters
-
- )}
- >
-
- {/* End Center Panel */}
-
- {/* RIGHT PANEL - Calendar & Open Tickets */}
-
- {/* Calendar Widget */}
-
-
- Calendar
-
-
- {
- setCalendarFilter(dateStr);
- setCurrentPage('triage');
- }}
- />
-
-
- {/* Open Vendor Tickets */}
-
-
-
-
- Open Tickets
-
- {canWrite() && (
-
- )}
-
-
-
- {jiraTickets.filter(t => !isClosedStatus(t.status)).length}
-
-
Active
-
-
- {jiraTickets.filter(t => !isClosedStatus(t.status)).slice(0, 10).map(ticket => (
-
-
-
- {ticket.ticket_key}
-
- {canWrite() && (
-
-
- {canDelete(ticket) && (
-
- )}
-
- )}
-
-
{ticket.cve_id}
-
{ticket.vendor}
- {ticket.summary &&
{ticket.summary}
}
-
-
-
- {ticket.status}
-
-
-
- ))}
- {jiraTickets.filter(t => !isClosedStatus(t.status)).length === 0 && (
-
- )}
-
-
-
- {/* Archer Risk Acceptance Tickets */}
-
{ setReportingExcFilter(exc); setCurrentPage('triage'); }}
- onAddTicket={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
- canDeleteTicket={canDelete}
- />
-
- {/* Ivanti Workflows */}
-
-
-
-
- Ivanti Workflows
-
- {canWrite() && (
-
- )}
-
-
- {/* Last synced line */}
-
- {ivantiSyncedAt
- ? `Synced ${new Date(ivantiSyncedAt).toLocaleString()}`
- : 'Never synced'}
-
-
- {/* Archive Summary Bar */}
-
-
- {/* Archive list — shown when a state card is clicked */}
- {archiveFilter && (
-
-
-
- {archiveFilter} findings
-
-
-
- {archiveListLoading ? (
-
Loading…
- ) : archiveList.length === 0 ? (
-
- No {archiveFilter.toLowerCase()} findings
-
- ) : (
-
- {archiveList.map((a) => (
-
-
-
- {a.related_active ? (
-
- ) : (
-
- )}
-
- {a.finding_title || a.finding_id}
- {a.finding_id && (
-
- {a.finding_id.length > 20 ? a.finding_id.slice(0, 20) + '…' : a.finding_id}
-
- )}
-
-
-
- Last seen: {(a.last_severity && Number(a.last_severity) !== 0) ? Number(a.last_severity).toFixed(1) : '—'}
-
-
-
- {a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
-
- {a.related_active && (
-
- Similar finding active — ID: {a.related_active.id} ({a.related_active.severity ? Number(a.related_active.severity).toFixed(1) : '—'})
-
- )}
-
- ))}
-
- )}
-
- )}
-
- {ivantiLoading ? (
-
- ) : ivantiSyncStatus === 'error' ? (
- <>
-
-
- {ivantiTotal ?? '—'}
-
-
Total Workflows
-
-
- >
- ) : (
- <>
-
-
- {ivantiSyncStatus === 'never' ? '—' : (ivantiTotal ?? '—')}
-
-
Total Workflows
-
-
- {ivantiWorkflows.slice(0, 10).map((wf, idx) => (
-
-
-
- {wf.id?.value || wf.uuid?.slice(0, 8)}
-
- {wf.currentState && (
-
- {wf.currentState}
-
- )}
-
-
{wf.name}
-
- {wf.type && (
- {wf.type.replace(/_/g, ' ')}
- )}
- {wf.createdOn && (
- {wf.createdOn}
- )}
-
-
- ))}
- {ivantiSyncStatus !== 'never' && ivantiTotal === 0 && (
-
- )}
- {ivantiSyncStatus === 'never' && (
-
-
Click Sync to load workflow data
-
- )}
-
- >
- )}
-
-
- {/* End Right Panel */}
-
-
}
- {/* End Three Column Layout */}
-
- {/* Confirmation Modal */}
- setPendingConfirm(null)}
- />
);
diff --git a/frontend/src/components/CVECard.js b/frontend/src/components/CVECard.js
new file mode 100644
index 0000000..c9d8924
--- /dev/null
+++ b/frontend/src/components/CVECard.js
@@ -0,0 +1,453 @@
+import React, { useState } from 'react';
+import { ChevronDown, FileText, Eye, Edit2, Trash2, Upload, Plus, AlertCircle } from 'lucide-react';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+// --- Style constants ---
+
+const intelCard = {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
+ border: '2px solid rgba(14, 165, 233, 0.4)',
+ borderRadius: '0.5rem',
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12)',
+ position: 'relative',
+ overflow: 'hidden',
+};
+
+const vendorCardStyle = {
+ background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%)',
+ border: '1.5px solid rgba(14, 165, 233, 0.3)',
+ borderRadius: '0.5rem',
+ padding: '1rem',
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(14, 165, 233, 0.08)',
+ marginBottom: '0.75rem',
+};
+
+const ticketCardStyle = {
+ background: 'linear-gradient(135deg, rgba(19, 25, 55, 0.85) 0%, rgba(30, 39, 73, 0.75) 100%)',
+ border: '1px solid rgba(255, 184, 0, 0.3)',
+ borderRadius: '0.375rem',
+ padding: '0.75rem',
+ boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.04)',
+};
+
+const severityColors = {
+ critical: { bg: 'rgba(239, 68, 68, 0.25)', border: '#EF4444', text: '#FCA5A5', dot: '#EF4444' },
+ high: { bg: 'rgba(245, 158, 11, 0.25)', border: '#F59E0B', text: '#FCD34D', dot: '#F59E0B' },
+ medium: { bg: 'rgba(14, 165, 233, 0.25)', border: '#0EA5E9', text: '#7DD3FC', dot: '#0EA5E9' },
+ low: { bg: 'rgba(16, 185, 129, 0.25)', border: '#10B981', text: '#6EE7B7', dot: '#10B981' },
+};
+
+function getSeverityStyle(severity) {
+ const s = severityColors[severity?.toLowerCase()] || severityColors.medium;
+ return {
+ display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
+ background: `linear-gradient(135deg, ${s.bg} 0%, ${s.bg.replace('0.25', '0.2')} 100%)`,
+ border: `2px solid ${s.border}`, borderRadius: '0.375rem',
+ padding: '0.375rem 0.875rem', color: s.text, fontWeight: '700',
+ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.5px',
+ textShadow: `0 0 8px ${s.border}80`,
+ boxShadow: `0 0 16px ${s.border}4D, 0 4px 8px rgba(0, 0, 0, 0.4)`,
+ };
+}
+
+function GlowDot({ color }) {
+ return (
+
+ );
+}
+
+function isClosedStatus(status) {
+ if (!status) return false;
+ const lower = status.toLowerCase();
+ return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
+}
+
+function getTicketStatusColor(status) {
+ if (!status) return '#F59E0B';
+ if (isClosedStatus(status)) return '#10B981';
+ const lower = status.toLowerCase();
+ if (['open', 'to do', 'backlog', 'new'].some(s => lower === s)) return '#F59E0B';
+ return '#0EA5E9';
+}
+
+export default function CVECard({
+ cveId,
+ vendorEntries,
+ jiraTickets,
+ onEditCVE,
+ onDeleteEntry,
+ onDeleteAll,
+ onEditTicket,
+ onDeleteTicket,
+ onAddTicket,
+ onRequestConfirm,
+}) {
+ const { canWrite, canDelete, isAdmin } = useAuth();
+ const toast = useToast();
+ const [expanded, setExpanded] = useState(false);
+ const [docExpanded, setDocExpanded] = useState(null); // "cveId-vendor" key
+ const [documents, setDocuments] = useState({});
+ const [uploadingFile, setUploadingFile] = useState(false);
+ const [selectedDocuments, setSelectedDocuments] = useState([]);
+
+ const severityOrder = { Critical: 0, High: 1, Medium: 2, Low: 3 };
+ const highestSeverity = vendorEntries.reduce((highest, entry) => {
+ const cur = severityOrder[entry.severity] ?? 4;
+ const hi = severityOrder[highest] ?? 4;
+ return cur < hi ? entry.severity : highest;
+ }, vendorEntries[0].severity);
+ const totalDocCount = vendorEntries.reduce((sum, e) => sum + (e.document_count || 0), 0);
+ const overallStatuses = [...new Set(vendorEntries.map(e => e.status))];
+
+ // ⚠️ CONVENTION: Missing loading state — no visual indicator while documents are being fetched.
+ // Add a loading flag (e.g. loadingDocs state) and render a spinner/skeleton while the fetch is in flight.
+ const fetchDocuments = async (cveId, vendor) => {
+ const key = `${cveId}-${vendor}`;
+ if (documents[key]) return;
+ try {
+ const response = await fetch(`${API_BASE}/cves/${cveId}/documents?vendor=${vendor}`, { credentials: 'include' });
+ if (!response.ok) throw new Error('Failed to fetch documents');
+ const data = await response.json();
+ setDocuments(prev => ({ ...prev, [key]: data }));
+ } catch (err) {
+ toast.error(err.message);
+ }
+ };
+
+ const handleViewDocuments = async (cveId, vendor) => {
+ const key = `${cveId}-${vendor}`;
+ if (docExpanded === key) {
+ setDocExpanded(null);
+ } else {
+ setDocExpanded(key);
+ await fetchDocuments(cveId, vendor);
+ }
+ };
+
+ 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',
+ message: 'Are you sure you want to delete this document?',
+ confirmText: 'Delete',
+ onConfirm: async () => {
+ try {
+ const response = await fetch(`${API_BASE}/documents/${docId}`, { method: 'DELETE', credentials: 'include' });
+ if (!response.ok) throw new Error('Failed to delete document');
+ toast.success('Document deleted');
+ const key = `${cveId}-${vendor}`;
+ setDocuments(prev => { const next = { ...prev }; delete next[key]; return next; });
+ await fetchDocuments(cveId, vendor);
+ } catch (err) {
+ toast.error(err.message);
+ }
+ },
+ });
+ };
+
+ const toggleDocSelection = (docId) => {
+ setSelectedDocuments(prev => prev.includes(docId) ? prev.filter(id => id !== docId) : [...prev, docId]);
+ };
+
+ return (
+
+ {/* Clickable CVE Header */}
+
setExpanded(prev => !prev)}
+ role="button"
+ aria-expanded={expanded}
+ aria-label={`${cveId} - ${highestSeverity} severity, ${vendorEntries.length} vendors`}
+ >
+
+
+
+
+
{cveId}
+
+
+ {!expanded && (
+
+
+ {vendorEntries[0].description}
+
+
+
+
+ {highestSeverity}
+
+
+ {vendorEntries.length} vendor{vendorEntries.length > 1 ? 's' : ''}
+
+
+
+ {totalDocCount} doc{totalDocCount !== 1 ? 's' : ''}
+
+
+ {overallStatuses.join(', ')}
+
+
+
+ )}
+
+ {expanded && (
+
+
{vendorEntries[0].description}
+
+ Published: {vendorEntries[0].published_date}
+ •
+ {vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}
+ {isAdmin() && vendorEntries.length >= 2 && (
+
+ )}
+
+
+ )}
+
+
+
+
+ {/* Expanded vendor entries */}
+ {expanded && (
+
+
+ {vendorEntries.map((cve) => {
+ const key = `${cve.cve_id}-${cve.vendor}`;
+ const docs = documents[key] || [];
+ const isDocOpen = docExpanded === key;
+ const vendorTickets = jiraTickets.filter(t => t.cve_id === cve.cve_id && t.vendor === cve.vendor);
+
+ return (
+
+
+
+
+
{cve.vendor}
+
+
+ {cve.severity}
+
+
+
+ Status: {cve.status}
+
+
+ {cve.document_count} doc{cve.document_count !== 1 ? 's' : ''}
+
+
+
+
+
+ {canWrite() && (
+
+ )}
+ {canDelete(cve) && (
+
+ )}
+
+
+
+ {/* Documents */}
+ {isDocOpen && (
+
+
+
+ Documents ({docs.length})
+
+ {docs.length > 0 ? (
+
+ {docs.map(doc => (
+
+
+
toggleDocSelection(doc.id)}
+ className="w-4 h-4 text-intel-accent rounded focus:ring-2 focus:ring-intel-accent bg-intel-dark border-intel-accent/50"
+ aria-label={`Select document ${doc.name}`}
+ />
+
+
+
{doc.name}
+
+ {doc.type} • {doc.file_size}
+ {doc.notes && • {doc.notes}}
+
+
+
+
+
+ View
+
+ {isAdmin() && (
+
+ )}
+
+
+ ))}
+
+ ) : (
+
No documents attached
+ )}
+ {canWrite() && (
+
+ )}
+
+ )}
+
+ {/* JIRA Tickets */}
+ {(vendorTickets.length > 0 || canWrite()) && (
+
+
+
+
+ JIRA Tickets ({vendorTickets.length})
+
+ {canWrite() && (
+
+ )}
+
+ {vendorTickets.length > 0 ? (
+
+ {vendorTickets.map(ticket => (
+
+
+ {canWrite() && (
+
+
+ {canDelete(ticket) && (
+
+ )}
+
+ )}
+
+ ))}
+
+ ) : (
+
No JIRA tickets linked
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/CVEFilters.js b/frontend/src/components/CVEFilters.js
new file mode 100644
index 0000000..d460d93
--- /dev/null
+++ b/frontend/src/components/CVEFilters.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import { Search, Filter, AlertCircle } from 'lucide-react';
+
+const cardStyle = {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
+ border: '2px solid rgba(14, 165, 233, 0.4)',
+ borderRadius: '0.5rem',
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12)',
+ position: 'relative',
+ overflow: 'hidden',
+ padding: '1.5rem',
+};
+
+const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
+
+export default function CVEFilters({ searchQuery, onSearchChange, selectedVendor, onVendorChange, vendors, selectedSeverity, onSeverityChange }) {
+ return (
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="intel-input w-full"
+ aria-label="Search CVEs by ID or description"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/IvantiWorkflowPanel.js b/frontend/src/components/IvantiWorkflowPanel.js
new file mode 100644
index 0000000..f9a2a8a
--- /dev/null
+++ b/frontend/src/components/IvantiWorkflowPanel.js
@@ -0,0 +1,246 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { Activity, RefreshCw, Loader, AlertCircle, CheckCircle, AlertTriangle } from 'lucide-react';
+import { useAuth } from '../contexts/AuthContext';
+import ArchiveSummaryBar from './pages/ArchiveSummaryBar';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+const cardStyle = {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
+ border: '2px solid rgba(14, 165, 233, 0.4)',
+ borderRadius: '0.5rem',
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12)',
+ position: 'relative',
+ overflow: 'hidden',
+ padding: '1.5rem',
+ borderLeft: '3px solid #0D9488',
+};
+
+const workflowItemStyle = {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)',
+ border: '1px solid rgba(13, 148, 136, 0.25)',
+ borderRadius: '0.375rem',
+ padding: '0.5rem',
+ boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
+};
+
+export default function IvantiWorkflowPanel() {
+ const { canWrite, getActiveTeamsParam } = useAuth();
+ const [total, setTotal] = useState(null);
+ const [workflows, setWorkflows] = useState([]);
+ const [syncedAt, setSyncedAt] = useState(null);
+ const [syncStatus, setSyncStatus] = useState(null);
+ const [syncError, setSyncError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [syncing, setSyncing] = useState(false);
+ const [archiveFilter, setArchiveFilter] = useState(null);
+ const [archiveRefreshKey, setArchiveRefreshKey] = useState(0);
+ const [archiveList, setArchiveList] = useState([]);
+ const [archiveListLoading, setArchiveListLoading] = useState(false);
+
+ const applyState = (data) => {
+ setTotal(data.total ?? 0);
+ setWorkflows(data.workflows || []);
+ setSyncedAt(data.synced_at || null);
+ setSyncStatus(data.sync_status || null);
+ setSyncError(data.error_message || null);
+ };
+
+ const fetchWorkflows = useCallback(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' });
+ const data = await response.json();
+ if (response.ok) applyState(data);
+ } catch (err) {
+ console.error('Error loading Ivanti workflows:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const syncWorkflows = async () => {
+ setSyncing(true);
+ try {
+ const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, { method: 'POST', credentials: 'include' });
+ const data = await response.json();
+ if (response.ok) applyState(data);
+ } catch (err) {
+ console.error('Error syncing Ivanti workflows:', err);
+ } finally {
+ setSyncing(false);
+ setArchiveRefreshKey(k => k + 1);
+ }
+ };
+
+ const handleArchiveStateClick = (state) => {
+ const newFilter = archiveFilter === state ? null : state;
+ setArchiveFilter(newFilter);
+ if (newFilter) {
+ setArchiveListLoading(true);
+ const teamsParam = getActiveTeamsParam();
+ const url = teamsParam
+ ? `${API_BASE}/ivanti/archive?state=${newFilter}&teams=${encodeURIComponent(teamsParam)}`
+ : `${API_BASE}/ivanti/archive?state=${newFilter}`;
+ fetch(url, { credentials: 'include' })
+ .then(res => res.ok ? res.json() : Promise.reject())
+ .then(data => setArchiveList(data.archives || []))
+ .catch(() => setArchiveList([]))
+ .finally(() => setArchiveListLoading(false));
+ } else {
+ setArchiveList([]);
+ }
+ };
+
+ useEffect(() => { fetchWorkflows(); }, [fetchWorkflows]);
+
+ return (
+
+
+
+
+ Ivanti Workflows
+
+ {canWrite() && (
+
+ )}
+
+
+
+ {syncedAt ? `Synced ${new Date(syncedAt).toLocaleString()}` : 'Never synced'}
+
+
+ {/* Archive Summary */}
+
+
+ {/* Archive list */}
+ {archiveFilter && (
+
+
+
+ {archiveFilter} findings
+
+
+
+ {archiveListLoading ? (
+
Loading…
+ ) : archiveList.length === 0 ? (
+
+ No {archiveFilter.toLowerCase()} findings
+
+ ) : (
+
+ {archiveList.map((a) => (
+
+
+
+ {a.related_active ? (
+
+ ) : (
+
+ )}
+
+ {a.finding_title || a.finding_id}
+ {a.finding_id && (
+
+ {a.finding_id.length > 20 ? a.finding_id.slice(0, 20) + '…' : a.finding_id}
+
+ )}
+
+
+
+ Last seen: {(a.last_severity && Number(a.last_severity) !== 0) ? Number(a.last_severity).toFixed(1) : '—'}
+
+
+
+ {a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
+
+ {a.related_active && (
+
+ Similar finding active — ID: {a.related_active.id} ({a.related_active.severity ? Number(a.related_active.severity).toFixed(1) : '—'})
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Main content */}
+ {loading ? (
+
+ ) : syncStatus === 'error' ? (
+ <>
+
+
+ {total ?? '—'}
+
+
Total Workflows
+
+
+ >
+ ) : (
+ <>
+
+
+ {syncStatus === 'never' ? '—' : (total ?? '—')}
+
+
Total Workflows
+
+
+ {workflows.slice(0, 10).map((wf, idx) => (
+
+
+
+ {wf.id?.value || wf.uuid?.slice(0, 8)}
+
+ {wf.currentState && (
+
+ {wf.currentState}
+
+ )}
+
+
{wf.name}
+
+ {wf.type && {wf.type.replace(/_/g, ' ')}}
+ {wf.createdOn && {wf.createdOn}}
+
+
+ ))}
+ {syncStatus !== 'never' && total === 0 && (
+
+ )}
+ {syncStatus === 'never' && (
+
+
Click Sync to load workflow data
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/OpenTicketsPanel.js b/frontend/src/components/OpenTicketsPanel.js
new file mode 100644
index 0000000..9695edb
--- /dev/null
+++ b/frontend/src/components/OpenTicketsPanel.js
@@ -0,0 +1,118 @@
+import React from 'react';
+import { AlertCircle, Plus, Edit2, Trash2, CheckCircle } from 'lucide-react';
+import { useAuth } from '../contexts/AuthContext';
+
+const cardStyle = {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
+ border: '2px solid rgba(14, 165, 233, 0.4)',
+ borderRadius: '0.5rem',
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12)',
+ position: 'relative',
+ overflow: 'hidden',
+ padding: '1.5rem',
+ borderLeft: '3px solid #F59E0B',
+};
+
+const ticketItemStyle = {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)',
+ border: '1px solid rgba(245, 158, 11, 0.25)',
+ borderRadius: '0.375rem',
+ padding: '0.5rem',
+ boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
+};
+
+function isClosedStatus(status) {
+ if (!status) return false;
+ const lower = status.toLowerCase();
+ return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
+}
+
+function getTicketStatusColor(status) {
+ if (!status) return '#F59E0B';
+ if (isClosedStatus(status)) return '#10B981';
+ const lower = status.toLowerCase();
+ if (['open', 'to do', 'backlog', 'new'].some(s => lower === s)) return '#F59E0B';
+ return '#0EA5E9';
+}
+
+export default function OpenTicketsPanel({ tickets, onAdd, onEdit, onDelete }) {
+ const { canWrite, canDelete } = useAuth();
+ const openTickets = tickets.filter(t => !isClosedStatus(t.status));
+
+ return (
+
+
+
+
+ Open Tickets
+
+ {canWrite() && (
+
+ )}
+
+
+
+
+ {openTickets.length}
+
+
Active
+
+
+
+ {openTickets.slice(0, 10).map(ticket => (
+
+
+
+ {ticket.ticket_key}
+
+ {canWrite() && (
+
+
+ {canDelete(ticket) && (
+
+ )}
+
+ )}
+
+
{ticket.cve_id}
+
{ticket.vendor}
+ {ticket.summary &&
{ticket.summary}
}
+
+
+
+ {ticket.status}
+
+
+
+ ))}
+ {openTickets.length === 0 && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/QuickCVELookup.js b/frontend/src/components/QuickCVELookup.js
new file mode 100644
index 0000000..a771250
--- /dev/null
+++ b/frontend/src/components/QuickCVELookup.js
@@ -0,0 +1,112 @@
+import React, { useState } from 'react';
+import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
+import { useToast } from '../contexts/ToastContext';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+const cardStyle = {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
+ border: '2px solid rgba(14, 165, 233, 0.4)',
+ borderRadius: '0.5rem',
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12)',
+ position: 'relative',
+ overflow: 'hidden',
+ padding: '1.5rem',
+};
+
+export default function QuickCVELookup() {
+ const [query, setQuery] = useState('');
+ const [result, setResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const toast = useToast();
+
+ const handleLookup = async () => {
+ const trimmed = query.trim();
+ if (!trimmed) return;
+
+ setLoading(true);
+ try {
+ const response = await fetch(`${API_BASE}/cves/check/${encodeURIComponent(trimmed)}`, {
+ credentials: 'include'
+ });
+ if (!response.ok) throw new Error('Failed to check CVE');
+ const data = await response.json();
+ setResult(data);
+ } catch (err) {
+ toast.error(err.message);
+ setResult({ error: err.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Quick CVE Lookup
+
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleLookup()}
+ className="flex-1 intel-input"
+ aria-label="CVE ID to look up"
+ />
+
+
+
+ {result && (
+
+ {result.error ? (
+
+
+
+
Error
+
{result.error}
+
+
+ ) : result.exists ? (
+
+
+
+
+ ✓ CVE Addressed ({result.vendors.length} vendor{result.vendors.length > 1 ? 's' : ''})
+
+
+ {result.vendors.map((vendorInfo, idx) => (
+
+
{vendorInfo.vendor}
+
+
Severity: {vendorInfo.severity}
+
Status: {vendorInfo.status}
+
Documents: {vendorInfo.total_documents} attached
+
+
+ ))}
+
+
+
+ ) : (
+
+
+
+
Not Found
+
This CVE has not been addressed yet. No entry exists in the database.
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/StatsBar.js b/frontend/src/components/StatsBar.js
new file mode 100644
index 0000000..e2c5b5d
--- /dev/null
+++ b/frontend/src/components/StatsBar.js
@@ -0,0 +1,77 @@
+import React from 'react';
+
+const statCard = {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%)',
+ border: '2px solid #0EA5E9',
+ borderRadius: '0.5rem',
+ padding: '1rem',
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.15)',
+ position: 'relative',
+ overflow: 'hidden',
+ cursor: 'pointer',
+ transition: 'transform 0.15s, box-shadow 0.15s',
+};
+
+const topGlow = (color) => ({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ height: '2px',
+ background: `linear-gradient(90deg, transparent, ${color}, transparent)`,
+ boxShadow: `0 0 8px ${color}80`,
+});
+
+function StatCard({ label, value, color = '#0EA5E9', borderColor, onClick, active }) {
+ const cardStyle = {
+ ...statCard,
+ ...(borderColor ? { border: `2px solid ${borderColor}`, boxShadow: `0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px ${borderColor}26, inset 0 1px 0 ${borderColor}26` } : {}),
+ ...(active ? { transform: 'scale(1.03)', boxShadow: `0 4px 24px rgba(0, 0, 0, 0.6), 0 0 28px ${color}40` } : {}),
+ };
+
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+export default function StatsBar({ totalCVEs, vendorEntries, openTickets, criticalCount, onFilterSeverity, activeSeverity }) {
+ return (
+ // ⚠️ CONVENTION: Use inline styles or App.css classes instead of Tailwind utility classes (grid grid-cols-1 md:grid-cols-4 gap-4)
+
+ onFilterSeverity && onFilterSeverity('All Severities')}
+ active={activeSeverity === 'All Severities'}
+ />
+
+
+ onFilterSeverity && onFilterSeverity(activeSeverity === 'Critical' ? 'All Severities' : 'Critical')}
+ active={activeSeverity === 'Critical'}
+ />
+
+ );
+}
diff --git a/frontend/src/components/modals/AddCVEModal.js b/frontend/src/components/modals/AddCVEModal.js
new file mode 100644
index 0000000..6e33caf
--- /dev/null
+++ b/frontend/src/components/modals/AddCVEModal.js
@@ -0,0 +1,173 @@
+import React, { useState } from 'react';
+import { XCircle, Loader, CheckCircle, AlertCircle } from 'lucide-react';
+import { useToast } from '../../contexts/ToastContext';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+export default function AddCVEModal({ onClose, onSuccess }) {
+ const toast = useToast();
+ const [form, setForm] = useState({
+ cve_id: '',
+ vendor: '',
+ severity: 'Medium',
+ description: '',
+ published_date: new Date().toISOString().split('T')[0],
+ });
+ const [nvdLoading, setNvdLoading] = useState(false);
+ const [nvdError, setNvdError] = useState(null);
+ const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
+
+ const lookupNVD = async (cveId) => {
+ const trimmed = cveId.trim();
+ if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
+
+ setNvdLoading(true);
+ setNvdError(null);
+ setNvdAutoFilled(false);
+
+ try {
+ const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, { credentials: 'include' });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'NVD lookup failed');
+ }
+ const data = await response.json();
+ setForm(prev => ({
+ ...prev,
+ description: prev.description || data.description,
+ severity: data.severity,
+ published_date: data.published_date || prev.published_date,
+ }));
+ setNvdAutoFilled(true);
+ } catch (err) {
+ setNvdError(err.message);
+ } finally {
+ setNvdLoading(false);
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ try {
+ const response = await fetch(`${API_BASE}/cves`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(form),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to add CVE');
+ }
+ toast.success(`CVE ${form.cve_id} added for vendor: ${form.vendor}`);
+ onSuccess();
+ onClose();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ };
+
+ return (
+
+
+
+
+
Add CVE Entry
+
+
+
+
+
+ Tip: You can add the same CVE-ID multiple times with different vendors.
+ Each vendor will have its own documents folder.
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/modals/ArcherTicketModal.js b/frontend/src/components/modals/ArcherTicketModal.js
new file mode 100644
index 0000000..5405294
--- /dev/null
+++ b/frontend/src/components/modals/ArcherTicketModal.js
@@ -0,0 +1,165 @@
+import React, { useState } from 'react';
+import { XCircle } from 'lucide-react';
+import { useToast } from '../../contexts/ToastContext';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+/**
+ * Shared modal for adding and editing Archer risk acceptance tickets.
+ * Props:
+ * - ticket: existing ticket (edit mode) or null (add mode)
+ * - context: { cve_id, vendor } when adding from a CVE card
+ * - onClose: close handler
+ * - onSuccess: refresh handler
+ */
+export default function ArcherTicketModal({ ticket, context, onClose, onSuccess }) {
+ const toast = useToast();
+ const isEdit = !!ticket;
+
+ const [form, setForm] = useState({
+ exc_number: ticket?.exc_number || '',
+ archer_url: ticket?.archer_url || '',
+ status: ticket?.status || 'Draft',
+ cve_id: ticket?.cve_id || context?.cve_id || '',
+ vendor: ticket?.vendor || context?.vendor || '',
+ });
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ try {
+ if (isEdit) {
+ const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ exc_number: form.exc_number,
+ archer_url: form.archer_url,
+ status: form.status,
+ }),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to update Archer ticket');
+ }
+ toast.success('Archer ticket updated');
+ } else {
+ const response = await fetch(`${API_BASE}/archer-tickets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(form),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to create Archer ticket');
+ }
+ toast.success('Archer ticket added');
+ }
+ onSuccess();
+ onClose();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {isEdit ? 'Edit Archer Risk Ticket' : 'Add Archer Risk Ticket'}
+
+
+
+
+ {isEdit && (
+
+ {ticket.cve_id} / {ticket.vendor}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/modals/EditCVEModal.js b/frontend/src/components/modals/EditCVEModal.js
new file mode 100644
index 0000000..d576def
--- /dev/null
+++ b/frontend/src/components/modals/EditCVEModal.js
@@ -0,0 +1,200 @@
+import React, { useState } from 'react';
+import { XCircle, RefreshCw, Loader, CheckCircle, AlertCircle } from 'lucide-react';
+import { useToast } from '../../contexts/ToastContext';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+export default function EditCVEModal({ cve, onClose, onSuccess }) {
+ const toast = useToast();
+ const [form, setForm] = useState({
+ cve_id: cve.cve_id,
+ vendor: cve.vendor,
+ severity: cve.severity,
+ description: cve.description || '',
+ published_date: cve.published_date || '',
+ status: cve.status || 'Open',
+ });
+ const [nvdLoading, setNvdLoading] = useState(false);
+ const [nvdError, setNvdError] = useState(null);
+ const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
+
+ const lookupNVD = async () => {
+ const trimmed = form.cve_id.trim();
+ if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
+
+ setNvdLoading(true);
+ setNvdError(null);
+ setNvdAutoFilled(false);
+
+ try {
+ const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, { credentials: 'include' });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'NVD lookup failed');
+ }
+ const data = await response.json();
+ setForm(prev => ({
+ ...prev,
+ description: data.description || prev.description,
+ severity: data.severity || prev.severity,
+ published_date: data.published_date || prev.published_date,
+ }));
+ setNvdAutoFilled(true);
+ } catch (err) {
+ setNvdError(err.message);
+ } finally {
+ setNvdLoading(false);
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ const body = {};
+ if (form.cve_id !== cve.cve_id) body.cve_id = form.cve_id;
+ if (form.vendor !== cve.vendor) body.vendor = form.vendor;
+ if (form.severity !== cve.severity) body.severity = form.severity;
+ if (form.description !== (cve.description || '')) body.description = form.description;
+ if (form.published_date !== (cve.published_date || '')) body.published_date = form.published_date;
+ if (form.status !== (cve.status || 'Open')) body.status = form.status;
+
+ if (Object.keys(body).length === 0) {
+ toast.info('No changes detected.');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${API_BASE}/cves/${cve.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to update CVE');
+ }
+ toast.success('CVE updated successfully');
+ onSuccess();
+ onClose();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ };
+
+ return (
+
+
+
+
+
Edit CVE Entry
+
+
+
+
+
+ Note: Changing CVE ID or Vendor will move associated documents to the new path.
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/modals/JiraTicketModal.js b/frontend/src/components/modals/JiraTicketModal.js
new file mode 100644
index 0000000..a41cea7
--- /dev/null
+++ b/frontend/src/components/modals/JiraTicketModal.js
@@ -0,0 +1,186 @@
+import React, { useState } from 'react';
+import { XCircle } from 'lucide-react';
+import { useToast } from '../../contexts/ToastContext';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+/**
+ * Shared modal for adding and editing JIRA tickets.
+ * Props:
+ * - ticket: existing ticket object (edit mode) or null (add mode)
+ * - context: { cve_id, vendor } when adding from a CVE card
+ * - onClose: close handler
+ * - onSuccess: refresh handler
+ */
+export default function JiraTicketModal({ ticket, context, onClose, onSuccess }) {
+ const toast = useToast();
+ const isEdit = !!ticket;
+
+ const [form, setForm] = useState({
+ cve_id: ticket?.cve_id || context?.cve_id || '',
+ vendor: ticket?.vendor || context?.vendor || '',
+ ticket_key: ticket?.ticket_key || '',
+ url: ticket?.url || '',
+ summary: ticket?.summary || '',
+ status: ticket?.status || 'Open',
+ });
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ try {
+ if (isEdit) {
+ const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ ticket_key: form.ticket_key,
+ url: form.url,
+ summary: form.summary,
+ status: form.status,
+ }),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to update ticket');
+ }
+ toast.success('JIRA ticket updated');
+ } else {
+ const response = await fetch(`${API_BASE}/jira-tickets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(form),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to create ticket');
+ }
+ toast.success('JIRA ticket added');
+ }
+ onSuccess();
+ onClose();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ };
+
+ const showCVEFields = !isEdit && !context;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/pages/HomePage.js b/frontend/src/components/pages/HomePage.js
new file mode 100644
index 0000000..bef9b40
--- /dev/null
+++ b/frontend/src/components/pages/HomePage.js
@@ -0,0 +1,449 @@
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import { XCircle, AlertCircle } from 'lucide-react';
+import { useAuth } from '../../contexts/AuthContext';
+import { useToast } from '../../contexts/ToastContext';
+import { useDebounce } from '../../hooks/useDebounce';
+import StatsBar from '../StatsBar';
+import QuickCVELookup from '../QuickCVELookup';
+import CVEFilters from '../CVEFilters';
+import CVECard from '../CVECard';
+import OpenTicketsPanel from '../OpenTicketsPanel';
+import IvantiWorkflowPanel from '../IvantiWorkflowPanel';
+import CalendarWidget from '../CalendarWidget';
+import ArcherPage from './ArcherPage';
+import ConfirmModal from '../ConfirmModal';
+import AddCVEModal from '../modals/AddCVEModal';
+import EditCVEModal from '../modals/EditCVEModal';
+import JiraTicketModal from '../modals/JiraTicketModal';
+import ArcherTicketModal from '../modals/ArcherTicketModal';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+function isClosedStatus(status) {
+ if (!status) return false;
+ const lower = status.toLowerCase();
+ return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
+}
+
+export default function HomePage({ onNavigate, showAddCVE, setShowAddCVE }) {
+ const { isAuthenticated, canDelete } = useAuth();
+ const toast = useToast();
+
+ // --- CVE data state ---
+ const [cves, setCves] = useState([]);
+ const [vendors, setVendors] = useState(['All Vendors']);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // --- Filter state ---
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedVendor, setSelectedVendor] = useState('All Vendors');
+ const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
+ const debouncedSearch = useDebounce(searchQuery, 300);
+
+ // --- Pagination ---
+ const [visibleCount, setVisibleCount] = useState(5);
+
+ // --- Tickets ---
+ const [jiraTickets, setJiraTickets] = useState([]);
+ const [archerTickets, setArcherTickets] = useState([]);
+
+ // --- Modal state ---
+ const [editingCVE, setEditingCVE] = useState(null);
+ const [jiraModal, setJiraModal] = useState(null); // { ticket?, context? }
+ const [archerModal, setArcherModal] = useState(null); // { ticket?, context? }
+ const [pendingConfirm, setPendingConfirm] = useState(null);
+
+ // --- Fetchers ---
+ const fetchCVEs = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const params = new URLSearchParams();
+ if (debouncedSearch) params.append('search', debouncedSearch);
+ if (selectedVendor !== 'All Vendors') params.append('vendor', selectedVendor);
+ if (selectedSeverity !== 'All Severities') params.append('severity', selectedSeverity);
+
+ const response = await fetch(`${API_BASE}/cves?${params}`, { credentials: 'include' });
+ if (!response.ok) throw new Error('Failed to fetch CVEs');
+ const data = await response.json();
+ setCves(data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [debouncedSearch, selectedVendor, selectedSeverity]);
+
+ const fetchVendors = useCallback(async () => {
+ try {
+ const response = await fetch(`${API_BASE}/vendors`, { credentials: 'include' });
+ if (!response.ok) throw new Error('Failed to fetch vendors');
+ const data = await response.json();
+ setVendors(['All Vendors', ...data]);
+ } catch (err) {
+ console.error('Error fetching vendors:', err);
+ }
+ }, []);
+
+ const fetchJiraTickets = useCallback(async () => {
+ try {
+ const response = await fetch(`${API_BASE}/jira-tickets`, { credentials: 'include' });
+ if (!response.ok) throw new Error('Failed to fetch JIRA tickets');
+ const data = await response.json();
+ setJiraTickets(data);
+ } catch (err) {
+ console.error('Error fetching JIRA tickets:', err);
+ }
+ }, []);
+
+ const fetchArcherTickets = useCallback(async () => {
+ try {
+ const response = await fetch(`${API_BASE}/archer-tickets`, { credentials: 'include' });
+ if (!response.ok) throw new Error('Failed to fetch Archer tickets');
+ const data = await response.json();
+ setArcherTickets(data);
+ } catch (err) {
+ console.error('Error fetching Archer tickets:', err);
+ }
+ }, []);
+
+ // --- Effects ---
+ useEffect(() => {
+ if (isAuthenticated) {
+ fetchCVEs();
+ fetchVendors();
+ fetchJiraTickets();
+ fetchArcherTickets();
+ }
+ }, [isAuthenticated, fetchCVEs, fetchVendors, fetchJiraTickets, fetchArcherTickets]);
+
+ // Reset visible count when filters change
+ useEffect(() => { setVisibleCount(5); }, [debouncedSearch, selectedVendor, selectedSeverity]);
+
+ // --- Memoized data ---
+ const groupedCVEs = useMemo(() =>
+ cves.reduce((acc, cve) => {
+ if (!acc[cve.cve_id]) acc[cve.cve_id] = [];
+ acc[cve.cve_id].push(cve);
+ return acc;
+ }, {}),
+ [cves]
+ );
+
+ const openTicketCount = useMemo(
+ () => jiraTickets.filter(t => !isClosedStatus(t.status)).length,
+ [jiraTickets]
+ );
+
+ const criticalCount = useMemo(
+ () => cves.filter(c => c.severity === 'Critical').length,
+ [cves]
+ );
+
+ // --- Handlers ---
+ const handleDeleteEntry = (cve) => {
+ setPendingConfirm({
+ title: 'Delete Vendor Entry',
+ message: `Are you sure you want to delete the "${cve.vendor}" entry for ${cve.cve_id}? This will also delete all associated documents.`,
+ confirmText: 'Delete',
+ onConfirm: async () => {
+ setPendingConfirm(null);
+ try {
+ const response = await fetch(`${API_BASE}/cves/${cve.id}`, { method: 'DELETE', credentials: 'include' });
+ if (!response.ok) {
+ const ct = response.headers.get('content-type');
+ if (ct && ct.includes('application/json')) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to delete CVE entry');
+ } else {
+ throw new Error(`Server returned ${response.status} ${response.statusText}`);
+ }
+ }
+ toast.success(`Deleted ${cve.vendor} entry for ${cve.cve_id}`);
+ fetchCVEs();
+ fetchVendors();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ },
+ });
+ };
+
+ const handleDeleteAll = (cveId, vendorCount) => {
+ setPendingConfirm({
+ title: 'Delete Entire CVE',
+ message: `Are you sure you want to delete ALL ${vendorCount} vendor entries for ${cveId}? This will permanently remove all associated documents and files.`,
+ confirmText: 'Delete All',
+ onConfirm: async () => {
+ setPendingConfirm(null);
+ try {
+ const response = await fetch(`${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`, { method: 'DELETE', credentials: 'include' });
+ if (!response.ok) {
+ const ct = response.headers.get('content-type');
+ if (ct && ct.includes('application/json')) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to delete CVE');
+ } else {
+ throw new Error(`Server returned ${response.status} ${response.statusText}`);
+ }
+ }
+ toast.success(`Deleted all entries for ${cveId}`);
+ fetchCVEs();
+ fetchVendors();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ },
+ });
+ };
+
+ const handleDeleteTicket = (ticket) => {
+ setPendingConfirm({
+ title: 'Delete Ticket',
+ message: `Delete ticket ${ticket.ticket_key}?`,
+ confirmText: 'Delete',
+ onConfirm: async () => {
+ setPendingConfirm(null);
+ try {
+ const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, { method: 'DELETE', credentials: 'include' });
+ if (!response.ok) throw new Error('Failed to delete ticket');
+ toast.success('Ticket deleted');
+ fetchJiraTickets();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ },
+ });
+ };
+
+ const handleDeleteArcherTicket = (ticket) => {
+ setPendingConfirm({
+ title: 'Delete Archer Ticket',
+ message: `Delete Archer ticket ${ticket.exc_number}?`,
+ confirmText: 'Delete',
+ onConfirm: async () => {
+ setPendingConfirm(null);
+ try {
+ const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, { method: 'DELETE', credentials: 'include' });
+ if (!response.ok) throw new Error('Failed to delete Archer ticket');
+ toast.success('Archer ticket deleted');
+ fetchArcherTickets();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ },
+ });
+ };
+
+ const handleFilterSeverity = (severity) => {
+ setSelectedSeverity(severity);
+ };
+
+ const totalCVEs = Object.keys(groupedCVEs).length;
+
+ return (
+ <>
+ {/* Stats Bar */}
+
+
+ {/* Two Column Layout */}
+
+ {/* CENTER PANEL */}
+
+
+
+
+
+ {/* Results Summary */}
+
+
+ {totalCVEs} CVE{totalCVEs !== 1 ? 's' : ''}
+ •
+ {cves.length} vendor entr{cves.length !== 1 ? 'ies' : 'y'}
+
+
+
+ {/* CVE List */}
+ {loading ? (
+
+
+
Scanning Vulnerabilities...
+
+ ) : error ? (
+
+
+
Error Loading CVEs
+
{error}
+
+
+ ) : (
+
+ {Object.entries(groupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => (
+
setEditingCVE(cve)}
+ onDeleteEntry={handleDeleteEntry}
+ onDeleteAll={handleDeleteAll}
+ onEditTicket={(ticket) => setJiraModal({ ticket })}
+ onDeleteTicket={handleDeleteTicket}
+ onAddTicket={(cve_id, vendor) => setJiraModal({ context: { cve_id, vendor } })}
+ onRequestConfirm={setPendingConfirm}
+ />
+ ))}
+
+ {/* Pagination */}
+ {totalCVEs > visibleCount && (
+
+
+ Showing {visibleCount} of {totalCVEs} CVEs
+
+
+
+
+
+
+ )}
+ {visibleCount > 5 && totalCVEs <= visibleCount && totalCVEs > 5 && (
+
+
+
+ )}
+
+ )}
+
+ {totalCVEs === 0 && !loading && (
+
+
+
No CVEs Found
+
Try adjusting your search criteria or filters
+
+ )}
+
+
+ {/* RIGHT PANEL */}
+
+ {/* Calendar */}
+
+
+ Calendar
+
+ {
+ onNavigate('triage', { calendarFilter: dateStr });
+ }}
+ />
+
+
+ {/* Open Tickets */}
+
setJiraModal({ context: null })}
+ onEdit={(ticket) => setJiraModal({ ticket })}
+ onDelete={handleDeleteTicket}
+ />
+
+ {/* Archer Tickets */}
+ setArcherModal({ ticket })}
+ onDeleteTicket={handleDeleteArcherTicket}
+ onFilterByExc={(exc) => onNavigate('triage', { reportingExcFilter: exc })}
+ onAddTicket={() => setArcherModal({ context: null })}
+ canDeleteTicket={canDelete}
+ />
+
+ {/* Ivanti Workflows */}
+
+
+
+
+ {/* Modals */}
+ {showAddCVE && (
+ setShowAddCVE(false)}
+ onSuccess={() => { fetchCVEs(); fetchVendors(); }}
+ />
+ )}
+
+ {editingCVE && (
+ setEditingCVE(null)}
+ onSuccess={() => { fetchCVEs(); fetchVendors(); }}
+ />
+ )}
+
+ {jiraModal && (
+ setJiraModal(null)}
+ onSuccess={fetchJiraTickets}
+ />
+ )}
+
+ {archerModal && (
+ setArcherModal(null)}
+ onSuccess={fetchArcherTickets}
+ />
+ )}
+
+ {/* Confirmation Modal */}
+ setPendingConfirm(null)}
+ />
+ >
+ );
+}
diff --git a/frontend/src/contexts/ToastContext.js b/frontend/src/contexts/ToastContext.js
new file mode 100644
index 0000000..0fab77f
--- /dev/null
+++ b/frontend/src/contexts/ToastContext.js
@@ -0,0 +1,117 @@
+import React, { createContext, useContext, useState, useCallback } from 'react';
+
+const ToastContext = createContext(null);
+
+let toastId = 0;
+
+export function ToastProvider({ children }) {
+ const [toasts, setToasts] = useState([]);
+
+ const addToast = useCallback((message, type = 'info', duration = 4000) => {
+ const id = ++toastId;
+ setToasts(prev => [...prev, { id, message, type }]);
+ if (duration > 0) {
+ setTimeout(() => {
+ setToasts(prev => prev.filter(t => t.id !== id));
+ }, duration);
+ }
+ return id;
+ }, []);
+
+ const removeToast = useCallback((id) => {
+ setToasts(prev => prev.filter(t => t.id !== id));
+ }, []);
+
+ const toast = useCallback((message, type, duration) => addToast(message, type, duration), [addToast]);
+ toast.success = (msg, duration) => addToast(msg, 'success', duration);
+ toast.error = (msg, duration) => addToast(msg, 'error', duration ?? 6000);
+ toast.warning = (msg, duration) => addToast(msg, 'warning', duration);
+ toast.info = (msg, duration) => addToast(msg, 'info', duration);
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+export function useToast() {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error('useToast must be used within a ToastProvider');
+ return ctx;
+}
+
+// --- Toast UI ---
+
+const TOAST_STYLES = {
+ container: {
+ position: 'fixed',
+ top: '1rem',
+ right: '1rem',
+ zIndex: 99999,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '0.5rem',
+ pointerEvents: 'none',
+ maxWidth: '400px',
+ },
+ toast: {
+ pointerEvents: 'auto',
+ padding: '0.75rem 1rem',
+ borderRadius: '0.5rem',
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.8rem',
+ color: '#E2E8F0',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
+ animation: 'toast-slide-in 0.2s ease-out',
+ cursor: 'pointer',
+ },
+ success: {
+ background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.08))',
+ border: '1px solid rgba(16, 185, 129, 0.5)',
+ },
+ error: {
+ background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.08))',
+ border: '1px solid rgba(239, 68, 68, 0.5)',
+ },
+ warning: {
+ background: 'linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(245, 158, 11, 0.08))',
+ border: '1px solid rgba(245, 158, 11, 0.5)',
+ },
+ info: {
+ background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.08))',
+ border: '1px solid rgba(14, 165, 233, 0.5)',
+ },
+};
+
+const TOAST_ICONS = {
+ success: '✓',
+ error: '✕',
+ warning: '⚠',
+ info: 'ℹ',
+};
+
+function ToastContainer({ toasts, onDismiss }) {
+ if (toasts.length === 0) return null;
+
+ return (
+
+ {toasts.map(t => (
+
onDismiss(t.id)}
+ role="alert"
+ aria-live="polite"
+ >
+ {TOAST_ICONS[t.type]}
+ {t.message}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/hooks/useDebounce.js b/frontend/src/hooks/useDebounce.js
new file mode 100644
index 0000000..630590a
--- /dev/null
+++ b/frontend/src/hooks/useDebounce.js
@@ -0,0 +1,16 @@
+import { useState, useEffect } from 'react';
+
+/**
+ * Debounces a value by the specified delay.
+ * Returns the debounced value — updates only after `delay` ms of inactivity.
+ */
+export function useDebounce(value, delay = 300) {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/frontend/src/index.js b/frontend/src/index.js
index 1a300f4..726998d 100644
--- a/frontend/src/index.js
+++ b/frontend/src/index.js
@@ -4,12 +4,15 @@ import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { AuthProvider } from './contexts/AuthContext';
+import { ToastProvider } from './contexts/ToastContext';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
-
+
+
+
);