Files
cve-dashboard/frontend/src/components/ConsolidationModal.js

578 lines
18 KiB
JavaScript
Raw Normal View History

import React, { useState, useEffect, useCallback } from 'react';
import { X, AlertCircle, Loader, FileText } from 'lucide-react';
import {
generateConsolidatedSummary,
generateConsolidatedDescription,
extractFirstCve,
extractCommonVendor,
} from '../utils/jiraConsolidation';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles — dark theme, monospace fonts, #0EA5E9 accent, gradient backgrounds
// ---------------------------------------------------------------------------
const STYLES = {
overlay: {
position: 'fixed',
inset: 0,
zIndex: 100,
background: 'rgba(10, 14, 39, 0.95)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
},
modal: {
position: 'relative',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98) 0%, rgba(15, 23, 42, 0.99) 100%)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '16px',
padding: '2rem',
width: '90%',
maxWidth: '640px',
maxHeight: '85vh',
overflowY: 'auto',
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(14,165,233,0.08)',
},
header: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: '1.25rem',
},
title: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.9rem',
fontWeight: 700,
color: '#0EA5E9',
textTransform: 'uppercase',
letterSpacing: '0.08em',
},
subtitle: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.72rem',
color: '#94A3B8',
marginTop: '0.25rem',
},
closeBtn: {
background: 'transparent',
border: 'none',
color: '#64748B',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
section: {
marginBottom: '1.25rem',
},
label: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.68rem',
fontWeight: 600,
color: '#7DD3FC',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: '0.4rem',
display: 'block',
},
input: {
background: 'rgba(15, 23, 42, 0.8)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
padding: '0.5rem 0.75rem',
color: '#F8FAFC',
fontSize: '0.82rem',
fontFamily: "'JetBrains Mono', monospace",
width: '100%',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.2s',
},
inputError: {
borderColor: 'rgba(239, 68, 68, 0.6)',
},
textarea: {
background: 'rgba(15, 23, 42, 0.8)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
padding: '0.5rem 0.75rem',
color: '#F8FAFC',
fontSize: '0.78rem',
fontFamily: "'JetBrains Mono', monospace",
width: '100%',
minHeight: '120px',
resize: 'vertical',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.2s',
},
readOnlyBadge: {
display: 'inline-block',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.7rem',
fontWeight: 600,
color: '#0EA5E9',
background: 'rgba(14, 165, 233, 0.1)',
border: '1px solid rgba(14, 165, 233, 0.3)',
borderRadius: '4px',
padding: '0.25rem 0.6rem',
},
previewList: {
maxHeight: '160px',
overflowY: 'auto',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '8px',
background: 'rgba(15, 23, 42, 0.6)',
},
previewItem: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.4rem 0.6rem',
borderBottom: '1px solid rgba(255, 255, 255, 0.04)',
},
previewItemText: {
flex: 1,
minWidth: 0,
overflow: 'hidden',
},
previewTitle: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.72rem',
color: '#CBD5E1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
previewHost: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.62rem',
color: '#64748B',
marginTop: '1px',
},
removeBtn: {
background: 'transparent',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '4px',
color: '#EF4444',
cursor: 'pointer',
padding: '0.15rem 0.3rem',
marginLeft: '0.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
transition: 'all 0.2s',
},
errorMsg: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.7rem',
color: '#EF4444',
marginTop: '0.3rem',
},
warningMsg: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.7rem',
color: '#F59E0B',
background: 'rgba(245, 158, 11, 0.08)',
border: '1px solid rgba(245, 158, 11, 0.2)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
marginBottom: '1rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
},
apiError: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.72rem',
color: '#EF4444',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
marginBottom: '1rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
},
actions: {
display: 'flex',
gap: '0.75rem',
marginTop: '1.5rem',
},
cancelBtn: {
flex: 1,
padding: '0.625rem',
background: 'transparent',
border: '1px solid rgba(100, 116, 139, 0.4)',
borderRadius: '0.375rem',
color: '#94A3B8',
cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.78rem',
transition: 'all 0.2s ease',
},
submitBtn: {
flex: 1.5,
padding: '0.625rem',
background: 'rgba(14, 165, 233, 0.1)',
border: '1px solid #0EA5E9',
borderRadius: '0.375rem',
color: '#0EA5E9',
cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.78rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.4rem',
},
submitBtnDisabled: {
opacity: 0.4,
cursor: 'not-allowed',
},
charCount: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.62rem',
color: '#64748B',
textAlign: 'right',
marginTop: '0.2rem',
},
};
/**
* ConsolidationModal Creates a single Jira ticket from multiple selected
* Ivanti queue items. Pre-populates summary, description, CVE, and vendor
* using aggregation functions.
*
* Props:
* items {Array} The selected queue items (full objects)
* onClose {Function} Close handler
* onSuccess {Function} Called with created ticket data on success
*/
export default function ConsolidationModal({ items, onClose, onSuccess }) {
// Internal state — copy of items that can be modified (items removed)
const [selectedItems, setSelectedItems] = useState(items);
// Form fields
const [summary, setSummary] = useState('');
const [description, setDescription] = useState('');
const [cveId, setCveId] = useState('');
const [vendor, setVendor] = useState('');
// Locked source context
const sourceContext = 'ivanti_queue';
// UI state
const [summaryError, setSummaryError] = useState(null);
const [apiError, setApiError] = useState(null);
const [submitting, setSubmitting] = useState(false);
// ---------------------------------------------------------------------------
// Initialize form fields on mount from the original items prop.
// Uses items (prop) not selectedItems (state) to avoid re-triggering on removal.
// ---------------------------------------------------------------------------
useEffect(() => {
if (items.length >= 2) {
setSummary(generateConsolidatedSummary(items));
setDescription(generateConsolidatedDescription(items));
setCveId(extractFirstCve(items));
setVendor(extractCommonVendor(items));
}
}, [items]);
// ---------------------------------------------------------------------------
// Remove an item from the selection (minimum 2 required)
// ---------------------------------------------------------------------------
const removeItem = useCallback((itemId) => {
setSelectedItems((prev) => prev.filter((i) => i.id !== itemId));
}, []);
// ---------------------------------------------------------------------------
// Escape key handler
// ---------------------------------------------------------------------------
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose?.();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
// ---------------------------------------------------------------------------
// Form validation
// ---------------------------------------------------------------------------
const canSubmit = selectedItems.length >= 2 && summary.trim().length > 0 && !submitting;
// ---------------------------------------------------------------------------
// Submit handler
// ---------------------------------------------------------------------------
const handleSubmit = async () => {
// Validate summary
if (!summary.trim()) {
setSummaryError('Summary is required.');
return;
}
if (summary.length > 255) {
setSummaryError('Summary must be 255 characters or fewer.');
return;
}
setSummaryError(null);
setApiError(null);
setSubmitting(true);
try {
// Step 1: Create the Jira ticket
const createPayload = {
summary: summary.trim(),
description,
cve_id: cveId.trim() || null,
vendor: vendor.trim() || null,
source_context: sourceContext,
};
const createRes = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(createPayload),
});
const createData = await createRes.json();
if (!createRes.ok && createRes.status !== 207) {
throw new Error(createData.error || `Failed to create Jira ticket (HTTP ${createRes.status})`);
}
const ticketId = createData.id;
// Step 2: Link queue items to the ticket via junction endpoint
const linkRes = await fetch(`${API_BASE}/jira-tickets/${ticketId}/queue-items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ queue_item_ids: selectedItems.map((i) => i.id) }),
});
if (!linkRes.ok) {
const linkData = await linkRes.json();
// Ticket was created but linking failed — partial success
console.warn('Junction link failed:', linkData.error || linkRes.status);
}
// Success — close modal and notify parent
onSuccess?.(createData);
} catch (err) {
setApiError(err.message);
} finally {
setSubmitting(false);
}
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="consolidation-modal-title"
style={STYLES.overlay}
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
>
<div style={STYLES.modal}>
{/* Header */}
<div style={STYLES.header}>
<div>
<div id="consolidation-modal-title" style={STYLES.title}>
<FileText style={{ width: '16px', height: '16px', display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
Create Consolidated Jira Ticket
</div>
<div style={STYLES.subtitle}>
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected for consolidation
</div>
</div>
<button
onClick={onClose}
style={STYLES.closeBtn}
title="Close"
aria-label="Close modal"
>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Minimum items warning */}
{selectedItems.length < 2 && (
<div style={STYLES.warningMsg}>
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
At least 2 items are required for consolidation. Add more items or close this modal.
</div>
)}
{/* API error */}
{apiError && (
<div style={STYLES.apiError}>
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
{apiError}
</div>
)}
{/* Selected items preview list */}
<div style={STYLES.section}>
<label style={STYLES.label}>Selected Items</label>
<div style={STYLES.previewList}>
{selectedItems.map((item) => (
<div key={item.id} style={STYLES.previewItem}>
<div style={STYLES.previewItemText}>
<div style={STYLES.previewTitle} title={item.finding_title}>
{item.finding_title || item.finding_id || 'Untitled'}
</div>
<div style={STYLES.previewHost}>
{item.hostname || 'No hostname'}
</div>
</div>
<button
onClick={() => removeItem(item.id)}
style={STYLES.removeBtn}
title="Remove from selection"
aria-label={`Remove ${item.finding_title || 'item'}`}
disabled={selectedItems.length <= 2}
>
<X style={{ width: '12px', height: '12px' }} />
</button>
</div>
))}
</div>
</div>
{/* Summary field (required, max 255 chars) */}
<div style={STYLES.section}>
<label style={STYLES.label} htmlFor="consolidation-summary">
Summary <span style={{ color: '#EF4444' }}>*</span>
</label>
<input
id="consolidation-summary"
type="text"
value={summary}
onChange={(e) => {
setSummary(e.target.value);
if (summaryError) setSummaryError(null);
}}
maxLength={255}
placeholder="Ticket summary (required)"
style={{ ...STYLES.input, ...(summaryError ? STYLES.inputError : {}) }}
/>
<div style={STYLES.charCount}>{summary.length}/255</div>
{summaryError && <div style={STYLES.errorMsg}>{summaryError}</div>}
</div>
{/* Description textarea */}
<div style={STYLES.section}>
<label style={STYLES.label} htmlFor="consolidation-description">
Description
</label>
<textarea
id="consolidation-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Aggregated description of selected findings"
style={STYLES.textarea}
/>
</div>
{/* CVE ID (optional) */}
<div style={STYLES.section}>
<label style={STYLES.label} htmlFor="consolidation-cve">
CVE ID <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
</label>
<input
id="consolidation-cve"
type="text"
value={cveId}
onChange={(e) => setCveId(e.target.value)}
placeholder="e.g. CVE-2024-12345"
style={STYLES.input}
/>
</div>
{/* Vendor (optional) */}
<div style={STYLES.section}>
<label style={STYLES.label} htmlFor="consolidation-vendor">
Vendor <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
</label>
<input
id="consolidation-vendor"
type="text"
value={vendor}
onChange={(e) => setVendor(e.target.value)}
placeholder="e.g. Microsoft"
style={STYLES.input}
/>
</div>
{/* Source Context (read-only) */}
<div style={STYLES.section}>
<label style={STYLES.label}>Source Context</label>
<span style={STYLES.readOnlyBadge}>{sourceContext}</span>
</div>
{/* Actions */}
<div style={STYLES.actions}>
<button
onClick={onClose}
style={STYLES.cancelBtn}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
e.currentTarget.style.color = '#CBD5E1';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
e.currentTarget.style.color = '#94A3B8';
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
style={{
...STYLES.submitBtn,
...(!canSubmit ? STYLES.submitBtnDisabled : {}),
}}
onMouseEnter={(e) => {
if (canSubmit) {
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.18)';
e.currentTarget.style.boxShadow = '0 0 20px rgba(14, 165, 233, 0.15)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{submitting ? (
<>
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
Creating
</>
) : (
'Create Ticket'
)}
</button>
</div>
</div>
</div>
);
}