Added JIRA ticket tracking feature
- New jira_tickets table (migration script included) - CRUD API endpoints for tickets with validation and audit logging - Dashboard section showing all open vendor tickets - JIRA tickets section within CVE vendor cards - Tickets linked to CVE + vendor with status tracking (Open/In Progress/Closed) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
39
backend/migrate_jira_tickets.js
Normal file
39
backend/migrate_jira_tickets.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Migration: Add jira_tickets table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting JIRA tickets migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Create jira_tickets table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cve_id TEXT NOT NULL,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
ticket_key TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
status TEXT DEFAULT 'Open' CHECK(status IN ('Open', 'In Progress', 'Closed')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (cve_id, vendor) REFERENCES cves(cve_id, vendor) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating table:', err);
|
||||||
|
else console.log('✓ jira_tickets table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_cve ON jira_tickets(cve_id, vendor)');
|
||||||
|
db.run('CREATE INDEX IF NOT EXISTS idx_jira_tickets_status ON jira_tickets(status)');
|
||||||
|
|
||||||
|
console.log('✓ Indexes created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
@@ -78,6 +78,7 @@ function isValidCveId(cveId) {
|
|||||||
const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low'];
|
const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low'];
|
||||||
const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved'];
|
const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved'];
|
||||||
const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
|
const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
|
||||||
|
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
||||||
|
|
||||||
// Validate vendor name - printable chars, reasonable length
|
// Validate vendor name - printable chars, reasonable length
|
||||||
function isValidVendor(vendor) {
|
function isValidVendor(vendor) {
|
||||||
@@ -876,6 +877,192 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== JIRA TICKET ENDPOINTS ==========
|
||||||
|
|
||||||
|
// Get all JIRA tickets (with optional filters)
|
||||||
|
app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
|
||||||
|
const { cve_id, vendor, status } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (cve_id) {
|
||||||
|
query += ' AND cve_id = ?';
|
||||||
|
params.push(cve_id);
|
||||||
|
}
|
||||||
|
if (vendor) {
|
||||||
|
query += ' AND vendor = ?';
|
||||||
|
params.push(vendor);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
query += ' AND status = ?';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC';
|
||||||
|
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching JIRA tickets:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create JIRA ticket
|
||||||
|
app.post('/api/jira-tickets', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!cve_id || !isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||||
|
}
|
||||||
|
if (!vendor || !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||||
|
}
|
||||||
|
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||||
|
}
|
||||||
|
if (url && (typeof url !== 'string' || url.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketStatus = status || 'Open';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error creating JIRA ticket:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'jira_ticket_create',
|
||||||
|
entityType: 'jira_ticket',
|
||||||
|
entityId: this.lastID.toString(),
|
||||||
|
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: this.lastID,
|
||||||
|
message: 'JIRA ticket created successfully'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update JIRA ticket
|
||||||
|
app.put('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { ticket_key, url, summary, status } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||||
|
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||||
|
}
|
||||||
|
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||||
|
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||||
|
}
|
||||||
|
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic update
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
||||||
|
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
||||||
|
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
||||||
|
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No fields to update.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||||
|
if (updateErr) {
|
||||||
|
console.error('Error updating JIRA ticket:', updateErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'jira_ticket_update',
|
||||||
|
entityType: 'jira_ticket',
|
||||||
|
entityId: id,
|
||||||
|
details: { before: existing, changes: req.body },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete JIRA ticket
|
||||||
|
app.delete('/api/jira-tickets/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!ticket) {
|
||||||
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
||||||
|
if (deleteErr) {
|
||||||
|
console.error('Error deleting JIRA ticket:', deleteErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'jira_ticket_delete',
|
||||||
|
entityType: 'jira_ticket',
|
||||||
|
entityId: id,
|
||||||
|
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'JIRA ticket deleted successfully' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ export default function App() {
|
|||||||
const [editNvdError, setEditNvdError] = useState(null);
|
const [editNvdError, setEditNvdError] = useState(null);
|
||||||
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
|
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
|
||||||
const [expandedCVEs, setExpandedCVEs] = useState({});
|
const [expandedCVEs, setExpandedCVEs] = useState({});
|
||||||
|
const [jiraTickets, setJiraTickets] = useState([]);
|
||||||
|
const [showAddTicket, setShowAddTicket] = useState(false);
|
||||||
|
const [showEditTicket, setShowEditTicket] = useState(false);
|
||||||
|
const [editingTicket, setEditingTicket] = useState(null);
|
||||||
|
const [ticketForm, setTicketForm] = useState({
|
||||||
|
cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open'
|
||||||
|
});
|
||||||
|
// For adding ticket from within a CVE card
|
||||||
|
const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor }
|
||||||
|
|
||||||
const toggleCVEExpand = (cveId) => {
|
const toggleCVEExpand = (cveId) => {
|
||||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||||
@@ -125,6 +134,19 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchJiraTickets = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/jira-tickets`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch JIRA tickets');
|
||||||
|
const data = await response.json();
|
||||||
|
setJiraTickets(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching JIRA tickets:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDocuments = async (cveId, vendor) => {
|
const fetchDocuments = async (cveId, vendor) => {
|
||||||
const key = `${cveId}-${vendor}`;
|
const key = `${cveId}-${vendor}`;
|
||||||
if (cveDocuments[key]) return;
|
if (cveDocuments[key]) return;
|
||||||
@@ -391,14 +413,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/cves/${cve.id}`, {
|
const url = `${API_BASE}/cves/${cve.id}`;
|
||||||
|
console.log('DELETE request to:', url);
|
||||||
|
const response = await fetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to delete CVE entry');
|
throw new Error(data.error || 'Failed to delete CVE entry');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(`Deleted ${cve.vendor} entry for ${cve.cve_id}`);
|
alert(`Deleted ${cve.vendor} entry for ${cve.cve_id}`);
|
||||||
@@ -415,14 +444,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`, {
|
const url = `${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`;
|
||||||
|
console.log('DELETE request to:', url);
|
||||||
|
const response = await fetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
throw new Error(data.error || 'Failed to delete CVE');
|
throw new Error(data.error || 'Failed to delete CVE');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Server returned ${response.status} ${response.statusText}. Check API_BASE configuration.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(`Deleted all entries for ${cveId}`);
|
alert(`Deleted all entries for ${cveId}`);
|
||||||
@@ -433,11 +469,97 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddTicket = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/jira-tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(ticketForm)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to create ticket');
|
||||||
|
}
|
||||||
|
alert('JIRA ticket added successfully!');
|
||||||
|
setShowAddTicket(false);
|
||||||
|
setAddTicketContext(null);
|
||||||
|
setTicketForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' });
|
||||||
|
fetchJiraTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTicket = (ticket) => {
|
||||||
|
setEditingTicket(ticket);
|
||||||
|
setTicketForm({
|
||||||
|
cve_id: ticket.cve_id,
|
||||||
|
vendor: ticket.vendor,
|
||||||
|
ticket_key: ticket.ticket_key,
|
||||||
|
url: ticket.url || '',
|
||||||
|
summary: ticket.summary || '',
|
||||||
|
status: ticket.status
|
||||||
|
});
|
||||||
|
setShowEditTicket(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTicket = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/jira-tickets/${editingTicket.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticket_key: ticketForm.ticket_key,
|
||||||
|
url: ticketForm.url,
|
||||||
|
summary: ticketForm.summary,
|
||||||
|
status: ticketForm.status
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to update ticket');
|
||||||
|
}
|
||||||
|
alert('JIRA ticket updated!');
|
||||||
|
setShowEditTicket(false);
|
||||||
|
setEditingTicket(null);
|
||||||
|
setTicketForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' });
|
||||||
|
fetchJiraTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTicket = async (ticket) => {
|
||||||
|
if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/jira-tickets/${ticket.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to delete ticket');
|
||||||
|
alert('Ticket deleted');
|
||||||
|
fetchJiraTickets();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddTicketForCVE = (cve_id, vendor) => {
|
||||||
|
setAddTicketContext({ cve_id, vendor });
|
||||||
|
setTicketForm({ cve_id, vendor, ticket_key: '', url: '', summary: '', status: 'Open' });
|
||||||
|
setShowAddTicket(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch CVEs from API when authenticated
|
// Fetch CVEs from API when authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
fetchCVEs();
|
fetchCVEs();
|
||||||
fetchVendors();
|
fetchVendors();
|
||||||
|
fetchJiraTickets();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
@@ -801,6 +923,175 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add JIRA Ticket Modal */}
|
||||||
|
{showAddTicket && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Add JIRA Ticket</h2>
|
||||||
|
<button onClick={() => { setShowAddTicket(false); setAddTicketContext(null); }} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleAddTicket} className="space-y-4">
|
||||||
|
{!addTicketContext && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">CVE ID *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="CVE-2024-1234"
|
||||||
|
value={ticketForm.cve_id}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, cve_id: e.target.value.toUpperCase()})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Cisco"
|
||||||
|
value={ticketForm.vendor}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, vendor: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{addTicketContext && (
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg text-sm text-blue-800">
|
||||||
|
Adding ticket for <strong>{addTicketContext.cve_id}</strong> / <strong>{addTicketContext.vendor}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ticket Key *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="VULN-1234"
|
||||||
|
value={ticketForm.ticket_key}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, ticket_key: e.target.value.toUpperCase()})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">JIRA URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://jira.company.com/browse/VULN-1234"
|
||||||
|
value={ticketForm.url}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, url: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Summary</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Brief description"
|
||||||
|
value={ticketForm.summary}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, summary: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={ticketForm.status}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, status: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||||
|
Add Ticket
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setShowAddTicket(false); setAddTicketContext(null); }} className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit JIRA Ticket Modal */}
|
||||||
|
{showEditTicket && editingTicket && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Edit JIRA Ticket</h2>
|
||||||
|
<button onClick={() => { setShowEditTicket(false); setEditingTicket(null); }} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600 mb-4">
|
||||||
|
{editingTicket.cve_id} / {editingTicket.vendor}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleUpdateTicket} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Ticket Key *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={ticketForm.ticket_key}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, ticket_key: e.target.value.toUpperCase()})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">JIRA URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={ticketForm.url}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, url: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Summary</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ticketForm.summary}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, summary: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={ticketForm.status}
|
||||||
|
onChange={(e) => setTicketForm({...ticketForm, status: e.target.value})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="flex-1 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setShowEditTicket(false); setEditingTicket(null); }} className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Check */}
|
{/* Quick Check */}
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg shadow-md p-6 mb-6 border-2 border-[#0476D9]">
|
<div className="bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg shadow-md p-6 mb-6 border-2 border-[#0476D9]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Quick CVE Status Check</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">Quick CVE Status Check</h2>
|
||||||
@@ -863,6 +1154,61 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Open Vendor Tickets Dashboard */}
|
||||||
|
{jiraTickets.filter(t => t.status !== 'Closed').length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6 border-l-4 border-orange-500">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-orange-500" />
|
||||||
|
Open Vendor Tickets ({jiraTickets.filter(t => t.status !== 'Closed').length})
|
||||||
|
</h2>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setAddTicketContext(null); setTicketForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' }); setShowAddTicket(true); }}
|
||||||
|
className="px-3 py-1 text-sm bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Ticket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{jiraTickets.filter(t => t.status !== 'Closed').map(ticket => (
|
||||||
|
<div key={ticket.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<a
|
||||||
|
href={ticket.url || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-sm font-semibold text-[#0476D9] hover:underline"
|
||||||
|
>
|
||||||
|
{ticket.ticket_key}
|
||||||
|
</a>
|
||||||
|
<span className="text-sm text-gray-600">{ticket.cve_id}</span>
|
||||||
|
<span className="text-sm text-gray-500">({ticket.vendor})</span>
|
||||||
|
{ticket.summary && <span className="text-sm text-gray-600 truncate max-w-xs">{ticket.summary}</span>}
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
ticket.status === 'Open' ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canWrite() && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleEditTicket(ticket)} className="text-gray-500 hover:text-orange-600">
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteTicket(ticket)} className="text-gray-500 hover:text-red-600">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{/* Search and Filters */}
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
@@ -1145,6 +1491,68 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* JIRA Tickets for this vendor */}
|
||||||
|
{(() => {
|
||||||
|
const vendorTickets = jiraTickets.filter(t => t.cve_id === cve.cve_id && t.vendor === cve.vendor);
|
||||||
|
return vendorTickets.length > 0 || canWrite() ? (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-300">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h5 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-orange-500" />
|
||||||
|
JIRA Tickets ({vendorTickets.length})
|
||||||
|
</h5>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => openAddTicketForCVE(cve.cve_id, cve.vendor)}
|
||||||
|
className="text-xs px-2 py-1 text-orange-600 hover:bg-orange-50 rounded border border-orange-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Add Ticket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{vendorTickets.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{vendorTickets.map(ticket => (
|
||||||
|
<div key={ticket.id} className="flex items-center justify-between p-2 bg-white rounded border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={ticket.url || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-sm font-semibold text-[#0476D9] hover:underline"
|
||||||
|
>
|
||||||
|
{ticket.ticket_key}
|
||||||
|
</a>
|
||||||
|
{ticket.summary && <span className="text-sm text-gray-600 truncate max-w-xs">{ticket.summary}</span>}
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
ticket.status === 'Open' ? 'bg-red-100 text-red-800' :
|
||||||
|
ticket.status === 'In Progress' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-green-100 text-green-800'
|
||||||
|
}`}>
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canWrite() && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => handleEditTicket(ticket)} className="p-1 text-gray-400 hover:text-orange-600">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteTicket(ticket)} className="p-1 text-gray-400 hover:text-red-600">
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500 italic">No JIRA tickets linked</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user