Add Jira Data Center integration with UAT test script and use case docs
This commit is contained in:
725
frontend/src/components/pages/JiraPage.js
Normal file
725
frontend/src/components/pages/JiraPage.js
Normal file
@@ -0,0 +1,725 @@
|
||||
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 (
|
||||
<div style={STYLES.page}>
|
||||
{/* Page header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.25rem', color: '#F8FAFC', fontWeight: 700 }}>Jira Tickets</h2>
|
||||
<p style={{ margin: '0.25rem 0 0', fontSize: '0.8rem', color: '#94A3B8' }}>
|
||||
Track and sync Jira issues linked to CVE findings
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{isAdmin() && (
|
||||
<button style={STYLES.btn} onClick={testConnection} disabled={connectionStatus === 'testing'}>
|
||||
{connectionStatus === 'testing' ? <Loader size={14} className="animate-spin" /> : connectionStatus?.connected ? <Wifi size={14} /> : <WifiOff size={14} />}
|
||||
Test Connection
|
||||
</button>
|
||||
)}
|
||||
<button style={STYLES.btn} onClick={() => setShowLookup(true)}>
|
||||
<Search size={14} /> Lookup Issue
|
||||
</button>
|
||||
{canWrite() && (
|
||||
<>
|
||||
<button style={STYLES.btn} onClick={() => { setShowCreateJira(true); setCreateJiraError(null); }}>
|
||||
<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); }}>
|
||||
<Plus size={14} /> Add Manual
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAdmin() && (
|
||||
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess }} onClick={syncAll} disabled={syncing}>
|
||||
{syncing ? <Loader size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
Sync All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection status banner */}
|
||||
{connectionStatus && connectionStatus !== 'testing' && (
|
||||
<div style={{ ...STYLES.card, padding: '0.75rem 1rem', marginBottom: '1rem', borderColor: connectionStatus.connected ? 'rgba(16, 185, 129, 0.3)' : 'rgba(239, 68, 68, 0.3)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem' }}>
|
||||
{connectionStatus.connected
|
||||
? <><CheckCircle size={16} color="#10B981" /><span style={{ color: '#6EE7B7' }}>Connected as {connectionStatus.user?.displayName || connectionStatus.user?.name}</span></>
|
||||
: <><AlertCircle size={16} color="#EF4444" /><span style={{ color: '#FCA5A5' }}>Connection failed: {connectionStatus.error || `HTTP ${connectionStatus.status}`}</span></>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync result banner */}
|
||||
{syncResult && (
|
||||
<div style={{ ...STYLES.card, padding: '0.75rem 1rem', marginBottom: '1rem', borderColor: 'rgba(14, 165, 233, 0.3)' }}>
|
||||
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>
|
||||
Sync complete: {syncResult.synced} updated, {syncResult.unchanged || 0} unchanged, {syncResult.failed} failed, {syncResult.skipped} skipped
|
||||
{syncResult.errors?.length > 0 && (
|
||||
<div style={{ marginTop: '0.25rem', fontSize: '0.75rem', color: '#FCA5A5' }}>
|
||||
{syncResult.errors.slice(0, 3).map((e, i) => <div key={i}>{e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1.5rem' }}>
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.label} style={STYLES.statCard}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: s.color }} />
|
||||
<div style={{ fontSize: '0.65rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>{s.label}</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: s.color, fontFamily: 'monospace' }}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
{rateLimit && (
|
||||
<div style={STYLES.statCard}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: rateLimit.daily.remaining < 100 ? '#EF4444' : '#8B5CF6' }} />
|
||||
<div style={{ fontSize: '0.65rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>API Budget</div>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 700, color: '#C4B5FD', fontFamily: 'monospace' }}>
|
||||
{rateLimit.daily.remaining}/{rateLimit.daily.limit}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.65rem', color: '#94A3B8' }}>burst: {rateLimit.burst.remaining}/{rateLimit.burst.limit}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ ...STYLES.card, display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', padding: '1rem 1.25rem' }}>
|
||||
<div style={{ flex: '1 1 250px' }}>
|
||||
<input
|
||||
style={STYLES.input}
|
||||
placeholder="Search tickets, CVEs, vendors..."
|
||||
value={filterSearch}
|
||||
onChange={e => setFilterSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
style={{ ...STYLES.input, width: 'auto', minWidth: '140px', cursor: 'pointer' }}
|
||||
value={filterStatus}
|
||||
onChange={e => setFilterStatus(e.target.value)}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: '#94A3B8' }}>
|
||||
<Loader size={24} className="animate-spin" style={{ margin: '0 auto 0.5rem' }} />
|
||||
Loading tickets...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: '#FCA5A5' }}>
|
||||
<AlertCircle size={20} style={{ margin: '0 auto 0.5rem' }} />
|
||||
{error}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: '#94A3B8' }}>
|
||||
{tickets.length === 0 ? 'No Jira tickets yet. Add one manually or create from Jira.' : 'No tickets match your filters.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ ...STYLES.card, padding: '0', overflow: 'auto' }}>
|
||||
<table style={STYLES.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={STYLES.th}>Ticket</th>
|
||||
<th style={STYLES.th}>CVE</th>
|
||||
<th style={STYLES.th}>Vendor</th>
|
||||
<th style={STYLES.th}>Summary</th>
|
||||
<th style={STYLES.th}>Status</th>
|
||||
<th style={STYLES.th}>Jira Status</th>
|
||||
<th style={STYLES.th}>Last Synced</th>
|
||||
<th style={STYLES.th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(t => (
|
||||
<tr key={t.id} style={{ transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14, 165, 233, 0.05)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<td style={STYLES.td}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#7DD3FC' }}>{t.ticket_key}</span>
|
||||
{t.url && (
|
||||
<a href={t.url} target="_blank" rel="noopener noreferrer" style={{ color: '#94A3B8' }} title="Open in Jira">
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</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, maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.summary || '-'}</td>
|
||||
<td style={STYLES.td}>
|
||||
<span style={STYLES.badge(STATUS_COLORS[t.status] || '#94A3B8')}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLORS[t.status] || '#94A3B8' }} />
|
||||
{t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...STYLES.td, fontSize: '0.8rem', color: '#CBD5E1' }}>{t.jira_status || '-'}</td>
|
||||
<td style={{ ...STYLES.td, fontSize: '0.75rem', color: '#94A3B8' }}>
|
||||
{t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
<td style={STYLES.td}>
|
||||
<div style={{ display: 'flex', gap: '0.3rem' }}>
|
||||
{canWrite() && t.ticket_key && (
|
||||
<button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => syncOne(t.id)} title="Sync with Jira">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && (
|
||||
<button style={{ ...STYLES.btn, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => {
|
||||
setEditingId(t.id);
|
||||
setForm({ cve_id: t.cve_id, vendor: t.vendor, ticket_key: t.ticket_key, url: t.url || '', summary: t.summary || '', status: t.status });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}} title="Edit">
|
||||
<Edit3 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && (
|
||||
<button style={{ ...STYLES.btn, ...STYLES.btnDanger, padding: '0.25rem 0.5rem', fontSize: '0.7rem' }} onClick={() => deleteTicket(t.id)} title="Delete">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lookup Modal */}
|
||||
{showLookup && (
|
||||
<div style={STYLES.modal}>
|
||||
<div style={STYLES.modalBackdrop} onClick={() => setShowLookup(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' }}>Lookup Jira Issue</h3>
|
||||
<button onClick={() => setShowLookup(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<input
|
||||
style={{ ...STYLES.input, flex: 1 }}
|
||||
placeholder="e.g. VULN-123"
|
||||
value={lookupKey}
|
||||
onChange={e => setLookupKey(e.target.value.toUpperCase())}
|
||||
onKeyDown={e => e.key === 'Enter' && doLookup()}
|
||||
/>
|
||||
<button style={STYLES.btn} onClick={doLookup} disabled={lookupLoading}>
|
||||
{lookupLoading ? <Loader size={14} className="animate-spin" /> : <Search size={14} />}
|
||||
Lookup
|
||||
</button>
|
||||
</div>
|
||||
{lookupError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{lookupError}</div>}
|
||||
{lookupResult && (
|
||||
<div style={{ background: 'rgba(15, 23, 42, 0.6)', borderRadius: '8px', padding: '1rem', fontSize: '0.85rem', color: '#E2E8F0' }}>
|
||||
<div style={{ fontWeight: 700, color: '#7DD3FC', marginBottom: '0.5rem' }}>{lookupResult.key}</div>
|
||||
<div><strong>Summary:</strong> {lookupResult.summary}</div>
|
||||
<div><strong>Status:</strong> {lookupResult.status}</div>
|
||||
<div><strong>Type:</strong> {lookupResult.issuetype}</div>
|
||||
<div><strong>Priority:</strong> {lookupResult.priority}</div>
|
||||
<div><strong>Assignee:</strong> {lookupResult.assignee || 'Unassigned'}</div>
|
||||
<div><strong>Updated:</strong> {lookupResult.updated ? new Date(lookupResult.updated).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{showForm && (
|
||||
<div style={STYLES.modal}>
|
||||
<div style={STYLES.modalBackdrop} onClick={() => setShowForm(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' }}>{editingId ? 'Edit Ticket' : 'Add Jira Ticket'}</h3>
|
||||
<button onClick={() => setShowForm(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
|
||||
</div>
|
||||
{formError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{formError}</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={form.cve_id} onChange={e => setForm(f => ({ ...f, cve_id: e.target.value }))} disabled={!!editingId} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor</label>
|
||||
<input style={STYLES.input} placeholder="Vendor name" value={form.vendor} onChange={e => setForm(f => ({ ...f, vendor: e.target.value }))} disabled={!!editingId} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Ticket Key</label>
|
||||
<input style={STYLES.input} placeholder="PROJECT-123" value={form.ticket_key} onChange={e => setForm(f => ({ ...f, ticket_key: e.target.value.toUpperCase() }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>URL</label>
|
||||
<input style={STYLES.input} placeholder="https://jira.example.com/browse/..." value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary</label>
|
||||
<input style={STYLES.input} placeholder="Brief description" value={form.summary} onChange={e => setForm(f => ({ ...f, summary: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Status</label>
|
||||
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={saveTicket} disabled={formSaving}>
|
||||
{formSaving ? <Loader size={14} className="animate-spin" /> : <CheckCircle size={14} />}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create in Jira Modal */}
|
||||
{showCreateJira && (
|
||||
<div style={STYLES.modal}>
|
||||
<div style={STYLES.modalBackdrop} onClick={() => setShowCreateJira(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' }}>Create Issue in Jira</h3>
|
||||
<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.
|
||||
</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 }))} />
|
||||
</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 }))} />
|
||||
</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} />
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={createInJira} disabled={createJiraSaving}>
|
||||
{createJiraSaving ? <Loader size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
Create in Jira
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user