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

@@ -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>