- Single-item: openCreateJiraFromQueue fetches notes for Remediate items and pre-fills the description with a Remediation Notes section - Multi-item: ConsolidationModal fetches notes for all Remediate items and appends them via appendRemediationNotes utility Previously notes were only integrated in IvantiTodoQueuePage.js but the actual Jira creation flow users interact with is in ReportingPage.js QueuePanel and ConsolidationModal.
601 lines
19 KiB
JavaScript
601 lines
19 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { X, AlertCircle, Loader, FileText } from 'lucide-react';
|
|
import {
|
|
generateConsolidatedSummary,
|
|
generateConsolidatedDescription,
|
|
extractFirstCve,
|
|
extractCommonVendor,
|
|
appendRemediationNotes,
|
|
} 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));
|
|
setCveId(extractFirstCve(items));
|
|
setVendor(extractCommonVendor(items));
|
|
|
|
// Build description, appending remediation notes for Remediate items
|
|
const baseDescription = generateConsolidatedDescription(items);
|
|
const remediateItems = items.filter(i => i.workflow_type === 'Remediate');
|
|
if (remediateItems.length > 0) {
|
|
Promise.all(
|
|
remediateItems.map(item =>
|
|
fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' })
|
|
.then(r => r.ok ? r.json() : [])
|
|
.catch(() => [])
|
|
)
|
|
).then(results => {
|
|
const notesMap = {};
|
|
remediateItems.forEach((item, idx) => {
|
|
if (results[idx] && results[idx].length > 0) {
|
|
notesMap[item.id] = results[idx];
|
|
}
|
|
});
|
|
setDescription(appendRemediationNotes(baseDescription, notesMap));
|
|
});
|
|
} else {
|
|
setDescription(baseDescription);
|
|
}
|
|
}
|
|
}, [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>
|
|
);
|
|
}
|