diff --git a/backend/migrations/add_fp_submissions_requeued_at.js b/backend/migrations/add_fp_submissions_requeued_at.js new file mode 100644 index 0000000..8918ac7 --- /dev/null +++ b/backend/migrations/add_fp_submissions_requeued_at.js @@ -0,0 +1,17 @@ +// Migration: Add requeued_at column to ivanti_fp_submissions table +const pool = require('../db'); + +async function run() { + console.log('Starting FP submissions requeued_at migration...'); + try { + await pool.query(`ALTER TABLE ivanti_fp_submissions ADD COLUMN IF NOT EXISTS requeued_at TIMESTAMPTZ DEFAULT NULL`); + console.log('✓ requeued_at column added (or already exists)'); + } catch (err) { + console.error('Error adding requeued_at column:', err.message); + process.exit(1); + } + console.log('Migration complete.'); + process.exit(0); +} + +run(); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 4828ad0..a9da053 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -16,6 +16,7 @@ const MIGRATIONS_DIR = __dirname; const POSTGRES_MIGRATIONS = [ 'add_decom_workflow_type.js', 'add_fp_submissions_dismissed.js', + 'add_fp_submissions_requeued_at.js', 'add_vcl_reporting_columns.js', 'add_vcl_vertical_metadata.js', ]; diff --git a/backend/routes/ivantiFpWorkflow.js b/backend/routes/ivantiFpWorkflow.js index 2b1a392..a3b32f4 100644 --- a/backend/routes/ivantiFpWorkflow.js +++ b/backend/routes/ivantiFpWorkflow.js @@ -137,7 +137,24 @@ const fpUpload = multer({ storage: uploadStorage, limits: { fileSize: 10 * 1024 function createIvantiFpWorkflowRouter() { const router = express.Router(); - // GET /documents/search + /** + * GET /api/ivanti/fp-workflow/documents/search + * + * Searches the documents library by name, CVE ID, or vendor. + * Returns up to 50 results ordered by upload date descending. + * + * @query {string} q — Optional search term; matches against name, cve_id, or vendor (ILIKE) + * @returns {Array} Array of document metadata objects + * - id {number} + * - cve_id {string} + * - vendor {string} + * - name {string} + * - type {string} + * - file_size {number} + * - mime_type {string} + * - uploaded_at {string} + * @error 500 Database error + */ router.get('/documents/search', requireAuth(), async (req, res) => { const q = (req.query.q || '').trim(); try { @@ -160,7 +177,36 @@ function createIvantiFpWorkflowRouter() { } }); - // POST / — Create FP workflow + /** + * POST /api/ivanti/fp-workflow + * + * Creates a new False Positive workflow in Ivanti and records the submission locally. + * Validates queue items belong to the user, are FP type, and are pending. + * Uploads local files and library documents as attachments. + * Marks associated queue items as complete on success. + * Requires Admin or Standard_User group. + * + * @body {Object} multipart/form-data + * - findingIds {string} JSON array of Ivanti finding IDs + * - queueItemIds {string} JSON array of todo queue item IDs + * - libraryDocIds {string} Optional JSON array of document library IDs to attach + * - name {string} Workflow name (required, max 255 chars) + * - reason {string} Reason for false positive (required) + * - description {string} Optional description (max 2000 chars) + * - expirationDate {string} Required, must be future date within 120 days + * - scopeOverride {string} Optional: Authorized, None, or Automated + * - attachments {File[]} Optional uploaded files (max 10, each max 10 MB) + * @returns {Object} + * - success {boolean} + * - workflowBatchId {number} Ivanti workflow batch ID + * - queueItemsUpdated {number} Count of queue items marked complete + * - status {string} "success" + * @error 400 Invalid input, queue item validation failure + * @error 403 Queue items belong to another user + * @error 429 Ivanti API rate limit + * @error 500 Ivanti API key not configured + * @error 502 Ivanti API connection or response failure + */ router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { fpUpload(req, res, (multerErr) => { if (multerErr) { @@ -272,7 +318,38 @@ function createIvantiFpWorkflowRouter() { }); }); - // GET /submissions + /** + * GET /api/ivanti/fp-workflow/submissions + * + * Returns all FP workflow submissions belonging to the authenticated user, + * enriched with Ivanti workflow state (rework notes, approval notes, current state). + * Automatically syncs lifecycle_status from Ivanti currentState when it differs. + * Includes submission history entries for each submission. + * + * @query None + * @returns {Array} Array of submission objects + * - id {number} + * - user_id {number} + * - username {string} + * - ivanti_workflow_batch_id {number} + * - ivanti_workflow_batch_uuid {string|null} + * - workflow_name {string} + * - reason {string} + * - description {string|null} + * - expiration_date {string} + * - scope_override {string} + * - finding_ids_json {string} + * - queue_item_ids_json {string} + * - attachment_count {number} + * - lifecycle_status {string} submitted|approved|rejected|rework|resubmitted + * - dismissed_at {string|null} + * - requeued_at {string|null} + * - history {Array} Submission history entries + * - ivanti_rework_note {string|null} Enriched from Ivanti API + * - ivanti_approval_note {string|null} Enriched from Ivanti API + * - ivanti_current_state {string|null} Enriched from Ivanti API + * @error 500 Internal server error + */ router.get('/submissions', requireAuth(), async (req, res) => { try { const { rows: submissions } = await pool.query(`SELECT * FROM ivanti_fp_submissions WHERE user_id = $1 ORDER BY created_at DESC`, [req.user.id]); @@ -324,7 +401,31 @@ function createIvantiFpWorkflowRouter() { } catch (err) { console.error('Error fetching FP submissions:', err); res.status(500).json({ error: 'Internal server error.' }); } }); - // PUT /submissions/:id — Edit FP workflow fields + /** + * PUT /api/ivanti/fp-workflow/submissions/:id + * + * Edits the workflow fields of an existing FP submission. Proxies the update + * to the Ivanti API, then updates the local record. Transitions lifecycle_status + * to "resubmitted" if currently rejected or rework. + * Requires Admin or Standard_User group. + * + * @param {string} id — Submission ID (URL parameter) + * @body {Object} + * - name {string} Workflow name (required, max 255 chars) + * - reason {string} Reason (required) + * - description {string} Optional (max 2000 chars) + * - expirationDate {string} Required, future date within 120 days + * - scopeOverride {string} Optional: Authorized, None, or Automated + * @returns {Object} + * - success {boolean} + * - submission {Object} The updated submission record + * @error 400 Validation errors or submission is finalized + * @error 403 Submission belongs to another user + * @error 404 Submission not found + * @error 429 Ivanti API rate limit + * @error 500 Ivanti API key not configured or local DB failure + * @error 502 Ivanti API connection or response failure + */ router.put('/submissions/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; @@ -459,7 +560,30 @@ function createIvantiFpWorkflowRouter() { }); }); - // POST /submissions/:id/findings — Map additional findings to existing workflow + /** + * POST /api/ivanti/fp-workflow/submissions/:id/findings + * + * Maps additional findings to an existing FP workflow in Ivanti. Resolves the + * workflow batch UUID, then maps each finding individually. Successfully mapped + * findings are merged into the submission's finding_ids_json and their queue items + * are marked complete. + * Requires Admin or Standard_User group. + * + * @param {string} id — Submission ID (URL parameter) + * @body {Object} + * - findingIds {Array} Finding IDs to map (at least one required) + * - queueItemIds {Array} Corresponding queue item IDs (at least one required) + * @returns {Object} + * - success {boolean} + * - addedFindings {Array} Successfully mapped finding IDs + * - failedFindings {Array} Finding IDs that failed to map + * - queueItemsUpdated {number} Count of queue items marked complete + * @error 400 Invalid input, queue item validation, or UUID resolution failure + * @error 403 Submission or queue items belong to another user + * @error 404 Submission not found + * @error 500 Ivanti API key not configured + * @error 502 All findings failed to map + */ router.post('/submissions/:id/findings', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; @@ -616,7 +740,27 @@ function createIvantiFpWorkflowRouter() { }); }); - // POST /submissions/:id/attachments — Upload additional attachments + /** + * POST /api/ivanti/fp-workflow/submissions/:id/attachments + * + * Uploads additional attachments (local files and/or library documents) to an + * existing FP workflow in Ivanti. Updates the local submission's attachment count + * and results. + * Requires Admin or Standard_User group. + * + * @param {string} id — Submission ID (URL parameter) + * @body {Object} multipart/form-data + * - attachments {File[]} Local files to upload (max 10, each max 10 MB) + * - libraryDocIds {string} Optional JSON array of document library IDs + * @returns {Object} + * - success {boolean} + * - attachmentResults {Array} Per-file upload results with filename, success, source + * - status {string} "success" if all uploaded, "partial" if some failed + * @error 400 No files provided, invalid file type, or submission is finalized + * @error 403 Submission belongs to another user + * @error 404 Submission not found + * @error 500 Ivanti API key not configured + */ router.post('/submissions/:id/attachments', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { fpUpload(req, res, (multerErr) => { if (multerErr) { @@ -747,7 +891,21 @@ function createIvantiFpWorkflowRouter() { }); }); - // PATCH /submissions/:id/dismiss — Dismiss a rejected submission + /** + * PATCH /api/ivanti/fp-workflow/submissions/:id/dismiss + * + * Dismisses a rejected FP submission by setting dismissed_at timestamp. + * Only rejected submissions can be dismissed. + * Requires Admin or Standard_User group. + * + * @param {string} id — Submission ID (URL parameter) + * @body None + * @returns {Object} { success: true } + * @error 400 Submission is not in rejected status + * @error 403 Submission belongs to another user + * @error 404 Submission not found + * @error 500 Internal server error + */ router.patch('/submissions/:id/dismiss', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; @@ -786,7 +944,145 @@ function createIvantiFpWorkflowRouter() { }); }); - // PATCH /submissions/:id/status — Update lifecycle status + /** + * POST /api/ivanti/fp-workflow/submissions/:id/requeue + * + * Re-queues findings from a rejected FP submission into the todo queue + * under a specified target workflow type. Creates new pending queue items + * for each finding referenced in the submission's queue_item_ids_json. + * Requires Admin or Standard_User group. + * + * @param {string} id — Submission ID (URL parameter) + * @body {Object} + * - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM + * - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars + * @returns {Object} + * - success {boolean} + * - items {Array} Newly created queue items with parsed cves + * - count {number} Number of items created + * @error 400 Invalid input, submission not rejected, or already re-queued + * @error 403 Submission belongs to another user + * @error 404 Submission not found + * @error 500 Internal server error + */ + router.post('/submissions/:id/requeue', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { + (async () => { + const submissionId = req.params.id; + const { workflow_type, vendor } = req.body; + + // Validate workflow_type + const VALID_REQUEUE_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE', 'DECOM']; + const INVENTORY_TYPES = ['CARD', 'GRANITE']; + if (!VALID_REQUEUE_TYPES.includes(workflow_type)) { + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, GRANITE, or DECOM.' }); + } + + // Validate vendor for FP/Archer/DECOM + if (!INVENTORY_TYPES.includes(workflow_type)) { + if (!vendor || typeof vendor !== 'string' || vendor.trim().length === 0) { + return res.status(400).json({ error: 'vendor is required for FP, Archer, and DECOM workflows.' }); + } + if (vendor.trim().length > 200) { + return res.status(400).json({ error: 'vendor must be 200 characters or fewer.' }); + } + } + + const vendorVal = INVENTORY_TYPES.includes(workflow_type) ? '' : vendor.trim(); + + // Fetch submission + const { rows: subRows } = await pool.query( + `SELECT * FROM ivanti_fp_submissions WHERE id = $1`, [submissionId] + ); + const submission = subRows[0]; + + if (!submission) return res.status(404).json({ error: 'Submission not found.' }); + if (submission.user_id !== req.user.id) return res.status(403).json({ error: 'You can only re-queue your own submissions.' }); + if (submission.lifecycle_status !== 'rejected') return res.status(400).json({ error: 'Only rejected submissions can be re-queued.' }); + if (submission.requeued_at) return res.status(400).json({ error: 'Findings from this submission have already been re-queued.' }); + + // Parse original queue item IDs + let queueItemIds = []; + try { + queueItemIds = JSON.parse(submission.queue_item_ids_json || '[]'); + } catch (e) { + return res.status(400).json({ error: 'Could not parse queue_item_ids_json.' }); + } + + if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) { + return res.status(400).json({ error: 'No queue items associated with this submission.' }); + } + + // Fetch original queue items to get finding data + const { rows: originalItems } = await pool.query( + `SELECT finding_id, finding_title, cves_json, ip_address, hostname FROM ivanti_todo_queue WHERE id = ANY($1)`, + [queueItemIds] + ); + + if (originalItems.length === 0) { + return res.status(400).json({ error: 'No original queue items found for this submission.' }); + } + + // INSERT new pending queue items for each finding + const newItems = []; + for (const item of originalItems) { + const { rows: inserted } = await pool.query( + `INSERT INTO ivanti_todo_queue + (user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [req.user.id, item.finding_id, item.finding_title, item.cves_json, item.ip_address, item.hostname, vendorVal, workflow_type] + ); + newItems.push(inserted[0]); + } + + // UPDATE submission to mark as requeued + await pool.query( + `UPDATE ivanti_fp_submissions SET requeued_at = NOW() WHERE id = $1`, + [submissionId] + ); + + // Audit log (fire-and-forget) + const finding_ids = originalItems.map(i => i.finding_id).filter(Boolean); + logAudit({ + userId: req.user.id, username: req.user.username, + action: 'fp_submission_requeued', entityType: 'ivanti_fp_submissions', + entityId: String(submission.id), + details: { target_workflow_type: workflow_type, items_created: newItems.length, finding_ids }, + ipAddress: req.ip + }); + + // Return items with parsed cves + const itemsWithCves = newItems.map(i => ({ + ...i, + cves: i.cves_json ? JSON.parse(i.cves_json) : [] + })); + + res.status(201).json({ success: true, items: itemsWithCves, count: newItems.length }); + })().catch((unexpectedErr) => { + console.error('Unexpected error in POST /submissions/:id/requeue:', unexpectedErr); + res.status(500).json({ success: false, error: 'Internal server error.' }); + }); + }); + + /** + * PATCH /api/ivanti/fp-workflow/submissions/:id/status + * + * Manually updates the lifecycle status of an FP submission. + * Validates the transition is allowed (e.g., approved submissions cannot be changed). + * Requires Admin or Standard_User group. + * + * @param {string} id — Submission ID (URL parameter) + * @body {Object} + * - lifecycle_status {string} New status. One of: submitted, approved, rejected, rework, resubmitted + * @returns {Object} + * - success {boolean} + * - previousStatus {string} + * - newStatus {string} + * @error 400 Invalid transition or invalid status value + * @error 403 Submission belongs to another user + * @error 404 Submission not found + * @error 500 Internal server error + */ router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => { (async () => { const submissionId = req.params.id; diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index c6a5135..59e8edd 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -3649,6 +3649,280 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) { ); } +// --------------------------------------------------------------------------- +// RequeueConfirmDialog — confirmation dialog for re-queuing rejected FP findings +// --------------------------------------------------------------------------- +const REQUEUE_WORKFLOW_OPTIONS = [ + { key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' }, + { key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' }, + { key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' }, + { key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }, +]; + +function RequeueConfirmDialog({ submission, onClose, onSuccess }) { + const [workflowType, setWorkflowType] = useState('FP'); + const [vendor, setVendor] = useState(() => { + // Pre-fill vendor from submission's queue items if available + try { + const items = JSON.parse(submission.queue_item_ids_json || '[]'); + if (items.length > 0 && submission.vendor) return submission.vendor; + } catch { /* ignore */ } + return ''; + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const needsVendor = workflowType === 'FP' || workflowType === 'Archer'; + const canSubmit = !loading && (!needsVendor || vendor.trim().length > 0); + + // Count findings + const findingCount = (() => { + try { + const queueIds = JSON.parse(submission.queue_item_ids_json || '[]'); + if (queueIds.length > 0) return queueIds.length; + const findingIds = JSON.parse(submission.finding_ids_json || '[]'); + return findingIds.length; + } catch { return 0; } + })(); + + const handleConfirm = async () => { + if (!canSubmit) return; + setLoading(true); + setError(''); + try { + const body = { workflow_type: workflowType }; + if (needsVendor) body.vendor = vendor.trim(); + + const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/requeue`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await res.json(); + if (!res.ok) { + setError(data.error || 'Re-queue failed.'); + setLoading(false); + return; + } + onSuccess(data); + } catch (err) { + setError(err.message || 'Network error.'); + setLoading(false); + } + }; + + return ReactDOM.createPortal( +
+
e.stopPropagation()} + style={{ + width: '100%', maxWidth: '460px', + background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))', + border: '2px solid rgba(245, 158, 11, 0.4)', + borderRadius: '0.75rem', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7), 0 0 28px rgba(245, 158, 11, 0.12)', + display: 'flex', flexDirection: 'column', + overflow: 'hidden', + }} + > + {/* Top accent line */} +
+ + {/* Header */} +
+
+ + + Re-queue Findings + +
+ +
+ + {/* Body */} +
+ {/* Finding count info */} +
+ + {findingCount} finding{findingCount !== 1 ? 's' : ''} will be re-queued from this rejected submission. + +
+ + {/* Workflow type selector */} +
+ +
+ {REQUEUE_WORKFLOW_OPTIONS.map(({ key, label, col, rgb }) => { + const active = workflowType === key; + return ( + + ); + })} +
+
+ + {/* Vendor input — conditional */} + {needsVendor && ( +
+ + setVendor(e.target.value)} + placeholder="e.g. Cisco, Juniper, ADTRAN…" + maxLength={200} + style={{ + width: '100%', boxSizing: 'border-box', + background: 'rgba(30, 41, 59, 0.6)', + border: '1px solid rgba(245, 158, 11, 0.25)', + borderRadius: '0.375rem', padding: '0.5rem 0.75rem', + fontFamily: 'monospace', fontSize: '0.8rem', color: '#E2E8F0', + outline: 'none', transition: 'border-color 0.2s', + }} + onFocus={(e) => { e.target.style.borderColor = '#F59E0B'; }} + onBlur={(e) => { e.target.style.borderColor = 'rgba(245, 158, 11, 0.25)'; }} + /> +
+ )} + + {/* Error message */} + {error && ( +
+ + + {error} + +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ); +} + // --------------------------------------------------------------------------- // FpEditModal — edit existing FP submissions (tabbed modal) // --------------------------------------------------------------------------- @@ -3666,6 +3940,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) { const [libraryDocs, setLibraryDocs] = useState([]); const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set()); const [statusValue, setStatusValue] = useState(''); + const [showRequeueDialog, setShowRequeueDialog] = useState(false); // Reset form when submission changes useEffect(() => { @@ -3806,7 +4081,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) { }; const labelStyle = { display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#94A3B8', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.06em' }; - return ReactDOM.createPortal( + const portal = ReactDOM.createPortal(
e.stopPropagation()} style={{ width: '640px', maxHeight: '85vh', display: 'flex', flexDirection: 'column', @@ -3831,6 +4106,28 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) { }}> {statusValue} + {submission.lifecycle_status === 'rejected' && ( + submission.requeued_at ? ( + + Already re-queued + + ) : ( + + ) + )}
, document.body ); + + return ( + <> + {portal} + {showRequeueDialog && setShowRequeueDialog(false)} + onSuccess={(data) => { + setShowRequeueDialog(false); + setResult({ type: 'success', message: `Re-queued ${data.count} finding(s) as ${data.items[0]?.workflow_type || 'new workflow'}` }); + if (onSuccess) onSuccess(); + }} + />} + + ); } // ---------------------------------------------------------------------------