feat(reporting): add Ivanti queue panel for batch FP/Archer staging
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 <noreply@anthropic.com>
This commit is contained in:
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
43
backend/migrations/add_ivanti_todo_queue_table.js
Normal file
@@ -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!');
|
||||
});
|
||||
208
backend/routes/ivantiTodoQueue.js
Normal file
208
backend/routes/ivantiTodoQueue.js
Normal file
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user