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:
421
frontend/src/components/pages/ArcherPage.js
Normal file
421
frontend/src/components/pages/ArcherPage.js
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user