feat: add Ivanti FP workflow submission from Queue

- Add shared ivantiApi.js helper (ivantiPost + ivantiMultipartPost)
- Add ivantiFpWorkflow.js backend route with validation, Ivanti API
  workflow creation, attachment uploads, submission tracking, and audit
- Add add_fp_submissions_table.js migration
- Wire route into server.js at /api/ivanti/fp-workflow
- Add FpWorkflowModal component in ReportingPage.js with form fields,
  drag-and-drop file upload, progress indicator, and result views
- Add Create FP Workflow button to QueuePanel footer (editor/admin only)
- Refactor ivantiWorkflows.js and ivantiFindings.js to use shared helper
This commit is contained in:
jramos
2026-04-07 16:20:24 -06:00
parent 7302ece958
commit 382bc81a7e
10 changed files with 1662 additions and 81 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart';
@@ -1242,7 +1242,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
// ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// ---------------------------------------------------------------------------
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted }) {
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, canWrite }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
@@ -1492,6 +1492,27 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
flexShrink: 0,
display: 'flex', gap: '0.5rem',
}}>
{/* Create FP Workflow — visible for editor/admin only */}
{canWrite && (
<button
onClick={() => onCreateFpWorkflow([...selectedIds])}
disabled={!isCreateFpButtonEnabled(items, selectedIds)}
title={!isCreateFpButtonEnabled(items, selectedIds) ? 'Select pending FP items to create a workflow' : ''}
style={{
flex: 1, padding: '0.45rem',
background: isCreateFpButtonEnabled(items, selectedIds) ? 'rgba(245,158,11,0.12)' : 'transparent',
border: `1px solid ${isCreateFpButtonEnabled(items, selectedIds) ? 'rgba(245,158,11,0.35)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.375rem',
color: isCreateFpButtonEnabled(items, selectedIds) ? '#F59E0B' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: isCreateFpButtonEnabled(items, selectedIds) ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
Create FP Workflow
</button>
)}
{/* Delete selected — only shown when items are selected */}
{selectedIds.size > 0 && (
<button
@@ -1534,6 +1555,563 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
);
}
// ---------------------------------------------------------------------------
// FP Workflow helpers (pure functions, exported for testing)
// ---------------------------------------------------------------------------
function isCreateFpButtonEnabled(items, selectedIds) {
return items.some(item =>
selectedIds.has(item.id) &&
item.workflow_type === 'FP' &&
item.status === 'pending'
);
}
function filterFpItems(items) {
return items.filter(item => item.workflow_type === 'FP');
}
function shouldShowFpButton(role) {
return role === 'editor' || role === 'admin';
}
// ---------------------------------------------------------------------------
// FpWorkflowModal — submit FP workflows to Ivanti API
// ---------------------------------------------------------------------------
const ALLOWED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
const [name, setName] = useState('');
const [reason, setReason] = useState('');
const [description, setDescription] = useState('');
const [expirationDate, setExpirationDate] = useState('');
const [scopeOverride, setScopeOverride] = useState('Authorized');
const [files, setFiles] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
const [errors, setErrors] = useState({});
const [result, setResult] = useState(null);
const fileInputRef = useRef(null);
const dropRef = useRef(null);
// Reset form when modal opens
useEffect(() => {
if (open) {
setName('');
setReason('');
setDescription('');
setExpirationDate('');
setScopeOverride('Authorized');
setFiles([]);
setSubmitting(false);
setProgress({ step: '', current: 0, total: 0 });
setErrors({});
setResult(null);
}
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e) => { if (e.key === 'Escape' && !submitting) onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, submitting, onClose]);
const isAllowedExtension = (filename) => {
const ext = '.' + filename.split('.').pop().toLowerCase();
return ALLOWED_EXTENSIONS.includes(ext);
};
const addFiles = (newFiles) => {
const fileErrors = [];
const valid = [];
Array.from(newFiles).forEach(f => {
if (!isAllowedExtension(f.name)) {
fileErrors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
} else if (f.size > MAX_FILE_SIZE) {
fileErrors.push(`"${f.name}" — exceeds 10 MB limit`);
} else {
valid.push(f);
}
});
if (fileErrors.length) {
setErrors(prev => ({ ...prev, files: fileErrors.join('; ') }));
} else {
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
}
if (valid.length) setFiles(prev => [...prev, ...valid]);
};
const removeFile = (idx) => {
setFiles(prev => prev.filter((_, i) => i !== idx));
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
};
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
const validate = () => {
const errs = {};
if (!name.trim()) errs.name = 'Workflow name is required';
else if (name.trim().length > 255) errs.name = 'Name must be 255 characters or fewer';
if (!reason.trim()) errs.reason = 'Reason is required';
if (description.length > 2000) errs.description = 'Description must be 2000 characters or fewer';
if (!expirationDate) errs.expirationDate = 'Expiration date is required';
else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const exp = new Date(expirationDate + 'T00:00:00');
if (exp <= today) errs.expirationDate = 'Expiration date must be in the future';
}
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
setSubmitting(true);
setProgress({ step: 'Creating workflow...', current: 0, total: 0 });
setResult(null);
try {
const formData = new FormData();
formData.append('name', name.trim());
formData.append('reason', reason.trim());
if (description.trim()) formData.append('description', description.trim());
formData.append('expirationDate', expirationDate);
formData.append('scopeOverride', scopeOverride);
formData.append('findingIds', JSON.stringify(selectedItems.map(i => i.finding_id)));
formData.append('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
files.forEach(f => formData.append('attachments', f));
if (files.length > 0) {
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: files.length });
}
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (res.ok && data.success) {
setResult({
success: true,
workflowBatchId: data.workflowBatchId,
generatedId: data.generatedId,
attachmentResults: data.attachmentResults || [],
status: data.status || 'success',
});
onSuccess();
} else {
let errorMsg = data.error || 'Workflow creation failed';
if (res.status === 401) errorMsg = 'Ivanti API key is invalid or missing. Contact your administrator.';
else if (res.status === 429) errorMsg = 'Ivanti API rate limit reached. Please try again in a few minutes.';
setResult({
success: false,
error: errorMsg,
workflowBatchId: data.workflowBatchId || null,
generatedId: data.generatedId || null,
attachmentResults: data.attachmentResults || [],
status: data.status || 'failed',
});
}
} catch (err) {
setResult({
success: false,
error: err.message || 'Network error — could not reach the server',
status: 'failed',
});
} finally {
setSubmitting(false);
}
};
if (!open) return null;
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
// ---- Styles ----
const overlayStyle = {
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const modalStyle = {
width: '640px', maxHeight: '90vh', overflow: 'auto',
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.75rem',
boxShadow: '0 12px 48px rgba(0,0,0,0.8)',
fontFamily: 'monospace',
};
const headerStyle = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(245,158,11,0.2)',
};
const sectionStyle = {
padding: '0.875rem 1.25rem',
borderBottom: '1px solid rgba(255,255,255,0.04)',
};
const labelStyle = {
display: 'block', fontSize: '0.68rem', fontWeight: '600',
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: '0.35rem',
};
const inputStyle = {
width: '100%', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.45rem 0.6rem',
color: '#CBD5E1', fontSize: '0.82rem', fontFamily: 'monospace',
outline: 'none',
};
const inputErrorStyle = { ...inputStyle, borderColor: '#EF4444' };
const textareaStyle = { ...inputStyle, minHeight: '60px', resize: 'vertical' };
const textareaErrorStyle = { ...textareaStyle, borderColor: '#EF4444' };
const errorTextStyle = { fontSize: '0.68rem', color: '#EF4444', marginTop: '0.2rem' };
const footerStyle = {
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.625rem',
padding: '0.875rem 1.25rem',
};
// ---- Result views ----
if (result) {
return ReactDOM.createPortal(
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
<div style={modalStyle} onClick={e => e.stopPropagation()}>
<div style={headerStyle}>
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: result.success ? '#10B981' : '#EF4444' }}>
{result.success ? 'Workflow Created' : 'Submission Failed'}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X size={16} />
</button>
</div>
<div style={{ padding: '1.5rem 1.25rem', textAlign: 'center' }}>
{result.success ? (
<>
<div style={{ marginBottom: '1rem' }}>
<Check size={36} style={{ color: '#10B981' }} />
</div>
<div style={{ fontSize: '1.1rem', fontWeight: '700', color: '#F59E0B', marginBottom: '0.5rem' }}>
{result.generatedId || `Batch #${result.workflowBatchId}`}
</div>
<div style={{ fontSize: '0.78rem', color: '#94A3B8', marginBottom: '1rem' }}>
FP workflow created successfully with {selectedItems.length} finding{selectedItems.length !== 1 ? 's' : ''}.
</div>
{result.attachmentResults.length > 0 && (
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
<div style={labelStyle}>Attachments</div>
{result.attachmentResults.map((a, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
<span>{a.filename}</span>
</div>
))}
</div>
)}
</>
) : (
<>
<div style={{ marginBottom: '1rem' }}>
<AlertTriangle size={36} style={{ color: '#EF4444' }} />
</div>
<div style={{ fontSize: '0.88rem', fontWeight: '600', color: '#E2E8F0', marginBottom: '0.5rem' }}>
{result.error}
</div>
{result.generatedId && (
<div style={{ fontSize: '0.78rem', color: '#F59E0B', marginBottom: '0.5rem' }}>
Workflow was created: {result.generatedId}
</div>
)}
{result.attachmentResults?.length > 0 && (
<div style={{ textAlign: 'left', marginBottom: '1rem' }}>
<div style={labelStyle}>Attachment Results</div>
{result.attachmentResults.map((a, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
<span>{a.filename}</span>
</div>
))}
</div>
)}
</>
)}
</div>
<div style={footerStyle}>
{!result.success && (
<button
onClick={() => setResult(null)}
style={{
padding: '0.45rem 1rem',
background: 'rgba(245,158,11,0.1)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: '0.375rem',
color: '#F59E0B', fontSize: '0.78rem', fontWeight: '600',
cursor: 'pointer', fontFamily: 'monospace',
}}
>
Retry
</button>
)}
<button
onClick={onClose}
style={{
padding: '0.45rem 1rem',
background: result.success ? 'rgba(16,185,129,0.12)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${result.success ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.1)'}`,
borderRadius: '0.375rem',
color: result.success ? '#10B981' : '#94A3B8',
fontSize: '0.78rem', fontWeight: '600',
cursor: 'pointer', fontFamily: 'monospace',
}}
>
Done
</button>
</div>
</div>
</div>,
document.body
);
}
// ---- Form view ----
return ReactDOM.createPortal(
<div style={overlayStyle} onClick={() => { if (!submitting) onClose(); }}>
<div style={modalStyle} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={headerStyle}>
<span style={{ fontSize: '0.88rem', fontWeight: '700', color: '#F59E0B' }}>
Create FP Workflow
</span>
<button onClick={() => { if (!submitting) onClose(); }} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X size={16} />
</button>
</div>
{/* Selected findings summary */}
<div style={sectionStyle}>
<div style={labelStyle}>Selected Findings ({selectedItems.length})</div>
<div style={{ maxHeight: '120px', overflow: 'auto' }}>
{selectedItems.map((item, i) => (
<div key={item.id || i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline', fontSize: '0.75rem', color: '#94A3B8', marginBottom: '0.3rem' }}>
<span style={{ color: '#F59E0B', fontWeight: '600', flexShrink: 0 }}>{item.finding_id}</span>
<span style={{ color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{item.finding_title || '—'}</span>
{item.cves_json && (() => {
try {
const cves = JSON.parse(item.cves_json);
return cves.length > 0 ? <span style={{ color: '#64748B', flexShrink: 0 }}>{cves.join(', ')}</span> : null;
} catch { return null; }
})()}
</div>
))}
</div>
</div>
{/* Form fields */}
<div style={sectionStyle}>
{/* Name */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Workflow Name <span style={{ color: '#EF4444' }}>*</span></span>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="FP — CVE-2024-XXXX — Vendor"
disabled={submitting}
maxLength={255}
style={errors.name ? inputErrorStyle : inputStyle}
/>
{errors.name && <div style={errorTextStyle}>{errors.name}</div>}
</label>
{/* Reason */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Reason / Justification <span style={{ color: '#EF4444' }}>*</span></span>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Explain why these findings are false positives..."
disabled={submitting}
style={errors.reason ? textareaErrorStyle : textareaStyle}
/>
{errors.reason && <div style={errorTextStyle}>{errors.reason}</div>}
</label>
{/* Description */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Description (optional)</span>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Additional context or details..."
disabled={submitting}
maxLength={2000}
style={errors.description ? textareaErrorStyle : textareaStyle}
/>
{errors.description && <div style={errorTextStyle}>{errors.description}</div>}
<div style={{ fontSize: '0.62rem', color: '#475569', textAlign: 'right', marginTop: '0.15rem' }}>{description.length}/2000</div>
</label>
{/* Expiration date */}
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
<span style={labelStyle}>Expiration Date <span style={{ color: '#EF4444' }}>*</span></span>
<input
type="date"
value={expirationDate}
onChange={e => setExpirationDate(e.target.value)}
disabled={submitting}
style={errors.expirationDate ? inputErrorStyle : inputStyle}
/>
{errors.expirationDate && <div style={errorTextStyle}>{errors.expirationDate}</div>}
</label>
{/* Scope override toggle */}
<div style={{ marginBottom: '0.25rem' }}>
<span style={labelStyle}>Scope Override Authorization</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{['Authorized', 'None'].map(val => {
const active = scopeOverride === val;
return (
<button
key={val}
onClick={() => setScopeOverride(val)}
disabled={submitting}
style={{
flex: 1, padding: '0.35rem',
background: active ? 'rgba(245,158,11,0.12)' : 'transparent',
border: `1px solid ${active ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.08)'}`,
borderRadius: '0.25rem',
color: active ? '#F59E0B' : '#475569',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
cursor: submitting ? 'not-allowed' : 'pointer',
transition: 'all 0.12s',
}}
>
{val}
</button>
);
})}
</div>
</div>
</div>
{/* File upload */}
<div style={sectionStyle}>
<div style={labelStyle}>Attachments</div>
<div
ref={dropRef}
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => fileInputRef.current?.click()}
style={{
border: '1px dashed rgba(14,165,233,0.25)',
borderRadius: '0.375rem',
padding: '1rem',
textAlign: 'center',
cursor: submitting ? 'not-allowed' : 'pointer',
background: 'rgba(14,165,233,0.03)',
transition: 'border-color 0.15s',
}}
>
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
Drop files here or click to browse
</div>
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
accept={ALLOWED_EXTENSIONS.join(',')}
/>
{errors.files && <div style={errorTextStyle}>{errors.files}</div>}
{files.length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
{files.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: '0.75rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<span style={{ fontSize: '0.68rem', color: '#475569', flexShrink: 0 }}>{formatSize(f.size)}</span>
<button
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
disabled={submitting}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.15rem' }}
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div style={footerStyle}>
{submitting && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: '#F59E0B' }}>
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
<span>{progress.step}</span>
</div>
)}
<button
onClick={() => { if (!submitting) onClose(); }}
disabled={submitting}
style={{
padding: '0.45rem 1rem',
background: 'none',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.375rem',
color: '#64748B', fontSize: '0.78rem', fontWeight: '600',
cursor: submitting ? 'not-allowed' : 'pointer',
fontFamily: 'monospace',
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting}
style={{
padding: '0.45rem 1.25rem',
background: submitting ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
border: `1px solid ${submitting ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
borderRadius: '0.375rem',
color: submitting ? '#92700C' : '#F59E0B',
fontSize: '0.78rem', fontWeight: '700',
cursor: submitting ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// ---------------------------------------------------------------------------
@@ -1718,6 +2296,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect }
const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' });
// FP Workflow modal state
const [fpModalOpen, setFpModalOpen] = useState(false);
const [fpModalItems, setFpModalItems] = useState([]);
// Queue API helpers
const fetchQueue = useCallback(async () => {
setQueueLoading(true);
@@ -1732,6 +2314,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}
}, []);
// FP Workflow handlers
const handleCreateFpWorkflow = useCallback((selectedIds) => {
const selectedSet = new Set(selectedIds);
const fpItems = filterFpItems(
queueItems.filter(item => selectedSet.has(item.id) && item.status === 'pending')
);
if (fpItems.length > 0) {
setFpModalItems(fpItems);
setFpModalOpen(true);
}
}, [queueItems]);
const handleFpWorkflowSuccess = useCallback(() => {
fetchQueue();
}, [fetchQueue]);
const addToQueue = useCallback(async () => {
if (!addPopover) return;
const { finding } = addPopover;
@@ -2336,6 +2934,14 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
onDelete={deleteQueueItem}
onDeleteMany={deleteQueueItems}
onClearCompleted={clearCompleted}
onCreateFpWorkflow={handleCreateFpWorkflow}
canWrite={canWrite}
/>
<FpWorkflowModal
open={fpModalOpen}
onClose={() => setFpModalOpen(false)}
selectedItems={fpModalItems}
onSuccess={handleFpWorkflowSuccess}
/>
</div>
);