Refactor home page: extract components, add toast system, debounce search
Major restructuring of the monolithic App.js (2484 lines) into focused, testable components: Architecture: - App.js is now a 189-line routing shell (header, nav, page switching) - HomePage.js orchestrates all home page state and layout - Each visual section is its own component with clear props API Extracted components: - StatsBar: clickable stat cards that filter by severity - QuickCVELookup: CVE existence check with inline results - CVEFilters: search + vendor/severity dropdowns - CVECard: expandable CVE with vendor entries, docs, tickets - OpenTicketsPanel: right sidebar open JIRA tickets - IvantiWorkflowPanel: right sidebar Ivanti workflow status + archive Extracted modals: - AddCVEModal: self-contained add form with NVD auto-fill - EditCVEModal: self-contained edit form with NVD update - JiraTicketModal: unified add/edit JIRA ticket modal - ArcherTicketModal: unified add/edit Archer ticket modal Performance optimizations: - Debounced search (300ms) via useDebounce hook — eliminates redundant API calls on every keystroke - Memoized groupedCVEs, openTicketCount, criticalCount via useMemo - Proper state updates (no direct mutation of cveDocuments) - useCallback on fetch functions to stabilize effect dependencies UX improvements: - Toast notification system replaces all alert() calls - Stat cards are now clickable to filter CVE list by severity - onKeyDown replaces deprecated onKeyPress - aria-labels added to interactive elements Infrastructure: - ToastContext with auto-dismiss, typed toasts (success/error/warning/info) - useDebounce custom hook for reuse across the app - Toast slide-in animation in App.css
This commit is contained in:
@@ -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 {
|
:root {
|
||||||
/* Base Colors - Modern Slate Foundation */
|
/* Base Colors - Modern Slate Foundation */
|
||||||
--intel-darkest: #0F172A;
|
--intel-darkest: #0F172A;
|
||||||
|
|||||||
2347
frontend/src/App.js
2347
frontend/src/App.js
File diff suppressed because it is too large
Load Diff
453
frontend/src/components/CVECard.js
Normal file
453
frontend/src/components/CVECard.js
Normal file
@@ -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 (
|
||||||
|
<span style={{
|
||||||
|
width: '8px', height: '8px', borderRadius: '50%', background: color,
|
||||||
|
boxShadow: `0 0 12px ${color}, 0 0 6px ${color}`, animation: 'pulse 2s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={intelCard} className="rounded-lg">
|
||||||
|
{/* Clickable CVE Header */}
|
||||||
|
<div
|
||||||
|
style={{ padding: '1.5rem', cursor: 'pointer', transition: 'all 0.2s', userSelect: 'none' }}
|
||||||
|
onClick={() => setExpanded(prev => !prev)}
|
||||||
|
role="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={`${cveId} - ${highestSeverity} severity, ${vendorEntries.length} vendors`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<ChevronDown className={`w-5 h-5 text-intel-accent transition-transform duration-200 flex-shrink-0 ${expanded ? 'rotate-0' : '-rotate-90'}`} />
|
||||||
|
<h3 className="text-2xl font-bold text-intel-accent font-mono tracking-tight">{cveId}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!expanded && (
|
||||||
|
<div className="ml-8">
|
||||||
|
<p style={{ color: '#E4E8F1', fontSize: '0.875rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginBottom: '0.5rem' }}>
|
||||||
|
{vendorEntries[0].description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span style={getSeverityStyle(highestSeverity)}>
|
||||||
|
<GlowDot color={severityColors[highestSeverity?.toLowerCase()]?.dot || '#0EA5E9'} />
|
||||||
|
{highestSeverity}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#E4E8F1', fontFamily: 'monospace' }}>
|
||||||
|
{vendorEntries.length} vendor{vendorEntries.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#E4E8F1', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
{totalDocCount} doc{totalDocCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#E4E8F1', fontFamily: 'monospace' }}>
|
||||||
|
{overallStatuses.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="ml-8">
|
||||||
|
<p className="text-white mb-3">{vendorEntries[0].description}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-300 font-mono">
|
||||||
|
<span>Published: {vendorEntries[0].published_date}</span>
|
||||||
|
<span className="text-intel-accent">•</span>
|
||||||
|
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
||||||
|
{isAdmin() && vendorEntries.length >= 2 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDeleteAll(cveId, vendorEntries.length); }}
|
||||||
|
className="ml-2 px-3 py-1 text-xs intel-button intel-button-danger flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Delete All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded vendor entries */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 (
|
||||||
|
<div key={cve.id} style={vendorCardStyle}>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h4 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#FFFFFF' }}>{cve.vendor}</h4>
|
||||||
|
<span style={getSeverityStyle(cve.severity)}>
|
||||||
|
<GlowDot color={severityColors[cve.severity?.toLowerCase()]?.dot || '#0EA5E9'} />
|
||||||
|
{cve.severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', fontSize: '0.875rem', color: '#E4E8F1', fontFamily: 'monospace' }}>
|
||||||
|
<span>Status: <span style={{ fontWeight: '500', color: '#FFFFFF' }}>{cve.status}</span></span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
{cve.document_count} doc{cve.document_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewDocuments(cve.cve_id, cve.vendor)}
|
||||||
|
className="px-4 py-2 text-intel-accent hover:bg-intel-medium rounded border border-intel-accent/50 transition-all flex items-center gap-2 font-mono text-xs uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
{isDocOpen ? 'Hide' : 'View'}
|
||||||
|
</button>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEditCVE(cve)}
|
||||||
|
className="px-3 py-2 text-intel-warning hover:bg-intel-medium rounded border border-intel-warning/50 transition-all flex items-center gap-1"
|
||||||
|
title="Edit CVE entry"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete(cve) && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteEntry(cve)}
|
||||||
|
className="px-3 py-2 text-intel-danger hover:bg-intel-medium rounded border border-intel-danger/50 transition-all flex items-center gap-1"
|
||||||
|
title="Delete this vendor entry"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents */}
|
||||||
|
{isDocOpen && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-intel-accent/30">
|
||||||
|
<h5 className="text-sm font-semibold text-white mb-3 flex items-center gap-2 font-mono uppercase tracking-wider">
|
||||||
|
<FileText className="w-4 h-4 text-intel-accent" />
|
||||||
|
Documents ({docs.length})
|
||||||
|
</h5>
|
||||||
|
{docs.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{docs.map(doc => (
|
||||||
|
<div key={doc.id} className="document-item flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedDocuments.includes(doc.id)}
|
||||||
|
onChange={() => 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}`}
|
||||||
|
/>
|
||||||
|
<FileText className="w-5 h-5 text-intel-accent" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-white font-mono">{doc.name}</p>
|
||||||
|
<p className="text-xs text-gray-300 capitalize font-mono">
|
||||||
|
{doc.type} <span className="text-intel-accent">•</span> {doc.file_size}
|
||||||
|
{doc.notes && <span> <span className="text-intel-accent">•</span> {doc.notes}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<a
|
||||||
|
href={`/${doc.file_path}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-3 py-1 text-sm text-intel-accent hover:bg-intel-medium rounded transition-all border border-intel-accent/50 font-mono uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{isAdmin() && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteDocument(doc.id, cve.cve_id, cve.vendor)}
|
||||||
|
className="px-3 py-1 text-sm text-intel-danger hover:bg-intel-medium rounded transition-all border border-intel-danger/50 flex items-center gap-1 font-mono uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No documents attached</p>
|
||||||
|
)}
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleFileUpload(cve.cve_id, cve.vendor)}
|
||||||
|
disabled={uploadingFile}
|
||||||
|
className="mt-3 px-4 py-2 text-sm text-gray-400 hover:text-intel-accent hover:bg-intel-medium rounded transition-all flex items-center gap-2 disabled:opacity-50 border border-gray-600 font-mono uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{uploadingFile ? 'Uploading...' : 'Upload Doc'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* JIRA Tickets */}
|
||||||
|
{(vendorTickets.length > 0 || canWrite()) && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-intel-warning/30">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h5 className="text-sm font-semibold text-white flex items-center gap-2 font-mono uppercase tracking-wider">
|
||||||
|
<AlertCircle className="w-4 h-4 text-intel-warning" />
|
||||||
|
JIRA Tickets ({vendorTickets.length})
|
||||||
|
</h5>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => onAddTicket(cve.cve_id, cve.vendor)}
|
||||||
|
className="text-xs px-3 py-1 intel-button intel-button-primary flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Add Ticket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{vendorTickets.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{vendorTickets.map(ticket => (
|
||||||
|
<div key={ticket.id} style={ticketCardStyle} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<a
|
||||||
|
href={ticket.url || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-sm font-semibold text-intel-accent hover:text-intel-warning transition-colors"
|
||||||
|
>
|
||||||
|
{ticket.ticket_key}
|
||||||
|
</a>
|
||||||
|
{ticket.summary && (
|
||||||
|
<span style={{ fontSize: '0.875rem', color: '#E4E8F1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '20rem' }}>
|
||||||
|
{ticket.summary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={getSeverityStyle(isClosedStatus(ticket.status) ? 'low' : 'high')}>
|
||||||
|
<GlowDot color={getTicketStatusColor(ticket.status)} />
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canWrite() && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => onEditTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-warning transition-colors">
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{canDelete(ticket) && (
|
||||||
|
<button onClick={() => onDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-intel-danger transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No JIRA tickets linked</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/components/CVEFilters.js
Normal file
73
frontend/src/components/CVEFilters.js
Normal file
@@ -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 (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
|
||||||
|
<Search className="inline w-4 h-4 mr-1" />
|
||||||
|
Search CVEs
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="CVE ID or description..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="intel-input w-full"
|
||||||
|
aria-label="Search CVEs by ID or description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
|
||||||
|
<Filter className="inline w-4 h-4 mr-1" />
|
||||||
|
Vendor
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedVendor}
|
||||||
|
onChange={(e) => onVendorChange(e.target.value)}
|
||||||
|
className="intel-input w-full"
|
||||||
|
aria-label="Filter by vendor"
|
||||||
|
>
|
||||||
|
{vendors.map(vendor => (
|
||||||
|
<option key={vendor} value={vendor}>{vendor}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
|
||||||
|
<AlertCircle className="inline w-4 h-4 mr-1" />
|
||||||
|
Severity
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedSeverity}
|
||||||
|
onChange={(e) => onSeverityChange(e.target.value)}
|
||||||
|
className="intel-input w-full"
|
||||||
|
aria-label="Filter by severity"
|
||||||
|
>
|
||||||
|
{severityLevels.map(level => (
|
||||||
|
<option key={level} value={level}>{level}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
frontend/src/components/IvantiWorkflowPanel.js
Normal file
246
frontend/src/components/IvantiWorkflowPanel.js
Normal file
@@ -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 (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0D9488', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
Ivanti Workflows
|
||||||
|
</h2>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={syncWorkflows}
|
||||||
|
disabled={syncing || loading}
|
||||||
|
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||||
|
title="Sync now"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${syncing ? 'animate-spin' : ''}`} />
|
||||||
|
{syncing ? 'Syncing…' : 'Sync'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 font-mono mb-4">
|
||||||
|
{syncedAt ? `Synced ${new Date(syncedAt).toLocaleString()}` : 'Never synced'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Archive Summary */}
|
||||||
|
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} teamsParam={getActiveTeamsParam()} />
|
||||||
|
|
||||||
|
{/* Archive list */}
|
||||||
|
{archiveFilter && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{archiveFilter} findings
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setArchiveFilter(null); setArchiveList([]); }}
|
||||||
|
style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
|
✕ Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{archiveListLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem' }}>Loading…</div>
|
||||||
|
) : archiveList.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem', border: '1px dashed rgba(100, 116, 139, 0.3)', borderRadius: '0.375rem' }}>
|
||||||
|
No {archiveFilter.toLowerCase()} findings
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
|
{archiveList.map((a) => (
|
||||||
|
<div key={a.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))', border: '1px solid rgba(100, 116, 139, 0.25)', borderLeft: a.related_active ? '3px solid #F59E0B' : '3px solid #10B981', borderRadius: '0.375rem', padding: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'start', gap: '0.375rem', flex: 1, minWidth: 0 }}>
|
||||||
|
{a.related_active ? (
|
||||||
|
<AlertTriangle style={{ width: '13px', height: '13px', color: '#F59E0B', flexShrink: 0, marginTop: '1px' }} />
|
||||||
|
) : (
|
||||||
|
<CheckCircle style={{ width: '13px', height: '13px', color: '#10B981', flexShrink: 0, marginTop: '1px' }} />
|
||||||
|
)}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#E2E8F0', display: 'block' }}>{a.finding_title || a.finding_id}</span>
|
||||||
|
{a.finding_id && (
|
||||||
|
<span title={a.finding_id} style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#64748B', display: 'block', marginTop: '0.1rem' }}>
|
||||||
|
{a.finding_id.length > 20 ? a.finding_id.slice(0, 20) + '…' : a.finding_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.55rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
||||||
|
Last seen: {(a.last_severity && Number(a.last_severity) !== 0) ? Number(a.last_severity).toFixed(1) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B', marginLeft: '1.375rem' }}>
|
||||||
|
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
|
||||||
|
</div>
|
||||||
|
{a.related_active && (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#0EA5E9', marginTop: '0.35rem', marginLeft: '1.375rem', padding: '0.2rem 0.4rem', background: 'rgba(14, 165, 233, 0.1)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.25rem', display: 'inline-block' }}>
|
||||||
|
Similar finding active — ID: {a.related_active.id} ({a.related_active.severity ? Number(a.related_active.severity).toFixed(1) : '—'})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400 font-mono">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : syncStatus === 'error' ? (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
{total ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 p-2 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||||
|
<AlertCircle className="w-4 h-4 text-intel-danger mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-red-400 font-mono">{syncError}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
{syncStatus === 'never' ? '—' : (total ?? '—')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{workflows.slice(0, 10).map((wf, idx) => (
|
||||||
|
<div key={wf.uuid ?? idx} style={workflowItemStyle}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<span className="font-mono text-xs font-semibold text-teal-300">
|
||||||
|
{wf.id?.value || wf.uuid?.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
{wf.currentState && (
|
||||||
|
<span style={{ fontSize: '0.65rem', padding: '0.2rem 0.4rem', borderRadius: '0.25rem', background: 'rgba(13, 148, 136, 0.2)', border: '1px solid #0D9488', color: '#0D9488', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
|
||||||
|
{wf.currentState}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white truncate mb-1">{wf.name}</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{wf.type && <span className="text-xs text-gray-400 font-mono">{wf.type.replace(/_/g, ' ')}</span>}
|
||||||
|
{wf.createdOn && <span className="text-xs text-gray-500">{wf.createdOn}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{syncStatus !== 'never' && total === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No workflows found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{syncStatus === 'never' && (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">Click Sync to load workflow data</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
frontend/src/components/OpenTicketsPanel.js
Normal file
118
frontend/src/components/OpenTicketsPanel.js
Normal file
@@ -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 (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#F59E0B', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245, 158, 11, 0.4)' }}>
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
Open Tickets
|
||||||
|
</h2>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||||
|
aria-label="Add ticket"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#F59E0B', textShadow: '0 0 16px rgba(245, 158, 11, 0.4)' }}>
|
||||||
|
{openTickets.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{openTickets.slice(0, 10).map(ticket => (
|
||||||
|
<div key={ticket.id} style={ticketItemStyle}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<a
|
||||||
|
href={ticket.url || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-xs font-semibold text-intel-accent hover:text-intel-warning transition-colors"
|
||||||
|
>
|
||||||
|
{ticket.ticket_key}
|
||||||
|
</a>
|
||||||
|
{canWrite() && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => onEdit(ticket)} className="text-gray-400 hover:text-intel-warning transition-colors" aria-label={`Edit ${ticket.ticket_key}`}>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{canDelete(ticket) && (
|
||||||
|
<button onClick={() => onDelete(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors" aria-label={`Delete ${ticket.ticket_key}`}>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||||
|
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||||
|
{ticket.summary && <div className="text-xs text-gray-300 mt-1 truncate">{ticket.summary}</div>}
|
||||||
|
<div className="mt-2">
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||||
|
fontSize: '0.65rem', padding: '0.25rem 0.5rem', borderRadius: '0.375rem',
|
||||||
|
background: `linear-gradient(135deg, rgba(245, 158, 11, 0.25), rgba(245, 158, 11, 0.2))`,
|
||||||
|
border: '2px solid #F59E0B', color: '#FCD34D', fontWeight: '700',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||||
|
}}>
|
||||||
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: getTicketStatusColor(ticket.status), boxShadow: `0 0 8px ${getTicketStatusColor(ticket.status)}` }} />
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{openTickets.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No open tickets</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/components/QuickCVELookup.js
Normal file
112
frontend/src/components/QuickCVELookup.js
Normal file
@@ -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 (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div className="scan-line"></div>
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0EA5E9', marginBottom: '0.75rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 16px rgba(14, 165, 233, 0.4)' }}>
|
||||||
|
Quick CVE Lookup
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter CVE ID (e.g., CVE-2024-1234)"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleLookup()}
|
||||||
|
className="flex-1 intel-input"
|
||||||
|
aria-label="CVE ID to look up"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleLookup}
|
||||||
|
disabled={loading}
|
||||||
|
className="intel-button intel-button-primary"
|
||||||
|
>
|
||||||
|
{loading ? 'Scanning...' : 'Scan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className={`mt-4 p-4 rounded border ${result.exists ? 'bg-intel-success/10 border-intel-success/30' : 'bg-intel-warning/10 border-intel-warning/30'}`}>
|
||||||
|
{result.error ? (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<XCircle className="w-5 h-5 text-intel-danger mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-intel-danger font-mono">Error</p>
|
||||||
|
<p className="text-sm text-gray-300">{result.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : result.exists ? (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-intel-success mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-intel-success font-mono">
|
||||||
|
✓ CVE Addressed ({result.vendors.length} vendor{result.vendors.length > 1 ? 's' : ''})
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{result.vendors.map((vendorInfo, idx) => (
|
||||||
|
<div key={idx} className="p-3 bg-intel-dark/70 rounded border border-intel-accent/30 shadow-lg">
|
||||||
|
<p className="font-semibold text-white mb-2 font-sans">{vendorInfo.vendor}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm text-gray-300 mb-2 font-mono">
|
||||||
|
<p><strong className="text-white">Severity:</strong> {vendorInfo.severity}</p>
|
||||||
|
<p><strong className="text-white">Status:</strong> {vendorInfo.status}</p>
|
||||||
|
<p><strong className="text-white">Documents:</strong> {vendorInfo.total_documents} attached</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-intel-warning mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-intel-warning font-mono">Not Found</p>
|
||||||
|
<p className="text-sm text-gray-300">This CVE has not been addressed yet. No entry exists in the database.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/components/StatsBar.js
Normal file
77
frontend/src/components/StatsBar.js
Normal file
@@ -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 (
|
||||||
|
<div style={cardStyle} onClick={onClick} role={onClick ? 'button' : undefined} tabIndex={onClick ? 0 : undefined} aria-label={`${label}: ${value}`}>
|
||||||
|
<div style={topGlow(color)}></div>
|
||||||
|
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color, textShadow: `0 0 16px ${color}66` }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="Total CVEs"
|
||||||
|
value={totalCVEs}
|
||||||
|
color="#0EA5E9"
|
||||||
|
onClick={() => onFilterSeverity && onFilterSeverity('All Severities')}
|
||||||
|
active={activeSeverity === 'All Severities'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Vendor Entries"
|
||||||
|
value={vendorEntries}
|
||||||
|
color="#E2E8F0"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Open Tickets"
|
||||||
|
value={openTickets}
|
||||||
|
color="#F59E0B"
|
||||||
|
borderColor="#F59E0B"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Critical"
|
||||||
|
value={criticalCount}
|
||||||
|
color="#EF4444"
|
||||||
|
borderColor="#EF4444"
|
||||||
|
onClick={() => onFilterSeverity && onFilterSeverity(activeSeverity === 'Critical' ? 'All Severities' : 'Critical')}
|
||||||
|
active={activeSeverity === 'Critical'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
frontend/src/components/modals/AddCVEModal.js
Normal file
173
frontend/src/components/modals/AddCVEModal.js
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="intel-card rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border-intel-accent">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-intel-accent font-mono">Add CVE Entry</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-intel-accent transition-colors" aria-label="Close">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 p-3 bg-intel-medium border border-intel-accent/30 rounded">
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
<strong className="text-intel-accent">Tip:</strong> You can add the same CVE-ID multiple times with different vendors.
|
||||||
|
Each vendor will have its own documents folder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="CVE-2024-1234"
|
||||||
|
value={form.cve_id}
|
||||||
|
onChange={(e) => { setForm({ ...form, cve_id: e.target.value.toUpperCase() }); setNvdAutoFilled(false); setNvdError(null); }}
|
||||||
|
onBlur={(e) => lookupNVD(e.target.value)}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
{nvdLoading && <Loader className="absolute right-3 top-2.5 w-5 h-5 text-intel-accent animate-spin" />}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Can be the same as existing CVE if adding another vendor</p>
|
||||||
|
{nvdAutoFilled && (
|
||||||
|
<p className="text-xs text-intel-success mt-1 flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3" /> Auto-filled from NVD
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{nvdError && (
|
||||||
|
<p className="text-xs text-intel-warning mt-1 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" /> {nvdError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Microsoft, Cisco, Oracle, etc."
|
||||||
|
value={form.vendor}
|
||||||
|
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Must be unique for this CVE-ID</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Severity *</label>
|
||||||
|
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="intel-input w-full">
|
||||||
|
<option value="Critical">Critical</option>
|
||||||
|
<option value="High">High</option>
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="Low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Description *</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
placeholder="Brief description of the vulnerability"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Published Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={form.published_date}
|
||||||
|
onChange={(e) => setForm({ ...form, published_date: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 intel-button intel-button-primary">Add CVE Entry</button>
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
frontend/src/components/modals/ArcherTicketModal.js
Normal file
165
frontend/src/components/modals/ArcherTicketModal.js
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-purple-400 font-mono">
|
||||||
|
{isEdit ? 'Edit Archer Risk Ticket' : 'Add Archer Risk Ticket'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-intel-accent transition-colors" aria-label="Close">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEdit && (
|
||||||
|
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
|
||||||
|
{ticket.cve_id} / {ticket.vendor}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="EXC-5754"
|
||||||
|
value={form.exc_number}
|
||||||
|
onChange={(e) => setForm({ ...form, exc_number: e.target.value.toUpperCase() })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://archer.example.com/..."
|
||||||
|
value={form.archer_url}
|
||||||
|
onChange={(e) => setForm({ ...form, archer_url: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="CVE-2024-1234"
|
||||||
|
value={form.cve_id}
|
||||||
|
onChange={(e) => setForm({ ...form, cve_id: e.target.value.toUpperCase() })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
readOnly={!!context}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Vendor name"
|
||||||
|
value={form.vendor}
|
||||||
|
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
readOnly={!!context}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Under Review">Under Review</option>
|
||||||
|
<option value="Accepted">Accepted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 intel-button intel-button-primary">
|
||||||
|
{isEdit ? 'Save Changes' : 'Create Ticket'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
frontend/src/components/modals/EditCVEModal.js
Normal file
200
frontend/src/components/modals/EditCVEModal.js
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="intel-card rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border-intel-accent">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-intel-accent font-mono">Edit CVE Entry</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-intel-accent transition-colors" aria-label="Close">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 p-3 bg-intel-medium border border-intel-warning/30 rounded">
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
<strong className="text-intel-warning">Note:</strong> Changing CVE ID or Vendor will move associated documents to the new path.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.cve_id}
|
||||||
|
onChange={(e) => { setForm({ ...form, cve_id: e.target.value.toUpperCase() }); setNvdAutoFilled(false); setNvdError(null); }}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
{nvdLoading && <Loader className="absolute right-3 top-2.5 w-5 h-5 text-intel-accent animate-spin" />}
|
||||||
|
</div>
|
||||||
|
{nvdAutoFilled && (
|
||||||
|
<p className="text-xs text-intel-success mt-1 flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3" /> Updated from NVD
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{nvdError && (
|
||||||
|
<p className="text-xs text-intel-warning mt-1 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" /> {nvdError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.vendor}
|
||||||
|
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Severity *</label>
|
||||||
|
<select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })} className="intel-input w-full">
|
||||||
|
<option value="Critical">Critical</option>
|
||||||
|
<option value="High">High</option>
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="Low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Description *</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Published Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={form.published_date}
|
||||||
|
onChange={(e) => setForm({ ...form, published_date: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status *</label>
|
||||||
|
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })} className="intel-input w-full">
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Addressed">Addressed</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Resolved">Resolved</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={lookupNVD}
|
||||||
|
disabled={nvdLoading}
|
||||||
|
className="intel-button intel-button-success flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${nvdLoading ? 'animate-spin' : ''}`} />
|
||||||
|
NVD Update
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="flex-1 intel-button intel-button-primary">Save Changes</button>
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
frontend/src/components/modals/JiraTicketModal.js
Normal file
186
frontend/src/components/modals/JiraTicketModal.js
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-intel-warning">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-intel-warning font-mono">
|
||||||
|
{isEdit ? 'Edit JIRA Ticket' : 'Add JIRA Ticket'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-intel-accent transition-colors" aria-label="Close">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context info */}
|
||||||
|
{(isEdit || context) && (
|
||||||
|
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
|
||||||
|
{isEdit ? `${ticket.cve_id} / ${ticket.vendor}` : `Adding ticket for `}
|
||||||
|
{context && !isEdit && (
|
||||||
|
<>
|
||||||
|
<strong className="text-intel-warning">{context.cve_id}</strong> / <strong className="text-intel-warning">{context.vendor}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{showCVEFields && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="CVE-2024-1234"
|
||||||
|
value={form.cve_id}
|
||||||
|
onChange={(e) => setForm({ ...form, cve_id: e.target.value.toUpperCase() })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Cisco"
|
||||||
|
value={form.vendor}
|
||||||
|
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Ticket Key *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="VULN-1234"
|
||||||
|
value={form.ticket_key}
|
||||||
|
onChange={(e) => setForm({ ...form, ticket_key: e.target.value.toUpperCase() })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">JIRA URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://jira.company.com/browse/VULN-1234"
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Summary</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Brief description"
|
||||||
|
value={form.summary}
|
||||||
|
onChange={(e) => setForm({ ...form, summary: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||||
|
className="intel-input w-full"
|
||||||
|
>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Closed">Closed</option>
|
||||||
|
{form.status && !['Open', 'In Progress', 'Closed'].includes(form.status) && (
|
||||||
|
<option value={form.status}>{form.status}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 intel-button intel-button-primary">
|
||||||
|
{isEdit ? 'Save Changes' : 'Add Ticket'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
449
frontend/src/components/pages/HomePage.js
Normal file
449
frontend/src/components/pages/HomePage.js
Normal file
@@ -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 */}
|
||||||
|
<StatsBar
|
||||||
|
totalCVEs={totalCVEs}
|
||||||
|
vendorEntries={cves.length}
|
||||||
|
openTickets={openTicketCount}
|
||||||
|
criticalCount={criticalCount}
|
||||||
|
onFilterSeverity={handleFilterSeverity}
|
||||||
|
activeSeverity={selectedSeverity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Two Column Layout */}
|
||||||
|
<div className="grid grid-cols-12 gap-6 mt-6">
|
||||||
|
{/* CENTER PANEL */}
|
||||||
|
<div className="col-span-12 lg:col-span-9 space-y-4">
|
||||||
|
<QuickCVELookup />
|
||||||
|
|
||||||
|
<CVEFilters
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
selectedVendor={selectedVendor}
|
||||||
|
onVendorChange={setSelectedVendor}
|
||||||
|
vendors={vendors}
|
||||||
|
selectedSeverity={selectedSeverity}
|
||||||
|
onSeverityChange={setSelectedSeverity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Results Summary */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-gray-400 font-mono text-sm">
|
||||||
|
<span className="text-intel-accent font-bold">{totalCVEs}</span> CVE{totalCVEs !== 1 ? 's' : ''}
|
||||||
|
<span className="text-gray-500 mx-2">•</span>
|
||||||
|
<span className="text-gray-300">{cves.length}</span> vendor entr{cves.length !== 1 ? 'ies' : 'y'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CVE List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="intel-card rounded-lg p-12 text-center">
|
||||||
|
<div className="loading-spinner w-12 h-12 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-400 font-mono text-sm uppercase tracking-wider">Scanning Vulnerabilities...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="intel-card rounded-lg p-12 text-center border-intel-danger">
|
||||||
|
<XCircle className="w-12 h-12 text-intel-danger mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-200 mb-2 font-mono">Error Loading CVEs</h3>
|
||||||
|
<p className="text-gray-400 mb-4">{error}</p>
|
||||||
|
<button onClick={fetchCVEs} className="intel-button intel-button-primary">Retry</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(groupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => (
|
||||||
|
<CVECard
|
||||||
|
key={cveId}
|
||||||
|
cveId={cveId}
|
||||||
|
vendorEntries={vendorEntries}
|
||||||
|
jiraTickets={jiraTickets}
|
||||||
|
onEditCVE={(cve) => 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 && (
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-gray-500 font-mono text-xs">
|
||||||
|
Showing {visibleCount} of {totalCVEs} CVEs
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(v => v + 5)}
|
||||||
|
className="intel-button intel-button-primary text-xs px-3 py-1"
|
||||||
|
>
|
||||||
|
Show 5 more
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(totalCVEs)}
|
||||||
|
className="intel-button text-xs px-3 py-1"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleCount > 5 && totalCVEs <= visibleCount && totalCVEs > 5 && (
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(5)}
|
||||||
|
className="intel-button text-xs px-3 py-1"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
|
||||||
|
>
|
||||||
|
Collapse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalCVEs === 0 && !loading && (
|
||||||
|
<div className="intel-card rounded-lg p-12 text-center">
|
||||||
|
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2 font-mono">No CVEs Found</h3>
|
||||||
|
<p className="text-gray-300">Try adjusting your search criteria or filters</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT PANEL */}
|
||||||
|
<div className="col-span-12 lg:col-span-3 space-y-4">
|
||||||
|
{/* Calendar */}
|
||||||
|
<div style={{
|
||||||
|
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)',
|
||||||
|
padding: '1.5rem',
|
||||||
|
borderLeft: '3px solid #0EA5E9',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0EA5E9', marginBottom: '1rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14, 165, 233, 0.4)' }}>
|
||||||
|
Calendar
|
||||||
|
</h2>
|
||||||
|
<CalendarWidget
|
||||||
|
onDateClick={(dateStr) => {
|
||||||
|
onNavigate('triage', { calendarFilter: dateStr });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Open Tickets */}
|
||||||
|
<OpenTicketsPanel
|
||||||
|
tickets={jiraTickets}
|
||||||
|
onAdd={() => setJiraModal({ context: null })}
|
||||||
|
onEdit={(ticket) => setJiraModal({ ticket })}
|
||||||
|
onDelete={handleDeleteTicket}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Archer Tickets */}
|
||||||
|
<ArcherPage
|
||||||
|
archerTickets={archerTickets}
|
||||||
|
onEditTicket={(ticket) => setArcherModal({ ticket })}
|
||||||
|
onDeleteTicket={handleDeleteArcherTicket}
|
||||||
|
onFilterByExc={(exc) => onNavigate('triage', { reportingExcFilter: exc })}
|
||||||
|
onAddTicket={() => setArcherModal({ context: null })}
|
||||||
|
canDeleteTicket={canDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ivanti Workflows */}
|
||||||
|
<IvantiWorkflowPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showAddCVE && (
|
||||||
|
<AddCVEModal
|
||||||
|
onClose={() => setShowAddCVE(false)}
|
||||||
|
onSuccess={() => { fetchCVEs(); fetchVendors(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingCVE && (
|
||||||
|
<EditCVEModal
|
||||||
|
cve={editingCVE}
|
||||||
|
onClose={() => setEditingCVE(null)}
|
||||||
|
onSuccess={() => { fetchCVEs(); fetchVendors(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jiraModal && (
|
||||||
|
<JiraTicketModal
|
||||||
|
ticket={jiraModal.ticket || null}
|
||||||
|
context={jiraModal.context || null}
|
||||||
|
onClose={() => setJiraModal(null)}
|
||||||
|
onSuccess={fetchJiraTickets}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{archerModal && (
|
||||||
|
<ArcherTicketModal
|
||||||
|
ticket={archerModal.ticket || null}
|
||||||
|
context={archerModal.context || null}
|
||||||
|
onClose={() => setArcherModal(null)}
|
||||||
|
onSuccess={fetchArcherTickets}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!pendingConfirm}
|
||||||
|
title={pendingConfirm?.title}
|
||||||
|
message={pendingConfirm?.message}
|
||||||
|
confirmText={pendingConfirm?.confirmText}
|
||||||
|
variant={pendingConfirm?.variant || 'danger'}
|
||||||
|
onConfirm={pendingConfirm?.onConfirm}
|
||||||
|
onCancel={() => setPendingConfirm(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/src/contexts/ToastContext.js
Normal file
117
frontend/src/contexts/ToastContext.js
Normal file
@@ -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 (
|
||||||
|
<ToastContext.Provider value={toast}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} onDismiss={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={TOAST_STYLES.container}>
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
style={{ ...TOAST_STYLES.toast, ...TOAST_STYLES[t.type] }}
|
||||||
|
onClick={() => onDismiss(t.id)}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '1rem', flexShrink: 0 }}>{TOAST_ICONS[t.type]}</span>
|
||||||
|
<span>{t.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/hooks/useDebounce.js
Normal file
16
frontend/src/hooks/useDebounce.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ import './index.css';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import reportWebVitals from './reportWebVitals';
|
import reportWebVitals from './reportWebVitals';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user