feat: add Ivanti Queue redirect for completed items

This commit is contained in:
jramos
2026-04-09 16:01:36 -06:00
parent 1963faf9b8
commit 0a7a7c2827
7 changed files with 881 additions and 3 deletions

View File

@@ -0,0 +1 @@
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,220 @@
# Design Document: Ivanti Queue Redirect
## Overview
The Ivanti Queue Redirect feature adds an optional redirect action to completed queue items, allowing users to create a new pending queue item under a different workflow type from an existing completed item. This supports the common scenario where a CARD inventory fix is done but the finding still needs FP or Archer processing, or where an item was assigned to the wrong workflow initially.
The feature consists of three parts:
1. A new backend API endpoint (`POST /api/ivanti/todo-queue/:id/redirect`) added to the existing `ivantiTodoQueue.js` route module
2. A redirect modal component in the frontend for collecting target workflow type and vendor
3. A redirect button on completed queue items in the existing QueuePanel
The redirect operation creates a new row in `ivanti_todo_queue` — it does not modify or delete the original completed item. This preserves the audit trail and allows the original item to remain visible as completed.
## Architecture
The feature follows the existing patterns in the codebase:
```mermaid
sequenceDiagram
participant U as User
participant QP as QueuePanel
participant RM as RedirectModal
participant API as POST /todo-queue/:id/redirect
participant DB as SQLite (ivanti_todo_queue)
participant AL as Audit Log
U->>QP: Clicks redirect button on completed item
QP->>RM: Opens modal with item context
U->>RM: Selects target workflow type + vendor
RM->>API: POST /api/ivanti/todo-queue/:id/redirect
API->>DB: SELECT original item (verify ownership + complete status)
API->>DB: INSERT new pending item with target workflow_type
API->>AL: logAudit (fire-and-forget)
API-->>RM: 201 + new item JSON
RM->>QP: Adds new item to list, closes modal, shows success
```
No new database tables or schema changes are required. The redirect creates a standard `ivanti_todo_queue` row using the existing schema. The only backend change outside the new endpoint is fixing the PUT validation message (Requirement 5).
## Components and Interfaces
### Backend: Redirect Endpoint
Added to `backend/routes/ivantiTodoQueue.js` inside the existing `createIvantiTodoQueueRouter` factory function.
```
POST /api/ivanti/todo-queue/:id/redirect
```
**Request body:**
```json
{
"workflow_type": "FP" | "Archer" | "CARD",
"vendor": "string (required for FP/Archer, omitted for CARD)"
}
```
**Success response (201):**
```json
{
"id": 42,
"user_id": 1,
"finding_id": "12345",
"finding_title": "...",
"cves_json": "[...]",
"ip_address": "10.0.0.1",
"hostname": "host.example.com",
"vendor": "Cisco",
"workflow_type": "FP",
"status": "pending",
"created_at": "...",
"updated_at": "...",
"cves": ["CVE-2024-1234"]
}
```
**Error responses:**
| Status | Condition |
|--------|-----------|
| 400 | Item not in "complete" status |
| 400 | Invalid workflow_type |
| 400 | Missing/invalid vendor for FP/Archer |
| 404 | Item not found or belongs to different user |
| 500 | Database error |
**Auth:** `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
### Backend: PUT Validation Fix
In the existing PUT `/:id` handler, the error message for invalid `workflow_type` currently says `"workflow_type must be FP or Archer."` — this will be updated to `"workflow_type must be FP, Archer, or CARD."`.
### Frontend: RedirectModal Component
A new modal component rendered inside the QueuePanel. It receives the item being redirected and collects:
- Target workflow type (radio buttons or select: FP, Archer, CARD)
- Vendor (text input, shown only when FP or Archer is selected)
The modal displays read-only context: finding title, finding ID, and current workflow type.
Props:
```js
{
item: Object, // The completed queue item being redirected
onClose: Function, // Close the modal
onRedirect: Function // Callback with the new item after successful redirect
}
```
### Frontend: QueuePanel Changes
- Add a redirect button (e.g., `CornerUpRight` or `ArrowRightLeft` icon from lucide-react) on each completed item row, next to the existing delete button
- Track `redirectItem` state — when set, render the RedirectModal
- On successful redirect, append the new item to the queue items list
### Frontend: API Call
```js
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${itemId}/redirect`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_type, vendor })
});
```
## Data Models
No schema changes. The redirect creates a standard `ivanti_todo_queue` row:
| Column | Source |
|--------|--------|
| user_id | `req.user.id` (current user) |
| finding_id | Copied from original item |
| finding_title | Copied from original item |
| cves_json | Copied from original item |
| ip_address | Copied from original item |
| hostname | Copied from original item |
| vendor | From request body (FP/Archer) or empty string (CARD) |
| workflow_type | From request body |
| status | `'pending'` (always) |
The original completed item remains unchanged.
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Redirect preserves finding data
*For any* completed queue item with arbitrary finding_id, finding_title, cves_json, ip_address, and hostname values, and *for any* valid target workflow type, redirecting that item SHALL produce a new queue item where finding_id, finding_title, cves_json, ip_address, and hostname are identical to the original, status is "pending", and workflow_type matches the requested target.
**Validates: Requirements 1.1, 1.7**
### Property 2: Vendor requirement is conditional on workflow type
*For any* redirect request, if the target workflow_type is "FP" or "Archer", the request SHALL be accepted if and only if vendor is a non-empty string of 200 characters or fewer. If the target workflow_type is "CARD", the request SHALL be accepted regardless of whether vendor is provided.
**Validates: Requirements 1.2, 1.3**
### Property 3: Successful redirect produces correct audit entry
*For any* successful redirect operation, the audit log entry SHALL contain action "queue_item_redirected", entityType "ivanti_todo_queue", the original item's ID as entityId, and details including the original workflow_type, the target workflow_type, the new item's ID, and the vendor.
**Validates: Requirements 2.1, 2.2**
## Error Handling
| Scenario | HTTP Status | Error Message | Behavior |
|----------|-------------|---------------|----------|
| Item not found or belongs to another user | 404 | "Queue item not found." | Consistent with existing DELETE/PUT pattern |
| Item status is not "complete" | 400 | "Only completed queue items can be redirected." | Prevents redirecting pending items |
| Invalid workflow_type | 400 | "workflow_type must be FP, Archer, or CARD." | Same message as batch/single add |
| Missing/invalid vendor for FP/Archer | 400 | "vendor is required for FP and Archer workflows." | Same message as existing endpoints |
| Vendor exceeds 200 chars | 400 | "vendor must be under 200 chars." | Same message as existing endpoints |
| Database insert failure | 500 | "Internal server error." | Consistent with existing error pattern |
| Frontend API error | — | Display error message from API in modal | Modal stays open so user can retry or cancel |
The redirect endpoint reuses the existing `isValidVendor()` helper and `VALID_WORKFLOW_TYPES` constant from `ivantiTodoQueue.js` for consistent validation.
Audit logging uses the existing fire-and-forget pattern — a failed audit log write does not block or fail the redirect response.
## Testing Strategy
### Unit Tests (Example-Based)
Backend:
- Redirect a completed CARD item to FP with vendor → 201, new item returned
- Redirect a completed FP item to CARD without vendor → 201, new item returned
- Redirect a pending item → 400
- Redirect another user's item → 404
- Redirect with invalid workflow_type → 400
- Redirect to FP without vendor → 400
- Redirect to FP with vendor > 200 chars → 400
- Redirect non-existent item → 404
- PUT with invalid workflow_type returns updated error message text
- Verify audit log is called with correct fields on successful redirect
Frontend:
- Redirect button visible on completed items, hidden on pending items
- Clicking redirect button opens modal with correct item context
- Modal shows vendor field for FP/Archer, hides for CARD
- Modal displays finding title, finding ID, current workflow type
- Successful redirect closes modal, adds new item to list, shows notification
- Failed redirect shows error message, modal stays open
### Property-Based Tests
Library: `fast-check` (JavaScript property-based testing library)
Each property test runs a minimum of 100 iterations.
- **Property 1**: Generate random queue item data (finding_id, finding_title, cves_json, ip_address, hostname with varying lengths, special characters, null optionals) and random valid workflow_type. Mock the database layer. Verify the INSERT parameters preserve all finding fields and set status to "pending".
- Tag: `Feature: ivanti-queue-redirect, Property 1: Redirect preserves finding data`
- **Property 2**: Generate random (workflow_type, vendor) pairs where workflow_type is drawn from valid types and vendor is drawn from a mix of valid strings, empty strings, whitespace, strings of length 200, and strings of length 201. Verify that the validation logic accepts/rejects correctly based on the conditional rule.
- Tag: `Feature: ivanti-queue-redirect, Property 2: Vendor requirement is conditional on workflow type`
- **Property 3**: Generate random successful redirect scenarios with varying item data and workflow types. Mock logAudit. Verify the audit call contains the correct action, entityType, entityId, and all required detail fields.
- Tag: `Feature: ivanti-queue-redirect, Property 3: Successful redirect produces correct audit entry`

View File

@@ -0,0 +1,72 @@
# Requirements Document
## Introduction
The Ivanti Queue Redirect feature gives users the option to redirect any completed queue item to a different workflow type. Not every completed item needs a redirect — many items are fully resolved once their workflow completes. However, some findings require further action under a different workflow. The primary use case is CARD items where the inventory fix is done but the finding still needs an FP or Archer workflow. It also supports correcting items that were assigned to the wrong team by redirecting them after a CARD fix. Redirecting is always a user-initiated, optional action that creates a new pending queue item with the target workflow type, preserving the original finding data.
## Glossary
- **Queue_Panel**: The slide-out panel in the frontend that displays the user's Ivanti todo queue items grouped by vendor, with CARD items in a separate top section.
- **Queue_Item**: A row in the `ivanti_todo_queue` table representing a finding assigned to a workflow type (FP, Archer, or CARD) with a status of pending or complete.
- **Redirect**: The action of creating a new pending Queue_Item from an existing completed Queue_Item, changing the workflow type and optionally setting a vendor.
- **Workflow_Type**: One of three processing tracks for a finding: FP (False Positive), Archer (risk acceptance), or CARD (inventory correction).
- **Vendor**: The vendor string associated with a Queue_Item. Required for FP and Archer workflow types, optional for CARD.
- **Redirect_API**: The backend endpoint `POST /api/ivanti/todo-queue/:id/redirect` that performs the redirect operation.
- **Redirect_Modal**: The frontend dialog that collects the target workflow type and vendor from the user before executing a redirect.
## Requirements
### Requirement 1: Redirect a Completed Queue Item via API
**User Story:** As an editor or admin, I want to redirect a completed queue item to a different workflow type, so that I can continue processing a finding under the correct workflow after initial work is done.
#### Acceptance Criteria
1. WHEN a user submits a redirect request for a completed Queue_Item, THE Redirect_API SHALL create a new Queue_Item with status "pending", the specified target Workflow_Type, and the same finding_id, finding_title, cves_json, ip_address, and hostname as the original Queue_Item.
2. WHEN a user submits a redirect request with a target Workflow_Type of "FP" or "Archer", THE Redirect_API SHALL require a non-empty vendor string of 200 characters or fewer.
3. WHEN a user submits a redirect request with a target Workflow_Type of "CARD", THE Redirect_API SHALL accept the request without requiring a vendor.
4. IF a user submits a redirect request for a Queue_Item that is not in "complete" status, THEN THE Redirect_API SHALL return a 400 error with a descriptive message.
5. IF a user submits a redirect request for a Queue_Item that belongs to a different user, THEN THE Redirect_API SHALL return a 404 error.
6. IF a user submits a redirect request with an invalid Workflow_Type, THEN THE Redirect_API SHALL return a 400 error indicating valid options are FP, Archer, or CARD.
7. WHEN a redirect is successfully completed, THE Redirect_API SHALL return the newly created Queue_Item with a 201 status code.
### Requirement 2: Audit Logging for Redirects
**User Story:** As an admin, I want redirect actions to be recorded in the audit log, so that I can track workflow changes for compliance and accountability.
#### Acceptance Criteria
1. WHEN a redirect is successfully completed, THE Redirect_API SHALL log an audit entry with action "queue_item_redirected", the user's ID and username, the original Queue_Item ID as entityId, and details including the original Workflow_Type, the target Workflow_Type, the new Queue_Item ID, and the vendor.
2. THE Redirect_API SHALL use entityType "ivanti_todo_queue" for all redirect audit entries.
### Requirement 3: Redirect UI in the Queue Panel
**User Story:** As a user, I want a redirect button on completed queue items, so that I can easily initiate a redirect without leaving the Queue_Panel.
#### Acceptance Criteria
1. WHILE a Queue_Item has status "complete", THE Queue_Panel SHALL display a redirect button on that item.
2. WHILE a Queue_Item has status "pending", THE Queue_Panel SHALL hide the redirect button on that item.
3. WHEN the user clicks the redirect button on a completed Queue_Item, THE Queue_Panel SHALL open the Redirect_Modal pre-populated with the finding details from the selected item.
### Requirement 4: Redirect Modal Workflow
**User Story:** As a user, I want a modal dialog to select the target workflow type and vendor when redirecting, so that I can confirm the redirect details before submitting.
#### Acceptance Criteria
1. THE Redirect_Modal SHALL display a workflow type selector with options FP, Archer, and CARD.
2. WHEN the user selects FP or Archer as the target Workflow_Type, THE Redirect_Modal SHALL display a required vendor input field.
3. WHEN the user selects CARD as the target Workflow_Type, THE Redirect_Modal SHALL hide the vendor input field.
4. THE Redirect_Modal SHALL display the finding title, finding ID, and current Workflow_Type of the item being redirected as read-only context.
5. WHEN the user confirms the redirect in the Redirect_Modal, THE Queue_Panel SHALL call the Redirect_API and add the newly created Queue_Item to the displayed list without requiring a full page refresh.
6. IF the Redirect_API returns an error, THEN THE Redirect_Modal SHALL display the error message to the user and remain open.
7. WHEN the redirect succeeds, THE Redirect_Modal SHALL close and THE Queue_Panel SHALL display a success notification.
### Requirement 5: Fix PUT Endpoint Validation Message
**User Story:** As a developer, I want the PUT endpoint validation message to accurately list all valid workflow types, so that API consumers receive correct error guidance.
#### Acceptance Criteria
1. WHEN a user submits an invalid workflow_type to the PUT /api/ivanti/todo-queue/:id endpoint, THE Redirect_API SHALL return an error message stating "workflow_type must be FP, Archer, or CARD".

View File

@@ -0,0 +1,105 @@
# Implementation Plan: Ivanti Queue Redirect
## Overview
Implement a redirect action for completed Ivanti queue items. The feature adds a `POST /api/ivanti/todo-queue/:id/redirect` endpoint to the existing route module, fixes the PUT validation message, creates a RedirectModal frontend component, and wires a redirect button into the QueuePanel for completed items. Tasks are ordered: backend bug fix, backend endpoint, frontend modal, frontend integration, with property tests alongside each layer.
## Tasks
- [x] 1. Fix PUT endpoint validation message
- [x] 1.1 Update PUT `/:id` workflow_type error message in `backend/routes/ivantiTodoQueue.js`
- Change `"workflow_type must be FP or Archer."` to `"workflow_type must be FP, Archer, or CARD."`
- _Requirements: 5.1_
- [x] 2. Add redirect endpoint to backend
- [x] 2.1 Add `POST /:id/redirect` route in `backend/routes/ivantiTodoQueue.js`
- Place inside the existing `createIvantiTodoQueueRouter` factory, before the DELETE routes
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
- Validate `workflow_type` against existing `VALID_WORKFLOW_TYPES` constant
- For FP/Archer: validate vendor using existing `isValidVendor()` helper; also check length ≤ 200
- For CARD: accept without vendor
- Fetch original item with `db.get()` scoped to `req.user.id`; return 404 if not found
- Return 400 if original item status is not `"complete"`
- INSERT new row copying finding_id, finding_title, cves_json, ip_address, hostname from original; set status `"pending"`, workflow_type and vendor from request body
- Fetch the inserted row, parse cves_json, return 201 with the new item
- Call `logAudit(db, ...)` fire-and-forget with action `"queue_item_redirected"`, entityType `"ivanti_todo_queue"`, entityId = original item ID, details: `{ original_workflow_type, target_workflow_type, new_item_id, vendor }`
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.1, 2.2_
- [ ]* 2.2 Write property test: redirect preserves finding data
- **Property 1: Redirect preserves finding data**
- Generate random queue item data (finding_id, finding_title, cves_json, ip_address, hostname with varying lengths and special characters) and random valid workflow_type
- Mock the database layer; verify the INSERT parameters preserve all finding fields and set status to "pending"
- Use `fast-check` with minimum 100 iterations
- **Validates: Requirements 1.1, 1.7**
- [ ]* 2.3 Write property test: vendor requirement is conditional on workflow type
- **Property 2: Vendor requirement is conditional on workflow type**
- Generate random (workflow_type, vendor) pairs where workflow_type is drawn from VALID_WORKFLOW_TYPES and vendor from a mix of valid strings, empty strings, whitespace, strings of length 200, and strings of length 201
- Verify validation accepts/rejects correctly based on the conditional rule
- Use `fast-check` with minimum 100 iterations
- **Validates: Requirements 1.2, 1.3**
- [ ]* 2.4 Write property test: successful redirect produces correct audit entry
- **Property 3: Successful redirect produces correct audit entry**
- Generate random successful redirect scenarios with varying item data and workflow types
- Mock `logAudit`; verify the call contains action `"queue_item_redirected"`, entityType `"ivanti_todo_queue"`, original item ID as entityId, and details with original_workflow_type, target_workflow_type, new_item_id, vendor
- Use `fast-check` with minimum 100 iterations
- **Validates: Requirements 2.1, 2.2**
- [x] 3. Checkpoint — Verify backend changes
- Ensure all tests pass, ask the user if questions arise.
- [x] 4. Create RedirectModal frontend component
- [x] 4.1 Create `frontend/src/components/RedirectModal.js`
- Props: `item` (the completed queue item), `onClose` (function), `onRedirect` (function called with new item)
- Display read-only context: finding title, finding ID, current workflow type
- Workflow type selector (radio buttons or select) with options FP, Archer, CARD
- Vendor text input shown only when FP or Archer is selected; required for those types
- Submit button calls `POST /api/ivanti/todo-queue/${item.id}/redirect` with `credentials: 'include'`
- On success: call `onRedirect(newItem)`, close modal
- On error: display error message from API response, keep modal open
- Loading state on submit button to prevent double-clicks
- Style with inline style objects following DESIGN_SYSTEM.md (dark theme, accent borders, gradient backgrounds)
- Use lucide-react icons (e.g., `CornerUpRight` or `ArrowRightLeft`)
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_
- [ ]* 4.2 Write unit tests for RedirectModal
- Test vendor field visible for FP/Archer, hidden for CARD
- Test read-only context displays finding title, finding ID, current workflow type
- Test error message displayed when API returns error
- Test modal stays open on error
- _Requirements: 4.2, 4.3, 4.4, 4.6_
- [x] 5. Integrate redirect button and modal into QueuePanel
- [x] 5.1 Add redirect button to completed items in QueuePanel (inside `frontend/src/App.js`)
- Add a redirect icon button (lucide-react) on each completed queue item row, next to the existing delete button
- Button visible only when `item.status === 'complete'`; hidden for pending items
- _Requirements: 3.1, 3.2_
- [x] 5.2 Wire RedirectModal state and rendering in QueuePanel
- Add `redirectItem` state (null or the item being redirected)
- Clicking the redirect button sets `redirectItem` to that item, opening the modal
- On successful redirect (`onRedirect` callback): append the new item to the queue items list, show a success notification, clear `redirectItem`
- On close: clear `redirectItem`
- Import and render `<RedirectModal>` conditionally when `redirectItem` is set
- _Requirements: 3.3, 4.5, 4.7_
- [ ]* 5.3 Write unit tests for redirect button visibility and modal integration
- Test redirect button rendered on completed items
- Test redirect button not rendered on pending items
- Test clicking redirect button opens modal with correct item
- Test successful redirect adds new item to list
- _Requirements: 3.1, 3.2, 3.3, 4.5_
- [x] 6. Final checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests validate universal correctness properties from the design document
- Unit tests validate specific examples and edge cases
- The project uses plain JavaScript (no TypeScript), fast-check for PBT, and react-scripts test (Jest)
- The QueuePanel component is defined inside `App.js`, not a separate file

View File

@@ -293,7 +293,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
}
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
}
if (status !== undefined && !VALID_STATUSES.includes(status)) {
return res.status(400).json({ error: 'status must be pending or complete.' });
@@ -358,6 +358,110 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
);
});
/**
* POST /api/ivanti/todo-queue/:id/redirect
*
* Redirect a completed queue item to a different workflow type.
* Creates a new pending item copying finding data from the original.
*
* @param {string} id - Original queue item ID (URL parameter)
* @body {string} workflow_type - Target workflow type: 'FP', 'Archer', or 'CARD'
* @body {string} [vendor] - Required for FP/Archer (max 200 chars); ignored for CARD
*
* @returns {Object} 201 - Newly created queue item with parsed cves array
* @returns {Object} 400 - { error: string } on validation failure or item not complete
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.post('/:id/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const { workflow_type, vendor } = req.body;
// --- Validation ---
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' });
}
if (workflow_type !== 'CARD') {
if (!isValidVendor(vendor)) {
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
}
}
if (vendor !== undefined && vendor !== '' && typeof vendor === 'string' && vendor.trim().length > 200) {
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
}
const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim();
// --- Fetch original item scoped to current user ---
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
[id, req.user.id],
(err, original) => {
if (err) {
console.error('Error fetching queue item for redirect:', err);
return res.status(500).json({ error: 'Internal server error.' });
}
if (!original) {
return res.status(404).json({ error: 'Queue item not found.' });
}
if (original.status !== 'complete') {
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
}
// --- INSERT new row copying finding data from original ---
db.run(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type],
function (insertErr) {
if (insertErr) {
console.error('Error inserting redirected queue item:', insertErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const newId = this.lastID;
// --- Fetch the inserted row ---
db.get(
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
[newId],
(fetchErr, row) => {
if (fetchErr || !row) {
console.error('Error fetching redirected queue item:', fetchErr);
return res.status(500).json({ error: 'Internal server error.' });
}
// Audit log (fire-and-forget)
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'queue_item_redirected',
entityType: 'ivanti_todo_queue',
entityId: String(original.id),
details: {
original_workflow_type: original.workflow_type,
target_workflow_type: workflow_type,
new_item_id: newId,
vendor: vendorVal,
},
ipAddress: req.ip,
});
return res.status(201).json({
...row,
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
});
}
);
}
);
}
);
});
/**
* DELETE /api/ivanti/todo-queue/completed
*

View File

@@ -0,0 +1,319 @@
import React, { useState } from 'react';
import { CornerUpRight, X, Loader, AlertCircle } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const 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' },
];
export default function RedirectModal({ item, onClose, onRedirect }) {
const [workflowType, setWorkflowType] = useState('FP');
const [vendor, setVendor] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const needsVendor = workflowType === 'FP' || workflowType === 'Archer';
const canSubmit = !loading && (!needsVendor || vendor.trim().length > 0);
const handleSubmit = 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/todo-queue/${item.id}/redirect`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Redirect failed.');
setLoading(false);
return;
}
onRedirect(data);
onClose();
} catch (err) {
setError(err.message || 'Network error.');
setLoading(false);
}
};
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(10, 14, 39, 0.92)',
backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000,
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(14, 165, 233, 0.4)',
borderRadius: '0.75rem',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7), 0 0 28px rgba(14, 165, 233, 0.12)',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Top accent line */}
<div style={{
height: '2px',
background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)',
boxShadow: '0 0 8px rgba(14, 165, 233, 0.4)',
}} />
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<CornerUpRight style={{ width: '18px', height: '18px', color: '#0EA5E9' }} />
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.95rem', fontWeight: '700',
color: '#E2E8F0',
textTransform: 'uppercase', letterSpacing: '0.08em',
}}>
Redirect Queue Item
</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' }}>
{/* Read-only context */}
<div style={{
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
display: 'flex', flexDirection: 'column', gap: '0.375rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Finding Title
</span>
</div>
<div style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.78rem', fontWeight: '600',
color: '#CBD5E1',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={item.finding_title}>
{item.finding_title || '—'}
</div>
<div style={{ display: 'flex', gap: '1.5rem', marginTop: '0.25rem' }}>
<div>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Finding ID
</span>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.72rem', color: '#94A3B8', marginTop: '2px' }}>
{item.finding_id || '—'}
</div>
</div>
<div>
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
Current Type
</span>
<div style={{ marginTop: '2px' }}>
{(() => {
const opt = WORKFLOW_OPTIONS.find((o) => o.key === item.workflow_type) || WORKFLOW_OPTIONS[0];
return (
<span style={{
display: 'inline-block',
padding: '0.1rem 0.4rem',
borderRadius: '0.2rem',
background: `rgba(${opt.rgb}, 0.12)`,
border: `1px solid rgba(${opt.rgb}, 0.3)`,
color: opt.col,
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.65rem', fontWeight: '700',
}}>
{item.workflow_type}
</span>
);
})()}
</div>
</div>
</div>
</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' }}>
{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: "'JetBrains Mono', monospace", fontSize: '0.72rem', fontWeight: '700',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
<input
type="radio"
name="redirect-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(14, 165, 233, 0.25)',
borderRadius: '0.375rem',
padding: '0.5rem 0.75rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8rem',
color: '#E2E8F0',
outline: 'none',
transition: 'border-color 0.2s',
}}
onFocus={(e) => { e.target.style.borderColor = '#0EA5E9'; }}
onBlur={(e) => { e.target.style.borderColor = 'rgba(14, 165, 233, 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: "'JetBrains Mono', 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(14, 165, 233, 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: "'JetBrains Mono', monospace", fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.45rem 1.1rem',
background: canSubmit
? 'linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.1))'
: 'transparent',
border: `1.5px solid ${canSubmit ? '#0EA5E9' : 'rgba(255,255,255,0.06)'}`,
borderRadius: '0.375rem',
color: canSubmit ? '#38BDF8' : '#334155',
fontFamily: "'JetBrains Mono', 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' }} />
) : (
<CornerUpRight style={{ width: '14px', height: '14px' }} />
)}
{loading ? 'Redirecting…' : 'Redirect'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle } from 'lucide-react';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext';
import IvantiCountsChart from './IvantiCountsChart';
import CveTooltip from '../CveTooltip';
import RedirectModal from '../RedirectModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v2';
@@ -1248,11 +1249,13 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
// ---------------------------------------------------------------------------
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
// ---------------------------------------------------------------------------
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, canWrite }) {
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite }) {
const pendingCount = items.filter((i) => i.status === 'pending').length;
const completedCount = items.filter((i) => i.status === 'complete').length;
const [selectedIds, setSelectedIds] = useState(new Set());
const [redirectItem, setRedirectItem] = useState(null);
const [redirectSuccess, setRedirectSuccess] = useState(null);
// Drop any selected IDs that no longer exist in items
useEffect(() => {
@@ -1277,6 +1280,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
setSelectedIds(new Set());
};
const handleRedirectSuccess = (newItem) => {
if (onRedirectComplete) onRedirectComplete(newItem);
setRedirectItem(null);
setRedirectSuccess(`Redirected to ${newItem.workflow_type}`);
setTimeout(() => setRedirectSuccess(null), 3000);
};
// CARD items are their own top section; everything else groups by vendor
const grouped = useMemo(() => {
const cardItems = items.filter((i) => i.workflow_type === 'CARD');
@@ -1508,6 +1518,19 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
{item.workflow_type}
</span>
{/* Redirect button — completed items only */}
{canWrite && done && (
<button
onClick={() => setRedirectItem(item)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
title="Redirect to another workflow"
>
<CornerUpRight style={{ width: '13px', height: '13px' }} />
</button>
)}
{/* Delete button */}
<button
onClick={() => onDelete(item.id)}
@@ -1594,6 +1617,35 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
</button>
</div>
</div>
{/* Redirect success notification */}
{redirectSuccess && (
<div style={{
position: 'fixed', top: '1rem', right: '440px',
zIndex: 10001,
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem 1rem',
background: 'rgba(16, 185, 129, 0.15)',
border: '1px solid rgba(16, 185, 129, 0.4)',
borderRadius: '0.375rem',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.75rem', fontWeight: '600',
color: '#10B981',
}}>
<Check style={{ width: '14px', height: '14px' }} />
{redirectSuccess}
</div>
)}
{/* Redirect modal */}
{redirectItem && (
<RedirectModal
item={redirectItem}
onClose={() => setRedirectItem(null)}
onRedirect={handleRedirectSuccess}
/>
)}
</>
);
}
@@ -3268,6 +3320,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
onDeleteMany={deleteQueueItems}
onClearCompleted={clearCompleted}
onCreateFpWorkflow={handleCreateFpWorkflow}
onRedirectComplete={(newItem) => {
setQueueItems((prev) => [...prev, newItem].sort((a, b) =>
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
));
}}
canWrite={canWrite}
/>
<FpWorkflowModal