diff --git a/backend/migrations/add_card_workflow_type.js b/backend/migrations/add_card_workflow_type.js new file mode 100644 index 0000000..143d727 --- /dev/null +++ b/backend/migrations/add_card_workflow_type.js @@ -0,0 +1,78 @@ +// Migration: Add CARD to workflow_type CHECK constraint on ivanti_todo_queue +// SQLite cannot ALTER a CHECK constraint, so this recreates the table. +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'cve_database.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting add_card_workflow_type migration...'); + +db.serialize(() => { + db.run('PRAGMA foreign_keys = OFF', (err) => { + if (err) console.error('PRAGMA error:', err); + }); + + db.run('BEGIN TRANSACTION', (err) => { + if (err) { console.error('BEGIN error:', err); return; } + }); + + db.run(` + CREATE TABLE ivanti_todo_queue_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + finding_id TEXT NOT NULL, + finding_title TEXT, + cves_json TEXT, + vendor TEXT NOT NULL, + workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `, (err) => { + if (err) console.error('Error creating new table:', err); + else console.log('✓ ivanti_todo_queue_new created'); + }); + + db.run( + 'INSERT INTO ivanti_todo_queue_new SELECT * FROM ivanti_todo_queue', + (err) => { + if (err) console.error('Error copying data:', err); + else console.log('✓ Data copied'); + } + ); + + db.run('DROP TABLE ivanti_todo_queue', (err) => { + if (err) console.error('Error dropping old table:', err); + else console.log('✓ Old table dropped'); + }); + + db.run( + 'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue', + (err) => { + if (err) console.error('Error renaming table:', err); + else console.log('✓ Table renamed'); + } + ); + + db.run( + 'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)', + (err) => { + if (err) console.error('Error creating index:', err); + else console.log('✓ Index recreated'); + } + ); + + db.run('COMMIT', (err) => { + if (err) console.error('COMMIT error:', err); + else console.log('✓ Transaction committed'); + }); + + db.run('PRAGMA foreign_keys = ON', () => {}); +}); + +db.close(() => { + console.log('Migration complete!'); +}); diff --git a/backend/migrations/add_ivanti_todo_queue_table.js b/backend/migrations/add_ivanti_todo_queue_table.js new file mode 100644 index 0000000..11a167f --- /dev/null +++ b/backend/migrations/add_ivanti_todo_queue_table.js @@ -0,0 +1,43 @@ +// Migration: Add ivanti_todo_queue table +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'cve_database.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting ivanti_todo_queue migration...'); + +db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_todo_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + finding_id TEXT NOT NULL, + finding_title TEXT, + cves_json TEXT, + vendor TEXT NOT NULL, + workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer')), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `, (err) => { + if (err) console.error('Error creating table:', err); + else console.log('✓ ivanti_todo_queue table created'); + }); + + db.run( + 'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)', + (err) => { + if (err) console.error('Error creating index:', err); + else console.log('✓ User+status index created'); + } + ); + + console.log('✓ Migration statements queued'); +}); + +db.close(() => { + console.log('Migration complete!'); +}); diff --git a/backend/migrations/add_todo_queue_ip_address.js b/backend/migrations/add_todo_queue_ip_address.js new file mode 100644 index 0000000..711f667 --- /dev/null +++ b/backend/migrations/add_todo_queue_ip_address.js @@ -0,0 +1,25 @@ +// Migration: Add ip_address column to ivanti_todo_queue +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, '..', 'cve_database.db'); +const db = new sqlite3.Database(dbPath); + +console.log('Starting add_todo_queue_ip_address migration...'); + +db.run( + 'ALTER TABLE ivanti_todo_queue ADD COLUMN ip_address TEXT', + (err) => { + if (err) { + // Column may already exist if migration was run before + if (err.message.includes('duplicate column name')) { + console.log('✓ ip_address column already exists, skipping'); + } else { + console.error('Error adding column:', err); + } + } else { + console.log('✓ ip_address column added'); + } + db.close(() => console.log('Migration complete!')); + } +); diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js new file mode 100644 index 0000000..3d4adf1 --- /dev/null +++ b/backend/routes/ivantiTodoQueue.js @@ -0,0 +1,214 @@ +// routes/ivantiTodoQueue.js +const express = require('express'); + +const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD']; +const VALID_STATUSES = ['pending', 'complete']; + +function isValidVendor(vendor) { + return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; +} + +function createIvantiTodoQueueRouter(db, requireAuth) { + const router = express.Router(); + + // GET /api/ivanti/todo-queue + // Fetch current user's queue items, ordered by vendor then created_at + router.get('/', requireAuth(db), (req, res) => { + db.all( + `SELECT * FROM ivanti_todo_queue + WHERE user_id = ? + ORDER BY vendor ASC, created_at ASC`, + [req.user.id], + (err, rows) => { + if (err) { + console.error('Error fetching todo queue:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + // Parse cves_json back to array for each row + const parsed = rows.map((r) => ({ + ...r, + cves: r.cves_json ? JSON.parse(r.cves_json) : [], + })); + res.json(parsed); + } + ); + }); + + // POST /api/ivanti/todo-queue + // Add a finding to the queue + router.post('/', requireAuth(db), (req, res) => { + const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body; + + if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { + return res.status(400).json({ error: 'finding_id is required.' }); + } + if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { + return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' }); + } + // Vendor is required for FP and Archer, optional for CARD + if (workflow_type !== 'CARD' && !isValidVendor(vendor)) { + return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); + } + if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) { + return res.status(400).json({ error: 'vendor must be under 200 chars.' }); + } + + const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim(); + const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null; + const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null; + const title = finding_title && typeof finding_title === 'string' + ? finding_title.slice(0, 500) + : null; + + db.run( + `INSERT INTO ivanti_todo_queue + (user_id, finding_id, finding_title, cves_json, ip_address, vendor, workflow_type) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [req.user.id, finding_id.trim(), title, cvesJson, ipVal, vendorVal, workflow_type], + function (err) { + if (err) { + console.error('Error adding to queue:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + db.get( + 'SELECT * FROM ivanti_todo_queue WHERE id = ?', + [this.lastID], + (err2, row) => { + if (err2 || !row) { + return res.status(201).json({ id: this.lastID, message: 'Added to queue.' }); + } + res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] }); + } + ); + } + ); + }); + + // PUT /api/ivanti/todo-queue/:id + // Update vendor, workflow_type, or status — scoped to current user + router.put('/:id', requireAuth(db), (req, res) => { + const { id } = req.params; + const { vendor, workflow_type, status } = req.body; + + if (vendor !== undefined && !isValidVendor(vendor)) { + return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' }); + } + if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) { + return res.status(400).json({ error: 'workflow_type must be FP or Archer.' }); + } + if (status !== undefined && !VALID_STATUSES.includes(status)) { + return res.status(400).json({ error: 'status must be pending or complete.' }); + } + + db.get( + 'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', + [id, req.user.id], + (err, existing) => { + if (err) { + console.error(err); + return res.status(500).json({ error: 'Internal server error.' }); + } + if (!existing) { + return res.status(404).json({ error: 'Queue item not found.' }); + } + + const updates = []; + const params = []; + + if (vendor !== undefined) { + updates.push('vendor = ?'); + params.push(vendor.trim()); + } + if (workflow_type !== undefined) { + updates.push('workflow_type = ?'); + params.push(workflow_type); + } + if (status !== undefined) { + updates.push('status = ?'); + params.push(status); + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'No fields to update.' }); + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id, req.user.id); + + db.run( + `UPDATE ivanti_todo_queue SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, + params, + function (err2) { + if (err2) { + console.error(err2); + return res.status(500).json({ error: 'Internal server error.' }); + } + db.get( + 'SELECT * FROM ivanti_todo_queue WHERE id = ?', + [id], + (err3, row) => { + if (err3 || !row) { + return res.json({ message: 'Queue item updated.' }); + } + res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] }); + } + ); + } + ); + } + ); + }); + + // DELETE /api/ivanti/todo-queue/completed + // Bulk-delete all completed items for the current user + // IMPORTANT: This route must be registered BEFORE DELETE /:id + router.delete('/completed', requireAuth(db), (req, res) => { + db.run( + "DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'", + [req.user.id], + function (err) { + if (err) { + console.error('Error clearing completed queue items:', err); + return res.status(500).json({ error: 'Internal server error.' }); + } + res.json({ message: 'Completed items cleared.', deleted: this.changes }); + } + ); + }); + + // DELETE /api/ivanti/todo-queue/:id + // Delete a single item — scoped to current user + router.delete('/:id', requireAuth(db), (req, res) => { + const { id } = req.params; + + db.get( + 'SELECT id FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', + [id, req.user.id], + (err, row) => { + if (err) { + console.error(err); + return res.status(500).json({ error: 'Internal server error.' }); + } + if (!row) { + return res.status(404).json({ error: 'Queue item not found.' }); + } + + db.run( + 'DELETE FROM ivanti_todo_queue WHERE id = ? AND user_id = ?', + [id, req.user.id], + function (err2) { + if (err2) { + console.error(err2); + return res.status(500).json({ error: 'Internal server error.' }); + } + res.json({ message: 'Queue item deleted.' }); + } + ); + } + ); + }); + + return router; +} + +module.exports = createIvantiTodoQueueRouter; diff --git a/backend/server.js b/backend/server.js index e2b9c83..0cd3059 100644 --- a/backend/server.js +++ b/backend/server.js @@ -22,6 +22,7 @@ const createKnowledgeBaseRouter = require('./routes/knowledgeBase'); const createArcherTicketsRouter = require('./routes/archerTickets'); const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); const createIvantiFindingsRouter = require('./routes/ivantiFindings'); +const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue'); const app = express(); const PORT = process.env.PORT || 3001; @@ -123,8 +124,35 @@ app.use('/uploads', express.static('uploads', { // Database connection const db = new sqlite3.Database('./cve_database.db', (err) => { - if (err) console.error('Database connection error:', err); - else console.log('Connected to CVE database'); + if (err) { + console.error('Database connection error:', err); + return; + } + console.log('Connected to CVE database'); + + // Ensure ivanti_todo_queue table exists (idempotent migration) + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_todo_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + finding_id TEXT NOT NULL, + finding_title TEXT, + cves_json TEXT, + ip_address TEXT, + vendor TEXT NOT NULL, + workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `, (err2) => { + if (err2) console.error('Failed to create ivanti_todo_queue table:', err2); + else db.run( + 'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)', + (err3) => { if (err3) console.error('Failed to create todo_queue index:', err3); } + ); + }); }); // Auth routes (public) @@ -187,6 +215,9 @@ app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth)); // Ivanti / RiskSense host findings routes (all authenticated users) app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth)); +// Ivanti queue routes — per-user staging queue for FP / Archer workflows +app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth)); + // ========== CVE ENDPOINTS ========== // Get all CVEs with optional filters (authenticated users) diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index fba13f4..bfd12c7 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -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 } from 'lucide-react'; +import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react'; import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; @@ -1074,6 +1074,465 @@ function TableCell({ colKey, finding, canWrite }) { } } +// --------------------------------------------------------------------------- +// AddToQueuePopover — portal-based popover for adding a finding to the queue +// --------------------------------------------------------------------------- +function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd, onCancel }) { + const panelRef = useRef(null); + const inputRef = useRef(null); + const [pos, setPos] = useState({ top: 0, left: 0 }); + + useEffect(() => { + if (!anchorRect) return; + const PANEL_W = 260; + const PANEL_H = 360; // conservative estimate (3 workflow buttons) + const spaceBelow = window.innerHeight - anchorRect.bottom - 6; + const top = spaceBelow >= PANEL_H + ? anchorRect.bottom + 6 + : Math.max(8, anchorRect.top - PANEL_H - 6); + const left = Math.min(anchorRect.left, window.innerWidth - PANEL_W - 8); + setPos({ top, left }); + setTimeout(() => inputRef.current?.focus(), 0); + }, [anchorRect]); + + // Close on outside click + useEffect(() => { + const handler = (e) => { + if (panelRef.current && !panelRef.current.contains(e.target)) onCancel(); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [onCancel]); + + // Close on Escape + useEffect(() => { + const handler = (e) => { if (e.key === 'Escape') onCancel(); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onCancel]); + + const isCard = queueForm.workflowType === 'CARD'; + const canSubmit = isCard || queueForm.vendor.trim().length > 0; + + return ReactDOM.createPortal( +
+ {/* Header */} +
+ Add to Ivanti Queue +
+
+ {finding.id} +
+ + {/* Vendor input — hidden for CARD */} + {isCard ? ( +
+ No vendor required — disposition handled in CARD +
+ ) : ( + + )} + + {/* Workflow type toggle */} +
+ + Workflow Type + +
+ {[ + { key: 'FP', col: '#F59E0B', rgb: '245,158,11' }, + { key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' }, + { key: 'CARD', col: '#10B981', rgb: '16,185,129' }, + ].map(({ key, col, rgb }) => { + const active = queueForm.workflowType === key; + return ( + + ); + })} +
+
+ + {/* Actions */} +
+ + +
+
, + document.body + ); +} + +// --------------------------------------------------------------------------- +// QueuePanel — fixed slide-out panel showing the user's Ivanti queue +// --------------------------------------------------------------------------- +function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted }) { + const pendingCount = items.filter((i) => i.status === 'pending').length; + const completedCount = items.filter((i) => i.status === 'complete').length; + + const [selectedIds, setSelectedIds] = useState(new Set()); + + // Drop any selected IDs that no longer exist in items + useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const valid = new Set(items.map((i) => i.id)); + const next = new Set([...prev].filter((id) => valid.has(id))); + return next.size === prev.size ? prev : next; + }); + }, [items]); + + const toggleSelect = (id) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const handleDeleteSelected = () => { + onDeleteMany([...selectedIds]); + setSelectedIds(new Set()); + }; + + // CARD items are their own top section; everything else groups by vendor + const grouped = useMemo(() => { + const cardItems = items.filter((i) => i.workflow_type === 'CARD'); + const otherItems = items.filter((i) => i.workflow_type !== 'CARD'); + + const map = {}; + otherItems.forEach((item) => { + const v = item.vendor || 'Unknown'; + if (!map[v]) map[v] = []; + map[v].push(item); + }); + const vendorGroups = Object.keys(map).sort().map((vendor) => ({ + key: vendor, label: vendor, items: map[vendor], isCard: false, + })); + + return cardItems.length > 0 + ? [{ key: '__CARD__', label: 'CARD', items: cardItems, isCard: true }, ...vendorGroups] + : vendorGroups; + }, [items]); + + return ( + <> + {/* Backdrop */} + {open && ( +
+ )} + + {/* Panel */} +
+ {/* Header */} +
+
+ + + Ivanti Queue + + {pendingCount > 0 && ( + + {pendingCount} + + )} +
+ +
+ + {/* Body */} +
+ {items.length === 0 ? ( +
+ No items in queue.
+ + Check a row in the findings table to add it. + +
+ ) : grouped.map(({ key, label, items: groupItems, isCard }) => ( +
+ {/* Group header */} +
+ + {label} + + + {groupItems.length} + +
+ + {/* Items */} + {groupItems.map((item) => { + const done = item.status === 'complete'; + const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' } + : item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' } + : { col: '#10B981', rgb: '16,185,129' }; + const cves = item.cves || []; + const cveDisplay = cves.length > 0 + ? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '') + : '—'; + const isCardItem = item.workflow_type === 'CARD'; + return ( +
+ {/* Selection checkbox — for bulk delete */} + toggleSelect(item.id)} + style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }} + title="Select for deletion" + /> + + {/* Complete checkbox */} + onUpdate(item.id, { status: done ? 'pending' : 'complete' })} + style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }} + /> + + {/* Content */} +
+
+ {item.finding_id} +
+ {isCardItem ? ( + item.ip_address && ( +
+ {item.ip_address} +
+ ) + ) : ( + cves.length > 0 && ( +
+ {cveDisplay} +
+ ) + )} +
+ + {/* Workflow type badge */} + + {item.workflow_type} + + + {/* Delete button */} + +
+ ); + })} +
+ ))} +
+ + {/* Footer */} +
+ {/* Delete selected — only shown when items are selected */} + {selectedIds.size > 0 && ( + + )} + +
+
+ + ); +} + // --------------------------------------------------------------------------- // Main ReportingPage // --------------------------------------------------------------------------- @@ -1174,6 +1633,7 @@ export default function ReportingPage({ filterDate, filterEXC }) { fetchFindings(); fetchCounts(); fetchFPWorkflowCounts(); + fetchQueue(); }, []); // eslint-disable-line // Set/clear a single column filter @@ -1250,6 +1710,116 @@ export default function ReportingPage({ filterDate, filterEXC }) { const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0); + // Queue state + const [queueItems, setQueueItems] = useState([]); + const [queueOpen, setQueueOpen] = useState(false); + const [queueLoading, setQueueLoading] = useState(false); + const [addPopover, setAddPopover] = useState(null); // { finding, anchorRect } + const [queueForm, setQueueForm] = useState({ vendor: '', workflowType: 'FP' }); + + // Queue API helpers + const fetchQueue = useCallback(async () => { + setQueueLoading(true); + try { + const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' }); + const data = await res.json(); + if (res.ok) setQueueItems(data); + } catch (e) { + console.error('Error fetching queue:', e); + } finally { + setQueueLoading(false); + } + }, []); + + const addToQueue = useCallback(async () => { + if (!addPopover) return; + const { finding } = addPopover; + try { + const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + finding_id: finding.id, + finding_title: finding.title || null, + cves: finding.cves || [], + ip_address: finding.ipAddress || null, + vendor: queueForm.vendor.trim(), + workflow_type: queueForm.workflowType, + }), + }); + const data = await res.json(); + if (res.ok) { + setQueueItems((prev) => [...prev, data].sort((a, b) => + a.vendor.localeCompare(b.vendor) || a.id - b.id + )); + } + } catch (e) { + console.error('Error adding to queue:', e); + } + setAddPopover(null); + setQueueForm({ vendor: '', workflowType: 'FP' }); + }, [addPopover, queueForm]); + + const updateQueueItem = useCallback(async (id, changes) => { + try { + const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(changes), + }); + const data = await res.json(); + if (res.ok) { + setQueueItems((prev) => prev.map((item) => item.id === id ? data : item)); + } + } catch (e) { + console.error('Error updating queue item:', e); + } + }, []); + + const deleteQueueItem = useCallback(async (id) => { + try { + const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) setQueueItems((prev) => prev.filter((item) => item.id !== id)); + } catch (e) { + console.error('Error deleting queue item:', e); + } + }, []); + + const deleteQueueItems = useCallback(async (ids) => { + try { + await Promise.all(ids.map((id) => + fetch(`${API_BASE}/ivanti/todo-queue/${id}`, { method: 'DELETE', credentials: 'include' }) + )); + const removed = new Set(ids); + setQueueItems((prev) => prev.filter((item) => !removed.has(item.id))); + } catch (e) { + console.error('Error bulk-deleting queue items:', e); + } + }, []); + + const clearCompleted = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/ivanti/todo-queue/completed`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) setQueueItems((prev) => prev.filter((item) => item.status !== 'complete')); + } catch (e) { + console.error('Error clearing completed queue items:', e); + } + }, []); + + const isQueued = useCallback((findingId) => + queueItems.some((item) => item.finding_id === findingId), + [queueItems]); + + const pendingQueueCount = queueItems.filter((i) => i.status === 'pending').length; + const [exportMenuOpen, setExportMenuOpen] = useState(false); const exportBtnRef = useRef(null); @@ -1540,6 +2110,35 @@ export default function ReportingPage({ filterDate, filterEXC }) {
)} + {/* Queue button */} +