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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user