feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal

- Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table
- Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes
- Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry
- Add FpEditModal with tabbed UI (Details, Findings, Attachments, History)
- Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon
- Add submissions list section to QueuePanel with lifecycle status badges
- Wire state and data flow in ReportingPage for submissions fetch and edit callbacks
This commit is contained in:
jramos
2026-04-13 12:27:56 -06:00
parent 57f11c362b
commit df30430956
7 changed files with 2092 additions and 7 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, Upload, FileText, Check, AlertTriangle, CornerUpRight } 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, CornerUpRight, Edit3 } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart';
@@ -178,6 +178,22 @@ function workflowStyle(state) {
}
}
function lifecycleStatusBadge(status) {
switch ((status || '').toLowerCase()) {
case 'submitted':
case 'resubmitted':
return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.4)', text: '#0EA5E9' };
case 'approved':
return { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.4)', text: '#10B981' };
case 'rejected':
return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' };
case 'rework':
return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' };
default:
return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' };
}
}
// ---------------------------------------------------------------------------
// SVG Donut Chart — Open vs Closed findings
// ---------------------------------------------------------------------------
@@ -922,7 +938,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
// ---------------------------------------------------------------------------
// Render a single table cell by column key
// ---------------------------------------------------------------------------
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave }) {
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission }) {
switch (colKey) {
case 'findingId':
return (
@@ -1045,22 +1061,42 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
const wf = finding.workflow;
if (!wf || !wf.id) return <td style={{ padding: '0.45rem 0.75rem', color: '#334155' }}></td>;
const ws = workflowStyle(wf.state);
const isEditable = ['reworked', 'rejected', 'expired'].includes((wf.state || '').toLowerCase());
const handleBadgeClick = isEditable ? () => {
const numericId = parseInt(String(wf.id).replace(/\D/g, ''), 10);
const sub = fpSubmissions.find(s => s.ivanti_workflow_batch_id === numericId);
if (sub) onEditSubmission(sub);
} : undefined;
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span
title={`${wf.id} · ${wf.state || 'Unknown'} · ${wf.type || ''}`}
title={`${wf.id} · ${wf.state || 'Unknown'} · ${wf.type || ''}${isEditable ? ' · Click to edit' : ''}`}
onClick={handleBadgeClick}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
background: ws.bg, border: `1px solid ${ws.border}`,
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: ws.text, cursor: 'default',
color: ws.text,
cursor: isEditable ? 'pointer' : 'default',
transition: 'all 0.15s',
}}
onMouseEnter={isEditable ? (e) => {
e.currentTarget.style.borderColor = ws.text;
e.currentTarget.style.background = ws.bg.replace('0.12', '0.2');
} : undefined}
onMouseLeave={isEditable ? (e) => {
e.currentTarget.style.borderColor = ws.border;
e.currentTarget.style.background = ws.bg;
} : undefined}
>
{wf.id}
<span style={{ fontSize: '0.58rem', opacity: 0.8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{wf.state}
</span>
{isEditable && <Edit3 style={{ width: '10px', height: '10px', opacity: 0.7 }} />}
</span>
</td>
);
@@ -1249,7 +1285,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, onCreateFpWorkflow, onRedirectComplete, canWrite }) {
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
@@ -1548,6 +1584,88 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
))}
</div>
{/* Submissions section */}
{fpSubmissions && fpSubmissions.length > 0 && (
<div style={{ padding: '0 1.25rem 0.75rem' }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', marginBottom: '0.375rem',
borderBottom: '1px solid rgba(245,158,11,0.2)',
}}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Submissions
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{fpSubmissions.length}
</span>
</div>
{fpSubmissions.map((sub) => {
const lsBadge = lifecycleStatusBadge(sub.lifecycle_status);
const findingCount = (() => {
try { return JSON.parse(sub.finding_ids_json || '[]').length; } catch { return 0; }
})();
const clickable = canWrite && onEditSubmission;
return (
<div
key={sub.id}
onClick={clickable ? () => onEditSubmission(sub) : undefined}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.45rem 0.625rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(245,158,11,0.04)',
border: '1px solid rgba(245,158,11,0.1)',
cursor: clickable ? 'pointer' : 'default',
transition: 'all 0.15s',
}}
onMouseEnter={clickable ? (e) => {
e.currentTarget.style.borderColor = 'rgba(245,158,11,0.3)';
e.currentTarget.style.background = 'rgba(245,158,11,0.08)';
} : undefined}
onMouseLeave={clickable ? (e) => {
e.currentTarget.style.borderColor = 'rgba(245,158,11,0.1)';
e.currentTarget.style.background = 'rgba(245,158,11,0.04)';
} : undefined}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
color: '#CBD5E1',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={sub.workflow_name}>
{sub.workflow_name || `Batch ${sub.ivanti_workflow_batch_id}`}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '2px' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }}>
#{sub.ivanti_workflow_batch_id}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#475569' }}>
{findingCount} finding{findingCount !== 1 ? 's' : ''}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
{sub.created_at ? new Date(sub.created_at).toLocaleDateString() : ''}
</span>
</div>
</div>
<span style={{
flexShrink: 0,
padding: '0.1rem 0.35rem',
borderRadius: '0.2rem',
background: lsBadge.bg,
border: `1px solid ${lsBadge.border}`,
color: lsBadge.text,
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
textTransform: 'uppercase', letterSpacing: '0.04em',
}}>
{sub.lifecycle_status || 'submitted'}
</span>
</div>
);
})}
</div>
)}
{/* Footer */}
<div style={{
padding: '0.75rem 1.25rem',
@@ -2205,6 +2323,487 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
);
}
// ---------------------------------------------------------------------------
// FpEditModal — edit existing FP submissions (tabbed modal)
// ---------------------------------------------------------------------------
function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
const [activeTab, setActiveTab] = useState('details');
const [name, setName] = useState('');
const [reason, setReason] = useState('');
const [description, setDescription] = useState('');
const [expirationDate, setExpirationDate] = useState('');
const [scopeOverride, setScopeOverride] = useState('');
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState({});
const [result, setResult] = useState(null);
const [files, setFiles] = useState([]);
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
const [statusValue, setStatusValue] = useState('');
// Reset form when submission changes
useEffect(() => {
if (submission) {
setName(submission.workflow_name || '');
setReason(submission.reason || '');
setDescription(submission.description || '');
setExpirationDate(submission.expiration_date || '');
setScopeOverride(submission.scope_override || '');
setStatusValue(submission.lifecycle_status || 'submitted');
setActiveTab('details');
setErrors({});
setResult(null);
setFiles([]);
setAdditionalFindingIds(new Set());
}
}, [submission]);
if (!open || !submission) return null;
const isApproved = (submission.lifecycle_status || '').toLowerCase() === 'approved';
const currentFindings = (() => {
try { return JSON.parse(submission.finding_ids_json || '[]'); } catch { return []; }
})();
const existingAttachments = (() => {
try { return JSON.parse(submission.attachment_results_json || '[]'); } catch { return []; }
})();
const history = submission.history || [];
const pendingFpQueue = (queueItems || []).filter(i =>
i.workflow_type === 'FP' && i.status === 'pending' && !currentFindings.includes(String(i.finding_id))
);
const handleSaveDetails = async () => {
setSaving(true); setErrors({}); setResult(null);
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}`, {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, reason, description, expirationDate, scopeOverride }),
});
const data = await res.json();
if (res.ok) {
setResult({ type: 'success', message: 'Details saved successfully.' });
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to save details.' });
}
} catch (e) {
setResult({ type: 'error', message: 'Network error saving details.' });
} finally { setSaving(false); }
};
const handleAddFindings = async () => {
if (additionalFindingIds.size === 0) return;
setSaving(true); setResult(null);
const selectedItems = pendingFpQueue.filter(i => additionalFindingIds.has(i.id));
const findingIds = selectedItems.map(i => String(i.finding_id));
const queueItemIds = selectedItems.map(i => i.id);
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/findings`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ findingIds, queueItemIds }),
});
const data = await res.json();
if (res.ok) {
setResult({ type: 'success', message: `Added ${findingIds.length} finding(s).` });
setAdditionalFindingIds(new Set());
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to add findings.' });
}
} catch (e) {
setResult({ type: 'error', message: 'Network error adding findings.' });
} finally { setSaving(false); }
};
const handleUploadAttachments = async () => {
if (files.length === 0) return;
setSaving(true); setResult(null);
const formData = new FormData();
files.forEach(f => formData.append('files', f));
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/attachments`, {
method: 'POST', credentials: 'include', body: formData,
});
const data = await res.json();
if (res.ok) {
const successCount = (data.attachmentResults || []).filter(r => r.success).length;
setResult({ type: 'success', message: `Uploaded ${successCount} file(s).` });
setFiles([]);
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to upload attachments.' });
}
} catch (e) {
setResult({ type: 'error', message: 'Network error uploading attachments.' });
} finally { setSaving(false); }
};
const handleStatusChange = async (newStatus) => {
setSaving(true); setResult(null);
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/status`, {
method: 'PATCH', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lifecycle_status: newStatus }),
});
const data = await res.json();
if (res.ok) {
setResult({ type: 'success', message: `Status changed to ${newStatus}.` });
setStatusValue(newStatus);
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to change status.' });
}
} catch (e) {
setResult({ type: 'error', message: 'Network error changing status.' });
} finally { setSaving(false); }
};
const lsBadge = lifecycleStatusBadge(statusValue);
const tabs = ['details', 'findings', 'attachments', 'history'];
const inputStyle = {
width: '100%', boxSizing: 'border-box',
background: isApproved ? 'rgba(100,116,139,0.06)' : 'rgba(14,165,233,0.05)',
border: `1px solid ${isApproved ? 'rgba(100,116,139,0.15)' : 'rgba(14,165,233,0.2)'}`,
borderRadius: '0.25rem', padding: '0.4rem 0.5rem',
color: isApproved ? '#64748B' : '#CBD5E1',
fontSize: '0.78rem', fontFamily: 'monospace', outline: 'none',
};
const labelStyle = { display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#94A3B8', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.06em' };
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 10010, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '640px', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.5rem',
boxShadow: '0 20px 60px rgba(0,0,0,0.8)',
}}>
{/* Header */}
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid rgba(14,165,233,0.15)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<Edit3 style={{ width: '18px', height: '18px', color: '#F59E0B' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0' }}>
Edit FP Workflow
</span>
<span style={{
padding: '0.1rem 0.4rem', borderRadius: '0.2rem',
background: lsBadge.bg, border: `1px solid ${lsBadge.border}`,
color: lsBadge.text, fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700',
textTransform: 'uppercase',
}}>
{statusValue}
</span>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B', marginTop: '0.25rem' }}>
{submission.workflow_name || `Batch #${submission.ivanti_workflow_batch_id}`}
</div>
{isApproved && (
<div style={{ marginTop: '0.5rem', padding: '0.35rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.2)', fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981' }}>
This submission is finalized and cannot be edited.
</div>
)}
</div>
{/* Tab bar */}
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
{tabs.map(tab => (
<button key={tab} onClick={() => { setActiveTab(tab); setResult(null); }} style={{
flex: 1, padding: '0.5rem', background: 'none',
border: 'none', borderBottom: activeTab === tab ? '2px solid #0EA5E9' : '2px solid transparent',
color: activeTab === tab ? '#0EA5E9' : '#475569',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}>
{tab}
</button>
))}
</div>
{/* Status change row */}
{!isApproved && (
<div style={{ padding: '0.5rem 1.25rem', borderBottom: '1px solid rgba(255,255,255,0.04)', display: 'flex', alignItems: 'center', gap: '0.5rem', flexShrink: 0 }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B' }}>Status:</span>
<select
value={statusValue}
onChange={(e) => handleStatusChange(e.target.value)}
disabled={saving}
style={{
background: 'rgba(14,165,233,0.05)', border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.25rem 0.4rem',
color: '#CBD5E1', fontSize: '0.72rem', fontFamily: 'monospace', outline: 'none',
cursor: saving ? 'not-allowed' : 'pointer',
}}
>
{['submitted', 'approved', 'rejected', 'rework', 'resubmitted'].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
)}
{/* Result banner */}
{result && (
<div style={{
margin: '0.5rem 1.25rem 0', padding: '0.35rem 0.5rem', borderRadius: '0.25rem',
background: result.type === 'success' ? 'rgba(16,185,129,0.1)' : 'rgba(239,68,68,0.1)',
border: `1px solid ${result.type === 'success' ? 'rgba(16,185,129,0.3)' : 'rgba(239,68,68,0.3)'}`,
fontFamily: 'monospace', fontSize: '0.72rem',
color: result.type === 'success' ? '#10B981' : '#EF4444',
display: 'flex', alignItems: 'center', gap: '0.375rem',
}}>
{result.type === 'success' ? <Check style={{ width: '12px', height: '12px' }} /> : <AlertCircle style={{ width: '12px', height: '12px' }} />}
{result.message}
</div>
)}
{/* Tab content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem 1.25rem' }}>
{/* Details tab */}
{activeTab === 'details' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div>
<label style={labelStyle}>Workflow Name</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} disabled={isApproved} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Reason</label>
<select value={reason} onChange={(e) => setReason(e.target.value)} disabled={isApproved} style={inputStyle}>
<option value="">Select reason</option>
<option value="Scanner false positive">Scanner false positive</option>
<option value="Compensating control">Compensating control</option>
<option value="Risk accepted">Risk accepted</option>
<option value="Not applicable">Not applicable</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<label style={labelStyle}>Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} disabled={isApproved} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Expiration Date</label>
<input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} disabled={isApproved} style={inputStyle} />
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Scope Override</label>
<select value={scopeOverride} onChange={(e) => setScopeOverride(e.target.value)} disabled={isApproved} style={inputStyle}>
<option value="">Default</option>
<option value="Authorized">Authorized</option>
<option value="Unauthorized">Unauthorized</option>
</select>
</div>
</div>
{!isApproved && (
<button onClick={handleSaveDetails} disabled={saving} style={{
alignSelf: 'flex-end', padding: '0.4rem 1rem',
background: saving ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
border: `1px solid ${saving ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
borderRadius: '0.375rem', color: saving ? '#92700C' : '#F59E0B',
fontSize: '0.75rem', fontWeight: '700', cursor: saving ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{saving ? 'Saving…' : 'Save Details'}
</button>
)}
</div>
)}
{/* Findings tab */}
{activeTab === 'findings' && (
<div>
<div style={{ marginBottom: '0.75rem' }}>
<span style={labelStyle}>Current Findings ({currentFindings.length})</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{currentFindings.length === 0 ? (
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569' }}>No findings mapped.</span>
) : currentFindings.map(fid => (
<span key={fid} style={{
padding: '0.1rem 0.35rem', borderRadius: '0.2rem',
background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)',
fontFamily: 'monospace', fontSize: '0.65rem', color: '#0EA5E9',
}}>
{fid}
</span>
))}
</div>
</div>
{!isApproved && pendingFpQueue.length > 0 && (
<div>
<span style={labelStyle}>Add Pending FP Queue Items</span>
<div style={{ maxHeight: '200px', overflowY: 'auto', marginTop: '0.25rem' }}>
{pendingFpQueue.map(item => (
<label key={item.id} style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.35rem 0.5rem', marginBottom: '0.15rem',
borderRadius: '0.25rem',
background: additionalFindingIds.has(item.id) ? 'rgba(245,158,11,0.08)' : 'transparent',
border: `1px solid ${additionalFindingIds.has(item.id) ? 'rgba(245,158,11,0.2)' : 'rgba(255,255,255,0.04)'}`,
cursor: 'pointer',
}}>
<input type="checkbox" checked={additionalFindingIds.has(item.id)}
onChange={() => setAdditionalFindingIds(prev => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})}
style={{ accentColor: '#F59E0B', width: '13px', height: '13px' }}
/>
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#CBD5E1' }}>{item.finding_id}</span>
{item.hostname && <span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' }}>{item.hostname}</span>}
</label>
))}
</div>
<button onClick={handleAddFindings} disabled={saving || additionalFindingIds.size === 0} style={{
marginTop: '0.5rem', padding: '0.4rem 1rem',
background: additionalFindingIds.size > 0 ? 'rgba(245,158,11,0.15)' : 'transparent',
border: `1px solid ${additionalFindingIds.size > 0 ? 'rgba(245,158,11,0.4)' : 'rgba(255,255,255,0.05)'}`,
borderRadius: '0.375rem',
color: additionalFindingIds.size > 0 ? '#F59E0B' : '#334155',
fontSize: '0.75rem', fontWeight: '700', cursor: additionalFindingIds.size > 0 ? 'pointer' : 'not-allowed',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{saving ? 'Adding…' : `Add ${additionalFindingIds.size} Finding(s)`}
</button>
</div>
)}
{!isApproved && pendingFpQueue.length === 0 && (
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569' }}>No pending FP queue items available to add.</span>
)}
</div>
)}
{/* Attachments tab */}
{activeTab === 'attachments' && (
<div>
<div style={{ marginBottom: '0.75rem' }}>
<span style={labelStyle}>Existing Attachments ({existingAttachments.length})</span>
{existingAttachments.length === 0 ? (
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', marginTop: '0.25rem' }}>No attachments.</div>
) : (
<div style={{ marginTop: '0.25rem' }}>
{existingAttachments.map((att, idx) => (
<div key={idx} style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.3rem 0.5rem', marginBottom: '0.15rem',
borderRadius: '0.25rem',
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.1)',
}}>
<FileText style={{ width: '12px', height: '12px', color: '#0EA5E9', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#CBD5E1', flex: 1 }}>{att.filename}</span>
<span style={{
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
color: att.success ? '#10B981' : '#EF4444',
}}>
{att.success ? 'OK' : 'FAILED'}
</span>
</div>
))}
</div>
)}
</div>
{!isApproved && (
<div>
<span style={labelStyle}>Upload New Attachments</span>
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files))}
style={{ display: 'block', marginTop: '0.25rem', fontFamily: 'monospace', fontSize: '0.72rem', color: '#94A3B8' }}
/>
{files.length > 0 && (
<div style={{ marginTop: '0.35rem' }}>
{files.map((f, idx) => (
<div key={idx} style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#CBD5E1', padding: '0.15rem 0' }}>
<Upload style={{ width: '10px', height: '10px', display: 'inline', marginRight: '0.25rem', color: '#F59E0B' }} />
{f.name} <span style={{ color: '#475569' }}>({(f.size / 1024).toFixed(1)} KB)</span>
</div>
))}
<button onClick={handleUploadAttachments} disabled={saving} style={{
marginTop: '0.5rem', padding: '0.4rem 1rem',
background: 'rgba(245,158,11,0.15)',
border: '1px solid rgba(245,158,11,0.4)',
borderRadius: '0.375rem', color: '#F59E0B',
fontSize: '0.75rem', fontWeight: '700', cursor: saving ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
{saving ? 'Uploading…' : `Upload ${files.length} File(s)`}
</button>
</div>
)}
</div>
)}
</div>
)}
{/* History tab */}
{activeTab === 'history' && (
<div>
{history.length === 0 ? (
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', textAlign: 'center', padding: '2rem 0' }}>
No history entries.
</div>
) : history.map((entry, idx) => {
const details = (() => {
try { return JSON.parse(entry.change_details_json || '{}'); } catch { return {}; }
})();
return (
<div key={entry.id || idx} style={{
padding: '0.5rem 0.625rem', marginBottom: '0.35rem',
borderRadius: '0.25rem',
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.08)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: '#0EA5E9', textTransform: 'uppercase',
}}>
{(entry.change_type || '').replace(/_/g, ' ')}
</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#475569' }}>
{entry.created_at ? new Date(entry.created_at).toLocaleString() : ''}
</span>
</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B', marginTop: '0.2rem' }}>
by {entry.username || 'unknown'}
</div>
{entry.change_type === 'status_changed' && details.from && (
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8', marginTop: '0.15rem' }}>
{details.from} {details.to}
</div>
)}
{entry.change_type === 'findings_added' && details.addedFindingIds && (
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8', marginTop: '0.15rem' }}>
+{details.addedFindingIds.length} finding(s)
</div>
)}
{entry.change_type === 'attachments_added' && details.files && (
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94A3B8', marginTop: '0.15rem' }}>
{details.files.length} file(s) uploaded
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// SelectionToolbar — batch action bar for multi-selected findings
// ---------------------------------------------------------------------------
@@ -2471,6 +3070,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchCounts();
fetchFPWorkflowCounts();
fetchQueue();
fetchFpSubmissions();
}, []); // eslint-disable-line
// Set/clear a single column filter
@@ -2558,6 +3158,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [fpModalOpen, setFpModalOpen] = useState(false);
const [fpModalItems, setFpModalItems] = useState([]);
// FP Submission editing state
const [fpSubmissions, setFpSubmissions] = useState([]);
const [editSubmission, setEditSubmission] = useState(null);
// Queue API helpers
const fetchQueue = useCallback(async () => {
setQueueLoading(true);
@@ -2572,6 +3176,16 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
}
}, []);
const fetchFpSubmissions = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions`, { credentials: 'include' });
const data = await res.json();
if (res.ok) setFpSubmissions(data);
} catch (e) {
console.error('Error fetching FP submissions:', e);
}
}, []);
// FP Workflow handlers
const handleCreateFpWorkflow = useCallback((selectedIds) => {
const selectedSet = new Set(selectedIds);
@@ -2588,6 +3202,16 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchQueue();
}, [fetchQueue]);
const handleEditSubmission = useCallback((submission) => {
setEditSubmission(submission);
}, []);
const handleEditSuccess = useCallback(() => {
fetchFpSubmissions();
fetchQueue();
fetchFindings();
}, [fetchFpSubmissions, fetchQueue]); // eslint-disable-line
const addToQueue = useCallback(async () => {
if (!addPopover) return;
const { finding } = addPopover;
@@ -3265,7 +3889,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
/>
</td>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} />
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} />
))}
</tr>
);
@@ -3326,6 +3950,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
));
}}
canWrite={canWrite}
fpSubmissions={fpSubmissions}
onEditSubmission={handleEditSubmission}
/>
<FpWorkflowModal
open={fpModalOpen}
@@ -3333,6 +3959,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
selectedItems={fpModalItems}
onSuccess={handleFpWorkflowSuccess}
/>
<FpEditModal
open={!!editSubmission}
onClose={() => setEditSubmission(null)}
submission={editSubmission}
queueItems={queueItems}
onSuccess={handleEditSuccess}
/>
<CveTooltip
cveId={tooltipCveId}
anchorRect={tooltipAnchorRect}