From 887d11610e6971711a9b439043ff81728dc118e4 Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 26 Mar 2026 14:10:53 -0600 Subject: [PATCH] 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 */} +