Add flexible Jira ticket creation — CVE/Vendor optional, source context tracking

Make CVE ID and Vendor optional when creating Jira tickets. Add source_context
field to track ticket origin (cve, archer, ivanti_queue, email, manual).

- Migration: drop NOT NULL on cve_id/vendor, add source_context column with CHECK
- Backend: update create/update/get endpoints for optional fields and source_context
- Frontend: update creation modal with optional labels and source context dropdown
- Add Create Jira Ticket action from Ivanti queue (pre-populates from finding)
- Add Create Jira Ticket action from Archer detail view (pre-populates from ticket)
- Add source context badge column, filter dropdown, and search to ticket list
This commit is contained in:
Jordan Ramos
2026-05-21 15:06:16 -06:00
parent 940cb3251c
commit dff1fa3cc9
7 changed files with 1117 additions and 94 deletions

View File

@@ -0,0 +1,73 @@
// Migration: Add flexible Jira ticket creation support
// - Drops NOT NULL on cve_id and vendor columns
// - Adds source_context column with CHECK constraint
// - Backfills existing rows with source_context = 'manual'
// - Adds index on source_context
// Idempotent — safe to run multiple times.
const pool = require('../db');
async function run() {
console.log('Starting flexible Jira ticket creation migration...');
// Verify jira_tickets table exists before proceeding
const { rows } = await pool.query(`
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
`);
if (rows.length === 0) {
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
process.exit(1);
}
console.log('✓ jira_tickets table exists');
// Drop NOT NULL constraint on cve_id (idempotent — no-op if already nullable)
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN cve_id DROP NOT NULL`);
console.log('✓ cve_id NOT NULL constraint dropped (or was already nullable)');
// Drop NOT NULL constraint on vendor (idempotent — no-op if already nullable)
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN vendor DROP NOT NULL`);
console.log('✓ vendor NOT NULL constraint dropped (or was already nullable)');
// Add source_context column with default value (IF NOT EXISTS makes it idempotent)
await pool.query(`
ALTER TABLE jira_tickets
ADD COLUMN IF NOT EXISTS source_context TEXT DEFAULT 'manual'
`);
console.log('✓ source_context column added (or already exists)');
// Add CHECK constraint for allowed source_context values (idempotent guard)
await pool.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'jira_tickets_source_context_check'
) THEN
ALTER TABLE jira_tickets
ADD CONSTRAINT jira_tickets_source_context_check
CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual'));
END IF;
END $$;
`);
console.log('✓ source_context CHECK constraint added (or already exists)');
// Backfill existing rows where source_context is NULL
const result = await pool.query(`
UPDATE jira_tickets SET source_context = 'manual' WHERE source_context IS NULL
`);
console.log(`✓ Backfilled ${result.rowCount} rows with source_context = 'manual'`);
// Add index on source_context for filtering performance
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_jira_tickets_source_context
ON jira_tickets(source_context)
`);
console.log('✓ source_context index created (or already exists)');
console.log('Migration complete.');
process.exit(0);
}
run().catch(err => {
console.error('Migration failed:', err.message);
process.exit(1);
});

View File

@@ -21,6 +21,7 @@ const POSTGRES_MIGRATIONS = [
'add_vcl_vertical_metadata.js',
'add_vcl_multi_vertical.js',
'add_compliance_item_history.js',
'add_flexible_jira_ticket_creation.js',
];
async function runAll() {

View File

@@ -35,6 +35,16 @@ function createJiraTicketsRouter() {
// Jira API integration endpoints
// -----------------------------------------------------------------------
/**
* GET /api/jira-tickets/connection-test
*
* Tests connectivity to the configured Jira instance.
*
* @requires Admin group
* @returns {object} 200 - { connected: true, user: { name, displayName, ... } }
* @returns {object} 502 - { connected: false, error: string } on connection failure
* @returns {object} 503 - { error: string } when Jira API is not configured
*/
router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
@@ -60,10 +70,33 @@ function createJiraTicketsRouter() {
}
});
/**
* GET /api/jira-tickets/rate-limit
*
* Returns the current Jira API rate limit status (burst and daily counters).
*
* @requires Admin group
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
*/
router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
res.json(jiraApi.getRateLimitStatus());
});
/**
* GET /api/jira-tickets/lookup/:issueKey
*
* Looks up a single Jira issue by its key (e.g., PROJECT-123) and returns
* a summary of its fields.
*
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
* @requires Authenticated user
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
* @returns {object} 400 - { error: string } for invalid issue key format
* @returns {object} 404 - { error: string } when issue not found in Jira
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
* @returns {object} 502 - { error: string } on Jira API error
* @returns {object} 503 - { error: string } when Jira API is not configured
*/
router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' });
@@ -102,19 +135,63 @@ function createJiraTicketsRouter() {
}
});
/**
* POST /api/jira-tickets/create-in-jira
*
* Creates a new issue in Jira and saves a local tracking record.
*
* @requires Admin or Standard_User group
* @body {string} [cve_id] - Optional CVE ID (format: CVE-YYYY-NNNN+); stored as NULL if absent/empty
* @body {string} [vendor] - Optional vendor name (max 200 chars after trim); stored as NULL if absent/empty/whitespace
* @body {string} summary - Required issue summary (max 255 chars)
* @body {string} [description] - Optional issue description
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var)
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var)
* @body {string} [source_context] - One of: cve, archer, ivanti_queue, email, manual (defaults to 'manual')
* @returns {object} 201 - { id, ticket_key, jira_url, source_context, message }
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local DB save failed
* @returns {object} 400 - { error: string } for validation failures
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
* @returns {object} 502 - { error: string } on Jira API error
* @returns {object} 503 - { error: string } when Jira API is not configured
*/
router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' });
}
const { cve_id, vendor, summary, description, project_key, issue_type } = req.body;
const { cve_id, vendor, summary, description, project_key, issue_type, source_context } = req.body;
if (!cve_id || !isValidCveId(cve_id)) {
return res.status(400).json({ error: 'Valid CVE ID is required.' });
// --- CVE ID validation: optional, but must match format if non-empty ---
let normalizedCveId = null;
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
if (!isValidCveId(cve_id)) {
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
}
normalizedCveId = cve_id;
}
if (!vendor || !isValidVendor(vendor)) {
return res.status(400).json({ error: 'Valid vendor is required.' });
// --- Vendor validation: optional, but must be <= 200 chars after trim if non-empty ---
let normalizedVendor = null;
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 0) {
const trimmedVendor = vendor.trim();
if (trimmedVendor.length > 200) {
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
}
normalizedVendor = trimmedVendor;
}
// --- source_context validation: must be in allowed set if provided, default to 'manual' ---
const ALLOWED_SOURCE_CONTEXTS = ['cve', 'archer', 'ivanti_queue', 'email', 'manual'];
let normalizedSourceContext = 'manual';
if (source_context !== undefined && source_context !== null) {
if (!ALLOWED_SOURCE_CONTEXTS.includes(source_context)) {
return res.status(400).json({ error: 'source_context must be one of: cve, archer, ivanti_queue, email, manual.' });
}
normalizedSourceContext = source_context;
}
// --- Summary validation: required, non-empty, max 255 chars ---
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
}
@@ -153,10 +230,10 @@ function createJiraTicketsRouter() {
try {
const { rows } = await pool.query(
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by, source_context)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10)
RETURNING id`,
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id]
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id, normalizedSourceContext]
);
logAudit({
@@ -165,7 +242,7 @@ function createJiraTicketsRouter() {
action: 'jira_ticket_create_via_api',
entityType: 'jira_ticket',
entityId: rows[0].id.toString(),
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext },
ipAddress: req.ip
});
@@ -173,6 +250,7 @@ function createJiraTicketsRouter() {
id: rows[0].id,
ticket_key: ticketKey,
jira_url: jiraUrl,
source_context: normalizedSourceContext,
message: 'Jira issue created and linked successfully'
});
} catch (dbErr) {
@@ -189,6 +267,18 @@ function createJiraTicketsRouter() {
}
});
/**
* POST /api/jira-tickets/sync-all
*
* Syncs all local Jira ticket records with their current Jira status using
* bulk JQL search. Updates summary, status, and last_synced_at for each ticket.
* Stops early if rate limits are approaching.
*
* @requires Admin group
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
* @returns {object} 500 - { error: string } on internal error
* @returns {object} 503 - { error: string } when Jira API is not configured
*/
router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' });
@@ -284,6 +374,22 @@ function createJiraTicketsRouter() {
}
});
/**
* POST /api/jira-tickets/:id/sync
*
* Syncs a single local Jira ticket record with its current Jira status.
* Fetches the issue by ticket_key and updates summary, status, and last_synced_at.
*
* @param {string} id - Local ticket ID (path parameter)
* @requires Admin or Standard_User group
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
* @returns {object} 400 - { error: string } when ticket has no Jira key
* @returns {object} 404 - { error: string } when local ticket not found
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
* @returns {object} 500 - { error: string } on internal error
* @returns {object} 502 - { error: string } on Jira API error
* @returns {object} 503 - { error: string } when Jira API is not configured
*/
router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!jiraApi.isConfigured) {
return res.status(503).json({ error: 'Jira API is not configured.' });
@@ -347,8 +453,22 @@ function createJiraTicketsRouter() {
// Local CRUD endpoints
// -----------------------------------------------------------------------
/**
* GET /api/jira-tickets
*
* Lists all Jira tickets with optional filtering by query parameters.
* Results are ordered by created_at descending.
*
* @query {string} [cve_id] - Filter by exact CVE ID
* @query {string} [vendor] - Filter by exact vendor name
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
* @query {string} [source_context] - Filter by source context (cve, archer, ivanti_queue, email, manual)
* @requires Authenticated user
* @returns {array} 200 - Array of jira_tickets rows
* @returns {object} 500 - { error: string } on internal error
*/
router.get('/', requireAuth(), async (req, res) => {
const { cve_id, vendor, status } = req.query;
const { cve_id, vendor, status, source_context } = req.query;
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
const params = [];
@@ -366,6 +486,10 @@ function createJiraTicketsRouter() {
query += ` AND status = $${paramIndex++}`;
params.push(status);
}
if (source_context) {
query += ` AND source_context = $${paramIndex++}`;
params.push(source_context);
}
query += ' ORDER BY created_at DESC';
@@ -378,6 +502,23 @@ function createJiraTicketsRouter() {
}
});
/**
* POST /api/jira-tickets
*
* Creates a local Jira ticket record (without creating an issue in Jira).
* Used for manually tracking tickets that already exist in Jira.
*
* @requires Admin or Standard_User group
* @body {string} cve_id - Required CVE ID (format: CVE-YYYY-NNNN+)
* @body {string} vendor - Required vendor name (max 200 chars)
* @body {string} ticket_key - Required Jira ticket key (max 50 chars)
* @body {string} [url] - Optional Jira ticket URL (max 500 chars)
* @body {string} [summary] - Optional summary (max 500 chars)
* @body {string} [status] - Optional status: Open, In Progress, or Closed (defaults to Open)
* @returns {object} 201 - { id, message }
* @returns {object} 400 - { error: string } for validation failures
* @returns {object} 500 - { error: string } on internal error
*/
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
@@ -430,10 +571,32 @@ function createJiraTicketsRouter() {
}
});
/**
* PUT /api/jira-tickets/:id
*
* Updates an existing local Jira ticket record. Only provided fields are updated.
* The source_context field is immutable after creation — including it returns 400.
*
* @param {string} id - Local ticket ID (path parameter)
* @requires Admin or Standard_User group
* @body {string} [ticket_key] - Jira ticket key (max 50 chars)
* @body {string} [url] - Jira ticket URL (max 500 chars, null to clear)
* @body {string} [summary] - Summary (max 500 chars, null to clear)
* @body {string} [status] - Status: Open, In Progress, or Closed
* @returns {object} 200 - { message, changes }
* @returns {object} 400 - { error: string } for validation failures or source_context mutation attempt
* @returns {object} 404 - { error: string } when ticket not found
* @returns {object} 500 - { error: string } on internal error
*/
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { id } = req.params;
const { ticket_key, url, summary, status } = req.body;
// source_context is immutable after creation (Requirement 3.6)
if ('source_context' in req.body) {
return res.status(400).json({ error: 'source_context is immutable after creation' });
}
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
}
@@ -492,6 +655,20 @@ function createJiraTicketsRouter() {
}
});
/**
* DELETE /api/jira-tickets/:id
*
* Deletes a local Jira ticket record. Admin can delete any ticket.
* Standard_User can only delete tickets they created, and only if the ticket
* is not linked to an active compliance item.
*
* @param {string} id - Local ticket ID (path parameter)
* @requires Admin or Standard_User group
* @returns {object} 200 - { message }
* @returns {object} 403 - { error: string } when user lacks permission or ticket is linked to compliance
* @returns {object} 404 - { error: string } when ticket not found
* @returns {object} 500 - { error: string } on internal error
*/
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
const { id } = req.params;

View File

@@ -18,6 +18,7 @@ import CCPMetricsPage from './components/pages/CCPMetricsPage';
import JiraPage from './components/pages/JiraPage';
import AdminPage from './components/pages/AdminPage';
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
import ArcherPage from './components/pages/ArcherPage';
import FeedbackModal from './components/FeedbackModal';
import NotificationBell from './components/NotificationBell';
import './App.css';
@@ -2270,77 +2271,14 @@ export default function App() {
</div>
{/* Archer Risk Acceptance Tickets */}
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
<div className="flex justify-between items-center mb-4">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
<Shield className="w-5 h-5" />
Archer Risk Tickets
</h2>
{canWrite() && (
<button
onClick={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
>
<Plus className="w-3 h-3" />
</button>
)}
</div>
<div className="text-center mb-3">
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
{archerTickets.filter(t => t.status !== 'Accepted').length}
</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => (
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
<div className="flex items-start justify-between gap-2 mb-1">
<a
href={ticket.archer_url || '#'}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
>
{ticket.exc_number}
</a>
<div className="flex gap-1">
<button
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('triage'); }}
title="View findings referencing this ticket"
className="text-gray-400 hover:text-sky-400 transition-colors"
>
<Filter className="w-3 h-3" />
</button>
{canWrite() && (
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" />
</button>
)}
{canDelete(ticket) && (
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
<div className="text-xs text-gray-400">{ticket.vendor}</div>
<div className="mt-2">
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
<span style={{...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px'}}></span>
{ticket.status}
</span>
</div>
</div>
))}
{archerTickets.filter(t => t.status !== 'Accepted').length === 0 && (
<div className="text-center py-8">
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
</div>
)}
</div>
</div>
<ArcherPage
archerTickets={archerTickets}
onEditTicket={handleEditArcherTicket}
onDeleteTicket={handleDeleteArcherTicket}
onFilterByExc={(exc) => { setReportingExcFilter(exc); setCurrentPage('triage'); }}
onAddTicket={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
canDeleteTicket={canDelete}
/>
{/* Ivanti Workflows */}
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0D9488'}} className="rounded-lg">

View File

@@ -0,0 +1,421 @@
import React, { useState } from 'react';
import { Plus, X, Loader, Shield, Filter, Edit2, Trash2, CheckCircle } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles — matches tactical intelligence aesthetic
// ---------------------------------------------------------------------------
const STYLES = {
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(139, 92, 246, 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',
},
btn: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(139, 92, 246, 0.3)',
background: 'rgba(139, 92, 246, 0.1)',
color: '#C4B5FD',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
btnSuccess: {
border: '1px solid rgba(16, 185, 129, 0.3)',
background: 'rgba(16, 185, 129, 0.1)',
color: '#6EE7B7',
},
intelCard: {
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%)',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '0.75rem',
padding: '1.5rem',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
},
ticketCard: {
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)',
border: '1px solid rgba(139, 92, 246, 0.25)',
borderRadius: '0.375rem',
padding: '0.5rem',
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
},
badgeHigh: {
display: 'inline-flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.25rem 0.75rem',
borderRadius: '9999px',
fontSize: '0.7rem',
fontWeight: '600',
letterSpacing: '0.05em',
textTransform: 'uppercase',
border: '1px solid #F59E0B',
background: 'rgba(245, 158, 11, 0.15)',
color: '#F59E0B',
},
glowDot: (color) => ({
width: '8px',
height: '8px',
borderRadius: '50%',
background: color,
boxShadow: `0 0 6px ${color}, 0 0 12px ${color}`,
}),
};
// ---------------------------------------------------------------------------
// ArcherPage — Archer Risk Tickets panel with "Create Jira Ticket" action
// ---------------------------------------------------------------------------
export default function ArcherPage({
archerTickets = [],
onEditTicket,
onDeleteTicket,
onFilterByExc,
onAddTicket,
canDeleteTicket,
}) {
const { canWrite } = useAuth();
// Create Jira Ticket modal state
const [showCreateJiraModal, setShowCreateJiraModal] = useState(false);
const [createJiraForm, setCreateJiraForm] = useState({
summary: '',
cve_id: '',
vendor: '',
source_context: 'archer',
description: '',
project_key: '',
issue_type: '',
});
const [createJiraSaving, setCreateJiraSaving] = useState(false);
const [createJiraError, setCreateJiraError] = useState(null);
const [summaryError, setSummaryError] = useState(null);
const [createJiraSuccess, setCreateJiraSuccess] = useState(null);
// Open the Create Jira Ticket modal pre-populated with Archer ticket data
const openCreateJiraModal = (ticket) => {
setCreateJiraForm({
summary: ticket.exc_number || '',
cve_id: ticket.cve_id || '',
vendor: ticket.vendor || '',
source_context: 'archer',
description: '',
project_key: '',
issue_type: '',
});
setCreateJiraError(null);
setSummaryError(null);
setCreateJiraSuccess(null);
setShowCreateJiraModal(true);
};
// Submit the Create Jira Ticket form
const handleCreateJira = async () => {
setSummaryError(null);
const trimmedSummary = (createJiraForm.summary || '').trim();
if (!trimmedSummary) {
setSummaryError('Summary is required.');
return;
}
if (trimmedSummary.length > 255) {
setSummaryError('Summary must be 255 characters or fewer.');
return;
}
setCreateJiraError(null);
setCreateJiraSaving(true);
try {
const payload = {
summary: trimmedSummary,
source_context: 'archer',
};
if (createJiraForm.cve_id && createJiraForm.cve_id.trim()) {
payload.cve_id = createJiraForm.cve_id.trim();
}
if (createJiraForm.vendor && createJiraForm.vendor.trim()) {
payload.vendor = createJiraForm.vendor.trim();
}
if (createJiraForm.description && createJiraForm.description.trim()) {
payload.description = createJiraForm.description.trim();
}
if (createJiraForm.project_key && createJiraForm.project_key.trim()) {
payload.project_key = createJiraForm.project_key.trim();
}
if (createJiraForm.issue_type && createJiraForm.issue_type.trim()) {
payload.issue_type = createJiraForm.issue_type.trim();
}
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok && res.status !== 207) {
throw new Error(data.error || `HTTP ${res.status}`);
}
setCreateJiraSuccess(`Jira ticket ${data.ticket_key} created successfully.`);
// Reset form after short delay so user sees success
setTimeout(() => {
setShowCreateJiraModal(false);
setCreateJiraSuccess(null);
setCreateJiraForm({ summary: '', cve_id: '', vendor: '', source_context: 'archer', description: '', project_key: '', issue_type: '' });
}, 1500);
} catch (err) {
setCreateJiraError(err.message);
} finally {
setCreateJiraSaving(false);
}
};
const activeTickets = archerTickets.filter(t => t.status !== 'Accepted');
return (
<>
{/* Archer Risk Acceptance Tickets Card */}
<div style={{ ...STYLES.intelCard, borderLeft: '3px solid #8B5CF6' }} className="rounded-lg">
<div className="flex justify-between items-center mb-4">
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
<Shield className="w-5 h-5" />
Archer Risk Tickets
</h2>
{canWrite() && onAddTicket && (
<button
onClick={onAddTicket}
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
>
<Plus className="w-3 h-3" />
</button>
)}
</div>
<div className="text-center mb-3">
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
{activeTickets.length}
</div>
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{activeTickets.slice(0, 10).map(ticket => (
<div key={ticket.id} style={STYLES.ticketCard}>
<div className="flex items-start justify-between gap-2 mb-1">
<a
href={ticket.archer_url || '#'}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
>
{ticket.exc_number}
</a>
<div className="flex gap-1">
{onFilterByExc && (
<button
onClick={() => onFilterByExc(ticket.exc_number)}
title="View findings referencing this ticket"
className="text-gray-400 hover:text-sky-400 transition-colors"
>
<Filter className="w-3 h-3" />
</button>
)}
{canWrite() && (
<button
onClick={() => openCreateJiraModal(ticket)}
title="Create Jira Ticket"
className="text-gray-400 hover:text-green-400 transition-colors"
>
<Plus className="w-3 h-3" />
</button>
)}
{canWrite() && onEditTicket && (
<button onClick={() => onEditTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
<Edit2 className="w-3 h-3" />
</button>
)}
{canDeleteTicket && canDeleteTicket(ticket) && onDeleteTicket && (
<button onClick={() => onDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</div>
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
<div className="text-xs text-gray-400">{ticket.vendor}</div>
<div className="mt-2">
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
<span style={{ ...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px' }}></span>
{ticket.status}
</span>
</div>
</div>
))}
{activeTickets.length === 0 && (
<div className="text-center py-8">
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
</div>
)}
</div>
</div>
{/* Create Jira Ticket Modal */}
{showCreateJiraModal && (
<div style={STYLES.modal}>
<div style={STYLES.modalBackdrop} onClick={() => setShowCreateJiraModal(false)} />
<div style={STYLES.modalContent}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '1rem', fontFamily: 'monospace' }}>Create Jira Ticket from Archer</h3>
<button onClick={() => setShowCreateJiraModal(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}>
<X size={18} />
</button>
</div>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
Creates a Jira issue linked to this Archer risk ticket.
</p>
{createJiraSuccess && (
<div style={{ color: '#6EE7B7', fontSize: '0.85rem', marginBottom: '0.75rem', padding: '0.5rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '8px' }}>
{createJiraSuccess}
</div>
)}
{createJiraError && (
<div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem', padding: '0.5rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '8px' }}>
{createJiraError}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* Summary — required, pre-populated with exc_number */}
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>
Summary <span style={{ color: '#F59E0B' }}>*</span>
</label>
<input
style={{ ...STYLES.input, ...(summaryError ? { borderColor: 'rgba(239, 68, 68, 0.6)' } : {}) }}
placeholder="Issue summary (max 255 chars)"
value={createJiraForm.summary}
onChange={e => { setCreateJiraForm(f => ({ ...f, summary: e.target.value })); if (summaryError) setSummaryError(null); }}
maxLength={255}
/>
{summaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{summaryError}</div>}
</div>
{/* Source Context — locked to archer */}
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
<select
style={{ ...STYLES.input, cursor: 'not-allowed', opacity: 0.7 }}
value={createJiraForm.source_context}
disabled
>
<option value="archer">Archer Request</option>
</select>
</div>
{/* CVE ID — optional, pre-populated from Archer ticket */}
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
<input
style={STYLES.input}
placeholder="e.g. CVE-2024-12345"
value={createJiraForm.cve_id}
onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))}
/>
</div>
{/* Vendor — optional, pre-populated from Archer ticket */}
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
<input
style={STYLES.input}
placeholder="e.g. Microsoft"
value={createJiraForm.vendor}
onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))}
/>
</div>
{/* Description — optional */}
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description (optional)</label>
<textarea
style={{ ...STYLES.input, minHeight: '80px', resize: 'vertical' }}
placeholder="Detailed description..."
value={createJiraForm.description}
onChange={e => setCreateJiraForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
{/* Project Key and Issue Type */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
<input
style={STYLES.input}
placeholder="Uses .env default"
value={createJiraForm.project_key}
onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))}
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type (optional)</label>
<input
style={STYLES.input}
placeholder="Task"
value={createJiraForm.issue_type}
onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}
/>
</div>
</div>
{/* Submit button */}
<button
style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem', width: '100%' }}
onClick={handleCreateJira}
disabled={createJiraSaving}
>
{createJiraSaving ? <Loader size={14} className="animate-spin" /> : <Plus size={14} />}
Create Jira Ticket
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -136,6 +136,19 @@ const STATUS_COLORS = {
'Closed': '#10B981',
};
const SOURCE_CONTEXT_CONFIG = {
cve: { label: 'CVE', color: '#0EA5E9' },
archer: { label: 'Archer', color: '#8B5CF6' },
ivanti_queue: { label: 'Ivanti', color: '#F59E0B' },
email: { label: 'Email', color: '#10B981' },
manual: { label: 'Manual', color: '#94A3B8' },
};
const getSourceBadge = (sourceContext) => {
if (!sourceContext) return SOURCE_CONTEXT_CONFIG.cve; // legacy tickets default to CVE
return SOURCE_CONTEXT_CONFIG[sourceContext] || SOURCE_CONTEXT_CONFIG.cve;
};
// ---------------------------------------------------------------------------
// Component
@@ -150,6 +163,7 @@ export default function JiraPage() {
// Filters
const [filterStatus, setFilterStatus] = useState('');
const [filterSource, setFilterSource] = useState('');
const [filterSearch, setFilterSearch] = useState('');
// Connection test
@@ -178,9 +192,11 @@ export default function JiraPage() {
// Create-in-Jira modal
const [showCreateJira, setShowCreateJira] = useState(false);
const [createJiraForm, setCreateJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '' });
const [createJiraForm, setCreateJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '', source_context: '' });
const [createJiraError, setCreateJiraError] = useState(null);
const [createJiraSaving, setCreateJiraSaving] = useState(false);
const [createJiraLocked, setCreateJiraLocked] = useState({}); // { source_context: true } when set externally
const [summaryError, setSummaryError] = useState(null);
// ---------------------------------------------------------------------------
// Data fetching
@@ -333,21 +349,41 @@ export default function JiraPage() {
// Create in Jira
// ---------------------------------------------------------------------------
const createInJira = async () => {
// Inline summary validation
setSummaryError(null);
const trimmedSummary = (createJiraForm.summary || '').trim();
if (!trimmedSummary) {
setSummaryError('Summary is required.');
return;
}
if (trimmedSummary.length > 255) {
setSummaryError('Summary must be 255 characters or fewer.');
return;
}
setCreateJiraError(null);
setCreateJiraSaving(true);
try {
// Build payload — only include source_context when selected
const payload = { ...createJiraForm };
if (!payload.source_context) {
delete payload.source_context;
}
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(createJiraForm),
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok && res.status !== 207) {
throw new Error(data.error || `HTTP ${res.status}`);
}
setShowCreateJira(false);
setCreateJiraForm({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '' });
setCreateJiraForm({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '', source_context: '' });
setCreateJiraLocked({});
setSummaryError(null);
fetchTickets();
fetchRateLimit();
} catch (err) {
@@ -357,17 +393,43 @@ export default function JiraPage() {
}
};
// ---------------------------------------------------------------------------
// Open Create-in-Jira modal with optional pre-populated values and locks
// Called externally from Ivanti queue or Archer detail views
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-unused-vars
const openCreateJiraModal = (prePopulate = {}, locked = {}) => {
setCreateJiraForm({
cve_id: prePopulate.cve_id || '',
vendor: prePopulate.vendor || '',
summary: prePopulate.summary || '',
description: prePopulate.description || '',
project_key: prePopulate.project_key || '',
issue_type: prePopulate.issue_type || '',
source_context: prePopulate.source_context || '',
});
setCreateJiraLocked(locked);
setCreateJiraError(null);
setSummaryError(null);
setShowCreateJira(true);
};
// ---------------------------------------------------------------------------
// Filtering
// ---------------------------------------------------------------------------
const filtered = tickets.filter(t => {
if (filterStatus && t.status !== filterStatus) return false;
if (filterSource) {
const ticketSource = t.source_context || 'cve';
if (ticketSource !== filterSource) return false;
}
if (filterSearch) {
const q = filterSearch.toLowerCase();
return (t.ticket_key || '').toLowerCase().includes(q)
|| (t.cve_id || '').toLowerCase().includes(q)
|| (t.vendor || '').toLowerCase().includes(q)
|| (t.summary || '').toLowerCase().includes(q);
|| (t.summary || '').toLowerCase().includes(q)
|| (t.source_context || '').toLowerCase().includes(q);
}
return true;
});
@@ -405,7 +467,7 @@ export default function JiraPage() {
</button>
{canWrite() && (
<>
<button style={STYLES.btn} onClick={() => { setShowCreateJira(true); setCreateJiraError(null); }}>
<button style={STYLES.btn} onClick={() => { setShowCreateJira(true); setCreateJiraError(null); setSummaryError(null); setCreateJiraLocked({}); setCreateJiraForm({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '', source_context: '' }); }}>
<Plus size={14} /> Create in Jira
</button>
<button style={STYLES.btn} onClick={() => { setEditingId(null); setForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' }); setFormError(null); setShowForm(true); }}>
@@ -494,6 +556,18 @@ export default function JiraPage() {
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
<select
style={{ ...STYLES.input, width: 'auto', minWidth: '140px', cursor: 'pointer' }}
value={filterSource}
onChange={e => setFilterSource(e.target.value)}
>
<option value="">All Sources</option>
<option value="cve">CVE</option>
<option value="archer">Archer</option>
<option value="ivanti_queue">Ivanti</option>
<option value="email">Email</option>
<option value="manual">Manual</option>
</select>
</div>
{/* Table */}
@@ -519,6 +593,7 @@ export default function JiraPage() {
<th style={STYLES.th}>Ticket</th>
<th style={STYLES.th}>CVE</th>
<th style={STYLES.th}>Vendor</th>
<th style={STYLES.th}>Source</th>
<th style={STYLES.th}>Summary</th>
<th style={STYLES.th}>Status</th>
<th style={STYLES.th}>Jira Status</th>
@@ -544,6 +619,28 @@ export default function JiraPage() {
</td>
<td style={{ ...STYLES.td, fontFamily: 'monospace', fontSize: '0.8rem' }}>{t.cve_id}</td>
<td style={STYLES.td}>{t.vendor}</td>
<td style={STYLES.td}>
{(() => {
const badge = getSourceBadge(t.source_context);
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
padding: '0.15rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.65rem',
fontWeight: 600,
letterSpacing: '0.02em',
background: `${badge.color}22`,
color: badge.color,
border: `1px solid ${badge.color}44`,
whiteSpace: 'nowrap',
}}>
{badge.label}
</span>
);
})()}
</td>
<td style={{ ...STYLES.td, maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.summary || '-'}</td>
<td style={STYLES.td}>
<span style={STYLES.badge(STATUS_COLORS[t.status] || '#94A3B8')}>
@@ -682,21 +779,44 @@ export default function JiraPage() {
<button onClick={() => setShowCreateJira(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
</div>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
Creates a new issue in Jira via the REST API and links it to a CVE locally.
Creates a new issue in Jira via the REST API and tracks it locally.
</p>
{createJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{createJiraError}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID</label>
<input style={STYLES.input} placeholder="CVE-2024-1234" value={createJiraForm.cve_id} onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
<input style={STYLES.input} placeholder="e.g. CVE-2024-12345" value={createJiraForm.cve_id} onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor</label>
<input style={STYLES.input} placeholder="Vendor name" value={createJiraForm.vendor} onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} />
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
<input style={STYLES.input} placeholder="e.g. Microsoft" value={createJiraForm.vendor} onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary</label>
<input style={STYLES.input} placeholder="Issue summary (max 255 chars)" value={createJiraForm.summary} onChange={e => setCreateJiraForm(f => ({ ...f, summary: e.target.value }))} maxLength={255} />
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary <span style={{ color: '#F59E0B' }}>*</span></label>
<input
style={{ ...STYLES.input, ...(summaryError ? { borderColor: 'rgba(239, 68, 68, 0.6)' } : {}) }}
placeholder="Issue summary (max 255 chars)"
value={createJiraForm.summary}
onChange={e => { setCreateJiraForm(f => ({ ...f, summary: e.target.value })); if (summaryError) setSummaryError(null); }}
maxLength={255}
/>
{summaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{summaryError}</div>}
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
<select
style={{ ...STYLES.input, cursor: createJiraLocked.source_context ? 'not-allowed' : 'pointer', opacity: createJiraLocked.source_context ? 0.7 : 1 }}
value={createJiraForm.source_context}
onChange={e => setCreateJiraForm(f => ({ ...f, source_context: e.target.value }))}
disabled={createJiraLocked.source_context}
>
<option value=""> Select source </option>
<option value="cve">CVE</option>
<option value="archer">Archer Request</option>
<option value="ivanti_queue">Ivanti Queue</option>
<option value="email">Email</option>
<option value="manual">Manual</option>
</select>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description (optional)</label>

View File

@@ -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, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database } from 'lucide-react';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart';
@@ -1536,6 +1536,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
const [cardActionLoading, setCardActionLoading] = useState(false);
const [cardActionError, setCardActionError] = useState(null);
// Create Jira modal state
const [createJiraOpen, setCreateJiraOpen] = useState(false);
const [createJiraForm, setCreateJiraForm] = useState({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' });
const [createJiraError, setCreateJiraError] = useState(null);
const [createJiraSaving, setCreateJiraSaving] = useState(false);
const [createJiraSummaryError, setCreateJiraSummaryError] = useState(null);
// CARD Asset Search state
const [assetSearchOpen, setAssetSearchOpen] = useState(false);
const [assetSearchTeam, setAssetSearchTeam] = useState('');
@@ -1710,6 +1717,65 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
}
};
// Open Create Jira modal pre-populated from a queue item
const openCreateJiraFromQueue = (item) => {
// Parse cves_json — it may be a JSON string or already an array
let cves = [];
if (item.cves_json) {
try { cves = typeof item.cves_json === 'string' ? JSON.parse(item.cves_json) : item.cves_json; } catch { cves = []; }
} else if (item.cves && Array.isArray(item.cves)) {
cves = item.cves;
}
const firstCve = (Array.isArray(cves) && cves.length > 0) ? cves[0] : '';
const summary = (item.finding_title || '').slice(0, 255);
setCreateJiraForm({
summary,
cve_id: firstCve,
vendor: item.vendor || '',
source_context: 'ivanti_queue',
description: '',
project_key: '',
issue_type: '',
});
setCreateJiraError(null);
setCreateJiraSummaryError(null);
setCreateJiraOpen(true);
};
// Submit the Create Jira form
const submitCreateJira = async () => {
const trimmedSummary = (createJiraForm.summary || '').trim();
if (!trimmedSummary) {
setCreateJiraSummaryError('Summary is required.');
return;
}
if (trimmedSummary.length > 255) {
setCreateJiraSummaryError('Summary must be 255 characters or fewer.');
return;
}
setCreateJiraSummaryError(null);
setCreateJiraError(null);
setCreateJiraSaving(true);
try {
const payload = { ...createJiraForm };
if (!payload.source_context) delete payload.source_context;
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
setCreateJiraOpen(false);
setCreateJiraForm({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' });
} catch (err) {
setCreateJiraError(err.message);
} finally {
setCreateJiraSaving(false);
}
};
// Render a single queue item row
const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => {
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
@@ -1920,6 +1986,37 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
</button>
)}
{/* Create Jira Ticket button — pending items only */}
{canWrite && !done && (
<button
onClick={() => openCreateJiraFromQueue(item)}
style={{
background: 'rgba(14, 165, 233, 0.08)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '0.2rem',
padding: '0.15rem 0.35rem',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem',
color: '#7DD3FC',
fontFamily: 'monospace',
fontSize: '0.55rem',
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: '0.04em',
flexShrink: 0,
transition: 'all 0.12s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.18)'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.45)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.08)'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
title="Create Jira ticket from this queue item"
>
<Plus style={{ width: '10px', height: '10px' }} />
Jira
</button>
)}
{/* Delete button */}
<button
onClick={() => onDelete(item.id)}
@@ -2725,6 +2822,202 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
onRedirect={handleRedirectSuccess}
/>
)}
{/* Create Jira Ticket modal */}
{createJiraOpen && (
<div style={{ position: 'fixed', inset: 0, zIndex: 10100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }} onClick={() => setCreateJiraOpen(false)} />
<div style={{
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: 10101,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '1rem' }}>Create Issue in Jira</h3>
<button onClick={() => setCreateJiraOpen(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
</div>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
Creates a new Jira issue from this Ivanti queue item.
</p>
{createJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem', padding: '0.5rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.375rem' }}>{createJiraError}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary <span style={{ color: '#F59E0B' }}>*</span></label>
<input
style={{
background: 'rgba(15, 23, 42, 0.8)',
border: `1px solid ${createJiraSummaryError ? 'rgba(239, 68, 68, 0.6)' : 'rgba(14, 165, 233, 0.2)'}`,
borderRadius: '8px',
padding: '0.5rem 0.75rem',
color: '#F8FAFC',
fontSize: '0.85rem',
width: '100%',
outline: 'none',
boxSizing: 'border-box',
}}
placeholder="Issue summary (max 255 chars)"
value={createJiraForm.summary}
onChange={e => { setCreateJiraForm(f => ({ ...f, summary: e.target.value })); if (createJiraSummaryError) setCreateJiraSummaryError(null); }}
maxLength={255}
/>
{createJiraSummaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{createJiraSummaryError}</div>}
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
<input
style={{
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',
boxSizing: 'border-box',
}}
placeholder="e.g. CVE-2024-12345"
value={createJiraForm.cve_id}
onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))}
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
<input
style={{
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',
boxSizing: 'border-box',
}}
placeholder="e.g. Microsoft"
value={createJiraForm.vendor}
onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))}
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
<select
style={{
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',
boxSizing: 'border-box',
cursor: 'not-allowed',
opacity: 0.7,
}}
value={createJiraForm.source_context}
disabled
>
<option value="ivanti_queue">Ivanti Queue</option>
</select>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description (optional)</label>
<textarea
style={{
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',
boxSizing: 'border-box',
minHeight: '80px',
resize: 'vertical',
}}
placeholder="Detailed description..."
value={createJiraForm.description}
onChange={e => setCreateJiraForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
<input
style={{
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',
boxSizing: 'border-box',
}}
placeholder="Uses .env default"
value={createJiraForm.project_key}
onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))}
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type (optional)</label>
<input
style={{
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',
boxSizing: 'border-box',
}}
placeholder="Task"
value={createJiraForm.issue_type}
onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}
/>
</div>
</div>
<button
style={{
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(16, 185, 129, 0.3)',
background: 'rgba(16, 185, 129, 0.1)',
color: '#6EE7B7',
cursor: createJiraSaving ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
marginTop: '0.5rem',
width: '100%',
}}
onClick={submitCreateJira}
disabled={createJiraSaving}
>
{createJiraSaving ? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <Plus size={14} />}
Create in Jira
</button>
</div>
</div>
</div>
)}
</>
);
}