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