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

@@ -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>
)}
</>
);
}