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', }; // --------------------------------------------------------------------------- // 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 [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); // 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: '' }); const [createJiraError, setCreateJiraError] = useState(null); const [createJiraSaving, setCreateJiraSaving] = useState(false); // --------------------------------------------------------------------------- // 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); } }; // --------------------------------------------------------------------------- // 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 () => { setCreateJiraError(null); setCreateJiraSaving(true); try { const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(createJiraForm), }); 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: '' }); fetchTickets(); fetchRateLimit(); } catch (err) { setCreateJiraError(err.message); } finally { setCreateJiraSaving(false); } }; // --------------------------------------------------------------------------- // Filtering // --------------------------------------------------------------------------- const filtered = tickets.filter(t => { if (filterStatus && t.status !== filterStatus) 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); } return true; }); const counts = { total: tickets.length, open: tickets.filter(t => t.status === 'Open').length, inProgress: tickets.filter(t => t.status === 'In Progress').length, closed: tickets.filter(t => t.status === 'Closed').length, }; // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return (
{/* Page header */}

Jira Tickets

Track and sync Jira issues linked to CVE findings

{isAdmin() && ( )} {canWrite() && ( <> )} {isAdmin() && ( )}
{/* Connection status banner */} {connectionStatus && connectionStatus !== 'testing' && (
{connectionStatus.connected ? <>Connected as {connectionStatus.user?.displayName || connectionStatus.user?.name} : <>Connection failed: {connectionStatus.error || `HTTP ${connectionStatus.status}`} }
)} {/* Sync result banner */} {syncResult && (
Sync complete: {syncResult.synced} updated, {syncResult.unchanged || 0} unchanged, {syncResult.failed} failed, {syncResult.skipped} skipped {syncResult.errors?.length > 0 && (
{syncResult.errors.slice(0, 3).map((e, i) =>
{e}
)}
)}
)} {/* Stats row */}
{[ { label: 'Total', value: counts.total, color: '#0EA5E9' }, { label: 'Open', value: counts.open, color: '#F59E0B' }, { label: 'In Progress', value: counts.inProgress, color: '#0EA5E9' }, { label: 'Closed', value: counts.closed, color: '#10B981' }, ].map(s => (
{s.label}
{s.value}
))} {rateLimit && (
API Budget
{rateLimit.daily.remaining}/{rateLimit.daily.limit}
burst: {rateLimit.burst.remaining}/{rateLimit.burst.limit}
)}
{/* Filters */}
setFilterSearch(e.target.value)} />
{/* Table */} {loading ? (
Loading tickets...
) : error ? (
{error}
) : filtered.length === 0 ? (
{tickets.length === 0 ? 'No Jira tickets yet. Add one manually or create from Jira.' : 'No tickets match your filters.'}
) : (
{filtered.map(t => ( e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} > ))}
Ticket CVE Vendor Summary Status Jira Status Last Synced Actions
{t.ticket_key} {t.url && ( )}
{t.cve_id} {t.vendor} {t.summary || '-'} {t.status} {t.jira_status || '-'} {t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'}
{canWrite() && t.ticket_key && ( )} {canWrite() && ( )} {canWrite() && ( )}
)} {/* Lookup Modal */} {showLookup && (
setShowLookup(false)} />

Lookup Jira Issue

setLookupKey(e.target.value.toUpperCase())} onKeyDown={e => e.key === 'Enter' && doLookup()} />
{lookupError &&
{lookupError}
} {lookupResult && (
{lookupResult.key}
Summary: {lookupResult.summary}
Status: {lookupResult.status}
Type: {lookupResult.issuetype}
Priority: {lookupResult.priority}
Assignee: {lookupResult.assignee || 'Unassigned'}
Updated: {lookupResult.updated ? new Date(lookupResult.updated).toLocaleString() : '-'}
)}
)} {/* Add/Edit Modal */} {showForm && (
setShowForm(false)} />

{editingId ? 'Edit Ticket' : 'Add Jira Ticket'}

{formError &&
{formError}
}
setForm(f => ({ ...f, cve_id: e.target.value }))} disabled={!!editingId} />
setForm(f => ({ ...f, vendor: e.target.value }))} disabled={!!editingId} />
setForm(f => ({ ...f, ticket_key: e.target.value.toUpperCase() }))} />
setForm(f => ({ ...f, url: e.target.value }))} />
setForm(f => ({ ...f, summary: e.target.value }))} />
)} {/* Create in Jira Modal */} {showCreateJira && (
setShowCreateJira(false)} />

Create Issue in Jira

Creates a new issue in Jira via the REST API and links it to a CVE locally.

{createJiraError &&
{createJiraError}
}
setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} />
setCreateJiraForm(f => ({ ...f, summary: e.target.value }))} maxLength={255} />