Add re-queue findings from rejected FP submissions
New feature: users can re-queue findings from a rejected FP submission back into the Ivanti todo queue under a different workflow type (FP, Archer, CARD, GRANITE, or DECOM). Primary use case is when an FP is rejected with a recommendation to submit an Archer risk acceptance. Backend: - New migration: add requeued_at column to ivanti_fp_submissions - New endpoint: POST /api/ivanti/fp-workflow/submissions/:id/requeue - Validates workflow_type and vendor (required for FP/Archer/DECOM) - Creates new pending queue items from original finding data - Marks submission as requeued (prevents double re-queue) - Audit logs the action Frontend (ReportingPage.js): - RequeueConfirmDialog component with workflow type selector and vendor input - Re-queue Findings button in Edit FP Modal header (rejected submissions only) - Already re-queued label when submission.requeued_at is set - Success notification on completion
This commit is contained in:
17
backend/migrations/add_fp_submissions_requeued_at.js
Normal file
17
backend/migrations/add_fp_submissions_requeued_at.js
Normal file
@@ -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();
|
||||||
@@ -16,6 +16,7 @@ const MIGRATIONS_DIR = __dirname;
|
|||||||
const POSTGRES_MIGRATIONS = [
|
const POSTGRES_MIGRATIONS = [
|
||||||
'add_decom_workflow_type.js',
|
'add_decom_workflow_type.js',
|
||||||
'add_fp_submissions_dismissed.js',
|
'add_fp_submissions_dismissed.js',
|
||||||
|
'add_fp_submissions_requeued_at.js',
|
||||||
'add_vcl_reporting_columns.js',
|
'add_vcl_reporting_columns.js',
|
||||||
'add_vcl_vertical_metadata.js',
|
'add_vcl_vertical_metadata.js',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -137,7 +137,24 @@ const fpUpload = multer({ storage: uploadStorage, limits: { fileSize: 10 * 1024
|
|||||||
function createIvantiFpWorkflowRouter() {
|
function createIvantiFpWorkflowRouter() {
|
||||||
const router = express.Router();
|
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<Object>} 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) => {
|
router.get('/documents/search', requireAuth(), async (req, res) => {
|
||||||
const q = (req.query.q || '').trim();
|
const q = (req.query.q || '').trim();
|
||||||
try {
|
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) => {
|
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
fpUpload(req, res, (multerErr) => {
|
fpUpload(req, res, (multerErr) => {
|
||||||
if (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<Object>} 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<Object>} 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) => {
|
router.get('/submissions', requireAuth(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows: submissions } = await pool.query(`SELECT * FROM ivanti_fp_submissions WHERE user_id = $1 ORDER BY created_at DESC`, [req.user.id]);
|
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.' }); }
|
} 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) => {
|
router.put('/submissions/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const submissionId = req.params.id;
|
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<number|string>} Finding IDs to map (at least one required)
|
||||||
|
* - queueItemIds {Array<number>} 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) => {
|
router.post('/submissions/:id/findings', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const submissionId = req.params.id;
|
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<Object>} 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) => {
|
router.post('/submissions/:id/attachments', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
fpUpload(req, res, (multerErr) => {
|
fpUpload(req, res, (multerErr) => {
|
||||||
if (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) => {
|
router.patch('/submissions/:id/dismiss', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const submissionId = req.params.id;
|
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<Object>} 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) => {
|
router.patch('/submissions/:id/status', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const submissionId = req.params.id;
|
const submissionId = req.params.id;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 10020,
|
||||||
|
background: 'rgba(10, 14, 39, 0.92)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => 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 */}
|
||||||
|
<div style={{
|
||||||
|
height: '2px',
|
||||||
|
background: 'linear-gradient(90deg, transparent, #F59E0B, transparent)',
|
||||||
|
boxShadow: '0 0 8px rgba(245, 158, 11, 0.4)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
borderBottom: '1px solid rgba(245, 158, 11, 0.15)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||||
|
<RotateCcw style={{ width: '18px', height: '18px', color: '#F59E0B' }} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700',
|
||||||
|
color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
}}>
|
||||||
|
Re-queue Findings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
<X style={{ width: '18px', height: '18px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{/* Finding count info */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(15, 23, 42, 0.6)',
|
||||||
|
border: '1px solid rgba(245, 158, 11, 0.15)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#CBD5E1' }}>
|
||||||
|
<strong style={{ color: '#F59E0B' }}>{findingCount}</strong> finding{findingCount !== 1 ? 's' : ''} will be re-queued from this rejected submission.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow type selector */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||||
|
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}>
|
||||||
|
Target Workflow Type
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
{REQUEUE_WORKFLOW_OPTIONS.map(({ key, label, col, rgb }) => {
|
||||||
|
const active = workflowType === key;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: '0.375rem', padding: '0.45rem 0.5rem', borderRadius: '0.375rem',
|
||||||
|
background: active ? `rgba(${rgb}, 0.15)` : 'transparent',
|
||||||
|
border: `1.5px solid ${active ? `rgba(${rgb}, 0.5)` : 'rgba(255,255,255,0.08)'}`,
|
||||||
|
color: active ? col : '#475569',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
||||||
|
cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="requeue-workflow-type"
|
||||||
|
value={key}
|
||||||
|
checked={active}
|
||||||
|
onChange={() => setWorkflowType(key)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<span style={{
|
||||||
|
width: '8px', height: '8px', borderRadius: '50%',
|
||||||
|
background: active ? col : 'rgba(255,255,255,0.1)',
|
||||||
|
boxShadow: active ? `0 0 6px ${col}` : 'none',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vendor input — conditional */}
|
||||||
|
{needsVendor && (
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||||
|
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}>
|
||||||
|
Vendor <span style={{ color: '#EF4444' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={vendor}
|
||||||
|
onChange={(e) => 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)'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'flex-end', gap: '0.625rem',
|
||||||
|
padding: '0.875rem 1.25rem',
|
||||||
|
borderTop: '1px solid rgba(245, 158, 11, 0.1)',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '0.45rem 1rem', background: 'transparent',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)', borderRadius: '0.375rem',
|
||||||
|
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
padding: '0.45rem 1.1rem',
|
||||||
|
background: canSubmit
|
||||||
|
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(245, 158, 11, 0.1))'
|
||||||
|
: 'transparent',
|
||||||
|
border: `1.5px solid ${canSubmit ? '#F59E0B' : 'rgba(255,255,255,0.06)'}`,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: canSubmit ? '#FBBF24' : '#334155',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
||||||
|
) : (
|
||||||
|
<RotateCcw style={{ width: '14px', height: '14px' }} />
|
||||||
|
)}
|
||||||
|
{loading ? 'Re-queuing…' : 'Confirm Re-queue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// FpEditModal — edit existing FP submissions (tabbed modal)
|
// FpEditModal — edit existing FP submissions (tabbed modal)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -3666,6 +3940,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
const [libraryDocs, setLibraryDocs] = useState([]);
|
const [libraryDocs, setLibraryDocs] = useState([]);
|
||||||
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
|
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
|
||||||
const [statusValue, setStatusValue] = useState('');
|
const [statusValue, setStatusValue] = useState('');
|
||||||
|
const [showRequeueDialog, setShowRequeueDialog] = useState(false);
|
||||||
|
|
||||||
// Reset form when submission changes
|
// Reset form when submission changes
|
||||||
useEffect(() => {
|
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' };
|
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(
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 10010, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }} onClick={onClose}>
|
<div style={{ position: 'fixed', inset: 0, zIndex: 10010, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }} onClick={onClose}>
|
||||||
<div onClick={(e) => e.stopPropagation()} style={{
|
<div onClick={(e) => e.stopPropagation()} style={{
|
||||||
width: '640px', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
|
width: '640px', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
|
||||||
@@ -3831,6 +4106,28 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
}}>
|
}}>
|
||||||
{statusValue}
|
{statusValue}
|
||||||
</span>
|
</span>
|
||||||
|
{submission.lifecycle_status === 'rejected' && (
|
||||||
|
submission.requeued_at ? (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B', fontStyle: 'italic' }}>
|
||||||
|
Already re-queued
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRequeueDialog(true)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||||
|
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
|
||||||
|
background: 'rgba(245,158,11,0.08)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.3)',
|
||||||
|
color: '#F59E0B', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||||
|
cursor: 'pointer', lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw style={{ width: '12px', height: '12px' }} />
|
||||||
|
Re-queue Findings
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}>
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}>
|
||||||
<X style={{ width: '18px', height: '18px' }} />
|
<X style={{ width: '18px', height: '18px' }} />
|
||||||
@@ -4185,6 +4482,21 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{portal}
|
||||||
|
{showRequeueDialog && <RequeueConfirmDialog
|
||||||
|
submission={submission}
|
||||||
|
onClose={() => 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();
|
||||||
|
}}
|
||||||
|
/>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user