From e1c361496f0980f51d8760aa1d4096ceeb2bc183 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Thu, 21 May 2026 15:06:16 -0600 Subject: [PATCH] =?UTF-8?q?Add=20flexible=20Jira=20ticket=20creation=20?= =?UTF-8?q?=E2=80=94=20CVE/Vendor=20optional,=20source=20context=20trackin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make CVE ID and Vendor optional when creating Jira tickets. Add source_context field to track ticket origin (cve, archer, ivanti_queue, email, manual). - Migration: drop NOT NULL on cve_id/vendor, add source_context column with CHECK - Backend: update create/update/get endpoints for optional fields and source_context - Frontend: update creation modal with optional labels and source context dropdown - Add Create Jira Ticket action from Ivanti queue (pre-populates from finding) - Add Create Jira Ticket action from Archer detail view (pre-populates from ticket) - Add source context badge column, filter dropdown, and search to ticket list --- .../add_flexible_jira_ticket_creation.js | 73 +++ backend/routes/jiraTickets.js | 197 +++++++- frontend/src/App.js | 80 +--- frontend/src/components/pages/ArcherPage.js | 421 ++++++++++++++++++ frontend/src/components/pages/JiraPage.js | 144 +++++- .../src/components/pages/ReportingPage.js | 295 +++++++++++- 6 files changed, 1116 insertions(+), 94 deletions(-) create mode 100644 backend/migrations/add_flexible_jira_ticket_creation.js create mode 100644 frontend/src/components/pages/ArcherPage.js diff --git a/backend/migrations/add_flexible_jira_ticket_creation.js b/backend/migrations/add_flexible_jira_ticket_creation.js new file mode 100644 index 0000000..1fb917b --- /dev/null +++ b/backend/migrations/add_flexible_jira_ticket_creation.js @@ -0,0 +1,73 @@ +// Migration: Add flexible Jira ticket creation support +// - Drops NOT NULL on cve_id and vendor columns +// - Adds source_context column with CHECK constraint +// - Backfills existing rows with source_context = 'manual' +// - Adds index on source_context +// Idempotent — safe to run multiple times. +const pool = require('../db'); + +async function run() { + console.log('Starting flexible Jira ticket creation migration...'); + + // Verify jira_tickets table exists before proceeding + const { rows } = await pool.query(` + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'jira_tickets' + `); + if (rows.length === 0) { + console.error('✗ jira_tickets table does not exist. Cannot proceed.'); + process.exit(1); + } + console.log('✓ jira_tickets table exists'); + + // Drop NOT NULL constraint on cve_id (idempotent — no-op if already nullable) + await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN cve_id DROP NOT NULL`); + console.log('✓ cve_id NOT NULL constraint dropped (or was already nullable)'); + + // Drop NOT NULL constraint on vendor (idempotent — no-op if already nullable) + await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN vendor DROP NOT NULL`); + console.log('✓ vendor NOT NULL constraint dropped (or was already nullable)'); + + // Add source_context column with default value (IF NOT EXISTS makes it idempotent) + await pool.query(` + ALTER TABLE jira_tickets + ADD COLUMN IF NOT EXISTS source_context TEXT DEFAULT 'manual' + `); + console.log('✓ source_context column added (or already exists)'); + + // Add CHECK constraint for allowed source_context values (idempotent guard) + await pool.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'jira_tickets_source_context_check' + ) THEN + ALTER TABLE jira_tickets + ADD CONSTRAINT jira_tickets_source_context_check + CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual')); + END IF; + END $$; + `); + console.log('✓ source_context CHECK constraint added (or already exists)'); + + // Backfill existing rows where source_context is NULL + const result = await pool.query(` + UPDATE jira_tickets SET source_context = 'manual' WHERE source_context IS NULL + `); + console.log(`✓ Backfilled ${result.rowCount} rows with source_context = 'manual'`); + + // Add index on source_context for filtering performance + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_jira_tickets_source_context + ON jira_tickets(source_context) + `); + console.log('✓ source_context index created (or already exists)'); + + console.log('Migration complete.'); + process.exit(0); +} + +run().catch(err => { + console.error('Migration failed:', err.message); + process.exit(1); +}); diff --git a/backend/routes/jiraTickets.js b/backend/routes/jiraTickets.js index 9cd77ec..bc20949 100644 --- a/backend/routes/jiraTickets.js +++ b/backend/routes/jiraTickets.js @@ -35,6 +35,16 @@ function createJiraTicketsRouter() { // Jira API integration endpoints // ----------------------------------------------------------------------- + /** + * GET /api/jira-tickets/connection-test + * + * Tests connectivity to the configured Jira instance. + * + * @requires Admin group + * @returns {object} 200 - { connected: true, user: { name, displayName, ... } } + * @returns {object} 502 - { connected: false, error: string } on connection failure + * @returns {object} 503 - { error: string } when Jira API is not configured + */ router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' }); @@ -60,10 +70,33 @@ function createJiraTicketsRouter() { } }); + /** + * GET /api/jira-tickets/rate-limit + * + * Returns the current Jira API rate limit status (burst and daily counters). + * + * @requires Admin group + * @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } } + */ router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => { res.json(jiraApi.getRateLimitStatus()); }); + /** + * GET /api/jira-tickets/lookup/:issueKey + * + * Looks up a single Jira issue by its key (e.g., PROJECT-123) and returns + * a summary of its fields. + * + * @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123) + * @requires Authenticated user + * @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self } + * @returns {object} 400 - { error: string } for invalid issue key format + * @returns {object} 404 - { error: string } when issue not found in Jira + * @returns {object} 429 - { error: string } when Jira rate limit exceeded + * @returns {object} 502 - { error: string } on Jira API error + * @returns {object} 503 - { error: string } when Jira API is not configured + */ router.get('/lookup/:issueKey', requireAuth(), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured.' }); @@ -102,19 +135,63 @@ function createJiraTicketsRouter() { } }); + /** + * POST /api/jira-tickets/create-in-jira + * + * Creates a new issue in Jira and saves a local tracking record. + * + * @requires Admin or Standard_User group + * @body {string} [cve_id] - Optional CVE ID (format: CVE-YYYY-NNNN+); stored as NULL if absent/empty + * @body {string} [vendor] - Optional vendor name (max 200 chars after trim); stored as NULL if absent/empty/whitespace + * @body {string} summary - Required issue summary (max 255 chars) + * @body {string} [description] - Optional issue description + * @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var) + * @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var) + * @body {string} [source_context] - One of: cve, archer, ivanti_queue, email, manual (defaults to 'manual') + * @returns {object} 201 - { id, ticket_key, jira_url, source_context, message } + * @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local DB save failed + * @returns {object} 400 - { error: string } for validation failures + * @returns {object} 429 - { error: string } when Jira rate limit exceeded + * @returns {object} 502 - { error: string } on Jira API error + * @returns {object} 503 - { error: string } when Jira API is not configured + */ router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured.' }); } - const { cve_id, vendor, summary, description, project_key, issue_type } = req.body; + const { cve_id, vendor, summary, description, project_key, issue_type, source_context } = req.body; - if (!cve_id || !isValidCveId(cve_id)) { - return res.status(400).json({ error: 'Valid CVE ID is required.' }); + // --- CVE ID validation: optional, but must match format if non-empty --- + let normalizedCveId = null; + if (cve_id !== undefined && cve_id !== null && cve_id !== '') { + if (!isValidCveId(cve_id)) { + return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' }); + } + normalizedCveId = cve_id; } - if (!vendor || !isValidVendor(vendor)) { - return res.status(400).json({ error: 'Valid vendor is required.' }); + + // --- Vendor validation: optional, but must be <= 200 chars after trim if non-empty --- + let normalizedVendor = null; + if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 0) { + const trimmedVendor = vendor.trim(); + if (trimmedVendor.length > 200) { + return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' }); + } + normalizedVendor = trimmedVendor; } + + // --- source_context validation: must be in allowed set if provided, default to 'manual' --- + const ALLOWED_SOURCE_CONTEXTS = ['cve', 'archer', 'ivanti_queue', 'email', 'manual']; + let normalizedSourceContext = 'manual'; + if (source_context !== undefined && source_context !== null) { + if (!ALLOWED_SOURCE_CONTEXTS.includes(source_context)) { + return res.status(400).json({ error: 'source_context must be one of: cve, archer, ivanti_queue, email, manual.' }); + } + normalizedSourceContext = source_context; + } + + // --- Summary validation: required, non-empty, max 255 chars --- if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) { return res.status(400).json({ error: 'Summary is required (max 255 chars).' }); } @@ -153,10 +230,10 @@ function createJiraTicketsRouter() { try { const { rows } = await pool.query( - `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9) + `INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by, source_context) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10) RETURNING id`, - [cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id] + [normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id, normalizedSourceContext] ); logAudit({ @@ -165,7 +242,7 @@ function createJiraTicketsRouter() { action: 'jira_ticket_create_via_api', entityType: 'jira_ticket', entityId: rows[0].id.toString(), - details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey }, + details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext }, ipAddress: req.ip }); @@ -173,6 +250,7 @@ function createJiraTicketsRouter() { id: rows[0].id, ticket_key: ticketKey, jira_url: jiraUrl, + source_context: normalizedSourceContext, message: 'Jira issue created and linked successfully' }); } catch (dbErr) { @@ -189,6 +267,18 @@ function createJiraTicketsRouter() { } }); + /** + * POST /api/jira-tickets/sync-all + * + * Syncs all local Jira ticket records with their current Jira status using + * bulk JQL search. Updates summary, status, and last_synced_at for each ticket. + * Stops early if rate limits are approaching. + * + * @requires Admin group + * @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] } + * @returns {object} 500 - { error: string } on internal error + * @returns {object} 503 - { error: string } when Jira API is not configured + */ router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured.' }); @@ -284,6 +374,22 @@ function createJiraTicketsRouter() { } }); + /** + * POST /api/jira-tickets/:id/sync + * + * Syncs a single local Jira ticket record with its current Jira status. + * Fetches the issue by ticket_key and updates summary, status, and last_synced_at. + * + * @param {string} id - Local ticket ID (path parameter) + * @requires Admin or Standard_User group + * @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary } + * @returns {object} 400 - { error: string } when ticket has no Jira key + * @returns {object} 404 - { error: string } when local ticket not found + * @returns {object} 429 - { error: string } when Jira rate limit exceeded + * @returns {object} 500 - { error: string } on internal error + * @returns {object} 502 - { error: string } on Jira API error + * @returns {object} 503 - { error: string } when Jira API is not configured + */ router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { if (!jiraApi.isConfigured) { return res.status(503).json({ error: 'Jira API is not configured.' }); @@ -347,8 +453,22 @@ function createJiraTicketsRouter() { // Local CRUD endpoints // ----------------------------------------------------------------------- + /** + * GET /api/jira-tickets + * + * Lists all Jira tickets with optional filtering by query parameters. + * Results are ordered by created_at descending. + * + * @query {string} [cve_id] - Filter by exact CVE ID + * @query {string} [vendor] - Filter by exact vendor name + * @query {string} [status] - Filter by ticket status (Open, In Progress, Closed) + * @query {string} [source_context] - Filter by source context (cve, archer, ivanti_queue, email, manual) + * @requires Authenticated user + * @returns {array} 200 - Array of jira_tickets rows + * @returns {object} 500 - { error: string } on internal error + */ router.get('/', requireAuth(), async (req, res) => { - const { cve_id, vendor, status } = req.query; + const { cve_id, vendor, status, source_context } = req.query; let query = 'SELECT * FROM jira_tickets WHERE 1=1'; const params = []; @@ -366,6 +486,10 @@ function createJiraTicketsRouter() { query += ` AND status = $${paramIndex++}`; params.push(status); } + if (source_context) { + query += ` AND source_context = $${paramIndex++}`; + params.push(source_context); + } query += ' ORDER BY created_at DESC'; @@ -378,6 +502,23 @@ function createJiraTicketsRouter() { } }); + /** + * POST /api/jira-tickets + * + * Creates a local Jira ticket record (without creating an issue in Jira). + * Used for manually tracking tickets that already exist in Jira. + * + * @requires Admin or Standard_User group + * @body {string} cve_id - Required CVE ID (format: CVE-YYYY-NNNN+) + * @body {string} vendor - Required vendor name (max 200 chars) + * @body {string} ticket_key - Required Jira ticket key (max 50 chars) + * @body {string} [url] - Optional Jira ticket URL (max 500 chars) + * @body {string} [summary] - Optional summary (max 500 chars) + * @body {string} [status] - Optional status: Open, In Progress, or Closed (defaults to Open) + * @returns {object} 201 - { id, message } + * @returns {object} 400 - { error: string } for validation failures + * @returns {object} 500 - { error: string } on internal error + */ router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { cve_id, vendor, ticket_key, url, summary, status } = req.body; @@ -430,10 +571,32 @@ function createJiraTicketsRouter() { } }); + /** + * PUT /api/jira-tickets/:id + * + * Updates an existing local Jira ticket record. Only provided fields are updated. + * The source_context field is immutable after creation — including it returns 400. + * + * @param {string} id - Local ticket ID (path parameter) + * @requires Admin or Standard_User group + * @body {string} [ticket_key] - Jira ticket key (max 50 chars) + * @body {string} [url] - Jira ticket URL (max 500 chars, null to clear) + * @body {string} [summary] - Summary (max 500 chars, null to clear) + * @body {string} [status] - Status: Open, In Progress, or Closed + * @returns {object} 200 - { message, changes } + * @returns {object} 400 - { error: string } for validation failures or source_context mutation attempt + * @returns {object} 404 - { error: string } when ticket not found + * @returns {object} 500 - { error: string } on internal error + */ router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { ticket_key, url, summary, status } = req.body; + // source_context is immutable after creation (Requirement 3.6) + if ('source_context' in req.body) { + return res.status(400).json({ error: 'source_context is immutable after creation' }); + } + 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.' }); } @@ -492,6 +655,20 @@ function createJiraTicketsRouter() { } }); + /** + * DELETE /api/jira-tickets/:id + * + * Deletes a local Jira ticket record. Admin can delete any ticket. + * Standard_User can only delete tickets they created, and only if the ticket + * is not linked to an active compliance item. + * + * @param {string} id - Local ticket ID (path parameter) + * @requires Admin or Standard_User group + * @returns {object} 200 - { message } + * @returns {object} 403 - { error: string } when user lacks permission or ticket is linked to compliance + * @returns {object} 404 - { error: string } when ticket not found + * @returns {object} 500 - { error: string } on internal error + */ router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; diff --git a/frontend/src/App.js b/frontend/src/App.js index 22a3165..fb03df5 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -18,6 +18,7 @@ import CCPMetricsPage from './components/pages/CCPMetricsPage'; import JiraPage from './components/pages/JiraPage'; import AdminPage from './components/pages/AdminPage'; import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar'; +import ArcherPage from './components/pages/ArcherPage'; import FeedbackModal from './components/FeedbackModal'; import NotificationBell from './components/NotificationBell'; import './App.css'; @@ -2270,77 +2271,14 @@ export default function App() { {/* Archer Risk Acceptance Tickets */} -
-
-

- - Archer Risk Tickets -

- {canWrite() && ( - - )} -
-
-
- {archerTickets.filter(t => t.status !== 'Accepted').length} -
-
Active
-
-
- {archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => ( -
-
- - {ticket.exc_number} - -
- - {canWrite() && ( - - )} - {canDelete(ticket) && ( - - )} -
-
-
{ticket.cve_id}
-
{ticket.vendor}
-
- - - {ticket.status} - -
-
- ))} - {archerTickets.filter(t => t.status !== 'Accepted').length === 0 && ( -
- -

No active Archer tickets

-
- )} -
-
+ { setReportingExcFilter(exc); setCurrentPage('triage'); }} + onAddTicket={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }} + canDeleteTicket={canDelete} + /> {/* Ivanti Workflows */}
diff --git a/frontend/src/components/pages/ArcherPage.js b/frontend/src/components/pages/ArcherPage.js new file mode 100644 index 0000000..1242486 --- /dev/null +++ b/frontend/src/components/pages/ArcherPage.js @@ -0,0 +1,421 @@ +import React, { useState } from 'react'; +import { Plus, X, Loader, Shield, Filter, Edit2, Trash2, CheckCircle } from 'lucide-react'; +import { useAuth } from '../../contexts/AuthContext'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +// --------------------------------------------------------------------------- +// Styles — matches tactical intelligence aesthetic +// --------------------------------------------------------------------------- +const STYLES = { + 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(139, 92, 246, 0.25)', + borderRadius: '16px', + padding: '2rem', + width: '90%', + maxWidth: '520px', + maxHeight: '85vh', + overflowY: 'auto', + zIndex: 101, + }, + 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', + }, + btn: { + padding: '0.5rem 1rem', + borderRadius: '8px', + border: '1px solid rgba(139, 92, 246, 0.3)', + background: 'rgba(139, 92, 246, 0.1)', + color: '#C4B5FD', + cursor: 'pointer', + fontSize: '0.8rem', + fontWeight: 600, + display: 'inline-flex', + alignItems: 'center', + gap: '0.4rem', + transition: 'all 0.2s', + }, + btnSuccess: { + border: '1px solid rgba(16, 185, 129, 0.3)', + background: 'rgba(16, 185, 129, 0.1)', + color: '#6EE7B7', + }, + intelCard: { + background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%)', + border: '1px solid rgba(14, 165, 233, 0.15)', + borderRadius: '0.75rem', + padding: '1.5rem', + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03)', + }, + ticketCard: { + 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)', + }, + badgeHigh: { + display: 'inline-flex', + alignItems: 'center', + gap: '0.375rem', + padding: '0.25rem 0.75rem', + borderRadius: '9999px', + fontSize: '0.7rem', + fontWeight: '600', + letterSpacing: '0.05em', + textTransform: 'uppercase', + border: '1px solid #F59E0B', + background: 'rgba(245, 158, 11, 0.15)', + color: '#F59E0B', + }, + glowDot: (color) => ({ + width: '8px', + height: '8px', + borderRadius: '50%', + background: color, + boxShadow: `0 0 6px ${color}, 0 0 12px ${color}`, + }), +}; + +// --------------------------------------------------------------------------- +// ArcherPage — Archer Risk Tickets panel with "Create Jira Ticket" action +// --------------------------------------------------------------------------- +export default function ArcherPage({ + archerTickets = [], + onEditTicket, + onDeleteTicket, + onFilterByExc, + onAddTicket, + canDeleteTicket, +}) { + const { canWrite } = useAuth(); + + // Create Jira Ticket modal state + const [showCreateJiraModal, setShowCreateJiraModal] = useState(false); + const [createJiraForm, setCreateJiraForm] = useState({ + summary: '', + cve_id: '', + vendor: '', + source_context: 'archer', + description: '', + project_key: '', + issue_type: '', + }); + const [createJiraSaving, setCreateJiraSaving] = useState(false); + const [createJiraError, setCreateJiraError] = useState(null); + const [summaryError, setSummaryError] = useState(null); + const [createJiraSuccess, setCreateJiraSuccess] = useState(null); + + // Open the Create Jira Ticket modal pre-populated with Archer ticket data + const openCreateJiraModal = (ticket) => { + setCreateJiraForm({ + summary: ticket.exc_number || '', + cve_id: ticket.cve_id || '', + vendor: ticket.vendor || '', + source_context: 'archer', + description: '', + project_key: '', + issue_type: '', + }); + setCreateJiraError(null); + setSummaryError(null); + setCreateJiraSuccess(null); + setShowCreateJiraModal(true); + }; + + // Submit the Create Jira Ticket form + const handleCreateJira = async () => { + setSummaryError(null); + const trimmedSummary = (createJiraForm.summary || '').trim(); + if (!trimmedSummary) { + setSummaryError('Summary is required.'); + return; + } + if (trimmedSummary.length > 255) { + setSummaryError('Summary must be 255 characters or fewer.'); + return; + } + + setCreateJiraError(null); + setCreateJiraSaving(true); + try { + const payload = { + summary: trimmedSummary, + source_context: 'archer', + }; + if (createJiraForm.cve_id && createJiraForm.cve_id.trim()) { + payload.cve_id = createJiraForm.cve_id.trim(); + } + if (createJiraForm.vendor && createJiraForm.vendor.trim()) { + payload.vendor = createJiraForm.vendor.trim(); + } + if (createJiraForm.description && createJiraForm.description.trim()) { + payload.description = createJiraForm.description.trim(); + } + if (createJiraForm.project_key && createJiraForm.project_key.trim()) { + payload.project_key = createJiraForm.project_key.trim(); + } + if (createJiraForm.issue_type && createJiraForm.issue_type.trim()) { + payload.issue_type = createJiraForm.issue_type.trim(); + } + + const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok && res.status !== 207) { + throw new Error(data.error || `HTTP ${res.status}`); + } + setCreateJiraSuccess(`Jira ticket ${data.ticket_key} created successfully.`); + // Reset form after short delay so user sees success + setTimeout(() => { + setShowCreateJiraModal(false); + setCreateJiraSuccess(null); + setCreateJiraForm({ summary: '', cve_id: '', vendor: '', source_context: 'archer', description: '', project_key: '', issue_type: '' }); + }, 1500); + } catch (err) { + setCreateJiraError(err.message); + } finally { + setCreateJiraSaving(false); + } + }; + + const activeTickets = archerTickets.filter(t => t.status !== 'Accepted'); + + return ( + <> + {/* Archer Risk Acceptance Tickets Card */} +
+
+

+ + Archer Risk Tickets +

+ {canWrite() && onAddTicket && ( + + )} +
+
+
+ {activeTickets.length} +
+
Active
+
+
+ {activeTickets.slice(0, 10).map(ticket => ( +
+
+ + {ticket.exc_number} + +
+ {onFilterByExc && ( + + )} + {canWrite() && ( + + )} + {canWrite() && onEditTicket && ( + + )} + {canDeleteTicket && canDeleteTicket(ticket) && onDeleteTicket && ( + + )} +
+
+
{ticket.cve_id}
+
{ticket.vendor}
+
+ + + {ticket.status} + +
+
+ ))} + {activeTickets.length === 0 && ( +
+ +

No active Archer tickets

+
+ )} +
+
+ + {/* Create Jira Ticket Modal */} + {showCreateJiraModal && ( +
+
setShowCreateJiraModal(false)} /> +
+
+

Create Jira Ticket from Archer

+ +
+

+ Creates a Jira issue linked to this Archer risk ticket. +

+ + {createJiraSuccess && ( +
+ {createJiraSuccess} +
+ )} + {createJiraError && ( +
+ {createJiraError} +
+ )} + +
+ {/* Summary — required, pre-populated with exc_number */} +
+ + { setCreateJiraForm(f => ({ ...f, summary: e.target.value })); if (summaryError) setSummaryError(null); }} + maxLength={255} + /> + {summaryError &&
{summaryError}
} +
+ + {/* Source Context — locked to archer */} +
+ + +
+ + {/* CVE ID — optional, pre-populated from Archer ticket */} +
+ + setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} + /> +
+ + {/* Vendor — optional, pre-populated from Archer ticket */} +
+ + setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} + /> +
+ + {/* Description — optional */} +
+ +