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( +