feat: add Ivanti Queue redirect for completed items
This commit is contained in:
319
frontend/src/components/RedirectModal.js
Normal file
319
frontend/src/components/RedirectModal.js
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CornerUpRight, X, Loader, AlertCircle } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const WORKFLOW_OPTIONS = [
|
||||
{ key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||||
{ key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||||
{ key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
];
|
||||
|
||||
export default function RedirectModal({ item, onClose, onRedirect }) {
|
||||
const [workflowType, setWorkflowType] = useState('FP');
|
||||
const [vendor, setVendor] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const needsVendor = workflowType === 'FP' || workflowType === 'Archer';
|
||||
const canSubmit = !loading && (!needsVendor || vendor.trim().length > 0);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const body = { workflow_type: workflowType };
|
||||
if (needsVendor) body.vendor = vendor.trim();
|
||||
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/redirect`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Redirect failed.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onRedirect(data);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Network error.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(10, 14, 39, 0.92)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', maxWidth: '460px',
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7), 0 0 28px rgba(14, 165, 233, 0.12)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Top accent line */}
|
||||
<div style={{
|
||||
height: '2px',
|
||||
background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)',
|
||||
boxShadow: '0 0 8px rgba(14, 165, 233, 0.4)',
|
||||
}} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '1rem 1.25rem',
|
||||
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<CornerUpRight style={{ width: '18px', height: '18px', color: '#0EA5E9' }} />
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.95rem', fontWeight: '700',
|
||||
color: '#E2E8F0',
|
||||
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
}}>
|
||||
Redirect Queue Item
|
||||
</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>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{/* Read-only context */}
|
||||
<div style={{
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
display: 'flex', flexDirection: 'column', gap: '0.375rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
Finding Title
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.78rem', fontWeight: '600',
|
||||
color: '#CBD5E1',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={item.finding_title}>
|
||||
{item.finding_title || '—'}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1.5rem', marginTop: '0.25rem' }}>
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
Finding ID
|
||||
</span>
|
||||
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.72rem', color: '#94A3B8', marginTop: '2px' }}>
|
||||
{item.finding_id || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
Current Type
|
||||
</span>
|
||||
<div style={{ marginTop: '2px' }}>
|
||||
{(() => {
|
||||
const opt = WORKFLOW_OPTIONS.find((o) => o.key === item.workflow_type) || WORKFLOW_OPTIONS[0];
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.1rem 0.4rem',
|
||||
borderRadius: '0.2rem',
|
||||
background: `rgba(${opt.rgb}, 0.12)`,
|
||||
border: `1px solid rgba(${opt.rgb}, 0.3)`,
|
||||
color: opt.col,
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.65rem', fontWeight: '700',
|
||||
}}>
|
||||
{item.workflow_type}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow type selector */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
Target Workflow Type
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{WORKFLOW_OPTIONS.map(({ key, label, col, rgb }) => {
|
||||
const active = workflowType === key;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: '0.375rem',
|
||||
padding: '0.45rem 0.5rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: active ? `rgba(${rgb}, 0.15)` : 'transparent',
|
||||
border: `1.5px solid ${active ? `rgba(${rgb}, 0.5)` : 'rgba(255,255,255,0.08)'}`,
|
||||
color: active ? col : '#475569',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.72rem', fontWeight: '700',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="redirect-workflow-type"
|
||||
value={key}
|
||||
checked={active}
|
||||
onChange={() => setWorkflowType(key)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<span style={{
|
||||
width: '8px', height: '8px', borderRadius: '50%',
|
||||
background: active ? col : 'rgba(255,255,255,0.1)',
|
||||
boxShadow: active ? `0 0 6px ${col}` : 'none',
|
||||
transition: 'all 0.15s',
|
||||
}} />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor input — conditional */}
|
||||
{needsVendor && (
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
Vendor <span style={{ color: '#EF4444' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
placeholder="e.g. Cisco, Juniper, ADTRAN…"
|
||||
maxLength={200}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(30, 41, 59, 0.6)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8rem',
|
||||
color: '#E2E8F0',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = '#0EA5E9'; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', color: '#FCA5A5' }}>
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'flex-end', gap: '0.625rem',
|
||||
padding: '0.875rem 1.25rem',
|
||||
borderTop: '1px solid rgba(14, 165, 233, 0.1)',
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '0.45rem 1rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.45rem 1.1rem',
|
||||
background: canSubmit
|
||||
? 'linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.1))'
|
||||
: 'transparent',
|
||||
border: `1.5px solid ${canSubmit ? '#0EA5E9' : 'rgba(255,255,255,0.06)'}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: canSubmit ? '#38BDF8' : '#334155',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
||||
) : (
|
||||
<CornerUpRight style={{ width: '14px', height: '14px' }} />
|
||||
)}
|
||||
{loading ? 'Redirecting…' : 'Redirect'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
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 } 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 } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
import CveTooltip from '../CveTooltip';
|
||||
import RedirectModal from '../RedirectModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||
@@ -1248,11 +1249,13 @@ 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, canWrite }) {
|
||||
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite }) {
|
||||
const pendingCount = items.filter((i) => i.status === 'pending').length;
|
||||
const completedCount = items.filter((i) => i.status === 'complete').length;
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||
const [redirectItem, setRedirectItem] = useState(null);
|
||||
const [redirectSuccess, setRedirectSuccess] = useState(null);
|
||||
|
||||
// Drop any selected IDs that no longer exist in items
|
||||
useEffect(() => {
|
||||
@@ -1277,6 +1280,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleRedirectSuccess = (newItem) => {
|
||||
if (onRedirectComplete) onRedirectComplete(newItem);
|
||||
setRedirectItem(null);
|
||||
setRedirectSuccess(`Redirected to ${newItem.workflow_type}`);
|
||||
setTimeout(() => setRedirectSuccess(null), 3000);
|
||||
};
|
||||
|
||||
// CARD items are their own top section; everything else groups by vendor
|
||||
const grouped = useMemo(() => {
|
||||
const cardItems = items.filter((i) => i.workflow_type === 'CARD');
|
||||
@@ -1508,6 +1518,19 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
{item.workflow_type}
|
||||
</span>
|
||||
|
||||
{/* Redirect button — completed items only */}
|
||||
{canWrite && done && (
|
||||
<button
|
||||
onClick={() => setRedirectItem(item)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
||||
title="Redirect to another workflow"
|
||||
>
|
||||
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
@@ -1594,6 +1617,35 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Redirect success notification */}
|
||||
{redirectSuccess && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '1rem', right: '440px',
|
||||
zIndex: 10001,
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'rgba(16, 185, 129, 0.15)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.75rem', fontWeight: '600',
|
||||
color: '#10B981',
|
||||
}}>
|
||||
<Check style={{ width: '14px', height: '14px' }} />
|
||||
{redirectSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redirect modal */}
|
||||
{redirectItem && (
|
||||
<RedirectModal
|
||||
item={redirectItem}
|
||||
onClose={() => setRedirectItem(null)}
|
||||
onRedirect={handleRedirectSuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3268,6 +3320,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onDeleteMany={deleteQueueItems}
|
||||
onClearCompleted={clearCompleted}
|
||||
onCreateFpWorkflow={handleCreateFpWorkflow}
|
||||
onRedirectComplete={(newItem) => {
|
||||
setQueueItems((prev) => [...prev, newItem].sort((a, b) =>
|
||||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||||
));
|
||||
}}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
<FpWorkflowModal
|
||||
|
||||
Reference in New Issue
Block a user