import React, { useState, useEffect, useCallback } from 'react'; import { Search, RefreshCw, Plus, ExternalLink, Loader, AlertCircle, CheckCircle, Trash2, Edit3, X, Wifi, WifiOff, BarChart2 } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // --------------------------------------------------------------------------- // Styles — matches DESIGN_SYSTEM.md tactical intelligence aesthetic // --------------------------------------------------------------------------- const STYLES = { page: { minHeight: '60vh', }, card: { background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))', border: '1px solid rgba(14, 165, 233, 0.15)', borderRadius: '12px', padding: '1.5rem', marginBottom: '1rem', }, header: { fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', marginBottom: '1rem', }, statCard: { background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.95))', border: '1px solid rgba(14, 165, 233, 0.15)', borderRadius: '10px', padding: '1rem 1.25rem', position: 'relative', overflow: 'hidden', }, btn: { padding: '0.5rem 1rem', borderRadius: '8px', border: '1px solid rgba(14, 165, 233, 0.3)', background: 'rgba(14, 165, 233, 0.1)', color: '#7DD3FC', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.4rem', transition: 'all 0.2s', }, btnDanger: { border: '1px solid rgba(239, 68, 68, 0.3)', background: 'rgba(239, 68, 68, 0.1)', color: '#FCA5A5', }, btnSuccess: { border: '1px solid rgba(16, 185, 129, 0.3)', background: 'rgba(16, 185, 129, 0.1)', color: '#6EE7B7', }, 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', }, table: { width: '100%', borderCollapse: 'separate', borderSpacing: '0 4px', }, th: { textAlign: 'left', padding: '0.5rem 0.75rem', fontSize: '0.7rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em', borderBottom: '1px solid rgba(14, 165, 233, 0.1)', }, td: { padding: '0.6rem 0.75rem', fontSize: '0.85rem', color: '#E2E8F0', borderBottom: '1px solid rgba(51, 65, 85, 0.3)', }, badge: (color) => ({ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.6rem', borderRadius: '9999px', fontSize: '0.7rem', fontWeight: 600, border: `1px solid ${color}`, background: color.replace(')', ', 0.15)').replace('rgb', 'rgba'), color: color, }), 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(14, 165, 233, 0.25)', borderRadius: '16px', padding: '2rem', width: '90%', maxWidth: '520px', maxHeight: '85vh', overflowY: 'auto', zIndex: 101, }, }; const STATUS_COLORS = { 'Open': '#F59E0B', 'In Progress': '#0EA5E9', 'Closed': '#10B981', 'Done': '#10B981', 'Resolved': '#10B981', 'Approval/Handoff': '#8B5CF6', 'Prioritizing': '#0EA5E9', 'In Review': '#0EA5E9', 'In Development': '#0EA5E9', 'In Testing': '#0EA5E9', }; // Determine if a status represents a "closed/done" state function isClosedStatus(status) { if (!status) return false; const lower = status.toLowerCase(); return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s)); } function getStatusColor(status) { if (STATUS_COLORS[status]) return STATUS_COLORS[status]; if (isClosedStatus(status)) return '#10B981'; return '#F59E0B'; } 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 // --------------------------------------------------------------------------- export default function JiraPage() { const { canWrite, isAdmin } = useAuth(); // Data state const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Filters const [filterStatus, setFilterStatus] = useState(''); const [filterSource, setFilterSource] = useState(''); const [filterSearch, setFilterSearch] = useState(''); // Connection test const [connectionStatus, setConnectionStatus] = useState(null); // null | 'testing' | { connected, user?, error? } // Rate limit const [rateLimit, setRateLimit] = useState(null); // Sync const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); // Lookup modal const [showLookup, setShowLookup] = useState(false); const [lookupKey, setLookupKey] = useState(''); const [lookupResult, setLookupResult] = useState(null); const [lookupLoading, setLookupLoading] = useState(false); const [lookupError, setLookupError] = useState(null); const [linkingSaving, setLinkingSaving] = useState(false); const [linkingError, setLinkingError] = useState(null); const [linkingSuccess, setLinkingSuccess] = useState(null); // Add/Edit modal const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' }); const [formError, setFormError] = useState(null); const [formSaving, setFormSaving] = useState(false); // Create-in-Jira modal const [showCreateJira, setShowCreateJira] = useState(false); 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 // --------------------------------------------------------------------------- const fetchTickets = useCallback(async () => { setLoading(true); setError(null); try { const res = await fetch(`${API_BASE}/jira-tickets`, { credentials: 'include' }); if (!res.ok) throw new Error('Failed to fetch tickets'); const data = await res.json(); setTickets(data); } catch (err) { setError(err.message); } finally { setLoading(false); } }, []); useEffect(() => { fetchTickets(); }, [fetchTickets]); // --------------------------------------------------------------------------- // Connection test // --------------------------------------------------------------------------- const testConnection = async () => { setConnectionStatus('testing'); try { const res = await fetch(`${API_BASE}/jira-tickets/connection-test`, { credentials: 'include' }); const data = await res.json(); setConnectionStatus(data); } catch (err) { setConnectionStatus({ connected: false, error: err.message }); } }; // --------------------------------------------------------------------------- // Rate limit // --------------------------------------------------------------------------- const fetchRateLimit = async () => { try { const res = await fetch(`${API_BASE}/jira-tickets/rate-limit`, { credentials: 'include' }); if (res.ok) setRateLimit(await res.json()); } catch (_) { /* ignore */ } }; useEffect(() => { if (isAdmin()) fetchRateLimit(); }, [isAdmin]); // --------------------------------------------------------------------------- // Sync all // --------------------------------------------------------------------------- const syncAll = async () => { setSyncing(true); setSyncResult(null); try { const res = await fetch(`${API_BASE}/jira-tickets/sync-all`, { method: 'POST', credentials: 'include' }); const data = await res.json(); setSyncResult(data); fetchTickets(); fetchRateLimit(); } catch (err) { setSyncResult({ errors: [err.message] }); } finally { setSyncing(false); } }; // --------------------------------------------------------------------------- // Lookup // --------------------------------------------------------------------------- const doLookup = async () => { if (!lookupKey.trim()) return; setLookupLoading(true); setLookupError(null); setLookupResult(null); try { const res = await fetch(`${API_BASE}/jira-tickets/lookup/${encodeURIComponent(lookupKey.trim())}`, { credentials: 'include' }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || `HTTP ${res.status}`); } setLookupResult(await res.json()); } catch (err) { setLookupError(err.message); } finally { setLookupLoading(false); } }; // --------------------------------------------------------------------------- // Link existing Jira ticket — save to local DB without recreating in Jira // --------------------------------------------------------------------------- const linkExistingTicket = async (issue) => { setLinkingError(null); setLinkingSuccess(null); setLinkingSaving(true); try { const jiraUrl = `https://jira.charter.com/browse/${issue.key}`; const res = await fetch(`${API_BASE}/jira-tickets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket_key: issue.key, url: jiraUrl, summary: issue.summary || '', status: issue.status || 'Open', }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); setLinkingSuccess(`${issue.key} saved to dashboard.`); fetchTickets(); } catch (err) { setLinkingError(err.message); } finally { setLinkingSaving(false); } }; // --------------------------------------------------------------------------- // CRUD — save (create or update) // --------------------------------------------------------------------------- const saveTicket = async () => { setFormError(null); setFormSaving(true); try { const method = editingId ? 'PUT' : 'POST'; const url = editingId ? `${API_BASE}/jira-tickets/${editingId}` : `${API_BASE}/jira-tickets`; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(form), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || `HTTP ${res.status}`); } setShowForm(false); setEditingId(null); fetchTickets(); } catch (err) { setFormError(err.message); } finally { setFormSaving(false); } }; const deleteTicket = async (id) => { if (!window.confirm('Delete this Jira ticket record?')) return; try { const res = await fetch(`${API_BASE}/jira-tickets/${id}`, { method: 'DELETE', credentials: 'include' }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || `HTTP ${res.status}`); } fetchTickets(); } catch (err) { alert(err.message); } }; const syncOne = async (id) => { try { const res = await fetch(`${API_BASE}/jira-tickets/${id}/sync`, { method: 'POST', credentials: 'include' }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || `HTTP ${res.status}`); } fetchTickets(); fetchRateLimit(); } catch (err) { alert(err.message); } }; // --------------------------------------------------------------------------- // 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(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: '', source_context: '' }); setCreateJiraLocked({}); setSummaryError(null); fetchTickets(); fetchRateLimit(); } catch (err) { setCreateJiraError(err.message); } finally { setCreateJiraSaving(false); } }; // --------------------------------------------------------------------------- // 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.source_context || '').toLowerCase().includes(q); } return true; }); const counts = { total: tickets.length, open: tickets.filter(t => !isClosedStatus(t.status)).length, closed: tickets.filter(t => isClosedStatus(t.status)).length, }; // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return (
Track and sync Jira issues linked to CVE findings
| Ticket | CVE | Vendor | Source | Summary | Status | Last Synced | Actions |
|---|---|---|---|---|---|---|---|
| {t.cve_id} | {t.vendor} | {(() => { const badge = getSourceBadge(t.source_context); return ( {badge.label} ); })()} | {t.summary || '-'} | {t.status} | {t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'} |
{canWrite() && t.ticket_key && (
)}
{canWrite() && (
)}
{canWrite() && (
)}
|
Creates a new issue in Jira via the REST API and tracks it locally.
{createJiraError &&