diff --git a/backend/__tests__/ivanti-todo-queue-ticket-links.test.js b/backend/__tests__/ivanti-todo-queue-ticket-links.test.js
new file mode 100644
index 0000000..bfcc006
--- /dev/null
+++ b/backend/__tests__/ivanti-todo-queue-ticket-links.test.js
@@ -0,0 +1,137 @@
+/**
+ * Unit tests for GET /api/ivanti/todo-queue/ticket-links endpoint
+ * Validates: Requirements 6.3, 6.4
+ */
+const http = require('http');
+const express = require('express');
+
+// Mock auth middleware
+jest.mock('../middleware/auth', () => ({
+ requireAuth: () => (req, _res, next) => {
+ req.user = { id: 7, username: 'testuser' };
+ next();
+ },
+ requireGroup: () => (_req, _res, next) => next(),
+}));
+
+// Mock audit log
+jest.mock('../helpers/auditLog', () => jest.fn());
+
+// Mock the db pool
+jest.mock('../db', () => ({
+ query: jest.fn(() => Promise.resolve({ rows: [] })),
+ connect: jest.fn(),
+}));
+
+const pool = require('../db');
+const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
+
+/**
+ * Helper: send an HTTP request and return { statusCode, body }.
+ */
+function request(server, method, path) {
+ return new Promise((resolve, reject) => {
+ const addr = server.address();
+ const options = {
+ hostname: '127.0.0.1',
+ port: addr.port,
+ path,
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ };
+
+ const req = http.request(options, (res) => {
+ const chunks = [];
+ res.on('data', (chunk) => chunks.push(chunk));
+ res.on('end', () => {
+ const raw = Buffer.concat(chunks).toString();
+ let body;
+ try { body = JSON.parse(raw); } catch { body = raw; }
+ resolve({ statusCode: res.statusCode, body });
+ });
+ });
+
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+describe('GET /api/ivanti/todo-queue/ticket-links', () => {
+ let app;
+ let server;
+
+ beforeAll((done) => {
+ app = express();
+ app.use(express.json());
+ app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
+ server = app.listen(0, '127.0.0.1', done);
+ });
+
+ afterAll((done) => {
+ server.close(done);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns an empty links object when no associations exist', async () => {
+ pool.query.mockResolvedValueOnce({ rows: [] });
+
+ const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toEqual({ links: {} });
+ });
+
+ it('returns a map of queue_item_id to ticket info', async () => {
+ pool.query.mockResolvedValueOnce({
+ rows: [
+ { queue_item_id: 12, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
+ { queue_item_id: 15, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
+ { queue_item_id: 22, ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
+ ],
+ });
+
+ const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toEqual({
+ links: {
+ '12': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
+ '15': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
+ '22': { ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
+ },
+ });
+ });
+
+ it('filters by the authenticated user ID', async () => {
+ pool.query.mockResolvedValueOnce({ rows: [] });
+
+ await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
+
+ const [sql, params] = pool.query.mock.calls[0];
+ expect(sql).toContain('q.user_id = $1');
+ expect(params).toEqual([7]);
+ });
+
+ it('joins jira_ticket_queue_items with jira_tickets and ivanti_todo_queue', async () => {
+ pool.query.mockResolvedValueOnce({ rows: [] });
+
+ await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
+
+ const [sql] = pool.query.mock.calls[0];
+ expect(sql).toContain('jira_ticket_queue_items');
+ expect(sql).toContain('JOIN jira_tickets');
+ expect(sql).toContain('JOIN ivanti_todo_queue');
+ });
+
+ it('returns 500 on database error', async () => {
+ pool.query.mockRejectedValueOnce(new Error('DB connection failed'));
+
+ const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
+
+ expect(res.statusCode).toBe(500);
+ expect(res.body).toEqual({ error: 'Internal server error.' });
+ });
+});
diff --git a/backend/__tests__/jira-ticket-queue-items.test.js b/backend/__tests__/jira-ticket-queue-items.test.js
new file mode 100644
index 0000000..e750217
--- /dev/null
+++ b/backend/__tests__/jira-ticket-queue-items.test.js
@@ -0,0 +1,214 @@
+/**
+ * Unit Tests: POST /api/jira-tickets/:id/queue-items
+ *
+ * Feature: multi-item-jira-ticket
+ *
+ * Tests the junction endpoint that links queue items to a Jira ticket.
+ * Validates: Requirements 5.3, 6.1, 6.2
+ */
+
+const http = require('http');
+const express = require('express');
+
+// Mock the auth middleware so routes don't require real sessions/cookies.
+jest.mock('../middleware/auth', () => ({
+ requireAuth: () => (req, res, next) => {
+ req.user = { id: 1, username: 'test', group: 'Admin' };
+ next();
+ },
+ requireGroup: (...groups) => (req, res, next) => next(),
+}));
+
+// Mock the audit log helper to be a no-op.
+jest.mock('../helpers/auditLog', () => jest.fn());
+
+// Mock the jiraApi helper
+jest.mock('../helpers/jiraApi', () => ({
+ isConfigured: false,
+ getRateLimitStatus: jest.fn(() => ({
+ burst: { remaining: 60, limit: 60 },
+ daily: { remaining: 1440, limit: 1440 },
+ })),
+}));
+
+const pool = require('../db');
+jest.mock('../db', () => ({
+ query: jest.fn(),
+}));
+
+const createJiraTicketsRouter = require('../routes/jiraTickets');
+
+/**
+ * Helper: send an HTTP request to the test server and return { statusCode, body }.
+ */
+function request(server, method, path, body) {
+ return new Promise((resolve, reject) => {
+ const addr = server.address();
+ const options = {
+ hostname: '127.0.0.1',
+ port: addr.port,
+ path,
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ };
+
+ const req = http.request(options, (res) => {
+ const chunks = [];
+ res.on('data', (chunk) => chunks.push(chunk));
+ res.on('end', () => {
+ const raw = Buffer.concat(chunks).toString();
+ let parsed;
+ try { parsed = JSON.parse(raw); } catch { parsed = raw; }
+ resolve({ statusCode: res.statusCode, body: parsed });
+ });
+ });
+
+ req.on('error', reject);
+
+ if (body) {
+ req.write(JSON.stringify(body));
+ }
+ req.end();
+ });
+}
+
+describe('POST /api/jira-tickets/:id/queue-items', () => {
+ let app;
+ let server;
+
+ beforeAll((done) => {
+ app = express();
+ app.use(express.json());
+ app.use('/api/jira-tickets', createJiraTicketsRouter());
+ server = app.listen(0, '127.0.0.1', done);
+ });
+
+ afterAll((done) => {
+ server.close(done);
+ });
+
+ beforeEach(() => {
+ pool.query.mockReset();
+ });
+
+ // -------------------------------------------------------------------------
+ // Validation tests
+ // -------------------------------------------------------------------------
+
+ it('returns 400 when queue_item_ids is missing', async () => {
+ const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {});
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
+ });
+
+ it('returns 400 when queue_item_ids is an empty array', async () => {
+ const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
+ queue_item_ids: [],
+ });
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
+ });
+
+ it('returns 400 when queue_item_ids is not an array', async () => {
+ const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
+ queue_item_ids: 'not-an-array',
+ });
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
+ });
+
+ it('returns 400 when queue_item_ids contains non-integers', async () => {
+ const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
+ queue_item_ids: [1, 2.5, 3],
+ });
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
+ });
+
+ it('returns 400 when queue_item_ids contains strings', async () => {
+ const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
+ queue_item_ids: [1, 'abc', 3],
+ });
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
+ });
+
+ // -------------------------------------------------------------------------
+ // Ticket existence check
+ // -------------------------------------------------------------------------
+
+ it('returns 404 when jira ticket does not exist', async () => {
+ pool.query.mockResolvedValueOnce({ rows: [] }); // ticket lookup
+
+ const res = await request(server, 'POST', '/api/jira-tickets/999/queue-items', {
+ queue_item_ids: [1, 2, 3],
+ });
+ expect(res.statusCode).toBe(404);
+ expect(res.body.error).toBe('Jira ticket not found');
+ });
+
+ // -------------------------------------------------------------------------
+ // Queue item existence check
+ // -------------------------------------------------------------------------
+
+ it('returns 400 when some queue items do not exist', async () => {
+ pool.query
+ .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
+ .mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }] }); // only 2 of 3 exist
+
+ const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
+ queue_item_ids: [1, 2, 3],
+ });
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toBe('One or more queue items not found');
+ });
+
+ // -------------------------------------------------------------------------
+ // Successful linking
+ // -------------------------------------------------------------------------
+
+ it('returns 201 with linked_count on success', async () => {
+ pool.query
+ .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
+ .mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }, { id: 18 }] }) // all queue items exist
+ .mockResolvedValueOnce({ rowCount: 3 }); // insert result
+
+ const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
+ queue_item_ids: [12, 15, 18],
+ });
+ expect(res.statusCode).toBe(201);
+ expect(res.body.message).toBe('Queue items linked to ticket');
+ expect(res.body.ticket_id).toBe(42);
+ expect(res.body.linked_count).toBe(3);
+ });
+
+ it('returns linked_count reflecting ON CONFLICT DO NOTHING (duplicates ignored)', async () => {
+ pool.query
+ .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
+ .mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }] }) // all queue items exist
+ .mockResolvedValueOnce({ rowCount: 1 }); // only 1 new row (1 was duplicate)
+
+ const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
+ queue_item_ids: [12, 15],
+ });
+ expect(res.statusCode).toBe(201);
+ expect(res.body.linked_count).toBe(1);
+ });
+
+ // -------------------------------------------------------------------------
+ // Error handling
+ // -------------------------------------------------------------------------
+
+ it('returns 500 on database error', async () => {
+ pool.query
+ .mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
+ .mockResolvedValueOnce({ rows: [{ id: 12 }] }) // queue items exist
+ .mockRejectedValueOnce(new Error('Connection lost')); // insert fails
+
+ const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
+ queue_item_ids: [12],
+ });
+ expect(res.statusCode).toBe(500);
+ expect(res.body.error).toContain('Connection lost');
+ });
+});
diff --git a/backend/migrations/add_multi_item_jira_ticket.js b/backend/migrations/add_multi_item_jira_ticket.js
new file mode 100644
index 0000000..086104b
--- /dev/null
+++ b/backend/migrations/add_multi_item_jira_ticket.js
@@ -0,0 +1,65 @@
+// Migration: Add multi-item Jira ticket junction table
+// - Creates jira_ticket_queue_items table linking jira_tickets to ivanti_todo_queue items
+// - Adds UNIQUE constraint on (jira_ticket_id, queue_item_id)
+// - Adds indexes on queue_item_id and jira_ticket_id
+// Idempotent — safe to run multiple times.
+const pool = require('../db');
+
+async function run() {
+ console.log('Starting multi-item Jira ticket migration...');
+
+ // Verify prerequisite tables exist
+ const { rows: jiraTable } = await pool.query(`
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = 'jira_tickets'
+ `);
+ if (jiraTable.length === 0) {
+ console.error('✗ jira_tickets table does not exist. Cannot proceed.');
+ process.exit(1);
+ }
+ console.log('✓ jira_tickets table exists');
+
+ const { rows: queueTable } = await pool.query(`
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
+ `);
+ if (queueTable.length === 0) {
+ console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
+ process.exit(1);
+ }
+ console.log('✓ ivanti_todo_queue table exists');
+
+ // Create junction table
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
+ id SERIAL PRIMARY KEY,
+ jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
+ queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE (jira_ticket_id, queue_item_id)
+ )
+ `);
+ console.log('✓ jira_ticket_queue_items table created (or already exists)');
+
+ // Add index on queue_item_id for efficient lookup of tickets by queue item
+ await pool.query(`
+ CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
+ ON jira_ticket_queue_items(queue_item_id)
+ `);
+ console.log('✓ queue_item_id index created (or already exists)');
+
+ // Add index on jira_ticket_id for efficient lookup of queue items by ticket
+ await pool.query(`
+ CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
+ ON jira_ticket_queue_items(jira_ticket_id)
+ `);
+ console.log('✓ jira_ticket_id index created (or already exists)');
+
+ console.log('Migration complete.');
+ process.exit(0);
+}
+
+run().catch(err => {
+ console.error('Migration failed:', err.message);
+ process.exit(1);
+});
diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js
index 28ab2e6..533a49e 100644
--- a/backend/migrations/run-all.js
+++ b/backend/migrations/run-all.js
@@ -23,6 +23,7 @@ const POSTGRES_MIGRATIONS = [
'add_compliance_item_history.js',
'add_jira_sync_columns_pg.js',
'add_flexible_jira_ticket_creation.js',
+ 'add_multi_item_jira_ticket.js',
];
async function runAll() {
diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js
index 0eca663..5318350 100644
--- a/backend/routes/ivantiTodoQueue.js
+++ b/backend/routes/ivantiTodoQueue.js
@@ -396,6 +396,41 @@ function createIvantiTodoQueueRouter() {
}
});
+ /**
+ * GET /api/ivanti/todo-queue/ticket-links
+ *
+ * Returns Jira ticket associations for the current user's queue items.
+ * Joins jira_ticket_queue_items with jira_tickets to get ticket_key and url.
+ *
+ * @returns {Object} { links: { [queue_item_id]: { ticket_key, jira_url } } }
+ * @error 500 Internal server error
+ */
+ router.get('/ticket-links', requireAuth(), async (req, res) => {
+ try {
+ const { rows } = await pool.query(
+ `SELECT jtqi.queue_item_id, jt.ticket_key, jt.url AS jira_url
+ FROM jira_ticket_queue_items jtqi
+ JOIN jira_tickets jt ON jt.id = jtqi.jira_ticket_id
+ JOIN ivanti_todo_queue q ON q.id = jtqi.queue_item_id
+ WHERE q.user_id = $1`,
+ [req.user.id]
+ );
+
+ const links = {};
+ for (const row of rows) {
+ links[row.queue_item_id] = {
+ ticket_key: row.ticket_key,
+ jira_url: row.jira_url
+ };
+ }
+
+ res.json({ links });
+ } catch (err) {
+ console.error('Error fetching ticket links:', err);
+ res.status(500).json({ error: 'Internal server error.' });
+ }
+ });
+
/**
* DELETE /api/ivanti/todo-queue/completed
*
diff --git a/backend/routes/jiraTickets.js b/backend/routes/jiraTickets.js
index 1517151..bb7511a 100644
--- a/backend/routes/jiraTickets.js
+++ b/backend/routes/jiraTickets.js
@@ -762,6 +762,90 @@ function createJiraTicketsRouter() {
}
});
+ // -----------------------------------------------------------------------
+ // Junction table endpoint — link queue items to a Jira ticket
+ // -----------------------------------------------------------------------
+
+ /**
+ * POST /api/jira-tickets/:id/queue-items
+ *
+ * Records associations between a Jira ticket and Ivanti queue items that
+ * contributed to it. Uses ON CONFLICT DO NOTHING to handle duplicates.
+ *
+ * @param {string} id - Local Jira ticket ID (path parameter)
+ * @requires Admin or Standard_User group
+ * @body {number[]} queue_item_ids - Non-empty array of ivanti_todo_queue IDs
+ * @returns {object} 201 - { message, ticket_id, linked_count }
+ * @returns {object} 400 - { error: string } for validation failures
+ * @returns {object} 404 - { error: string } when ticket not found
+ * @returns {object} 500 - { error: string } on internal error
+ */
+ router.post('/:id/queue-items', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
+ const { id } = req.params;
+ const { queue_item_ids } = req.body;
+
+ // Validate queue_item_ids is a non-empty array of integers
+ if (!Array.isArray(queue_item_ids) || queue_item_ids.length === 0) {
+ return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
+ }
+
+ for (const qid of queue_item_ids) {
+ if (!Number.isInteger(qid)) {
+ return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
+ }
+ }
+
+ try {
+ // Verify the jira_ticket exists
+ const { rows: ticketRows } = await pool.query(
+ 'SELECT id FROM jira_tickets WHERE id = $1',
+ [id]
+ );
+ if (ticketRows.length === 0) {
+ return res.status(404).json({ error: 'Jira ticket not found' });
+ }
+
+ // Verify all referenced queue items exist
+ const { rows: existingItems } = await pool.query(
+ 'SELECT id FROM ivanti_todo_queue WHERE id = ANY($1::int[])',
+ [queue_item_ids]
+ );
+ if (existingItems.length !== queue_item_ids.length) {
+ return res.status(400).json({ error: 'One or more queue items not found' });
+ }
+
+ // Insert rows with ON CONFLICT DO NOTHING
+ const values = queue_item_ids.map((qid, idx) => `($1, $${idx + 2})`).join(', ');
+ const params = [id, ...queue_item_ids];
+
+ const { rowCount } = await pool.query(
+ `INSERT INTO jira_ticket_queue_items (jira_ticket_id, queue_item_id)
+ VALUES ${values}
+ ON CONFLICT (jira_ticket_id, queue_item_id) DO NOTHING`,
+ params
+ );
+
+ logAudit({
+ userId: req.user.id,
+ username: req.user.username,
+ action: 'jira_ticket_link_queue_items',
+ entityType: 'jira_ticket',
+ entityId: id,
+ details: { queue_item_ids, linked_count: rowCount },
+ ipAddress: req.ip
+ });
+
+ res.status(201).json({
+ message: 'Queue items linked to ticket',
+ ticket_id: parseInt(id, 10),
+ linked_count: rowCount
+ });
+ } catch (err) {
+ console.error('Error linking queue items to Jira ticket:', err);
+ res.status(500).json({ error: err.message || 'Internal server error.' });
+ }
+ });
+
return router;
}
diff --git a/frontend/src/components/ConsolidationModal.js b/frontend/src/components/ConsolidationModal.js
new file mode 100644
index 0000000..2aeaf83
--- /dev/null
+++ b/frontend/src/components/ConsolidationModal.js
@@ -0,0 +1,576 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { X, AlertCircle, Loader, FileText } from 'lucide-react';
+import {
+ generateConsolidatedSummary,
+ generateConsolidatedDescription,
+ extractFirstCve,
+ extractCommonVendor,
+} from '../utils/jiraConsolidation';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+// ---------------------------------------------------------------------------
+// Styles — dark theme, monospace fonts, #0EA5E9 accent, gradient backgrounds
+// ---------------------------------------------------------------------------
+const STYLES = {
+ overlay: {
+ position: 'fixed',
+ inset: 0,
+ zIndex: 100,
+ background: 'rgba(10, 14, 39, 0.95)',
+ backdropFilter: 'blur(8px)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '1rem',
+ },
+ modal: {
+ position: 'relative',
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98) 0%, rgba(15, 23, 42, 0.99) 100%)',
+ border: '1px solid rgba(14, 165, 233, 0.25)',
+ borderRadius: '16px',
+ padding: '2rem',
+ width: '90%',
+ maxWidth: '640px',
+ maxHeight: '85vh',
+ overflowY: 'auto',
+ boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(14,165,233,0.08)',
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ marginBottom: '1.25rem',
+ },
+ title: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.9rem',
+ fontWeight: 700,
+ color: '#0EA5E9',
+ textTransform: 'uppercase',
+ letterSpacing: '0.08em',
+ },
+ subtitle: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.72rem',
+ color: '#94A3B8',
+ marginTop: '0.25rem',
+ },
+ closeBtn: {
+ background: 'transparent',
+ border: 'none',
+ color: '#64748B',
+ cursor: 'pointer',
+ padding: '0.25rem',
+ borderRadius: '4px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ section: {
+ marginBottom: '1.25rem',
+ },
+ label: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.68rem',
+ fontWeight: 600,
+ color: '#7DD3FC',
+ textTransform: 'uppercase',
+ letterSpacing: '0.1em',
+ marginBottom: '0.4rem',
+ display: 'block',
+ },
+ input: {
+ background: 'rgba(15, 23, 42, 0.8)',
+ border: '1px solid rgba(14, 165, 233, 0.2)',
+ borderRadius: '8px',
+ padding: '0.5rem 0.75rem',
+ color: '#F8FAFC',
+ fontSize: '0.82rem',
+ fontFamily: "'JetBrains Mono', monospace",
+ width: '100%',
+ outline: 'none',
+ boxSizing: 'border-box',
+ transition: 'border-color 0.2s',
+ },
+ inputError: {
+ borderColor: 'rgba(239, 68, 68, 0.6)',
+ },
+ textarea: {
+ background: 'rgba(15, 23, 42, 0.8)',
+ border: '1px solid rgba(14, 165, 233, 0.2)',
+ borderRadius: '8px',
+ padding: '0.5rem 0.75rem',
+ color: '#F8FAFC',
+ fontSize: '0.78rem',
+ fontFamily: "'JetBrains Mono', monospace",
+ width: '100%',
+ minHeight: '120px',
+ resize: 'vertical',
+ outline: 'none',
+ boxSizing: 'border-box',
+ transition: 'border-color 0.2s',
+ },
+ readOnlyBadge: {
+ display: 'inline-block',
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.7rem',
+ fontWeight: 600,
+ color: '#0EA5E9',
+ background: 'rgba(14, 165, 233, 0.1)',
+ border: '1px solid rgba(14, 165, 233, 0.3)',
+ borderRadius: '4px',
+ padding: '0.25rem 0.6rem',
+ },
+ previewList: {
+ maxHeight: '160px',
+ overflowY: 'auto',
+ border: '1px solid rgba(14, 165, 233, 0.15)',
+ borderRadius: '8px',
+ background: 'rgba(15, 23, 42, 0.6)',
+ },
+ previewItem: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: '0.4rem 0.6rem',
+ borderBottom: '1px solid rgba(255, 255, 255, 0.04)',
+ },
+ previewItemText: {
+ flex: 1,
+ minWidth: 0,
+ overflow: 'hidden',
+ },
+ previewTitle: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.72rem',
+ color: '#CBD5E1',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+ previewHost: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.62rem',
+ color: '#64748B',
+ marginTop: '1px',
+ },
+ removeBtn: {
+ background: 'transparent',
+ border: '1px solid rgba(239, 68, 68, 0.3)',
+ borderRadius: '4px',
+ color: '#EF4444',
+ cursor: 'pointer',
+ padding: '0.15rem 0.3rem',
+ marginLeft: '0.5rem',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ transition: 'all 0.2s',
+ },
+ errorMsg: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.7rem',
+ color: '#EF4444',
+ marginTop: '0.3rem',
+ },
+ warningMsg: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.7rem',
+ color: '#F59E0B',
+ background: 'rgba(245, 158, 11, 0.08)',
+ border: '1px solid rgba(245, 158, 11, 0.2)',
+ borderRadius: '6px',
+ padding: '0.5rem 0.75rem',
+ marginBottom: '1rem',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ },
+ apiError: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.72rem',
+ color: '#EF4444',
+ background: 'rgba(239, 68, 68, 0.08)',
+ border: '1px solid rgba(239, 68, 68, 0.25)',
+ borderRadius: '6px',
+ padding: '0.5rem 0.75rem',
+ marginBottom: '1rem',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ },
+ actions: {
+ display: 'flex',
+ gap: '0.75rem',
+ marginTop: '1.5rem',
+ },
+ cancelBtn: {
+ flex: 1,
+ padding: '0.625rem',
+ background: 'transparent',
+ border: '1px solid rgba(100, 116, 139, 0.4)',
+ borderRadius: '0.375rem',
+ color: '#94A3B8',
+ cursor: 'pointer',
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.78rem',
+ transition: 'all 0.2s ease',
+ },
+ submitBtn: {
+ flex: 1.5,
+ padding: '0.625rem',
+ background: 'rgba(14, 165, 233, 0.1)',
+ border: '1px solid #0EA5E9',
+ borderRadius: '0.375rem',
+ color: '#0EA5E9',
+ cursor: 'pointer',
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.78rem',
+ fontWeight: 600,
+ textTransform: 'uppercase',
+ letterSpacing: '0.05em',
+ transition: 'all 0.2s ease',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '0.4rem',
+ },
+ submitBtnDisabled: {
+ opacity: 0.4,
+ cursor: 'not-allowed',
+ },
+ charCount: {
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.62rem',
+ color: '#64748B',
+ textAlign: 'right',
+ marginTop: '0.2rem',
+ },
+};
+
+/**
+ * ConsolidationModal — Creates a single Jira ticket from multiple selected
+ * Ivanti queue items. Pre-populates summary, description, CVE, and vendor
+ * using aggregation functions.
+ *
+ * Props:
+ * items {Array} — The selected queue items (full objects)
+ * onClose {Function} — Close handler
+ * onSuccess {Function} — Called with created ticket data on success
+ */
+export default function ConsolidationModal({ items, onClose, onSuccess }) {
+ // Internal state — copy of items that can be modified (items removed)
+ const [selectedItems, setSelectedItems] = useState(items);
+
+ // Form fields
+ const [summary, setSummary] = useState('');
+ const [description, setDescription] = useState('');
+ const [cveId, setCveId] = useState('');
+ const [vendor, setVendor] = useState('');
+
+ // Locked source context
+ const sourceContext = 'ivanti_queue';
+
+ // UI state
+ const [summaryError, setSummaryError] = useState(null);
+ const [apiError, setApiError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ // ---------------------------------------------------------------------------
+ // Initialize / regenerate form fields when selectedItems changes
+ // ---------------------------------------------------------------------------
+ useEffect(() => {
+ if (selectedItems.length >= 2) {
+ setSummary(generateConsolidatedSummary(selectedItems));
+ setDescription(generateConsolidatedDescription(selectedItems));
+ setCveId(extractFirstCve(selectedItems));
+ setVendor(extractCommonVendor(selectedItems));
+ }
+ }, []); // Only on mount — user edits are preserved after item removal
+
+ // ---------------------------------------------------------------------------
+ // Remove an item from the selection (minimum 2 required)
+ // ---------------------------------------------------------------------------
+ const removeItem = useCallback((itemId) => {
+ setSelectedItems((prev) => prev.filter((i) => i.id !== itemId));
+ }, []);
+
+ // ---------------------------------------------------------------------------
+ // Escape key handler
+ // ---------------------------------------------------------------------------
+ useEffect(() => {
+ const handleKey = (e) => {
+ if (e.key === 'Escape') onClose?.();
+ };
+ document.addEventListener('keydown', handleKey);
+ return () => document.removeEventListener('keydown', handleKey);
+ }, [onClose]);
+
+ // ---------------------------------------------------------------------------
+ // Form validation
+ // ---------------------------------------------------------------------------
+ const canSubmit = selectedItems.length >= 2 && summary.trim().length > 0 && !submitting;
+
+ // ---------------------------------------------------------------------------
+ // Submit handler
+ // ---------------------------------------------------------------------------
+ const handleSubmit = async () => {
+ // Validate summary
+ if (!summary.trim()) {
+ setSummaryError('Summary is required.');
+ return;
+ }
+ if (summary.length > 255) {
+ setSummaryError('Summary must be 255 characters or fewer.');
+ return;
+ }
+ setSummaryError(null);
+ setApiError(null);
+ setSubmitting(true);
+
+ try {
+ // Step 1: Create the Jira ticket
+ const createPayload = {
+ summary: summary.trim(),
+ description,
+ cve_id: cveId.trim() || null,
+ vendor: vendor.trim() || null,
+ source_context: sourceContext,
+ };
+
+ const createRes = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(createPayload),
+ });
+
+ const createData = await createRes.json();
+
+ if (!createRes.ok && createRes.status !== 207) {
+ throw new Error(createData.error || `Failed to create Jira ticket (HTTP ${createRes.status})`);
+ }
+
+ const ticketId = createData.id;
+
+ // Step 2: Link queue items to the ticket via junction endpoint
+ const linkRes = await fetch(`${API_BASE}/jira-tickets/${ticketId}/queue-items`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ queue_item_ids: selectedItems.map((i) => i.id) }),
+ });
+
+ if (!linkRes.ok) {
+ const linkData = await linkRes.json();
+ // Ticket was created but linking failed — partial success
+ console.warn('Junction link failed:', linkData.error || linkRes.status);
+ }
+
+ // Success — close modal and notify parent
+ onSuccess?.(createData);
+ } catch (err) {
+ setApiError(err.message);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // ---------------------------------------------------------------------------
+ // Render
+ // ---------------------------------------------------------------------------
+ return (
+
{ if (e.target === e.currentTarget) onClose?.(); }}
+ >
+
+ {/* Header */}
+
+
+
+
+ Create Consolidated Jira Ticket
+
+
+ {selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected for consolidation
+
+
+
+
+
+ {/* Minimum items warning */}
+ {selectedItems.length < 2 && (
+
+
+ At least 2 items are required for consolidation. Add more items or close this modal.
+
+ )}
+
+ {/* API error */}
+ {apiError && (
+
+ )}
+
+ {/* Selected items preview list */}
+
+
+
+ {selectedItems.map((item) => (
+
+
+
+ {item.finding_title || item.finding_id || 'Untitled'}
+
+
+ {item.hostname || 'No hostname'}
+
+
+
+
+ ))}
+
+
+
+ {/* Summary field (required, max 255 chars) */}
+
+
+
{
+ setSummary(e.target.value);
+ if (summaryError) setSummaryError(null);
+ }}
+ maxLength={255}
+ placeholder="Ticket summary (required)"
+ style={{ ...STYLES.input, ...(summaryError ? STYLES.inputError : {}) }}
+ />
+
{summary.length}/255
+ {summaryError &&
{summaryError}
}
+
+
+ {/* Description textarea */}
+
+
+
+
+ {/* CVE ID (optional) */}
+
+
+ setCveId(e.target.value)}
+ placeholder="e.g. CVE-2024-12345"
+ style={STYLES.input}
+ />
+
+
+ {/* Vendor (optional) */}
+
+
+ setVendor(e.target.value)}
+ placeholder="e.g. Microsoft"
+ style={STYLES.input}
+ />
+
+
+ {/* Source Context (read-only) */}
+
+
+ {sourceContext}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/pages/IvantiTodoQueuePage.js b/frontend/src/components/pages/IvantiTodoQueuePage.js
new file mode 100644
index 0000000..9fa8d7c
--- /dev/null
+++ b/frontend/src/components/pages/IvantiTodoQueuePage.js
@@ -0,0 +1,902 @@
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle } from 'lucide-react';
+import { useAuth } from '../../contexts/AuthContext';
+import ConsolidationModal from '../ConsolidationModal';
+import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+// ---------------------------------------------------------------------------
+// Styles — matches dark theme tactical intelligence aesthetic
+// ---------------------------------------------------------------------------
+const STYLES = {
+ page: {
+ minHeight: '60vh',
+ },
+ card: {
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
+ border: '1px solid rgba(14, 165, 233, 0.15)',
+ borderRadius: '12px',
+ padding: '1.5rem',
+ marginBottom: '1rem',
+ },
+ header: {
+ fontFamily: 'monospace',
+ fontSize: '0.7rem',
+ fontWeight: 700,
+ color: '#0EA5E9',
+ textTransform: 'uppercase',
+ letterSpacing: '0.15em',
+ marginBottom: '1rem',
+ },
+ toolbar: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: '1rem',
+ flexWrap: 'wrap',
+ gap: '0.5rem',
+ },
+ toolbarLeft: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.75rem',
+ },
+ toolbarRight: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ },
+ btn: {
+ padding: '0.5rem 1rem',
+ borderRadius: '8px',
+ border: '1px solid rgba(14, 165, 233, 0.3)',
+ background: 'rgba(14, 165, 233, 0.1)',
+ color: '#7DD3FC',
+ cursor: 'pointer',
+ fontSize: '0.8rem',
+ fontWeight: 600,
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ transition: 'all 0.2s',
+ },
+ btnActive: {
+ padding: '0.5rem 1rem',
+ borderRadius: '8px',
+ border: '1px solid rgba(14, 165, 233, 0.6)',
+ background: 'rgba(14, 165, 233, 0.25)',
+ color: '#0EA5E9',
+ cursor: 'pointer',
+ fontSize: '0.8rem',
+ fontWeight: 600,
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ transition: 'all 0.2s',
+ boxShadow: '0 0 12px rgba(14, 165, 233, 0.2)',
+ },
+ selectionCount: {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ color: '#F59E0B',
+ background: 'rgba(245, 158, 11, 0.1)',
+ border: '1px solid rgba(245, 158, 11, 0.3)',
+ borderRadius: '999px',
+ padding: '0.25rem 0.75rem',
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.35rem',
+ },
+ tableHeader: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.75rem',
+ padding: '0.5rem 0.75rem',
+ borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
+ marginBottom: '0.5rem',
+ },
+ tableHeaderLabel: {
+ fontFamily: 'monospace',
+ fontSize: '0.65rem',
+ fontWeight: 700,
+ color: '#64748B',
+ textTransform: 'uppercase',
+ letterSpacing: '0.1em',
+ },
+ queueItem: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: '0.625rem',
+ padding: '0.625rem 0.75rem',
+ marginBottom: '0.25rem',
+ borderRadius: '0.375rem',
+ background: 'rgba(14, 165, 233, 0.04)',
+ border: '1px solid rgba(14, 165, 233, 0.1)',
+ transition: 'background 0.15s, border-color 0.15s',
+ },
+ queueItemSelected: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: '0.625rem',
+ padding: '0.625rem 0.75rem',
+ marginBottom: '0.25rem',
+ borderRadius: '0.375rem',
+ background: 'rgba(14, 165, 233, 0.08)',
+ border: '1px solid rgba(14, 165, 233, 0.3)',
+ transition: 'background 0.15s, border-color 0.15s',
+ },
+ checkbox: {
+ accentColor: '#0EA5E9',
+ width: '16px',
+ height: '16px',
+ flexShrink: 0,
+ marginTop: '2px',
+ cursor: 'pointer',
+ },
+ selectAllCheckbox: {
+ accentColor: '#0EA5E9',
+ width: '14px',
+ height: '14px',
+ cursor: 'pointer',
+ },
+ floatingBar: {
+ position: 'fixed',
+ bottom: '1.5rem',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.75rem',
+ padding: '0.75rem 1.25rem',
+ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(15, 23, 42, 0.99))',
+ border: '1px solid rgba(14, 165, 233, 0.3)',
+ borderRadius: '12px',
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 16px rgba(14, 165, 233, 0.1)',
+ zIndex: 50,
+ },
+ floatingBarBadge: {
+ fontFamily: 'monospace',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ color: '#F59E0B',
+ background: 'rgba(245, 158, 11, 0.1)',
+ border: '1px solid rgba(245, 158, 11, 0.3)',
+ borderRadius: '999px',
+ padding: '0.25rem 0.75rem',
+ },
+ btnSuccess: {
+ padding: '0.5rem 1rem',
+ borderRadius: '8px',
+ border: '1px solid rgba(16, 185, 129, 0.4)',
+ background: 'rgba(16, 185, 129, 0.15)',
+ color: '#6EE7B7',
+ cursor: 'pointer',
+ fontSize: '0.8rem',
+ fontWeight: 600,
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ transition: 'all 0.2s',
+ },
+ btnDisabled: {
+ opacity: 0.4,
+ cursor: 'not-allowed',
+ },
+ btnCancel: {
+ padding: '0.5rem 1rem',
+ borderRadius: '8px',
+ border: '1px solid rgba(148, 163, 184, 0.3)',
+ background: 'rgba(148, 163, 184, 0.08)',
+ color: '#94A3B8',
+ cursor: 'pointer',
+ fontSize: '0.8rem',
+ fontWeight: 600,
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ transition: 'all 0.2s',
+ },
+ modal: {
+ position: 'fixed',
+ inset: 0,
+ zIndex: 100,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ modalBackdrop: {
+ position: 'fixed',
+ inset: 0,
+ background: 'rgba(0,0,0,0.7)',
+ backdropFilter: 'blur(4px)',
+ },
+ modalContent: {
+ position: 'relative',
+ background: 'linear-gradient(135deg, #1E293B, #0F172A)',
+ border: '1px solid rgba(14, 165, 233, 0.25)',
+ borderRadius: '16px',
+ padding: '2rem',
+ width: '90%',
+ maxWidth: '520px',
+ maxHeight: '85vh',
+ overflowY: 'auto',
+ zIndex: 101,
+ },
+ input: {
+ background: 'rgba(15, 23, 42, 0.8)',
+ border: '1px solid rgba(14, 165, 233, 0.2)',
+ borderRadius: '8px',
+ padding: '0.5rem 0.75rem',
+ color: '#F8FAFC',
+ fontSize: '0.85rem',
+ width: '100%',
+ outline: 'none',
+ },
+};
+
+// ---------------------------------------------------------------------------
+// IvantiTodoQueuePage — Full-page Ivanti queue with multi-select support
+// ---------------------------------------------------------------------------
+export default function IvantiTodoQueuePage() {
+ const { canWrite } = useAuth();
+
+ // Queue data state
+ const [queueItems, setQueueItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Ticket link badges state (Requirement 6.3, 6.4, 6.5)
+ const [ticketLinks, setTicketLinks] = useState({});
+
+ // Selection mode state (Requirement 1.1)
+ const [selectionMode, setSelectionMode] = useState(false);
+ const [selectedIds, setSelectedIds] = useState(new Set());
+
+ // Consolidation modal state (Requirement 2.3)
+ const [showConsolidationModal, setShowConsolidationModal] = useState(false);
+
+ // Single-item Jira creation modal state (Requirement 2.4)
+ const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
+ const [singleJiraItem, setSingleJiraItem] = useState(null);
+ const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue' });
+ const [singleJiraError, setSingleJiraError] = useState(null);
+ const [singleJiraSaving, setSingleJiraSaving] = useState(false);
+ const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
+
+ // ---------------------------------------------------------------------------
+ // Data fetching
+ // ---------------------------------------------------------------------------
+ const fetchQueue = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
+ const data = await res.json();
+ if (res.ok) {
+ // Parse cves from cves_json if not already parsed
+ const parsed = data.map((item) => {
+ if (item.cves) return item;
+ let cves = [];
+ if (item.cves_json) {
+ try { cves = JSON.parse(item.cves_json); } catch { cves = []; }
+ }
+ return { ...item, cves };
+ });
+ setQueueItems(parsed);
+ } else {
+ setError(data.error || 'Failed to fetch queue items.');
+ }
+ } catch (e) {
+ setError('Network error — could not fetch queue items.');
+ console.error('Error fetching queue:', e);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // ---------------------------------------------------------------------------
+ // Fetch ticket link associations (Requirements 6.3, 6.4, 6.5)
+ // ---------------------------------------------------------------------------
+ const fetchTicketLinks = useCallback(async () => {
+ try {
+ const res = await fetch(`${API_BASE}/ivanti/todo-queue/ticket-links`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setTicketLinks(data.links || {});
+ }
+ } catch (e) {
+ console.error('Error fetching ticket links:', e);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchQueue();
+ fetchTicketLinks();
+ }, [fetchQueue, fetchTicketLinks]);
+
+ // ---------------------------------------------------------------------------
+ // Visible items — only pending items are selectable
+ // ---------------------------------------------------------------------------
+ const visibleItems = useMemo(() => {
+ return queueItems.filter((item) => item.status === 'pending');
+ }, [queueItems]);
+
+ // ---------------------------------------------------------------------------
+ // Selection mode toggle (Requirement 1.1, 1.5)
+ // When deactivated, clear all selections
+ // ---------------------------------------------------------------------------
+ const toggleSelectionMode = useCallback(() => {
+ setSelectionMode((prev) => {
+ if (prev) {
+ // Deactivating — clear selections (Requirement 1.5)
+ setSelectedIds(new Set());
+ }
+ return !prev;
+ });
+ }, []);
+
+ // ---------------------------------------------------------------------------
+ // Individual item selection toggle (Requirement 1.2)
+ // ---------------------------------------------------------------------------
+ const toggleItemSelection = useCallback((id) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ }, []);
+
+ // ---------------------------------------------------------------------------
+ // Select All toggle (Requirement 1.4)
+ // Toggles all visible (filtered) queue item IDs into/out of selectedIds
+ // ---------------------------------------------------------------------------
+ const allVisibleSelected = useMemo(() => {
+ if (visibleItems.length === 0) return false;
+ return visibleItems.every((item) => selectedIds.has(item.id));
+ }, [visibleItems, selectedIds]);
+
+ const someVisibleSelected = useMemo(() => {
+ if (visibleItems.length === 0) return false;
+ return visibleItems.some((item) => selectedIds.has(item.id)) && !allVisibleSelected;
+ }, [visibleItems, selectedIds, allVisibleSelected]);
+
+ const toggleSelectAll = useCallback(() => {
+ if (allVisibleSelected) {
+ // Deselect all visible
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ visibleItems.forEach((item) => next.delete(item.id));
+ return next;
+ });
+ } else {
+ // Select all visible
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ visibleItems.forEach((item) => next.add(item.id));
+ return next;
+ });
+ }
+ }, [allVisibleSelected, visibleItems]);
+
+ // ---------------------------------------------------------------------------
+ // Preserve selections on scroll/re-render (Requirement 1.6)
+ // Clean up selectedIds that no longer exist in the queue
+ // ---------------------------------------------------------------------------
+ useEffect(() => {
+ setSelectedIds((prev) => {
+ if (prev.size === 0) return prev;
+ const validIds = new Set(queueItems.map((i) => i.id));
+ const next = new Set([...prev].filter((id) => validIds.has(id)));
+ return next.size === prev.size ? prev : next;
+ });
+ }, [queueItems]);
+
+ // ---------------------------------------------------------------------------
+ // Selected queue items (full objects) for modal use
+ // ---------------------------------------------------------------------------
+ const selectedQueueItems = useMemo(() => {
+ return queueItems.filter(item => selectedIds.has(item.id));
+ }, [queueItems, selectedIds]);
+
+ // ---------------------------------------------------------------------------
+ // Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
+ // ---------------------------------------------------------------------------
+ const handleCreateJiraTicket = useCallback(() => {
+ if (selectedIds.size === 0) return;
+
+ if (selectedIds.size === 1) {
+ // Single item — open single-item Jira creation modal (Requirement 2.4)
+ const item = queueItems.find(i => selectedIds.has(i.id));
+ if (!item) return;
+ setSingleJiraItem(item);
+ const items = [item];
+ setSingleJiraForm({
+ cve_id: extractFirstCve(items),
+ vendor: extractCommonVendor(items),
+ summary: generateConsolidatedSummary(items),
+ description: generateConsolidatedDescription(items),
+ source_context: 'ivanti_queue',
+ });
+ setSingleJiraError(null);
+ setSingleJiraSummaryError(null);
+ setShowSingleJiraModal(true);
+ } else {
+ // Multiple items — open Consolidation Modal (Requirement 2.3)
+ setShowConsolidationModal(true);
+ }
+ }, [selectedIds, queueItems]);
+
+ // ---------------------------------------------------------------------------
+ // Consolidation modal success handler
+ // ---------------------------------------------------------------------------
+ const handleConsolidationSuccess = useCallback(() => {
+ setShowConsolidationModal(false);
+ setSelectedIds(new Set());
+ setSelectionMode(false);
+ fetchQueue();
+ fetchTicketLinks();
+ }, [fetchQueue, fetchTicketLinks]);
+
+ // ---------------------------------------------------------------------------
+ // Single-item Jira creation — submit handler
+ // ---------------------------------------------------------------------------
+ const submitSingleJira = useCallback(async () => {
+ setSingleJiraSummaryError(null);
+ const trimmedSummary = (singleJiraForm.summary || '').trim();
+ if (!trimmedSummary) {
+ setSingleJiraSummaryError('Summary is required.');
+ return;
+ }
+ if (trimmedSummary.length > 255) {
+ setSingleJiraSummaryError('Summary must be 255 characters or fewer.');
+ return;
+ }
+
+ setSingleJiraError(null);
+ setSingleJiraSaving(true);
+ try {
+ const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(singleJiraForm),
+ });
+ const data = await res.json();
+ if (!res.ok && res.status !== 207) {
+ throw new Error(data.error || `HTTP ${res.status}`);
+ }
+
+ // If we have a ticket ID and a queue item, link them via junction table
+ if (data.id && singleJiraItem) {
+ try {
+ await fetch(`${API_BASE}/jira-tickets/${data.id}/queue-items`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ queue_item_ids: [singleJiraItem.id] }),
+ });
+ } catch (_) { /* junction link is best-effort */ }
+ }
+
+ setShowSingleJiraModal(false);
+ setSingleJiraItem(null);
+ setSelectedIds(new Set());
+ setSelectionMode(false);
+ fetchQueue();
+ fetchTicketLinks();
+ } catch (err) {
+ setSingleJiraError(err.message);
+ } finally {
+ setSingleJiraSaving(false);
+ }
+ }, [singleJiraForm, singleJiraItem, fetchQueue, fetchTicketLinks]);
+
+ // ---------------------------------------------------------------------------
+ // Cancel selection mode from floating bar
+ // ---------------------------------------------------------------------------
+ const cancelSelection = useCallback(() => {
+ setSelectedIds(new Set());
+ setSelectionMode(false);
+ }, []);
+
+ // ---------------------------------------------------------------------------
+ // Workflow type color helper
+ // ---------------------------------------------------------------------------
+ const getWorkflowColor = (workflowType) => {
+ switch (workflowType) {
+ case 'FP': return { col: '#F59E0B', rgb: '245,158,11' };
+ case 'Archer': return { col: '#0EA5E9', rgb: '14,165,233' };
+ case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
+ case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
+ case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
+ default: return { col: '#94A3B8', rgb: '148,163,184' };
+ }
+ };
+
+ // ---------------------------------------------------------------------------
+ // Render
+ // ---------------------------------------------------------------------------
+ return (
+
+
+ {/* Page header */}
+
+
+ Ivanti Todo Queue
+
+
+ {/* Toolbar */}
+
+
+ {/* Select toggle button (Requirement 1.1) */}
+ {canWrite() && (
+
+ )}
+
+ {/* Selection count indicator (Requirement 1.3) */}
+ {selectionMode && selectedIds.size > 0 && (
+
+ {selectedIds.size} selected
+
+ )}
+
+
+
+ {/* Refresh button */}
+
+
+
+
+ {/* Error state */}
+ {error && (
+
+ )}
+
+ {/* Loading state */}
+ {loading && queueItems.length === 0 && (
+
+
+
+ Loading queue items...
+
+
+ )}
+
+ {/* Empty state */}
+ {!loading && queueItems.length === 0 && !error && (
+
+
+
+ No items in queue.
+
+
+ )}
+
+ {/* Queue items table */}
+ {!loading && visibleItems.length > 0 && (
+ <>
+ {/* Table header with Select All (Requirement 1.4) */}
+
+ {selectionMode && (
+ { if (el) el.indeterminate = someVisibleSelected; }}
+ onChange={toggleSelectAll}
+ style={STYLES.selectAllCheckbox}
+ title={allVisibleSelected ? 'Deselect all' : 'Select all'}
+ aria-label="Select all queue items"
+ />
+ )}
+ Finding
+ Type
+ Vendor
+ Host
+
+
+ {/* Queue item rows */}
+ {visibleItems.map((item) => {
+ const isSelected = selectedIds.has(item.id);
+ const wfColor = getWorkflowColor(item.workflow_type);
+ const cves = item.cves || [];
+ const cveDisplay = cves.length > 0
+ ? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
+ : '';
+
+ return (
+
toggleItemSelection(item.id) : undefined}
+ role={selectionMode ? 'button' : undefined}
+ tabIndex={selectionMode ? 0 : undefined}
+ onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
+ >
+ {/* Selection checkbox (Requirement 1.2) */}
+ {selectionMode && (
+
{ e.stopPropagation(); toggleItemSelection(item.id); }}
+ onClick={(e) => e.stopPropagation()}
+ style={STYLES.checkbox}
+ aria-label={`Select ${item.finding_title || item.finding_id}`}
+ />
+ )}
+
+ {/* Finding info */}
+
+
+ {item.finding_title || item.finding_id}
+
+ {cveDisplay && (
+
+ {cveDisplay}
+
+ )}
+
+
+ {/* Ticket link badge (Requirements 6.3, 6.4) */}
+ {ticketLinks[item.id] && (
+
e.stopPropagation()}
+ style={{
+ fontFamily: 'monospace',
+ fontSize: '0.6rem',
+ fontWeight: 700,
+ color: '#6EE7B7',
+ background: 'rgba(16, 185, 129, 0.1)',
+ border: '1px solid rgba(16, 185, 129, 0.3)',
+ borderRadius: '999px',
+ padding: '0.15rem 0.5rem',
+ textDecoration: 'none',
+ whiteSpace: 'nowrap',
+ flexShrink: 0,
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.25rem',
+ transition: 'all 0.2s',
+ }}
+ title={`Open ${ticketLinks[item.id].ticket_key} in Jira`}
+ >
+ {ticketLinks[item.id].ticket_key} ↗
+
+ )}
+
+ {/* Workflow type badge */}
+
+
+ {item.workflow_type}
+
+
+
+ {/* Vendor */}
+
+ {item.vendor || '—'}
+
+
+ {/* Hostname / IP */}
+
+ {item.hostname && (
+
+ {item.hostname}
+
+ )}
+ {item.ip_address && (
+
+ {item.ip_address}
+
+ )}
+
+
+ );
+ })}
+ >
+ )}
+
+ {/* Completed items count */}
+ {!loading && queueItems.filter(i => i.status === 'complete').length > 0 && (
+
+ {queueItems.filter(i => i.status === 'complete').length} completed item(s) hidden
+
+ )}
+
+
+ {/* Floating Action Bar (Requirements 2.1, 2.2) */}
+ {selectionMode && selectedIds.size > 0 && (
+
+
+ {selectedIds.size} selected
+
+
+
+
+ )}
+
+ {/* Single-item Jira Creation Modal (Requirement 2.4) */}
+ {showSingleJiraModal && (
+
+
setShowSingleJiraModal(false)} />
+
+
+
Create Jira Ticket
+
+
+
+ Create a Jira issue for: {singleJiraItem?.finding_title || singleJiraItem?.finding_id}
+
+ {singleJiraError &&
{singleJiraError}
}
+
+
+
+ setSingleJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
+
+
+
+ setSingleJiraForm(f => ({ ...f, vendor: e.target.value }))} />
+
+
+
+
{ setSingleJiraForm(f => ({ ...f, summary: e.target.value })); if (singleJiraSummaryError) setSingleJiraSummaryError(null); }}
+ maxLength={255}
+ />
+ {singleJiraSummaryError &&
{singleJiraSummaryError}
}
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Consolidation Modal (Requirement 2.3) */}
+ {showConsolidationModal && (
+
setShowConsolidationModal(false)}
+ onSuccess={handleConsolidationSuccess}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/utils/jiraConsolidation.js b/frontend/src/utils/jiraConsolidation.js
new file mode 100644
index 0000000..5aa4f1a
--- /dev/null
+++ b/frontend/src/utils/jiraConsolidation.js
@@ -0,0 +1,92 @@
+/**
+ * Pure utility functions for consolidating multiple Ivanti queue items
+ * into a single Jira ticket's summary, description, CVE, and vendor fields.
+ */
+
+/**
+ * Generate a consolidated summary for a multi-item Jira ticket.
+ * Format: "[N findings] vendor - first_finding_title", truncated to 255 chars.
+ *
+ * @param {Array} items - Array of queue item objects
+ * @returns {string} Generated summary, at most 255 characters
+ */
+export function generateConsolidatedSummary(items) {
+ const count = items.length;
+ const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
+ const vendorLabel = vendors.length === 1 ? vendors[0] : 'Multiple Vendors';
+ const firstTitle = items[0]?.finding_title || 'Untitled';
+ const raw = `[${count} findings] ${vendorLabel} - ${firstTitle}`;
+ return raw.slice(0, 255);
+}
+
+/**
+ * Generate a structured description grouped by vendor for a consolidated Jira ticket.
+ *
+ * @param {Array} items - Array of queue item objects
+ * @returns {string} Structured description with header and vendor-grouped items
+ */
+export function generateConsolidatedDescription(items) {
+ const header = `Consolidated Jira ticket covering ${items.length} Ivanti queue findings.\n\n`;
+
+ // Group by vendor
+ const grouped = {};
+ for (const item of items) {
+ const vendor = item.vendor || 'Unknown Vendor';
+ if (!grouped[vendor]) grouped[vendor] = [];
+ grouped[vendor].push(item);
+ }
+
+ let body = '';
+ for (const [vendor, vendorItems] of Object.entries(grouped)) {
+ body += `== ${vendor} ==\n`;
+ for (const item of vendorItems) {
+ let cves = 'None';
+ if (item.cves_json) {
+ try {
+ const parsed = JSON.parse(item.cves_json);
+ if (Array.isArray(parsed) && parsed.length > 0) {
+ cves = parsed.join(', ');
+ }
+ } catch (e) {
+ cves = 'None';
+ }
+ }
+ body += `- ${item.finding_title}\n`;
+ body += ` CVEs: ${cves}\n`;
+ body += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n\n`;
+ }
+ }
+
+ return header + body;
+}
+
+/**
+ * Extract the first CVE from the first item that has a non-empty cves_json array.
+ *
+ * @param {Array} items - Array of queue item objects
+ * @returns {string} First CVE ID found, or empty string if none
+ */
+export function extractFirstCve(items) {
+ for (const item of items) {
+ if (item.cves_json) {
+ try {
+ const cves = JSON.parse(item.cves_json);
+ if (Array.isArray(cves) && cves.length > 0) return cves[0];
+ } catch (e) {
+ // Skip items with invalid JSON
+ }
+ }
+ }
+ return '';
+}
+
+/**
+ * Extract the common vendor if all items share the same vendor.
+ *
+ * @param {Array} items - Array of queue item objects
+ * @returns {string} Common vendor name if all items share it, empty string otherwise
+ */
+export function extractCommonVendor(items) {
+ const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
+ return vendors.length === 1 ? vendors[0] : '';
+}
diff --git a/frontend/src/utils/jiraConsolidation.test.js b/frontend/src/utils/jiraConsolidation.test.js
new file mode 100644
index 0000000..3b5157a
--- /dev/null
+++ b/frontend/src/utils/jiraConsolidation.test.js
@@ -0,0 +1,175 @@
+import {
+ generateConsolidatedSummary,
+ generateConsolidatedDescription,
+ extractFirstCve,
+ extractCommonVendor,
+} from './jiraConsolidation';
+
+describe('generateConsolidatedSummary', () => {
+ it('formats summary with count, common vendor, and first title', () => {
+ const items = [
+ { vendor: 'Microsoft', finding_title: 'RCE in Exchange' },
+ { vendor: 'Microsoft', finding_title: 'XSS in Outlook' },
+ ];
+ expect(generateConsolidatedSummary(items)).toBe(
+ '[2 findings] Microsoft - RCE in Exchange'
+ );
+ });
+
+ it('uses "Multiple Vendors" when vendors differ', () => {
+ const items = [
+ { vendor: 'Microsoft', finding_title: 'RCE in Exchange' },
+ { vendor: 'Adobe', finding_title: 'Buffer overflow' },
+ ];
+ expect(generateConsolidatedSummary(items)).toBe(
+ '[2 findings] Multiple Vendors - RCE in Exchange'
+ );
+ });
+
+ it('uses "Multiple Vendors" when vendor is null/empty', () => {
+ const items = [
+ { vendor: null, finding_title: 'Finding A' },
+ { vendor: '', finding_title: 'Finding B' },
+ ];
+ expect(generateConsolidatedSummary(items)).toBe(
+ '[2 findings] Multiple Vendors - Finding A'
+ );
+ });
+
+ it('uses "Untitled" when first item has no finding_title', () => {
+ const items = [{ vendor: 'Cisco' }, { vendor: 'Cisco', finding_title: 'Bug' }];
+ expect(generateConsolidatedSummary(items)).toBe(
+ '[2 findings] Cisco - Untitled'
+ );
+ });
+
+ it('truncates to 255 characters', () => {
+ const longTitle = 'A'.repeat(300);
+ const items = [{ vendor: 'V', finding_title: longTitle }];
+ const result = generateConsolidatedSummary(items);
+ expect(result.length).toBeLessThanOrEqual(255);
+ expect(result).toMatch(/^\[1 findings\] V - /);
+ });
+});
+
+describe('generateConsolidatedDescription', () => {
+ it('includes header with item count', () => {
+ const items = [
+ { vendor: 'Microsoft', finding_title: 'Bug', cves_json: '["CVE-2024-001"]', hostname: 'host1', ip_address: '10.0.0.1' },
+ ];
+ const result = generateConsolidatedDescription(items);
+ expect(result).toContain('Consolidated Jira ticket covering 1 Ivanti queue findings.');
+ });
+
+ it('groups items by vendor', () => {
+ const items = [
+ { vendor: 'Microsoft', finding_title: 'Bug A', hostname: 'h1', ip_address: '10.0.0.1' },
+ { vendor: 'Adobe', finding_title: 'Bug B', hostname: 'h2', ip_address: '10.0.0.2' },
+ { vendor: 'Microsoft', finding_title: 'Bug C', hostname: 'h3', ip_address: '10.0.0.3' },
+ ];
+ const result = generateConsolidatedDescription(items);
+ expect(result).toContain('== Microsoft ==');
+ expect(result).toContain('== Adobe ==');
+ expect(result).toContain('Bug A');
+ expect(result).toContain('Bug B');
+ expect(result).toContain('Bug C');
+ });
+
+ it('uses "Unknown Vendor" for null/empty vendor', () => {
+ const items = [
+ { vendor: null, finding_title: 'Bug', hostname: 'h1', ip_address: '10.0.0.1' },
+ ];
+ const result = generateConsolidatedDescription(items);
+ expect(result).toContain('== Unknown Vendor ==');
+ });
+
+ it('includes CVEs, hostname, and IP for each item', () => {
+ const items = [
+ { vendor: 'Cisco', finding_title: 'Vuln', cves_json: '["CVE-2024-100","CVE-2024-101"]', hostname: 'server1', ip_address: '192.168.1.1' },
+ ];
+ const result = generateConsolidatedDescription(items);
+ expect(result).toContain('CVEs: CVE-2024-100, CVE-2024-101');
+ expect(result).toContain('Host: server1 (192.168.1.1)');
+ });
+
+ it('shows "None" for items without CVEs', () => {
+ const items = [
+ { vendor: 'Cisco', finding_title: 'Vuln', hostname: 'h1', ip_address: '10.0.0.1' },
+ ];
+ const result = generateConsolidatedDescription(items);
+ expect(result).toContain('CVEs: None');
+ });
+
+ it('shows "N/A" for missing hostname and ip_address', () => {
+ const items = [{ vendor: 'Cisco', finding_title: 'Vuln' }];
+ const result = generateConsolidatedDescription(items);
+ expect(result).toContain('Host: N/A (N/A)');
+ });
+});
+
+describe('extractFirstCve', () => {
+ it('returns first CVE from first item with non-empty cves_json', () => {
+ const items = [
+ { cves_json: null },
+ { cves_json: '["CVE-2024-200","CVE-2024-201"]' },
+ { cves_json: '["CVE-2024-300"]' },
+ ];
+ expect(extractFirstCve(items)).toBe('CVE-2024-200');
+ });
+
+ it('returns empty string when no items have CVEs', () => {
+ const items = [
+ { cves_json: null },
+ { cves_json: '[]' },
+ ];
+ expect(extractFirstCve(items)).toBe('');
+ });
+
+ it('returns empty string for empty array', () => {
+ expect(extractFirstCve([])).toBe('');
+ });
+
+ it('skips items with invalid JSON in cves_json', () => {
+ const items = [
+ { cves_json: 'not-json' },
+ { cves_json: '["CVE-2024-500"]' },
+ ];
+ expect(extractFirstCve(items)).toBe('CVE-2024-500');
+ });
+});
+
+describe('extractCommonVendor', () => {
+ it('returns vendor when all items share the same vendor', () => {
+ const items = [
+ { vendor: 'Microsoft' },
+ { vendor: 'Microsoft' },
+ { vendor: 'Microsoft' },
+ ];
+ expect(extractCommonVendor(items)).toBe('Microsoft');
+ });
+
+ it('returns empty string when vendors differ', () => {
+ const items = [
+ { vendor: 'Microsoft' },
+ { vendor: 'Adobe' },
+ ];
+ expect(extractCommonVendor(items)).toBe('');
+ });
+
+ it('returns empty string when all vendors are null/empty', () => {
+ const items = [
+ { vendor: null },
+ { vendor: '' },
+ ];
+ expect(extractCommonVendor(items)).toBe('');
+ });
+
+ it('returns vendor when some items have null vendor but all non-null are same', () => {
+ const items = [
+ { vendor: 'Cisco' },
+ { vendor: null },
+ { vendor: 'Cisco' },
+ ];
+ expect(extractCommonVendor(items)).toBe('Cisco');
+ });
+});