From 887d11610e6971711a9b439043ff81728dc118e4 Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 26 Mar 2026 14:10:53 -0600 Subject: [PATCH 1/5] feat(reporting): add Ivanti queue panel for batch FP/Archer staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a persistent per-user staging queue so analysts can tag findings during review and batch-process Ivanti workflows in one focused session. Backend: - New ivanti_todo_queue table (user-scoped, vendor, workflow_type, status) - Table auto-created on server startup via idempotent CREATE IF NOT EXISTS - New route /api/ivanti/todo-queue: GET, POST, PUT/:id, DELETE/:id, DELETE/completed — all scoped to req.user.id Frontend (ReportingPage): - Fixed checkbox column on findings table; clicking opens an add-to-queue popover (portal) with vendor input and FP/Archer toggle - Already-queued rows show checked/disabled checkbox - Queue slide-out panel (420px fixed, CSS transition) with items grouped by vendor, per-item complete toggle + delete, Clear Completed footer - Queue button in header with live pending-count badge Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/add_ivanti_todo_queue_table.js | 43 ++ backend/routes/ivantiTodoQueue.js | 208 +++++++ backend/server.js | 34 +- .../src/components/pages/ReportingPage.js | 548 +++++++++++++++++- 4 files changed, 828 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/add_ivanti_todo_queue_table.js create mode 100644 backend/routes/ivantiTodoQueue.js 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/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js new file mode 100644 index 0000000..57b330e --- /dev/null +++ b/backend/routes/ivantiTodoQueue.js @@ -0,0 +1,208 @@ +// routes/ivantiTodoQueue.js +const express = require('express'); + +const VALID_WORKFLOW_TYPES = ['FP', 'Archer']; +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, 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 (!isValidVendor(vendor)) { + return res.status(400).json({ error: 'vendor is required (max 200 chars).' }); + } + if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { + return res.status(400).json({ error: 'workflow_type must be FP or Archer.' }); + } + + const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : 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, vendor, workflow_type) + VALUES (?, ?, ?, ?, ?, ?)`, + [req.user.id, finding_id.trim(), title, cvesJson, vendor.trim(), 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..1166f44 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,34 @@ 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, + 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 + ) + `, (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 +214,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..146292c 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,363 @@ 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; + setPos({ top: anchorRect.bottom + 6, left: anchorRect.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 canSubmit = queueForm.vendor.trim().length > 0; + + return ReactDOM.createPortal( +
+ {/* Header */} +
+ Add to Ivanti Queue +
+
+ {finding.id} +
+ + {/* Vendor input */} + + + {/* Workflow type toggle */} +
+ + Workflow Type + +
+ {['FP', 'Archer'].map((wt) => { + const active = queueForm.workflowType === wt; + const col = wt === 'FP' ? '#F59E0B' : '#0EA5E9'; + return ( + + ); + })} +
+
+ + {/* Actions */} +
+ + +
+
, + document.body + ); +} + +// --------------------------------------------------------------------------- +// QueuePanel — fixed slide-out panel showing the user's Ivanti queue +// --------------------------------------------------------------------------- +function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted }) { + const pendingCount = items.filter((i) => i.status === 'pending').length; + const completedCount = items.filter((i) => i.status === 'complete').length; + + // Group by vendor, sorted alphabetically + const grouped = useMemo(() => { + const map = {}; + items.forEach((item) => { + const v = item.vendor || 'Unknown'; + if (!map[v]) map[v] = []; + map[v].push(item); + }); + return Object.keys(map).sort().map((vendor) => ({ vendor, items: map[vendor] })); + }, [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(({ vendor, items: vendorItems }) => ( +
+ {/* Vendor group header */} +
+ + {vendor} + + + {vendorItems.length} + +
+ + {/* Items */} + {vendorItems.map((item) => { + const done = item.status === 'complete'; + const isFP = item.workflow_type === 'FP'; + const cves = item.cves || []; + const cveDisplay = cves.length > 0 + ? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '') + : '—'; + return ( +
+ {/* 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} +
+ {cves.length > 0 && ( +
+ {cveDisplay} +
+ )} +
+ + {/* Workflow type badge */} + + {item.workflow_type} + + + {/* Delete button */} + +
+ ); + })} +
+ ))} +
+ + {/* Footer */} +
+ +
+
+ + ); +} + // --------------------------------------------------------------------------- // Main ReportingPage // --------------------------------------------------------------------------- @@ -1174,6 +1531,7 @@ export default function ReportingPage({ filterDate, filterEXC }) { fetchFindings(); fetchCounts(); fetchFPWorkflowCounts(); + fetchQueue(); }, []); // eslint-disable-line // Set/clear a single column filter @@ -1250,6 +1608,103 @@ 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 || [], + 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 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 +1995,35 @@ export default function ReportingPage({ filterDate, filterEXC }) {
)} + {/* Queue button */} + ); })} @@ -1322,7 +1332,9 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted {/* Items */} {vendorItems.map((item) => { const done = item.status === 'complete'; - const isFP = item.workflow_type === 'FP'; + 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}` : '') @@ -1377,9 +1389,9 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted flexShrink: 0, padding: '0.1rem 0.35rem', borderRadius: '0.2rem', - background: isFP ? 'rgba(245,158,11,0.12)' : 'rgba(14,165,233,0.12)', - border: `1px solid ${isFP ? 'rgba(245,158,11,0.3)' : 'rgba(14,165,233,0.3)'}`, - color: isFP ? '#F59E0B' : '#0EA5E9', + background: `rgba(${wfColor.rgb},0.12)`, + border: `1px solid rgba(${wfColor.rgb},0.3)`, + color: wfColor.col, fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '700', }}> {item.workflow_type} From 6bf6371e51099891b20920b66d2dc0a9667dde90 Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 26 Mar 2026 14:52:06 -0600 Subject: [PATCH 3/5] feat(reporting): CARD workflow needs no vendor + own queue section CARD workflow type no longer requires a vendor/platform entry since asset disposition is handled entirely within CARD. In the popover the vendor field is replaced with a note when CARD is selected, and the Add button is enabled immediately. In the queue panel, CARD items are separated into their own top section (green header) rather than being mixed into vendor groups. Backend validation updated to skip vendor requirement for CARD. Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/ivantiTodoQueue.js | 19 ++-- .../src/components/pages/ReportingPage.js | 90 ++++++++++++------- 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 6b83f18..e18395b 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -42,15 +42,20 @@ function createIvantiTodoQueueRouter(db, requireAuth) { if (!finding_id || typeof finding_id !== 'string' || finding_id.trim().length === 0) { return res.status(400).json({ error: 'finding_id is required.' }); } - if (!isValidVendor(vendor)) { - return res.status(400).json({ error: 'vendor is required (max 200 chars).' }); - } if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP or Archer.' }); + 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 cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null; - const title = finding_title && typeof finding_title === 'string' + const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim(); + const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null; + const title = finding_title && typeof finding_title === 'string' ? finding_title.slice(0, 500) : null; @@ -58,7 +63,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { `INSERT INTO ivanti_todo_queue (user_id, finding_id, finding_title, cves_json, vendor, workflow_type) VALUES (?, ?, ?, ?, ?, ?)`, - [req.user.id, finding_id.trim(), title, cvesJson, vendor.trim(), workflow_type], + [req.user.id, finding_id.trim(), title, cvesJson, vendorVal, workflow_type], function (err) { if (err) { console.error('Error adding to queue:', err); diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index d1904ee..0b32740 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1111,7 +1111,8 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd return () => document.removeEventListener('keydown', handler); }, [onCancel]); - const canSubmit = queueForm.vendor.trim().length > 0; + const isCard = queueForm.workflowType === 'CARD'; + const canSubmit = isCard || queueForm.vendor.trim().length > 0; return ReactDOM.createPortal(
- {/* Vendor input */} - + {/* Vendor input — hidden for CARD */} + {isCard ? ( +
+ No vendor required — disposition handled in CARD +
+ ) : ( + + )} {/* Workflow type toggle */}
@@ -1232,15 +1245,24 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted const pendingCount = items.filter((i) => i.status === 'pending').length; const completedCount = items.filter((i) => i.status === 'complete').length; - // Group by vendor, sorted alphabetically + // 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 = {}; - items.forEach((item) => { + otherItems.forEach((item) => { const v = item.vendor || 'Unknown'; if (!map[v]) map[v] = []; map[v].push(item); }); - return Object.keys(map).sort().map((vendor) => ({ vendor, items: map[vendor] })); + 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 ( @@ -1313,24 +1335,24 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted Check a row in the findings table to add it.
- ) : grouped.map(({ vendor, items: vendorItems }) => ( -
- {/* Vendor group header */} + ) : grouped.map(({ key, label, items: groupItems, isCard }) => ( +
+ {/* Group header */}
- - {vendor} + + {label} - {vendorItems.length} + {groupItems.length}
{/* Items */} - {vendorItems.map((item) => { + {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' } From 89b1f57ef4e5dcf3a574cf6d0ca4bdcde440612e Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 26 Mar 2026 15:01:32 -0600 Subject: [PATCH 4/5] feat(reporting): store and display IP address on CARD queue items Adds ip_address column to ivanti_todo_queue so CARD entries carry the host IP needed to locate the asset in CARD. - Migration: ALTER TABLE ADD COLUMN ip_address TEXT (safe to re-run) - Backend: accepts ip_address in POST body, stores up to 64 chars - Frontend: captures finding.ipAddress when adding to queue; CARD items in the queue panel show the IP in green instead of the CVE list Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/add_todo_queue_ip_address.js | 25 ++++++++++++ backend/routes/ivantiTodoQueue.js | 9 +++-- backend/server.js | 1 + .../src/components/pages/ReportingPage.js | 39 +++++++++++++------ 4 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 backend/migrations/add_todo_queue_ip_address.js 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 index e18395b..3d4adf1 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -37,7 +37,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { // POST /api/ivanti/todo-queue // Add a finding to the queue router.post('/', requireAuth(db), (req, res) => { - const { finding_id, finding_title, cves, vendor, workflow_type } = req.body; + 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.' }); @@ -55,15 +55,16 @@ function createIvantiTodoQueueRouter(db, requireAuth) { 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, vendor, workflow_type) - VALUES (?, ?, ?, ?, ?, ?)`, - [req.user.id, finding_id.trim(), title, cvesJson, vendorVal, workflow_type], + (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); diff --git a/backend/server.js b/backend/server.js index e74e12d..0cd3059 100644 --- a/backend/server.js +++ b/backend/server.js @@ -138,6 +138,7 @@ const db = new sqlite3.Database('./cve_database.db', (err) => { 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')), diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 0b32740..1d3b95c 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1361,6 +1361,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted const cveDisplay = cves.length > 0 ? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '') : '—'; + const isCardItem = item.workflow_type === 'CARD'; return (
{item.finding_id}
- {cves.length > 0 && ( -
- {cveDisplay} -
+ {isCardItem ? ( + item.ip_address && ( +
+ {item.ip_address} +
+ ) + ) : ( + cves.length > 0 && ( +
+ {cveDisplay} +
+ ) )}
@@ -1673,8 +1687,9 @@ export default function ReportingPage({ filterDate, filterEXC }) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ finding_id: finding.id, - finding_title: finding.title || null, - cves: finding.cves || [], + finding_title: finding.title || null, + cves: finding.cves || [], + ip_address: finding.ipAddress || null, vendor: queueForm.vendor.trim(), workflow_type: queueForm.workflowType, }), From 7a2c56a11fb3453e3a2e65d762ea77d962ebd219 Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 26 Mar 2026 15:43:43 -0600 Subject: [PATCH 5/5] fix(reporting): visible queue checkbox + multi-select delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table: removed disabled={queued} from the row checkbox so accentColor renders properly — checked rows now show a solid blue tick instead of the greyed-out browser default. Queue panel: each item now has a small red selection checkbox (opacity 0.35 when idle, full when selected). Selecting any items reveals a red 'Delete (N)' button in the footer alongside 'Clear Completed'. Bulk deletes run in parallel; selection state is automatically pruned when items are removed via the individual trash button. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/pages/ReportingPage.js | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 1d3b95c..bfd12c7 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1241,10 +1241,35 @@ 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, onClearCompleted }) { +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'); @@ -1376,6 +1401,15 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onClearCompleted transition: 'opacity 0.15s', }} > + {/* 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 */} + {/* Delete selected — only shown when items are selected */} + {selectedIds.size > 0 && ( + + )}