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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user