Compare commits
5 Commits
enhancemen
...
enhancemen
| Author | SHA1 | Date | |
|---|---|---|---|
| c89404cf26 | |||
| af7a5becef | |||
| 7145117518 | |||
| 30739dc162 | |||
| b0d2f915bd |
50
backend/migrations/add_archer_tickets_table.js
Normal file
50
backend/migrations/add_archer_tickets_table.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Migration: Add archer_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 Archer tickets migration...');
|
||||
|
||||
db.serialize(() => {
|
||||
// Create archer_tickets table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS archer_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
exc_number TEXT NOT NULL UNIQUE,
|
||||
archer_url TEXT,
|
||||
status TEXT DEFAULT 'Draft' CHECK(status IN ('Draft', 'Open', 'Under Review', 'Accepted')),
|
||||
cve_id TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL,
|
||||
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('✓ archer_tickets table created');
|
||||
});
|
||||
|
||||
// Create indexes
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_cve ON archer_tickets(cve_id, vendor)', (err) => {
|
||||
if (err) console.error('Error creating CVE index:', err);
|
||||
else console.log('✓ CVE index created');
|
||||
});
|
||||
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_status ON archer_tickets(status)', (err) => {
|
||||
if (err) console.error('Error creating status index:', err);
|
||||
else console.log('✓ Status index created');
|
||||
});
|
||||
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_archer_tickets_exc ON archer_tickets(exc_number)', (err) => {
|
||||
if (err) console.error('Error creating EXC number index:', err);
|
||||
else console.log('✓ EXC number index created');
|
||||
});
|
||||
|
||||
console.log('✓ Indexes created');
|
||||
});
|
||||
|
||||
db.close(() => {
|
||||
console.log('Migration complete!');
|
||||
});
|
||||
223
backend/routes/archerTickets.js
Normal file
223
backend/routes/archerTickets.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// routes/archerTickets.js
|
||||
const express = require('express');
|
||||
const { requireAuth, requireRole } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
// Validation helpers
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
function isValidCveId(cveId) {
|
||||
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||
}
|
||||
|
||||
function isValidVendor(vendor) {
|
||||
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||
}
|
||||
|
||||
function createArcherTicketsRouter(db) {
|
||||
const router = express.Router();
|
||||
|
||||
// Get all Archer tickets (with optional filters)
|
||||
router.get('/', requireAuth(db), (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM archer_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 Archer tickets:', err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
// Create Archer ticket
|
||||
router.post('/', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { exc_number, archer_url, status, cve_id, vendor } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!exc_number || typeof exc_number !== 'string' || exc_number.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'EXC number is required.' });
|
||||
}
|
||||
if (!/^EXC-\d+$/.test(exc_number.trim())) {
|
||||
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
|
||||
}
|
||||
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 (archer_url && (typeof archer_url !== 'string' || archer_url.length > 500)) {
|
||||
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
|
||||
}
|
||||
if (status && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
||||
}
|
||||
|
||||
const validatedStatus = status || 'Draft';
|
||||
|
||||
db.run(
|
||||
`INSERT INTO archer_tickets (exc_number, archer_url, status, cve_id, vendor)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[exc_number.trim(), archer_url || null, validatedStatus, cve_id, vendor],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating Archer ticket:', err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'CREATE_ARCHER_TICKET',
|
||||
targetType: 'archer_ticket',
|
||||
targetId: this.lastID,
|
||||
details: { exc_number, archer_url, status: validatedStatus, cve_id, vendor },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
message: 'Archer ticket created successfully'
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Update Archer ticket
|
||||
router.put('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { exc_number, archer_url, status } = req.body;
|
||||
|
||||
// Validation
|
||||
if (exc_number !== undefined) {
|
||||
if (typeof exc_number !== 'string' || exc_number.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'EXC number cannot be empty.' });
|
||||
}
|
||||
if (!/^EXC-\d+$/.test(exc_number.trim())) {
|
||||
return res.status(400).json({ error: 'EXC number must be in format EXC-XXXX (e.g., EXC-5754).' });
|
||||
}
|
||||
}
|
||||
if (archer_url !== undefined && archer_url !== null && (typeof archer_url !== 'string' || archer_url.length > 500)) {
|
||||
return res.status(400).json({ error: 'Archer URL must be under 500 characters.' });
|
||||
}
|
||||
if (status !== undefined && !['Draft', 'Open', 'Under Review', 'Accepted'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Invalid status. Must be Draft, Open, Under Review, or Accepted.' });
|
||||
}
|
||||
|
||||
// Get existing ticket
|
||||
db.get('SELECT * FROM archer_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: 'Archer ticket not found.' });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (exc_number !== undefined) {
|
||||
updates.push('exc_number = ?');
|
||||
params.push(exc_number.trim());
|
||||
}
|
||||
if (archer_url !== undefined) {
|
||||
updates.push('archer_url = ?');
|
||||
params.push(archer_url || null);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update.' });
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(id);
|
||||
|
||||
db.run(
|
||||
`UPDATE archer_tickets SET ${updates.join(', ')} WHERE id = ?`,
|
||||
params,
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
return res.status(409).json({ error: 'An Archer ticket with this EXC number already exists.' });
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'UPDATE_ARCHER_TICKET',
|
||||
targetType: 'archer_ticket',
|
||||
targetId: id,
|
||||
details: { before: existing, changes: req.body },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket updated successfully', changes: this.changes });
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete Archer ticket
|
||||
router.delete('/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.get('SELECT * FROM archer_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: 'Archer ticket not found.' });
|
||||
}
|
||||
|
||||
db.run('DELETE FROM archer_tickets WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
action: 'DELETE_ARCHER_TICKET',
|
||||
targetType: 'archer_ticket',
|
||||
targetId: id,
|
||||
details: { deleted: ticket },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Archer ticket deleted successfully' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createArcherTicketsRouter;
|
||||
@@ -20,6 +20,7 @@ const logAudit = require('./helpers/auditLog');
|
||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -179,6 +180,9 @@ app.use('/api/weekly-reports', createWeeklyReportsRouter(db, upload));
|
||||
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
||||
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
||||
|
||||
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
||||
|
||||
// ========== CVE ENDPOINTS ==========
|
||||
|
||||
// Get all CVEs with optional filters (authenticated users)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown } from 'lucide-react';
|
||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield } from 'lucide-react';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import UserMenu from './components/UserMenu';
|
||||
@@ -200,6 +200,7 @@ export default function App() {
|
||||
const [editNvdError, setEditNvdError] = useState(null);
|
||||
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
|
||||
const [expandedCVEs, setExpandedCVEs] = useState({});
|
||||
const [visibleCount, setVisibleCount] = useState(5);
|
||||
const [jiraTickets, setJiraTickets] = useState([]);
|
||||
const [showAddTicket, setShowAddTicket] = useState(false);
|
||||
const [showEditTicket, setShowEditTicket] = useState(false);
|
||||
@@ -210,6 +211,16 @@ export default function App() {
|
||||
// For adding ticket from within a CVE card
|
||||
const [addTicketContext, setAddTicketContext] = useState(null); // { cve_id, vendor }
|
||||
|
||||
// Archer tickets state
|
||||
const [archerTickets, setArcherTickets] = useState([]);
|
||||
const [showAddArcherTicket, setShowAddArcherTicket] = useState(false);
|
||||
const [showEditArcherTicket, setShowEditArcherTicket] = useState(false);
|
||||
const [editingArcherTicket, setEditingArcherTicket] = useState(null);
|
||||
const [archerTicketForm, setArcherTicketForm] = useState({
|
||||
exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: ''
|
||||
});
|
||||
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
|
||||
|
||||
const toggleCVEExpand = (cveId) => {
|
||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||
};
|
||||
@@ -309,6 +320,19 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchArcherTickets = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/archer-tickets`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch Archer tickets');
|
||||
const data = await response.json();
|
||||
setArcherTickets(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching Archer tickets:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDocuments = async (cveId, vendor) => {
|
||||
const key = `${cveId}-${vendor}`;
|
||||
if (cveDocuments[key]) return;
|
||||
@@ -745,12 +769,98 @@ export default function App() {
|
||||
setShowAddTicket(true);
|
||||
};
|
||||
|
||||
// ========== ARCHER TICKET HANDLERS ==========
|
||||
|
||||
const handleAddArcherTicket = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/archer-tickets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(archerTicketForm)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create Archer ticket');
|
||||
}
|
||||
alert('Archer ticket added successfully!');
|
||||
setShowAddArcherTicket(false);
|
||||
setAddArcherTicketContext(null);
|
||||
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
|
||||
fetchArcherTickets();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditArcherTicket = (ticket) => {
|
||||
setEditingArcherTicket(ticket);
|
||||
setArcherTicketForm({
|
||||
exc_number: ticket.exc_number,
|
||||
archer_url: ticket.archer_url || '',
|
||||
status: ticket.status,
|
||||
cve_id: ticket.cve_id,
|
||||
vendor: ticket.vendor
|
||||
});
|
||||
setShowEditArcherTicket(true);
|
||||
};
|
||||
|
||||
const handleUpdateArcherTicket = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/archer-tickets/${editingArcherTicket.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
exc_number: archerTicketForm.exc_number,
|
||||
archer_url: archerTicketForm.archer_url,
|
||||
status: archerTicketForm.status
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to update Archer ticket');
|
||||
}
|
||||
alert('Archer ticket updated!');
|
||||
setShowEditArcherTicket(false);
|
||||
setEditingArcherTicket(null);
|
||||
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' });
|
||||
fetchArcherTickets();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteArcherTicket = async (ticket) => {
|
||||
if (!window.confirm(`Delete Archer ticket ${ticket.exc_number}?`)) return;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/archer-tickets/${ticket.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete Archer ticket');
|
||||
alert('Archer ticket deleted');
|
||||
fetchArcherTickets();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const openAddArcherTicketForCVE = (cve_id, vendor) => {
|
||||
setAddArcherTicketContext({ cve_id, vendor });
|
||||
setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id, vendor });
|
||||
setShowAddArcherTicket(true);
|
||||
};
|
||||
|
||||
// Fetch CVEs from API when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchCVEs();
|
||||
fetchVendors();
|
||||
fetchJiraTickets();
|
||||
fetchArcherTickets();
|
||||
fetchKnowledgeBaseArticles();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -760,6 +870,7 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchCVEs();
|
||||
setVisibleCount(5);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchQuery, selectedVendor, selectedSeverity]);
|
||||
@@ -1337,6 +1448,151 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Archer Ticket Modal */}
|
||||
{showAddArcherTicket && (
|
||||
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-purple-400 font-mono">Add Archer Risk Ticket</h2>
|
||||
<button onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddArcherTicket} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="EXC-5754"
|
||||
value={archerTicketForm.exc_number}
|
||||
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
|
||||
className="intel-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://archer.example.com/..."
|
||||
value={archerTicketForm.archer_url}
|
||||
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
|
||||
className="intel-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">CVE ID *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="CVE-2024-1234"
|
||||
value={archerTicketForm.cve_id}
|
||||
onChange={(e) => setArcherTicketForm({...archerTicketForm, cve_id: e.target.value.toUpperCase()})}
|
||||
className="intel-input w-full"
|
||||
readOnly={!!addArcherTicketContext}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Vendor *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Vendor name"
|
||||
value={archerTicketForm.vendor}
|
||||
onChange={(e) => setArcherTicketForm({...archerTicketForm, vendor: e.target.value})}
|
||||
className="intel-input w-full"
|
||||
readOnly={!!addArcherTicketContext}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
|
||||
<select
|
||||
value={archerTicketForm.status}
|
||||
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
|
||||
className="intel-input w-full"
|
||||
>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Under Review">Under Review</option>
|
||||
<option value="Accepted">Accepted</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="submit" className="flex-1 intel-button intel-button-primary">
|
||||
Create Ticket
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowAddArcherTicket(false); setAddArcherTicketContext(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Archer Ticket Modal */}
|
||||
{showEditArcherTicket && editingArcherTicket && (
|
||||
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full border-purple-500">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-purple-400 font-mono">Edit Archer Risk Ticket</h2>
|
||||
<button onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="text-gray-400 hover:text-intel-accent transition-colors">
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 bg-intel-medium rounded text-sm text-white mb-4 font-mono">
|
||||
{editingArcherTicket.cve_id} / {editingArcherTicket.vendor}
|
||||
</div>
|
||||
<form onSubmit={handleUpdateArcherTicket} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">EXC Number *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={archerTicketForm.exc_number}
|
||||
onChange={(e) => setArcherTicketForm({...archerTicketForm, exc_number: e.target.value.toUpperCase()})}
|
||||
className="intel-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Archer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={archerTicketForm.archer_url}
|
||||
onChange={(e) => setArcherTicketForm({...archerTicketForm, archer_url: e.target.value})}
|
||||
className="intel-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">Status</label>
|
||||
<select
|
||||
value={archerTicketForm.status}
|
||||
onChange={(e) => setArcherTicketForm({...archerTicketForm, status: e.target.value})}
|
||||
className="intel-input w-full"
|
||||
>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Under Review">Under Review</option>
|
||||
<option value="Accepted">Accepted</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="submit" className="flex-1 intel-button intel-button-primary">
|
||||
Save Changes
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowEditArcherTicket(false); setEditingArcherTicket(null); }} className="px-4 py-2 bg-intel-dark text-gray-400 rounded border border-gray-600 hover:bg-intel-medium transition-colors font-mono text-sm uppercase tracking-wider">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Three Column Layout */}
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
||||
@@ -1575,7 +1831,7 @@ export default function App() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => {
|
||||
{Object.entries(filteredGroupedCVEs).slice(0, visibleCount).map(([cveId, vendorEntries]) => {
|
||||
const isCVEExpanded = expandedCVEs[cveId];
|
||||
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
|
||||
const highestSeverity = vendorEntries.reduce((highest, entry) => {
|
||||
@@ -1847,6 +2103,40 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Show more / pagination footer */}
|
||||
{Object.keys(filteredGroupedCVEs).length > visibleCount && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-gray-500 font-mono text-xs">
|
||||
Showing {visibleCount} of {Object.keys(filteredGroupedCVEs).length} CVEs
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setVisibleCount(v => v + 5)}
|
||||
className="intel-button intel-button-primary text-xs px-3 py-1"
|
||||
>
|
||||
Show 5 more
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVisibleCount(Object.keys(filteredGroupedCVEs).length)}
|
||||
className="intel-button text-xs px-3 py-1"
|
||||
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{visibleCount > 5 && Object.keys(filteredGroupedCVEs).length <= visibleCount && Object.keys(filteredGroupedCVEs).length > 5 && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={() => setVisibleCount(5)}
|
||||
className="intel-button text-xs px-3 py-1"
|
||||
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#94A3B8' }}
|
||||
>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1993,6 +2283,70 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Archer Risk Acceptance Tickets */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
|
||||
<Shield className="w-5 h-5" />
|
||||
Archer Risk Tickets
|
||||
</h2>
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
|
||||
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center mb-3">
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
|
||||
{archerTickets.filter(t => t.status !== 'Accepted').length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => (
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<a
|
||||
href={ticket.archer_url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
|
||||
>
|
||||
{ticket.exc_number}
|
||||
</a>
|
||||
{canWrite() && (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||
<div className="mt-2">
|
||||
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
|
||||
<span style={{...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px'}}></span>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{archerTickets.filter(t => t.status !== 'Accepted').length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* End Right Panel */}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user