feat: add batch finding disposition — multi-select findings and bulk add to Ivanti queue

This commit is contained in:
jramos
2026-04-09 09:49:40 -06:00
parent 328e48ea8c
commit ccc3576706
6 changed files with 1036 additions and 20 deletions

View File

@@ -0,0 +1 @@
{"specId": "9f5c16d4-43ea-4d7a-beb1-9329d79a5acc", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,331 @@
# Design Document: Batch Finding Disposition
## Overview
This feature adds multi-select capability to the Vulnerability Triage page's findings table, enabling engineers to select multiple findings and add them all to the Ivanti Queue in a single operation. The current flow requires clicking each finding individually, configuring a popover, and submitting one at a time — this design replaces that with a batch selection toolbar and a bulk-add API endpoint while preserving the existing single-select popover for one-off additions.
The design touches three layers:
1. A new `POST /api/ivanti/todo-queue/batch` backend endpoint that accepts an array of findings in a single transactional insert
2. Frontend multi-select state management (selection set, shift-click range select, select-all)
3. A sticky Selection Toolbar component with workflow type toggles, vendor input, and batch submit
## Architecture
The feature extends the existing Ivanti Queue subsystem without introducing new services or tables. The `ivanti_todo_queue` table schema is unchanged — batch add simply inserts multiple rows in a single SQLite transaction.
```mermaid
flowchart TD
subgraph Frontend ["Frontend (ReportingPage.js)"]
CB[Row Checkboxes] --> SS[Selection State<br/>Set of finding IDs]
SS --> ST[Selection Toolbar]
ST -->|"Add to Queue"| BA[Batch API Call]
CB -->|"No selection + click"| PO[AddToQueuePopover<br/>existing single-add]
end
subgraph Backend ["Backend (ivantiTodoQueue.js)"]
BA -->|"POST /batch"| BH[Batch Handler]
BH -->|"BEGIN TRANSACTION"| DB[(ivanti_todo_queue)]
BH -->|"logAudit()"| AL[(audit_logs)]
PO -->|"POST /"| SH[Single Handler<br/>existing]
SH --> DB
end
```
### Key Design Decisions
1. **No new database table or migration** — batch insert reuses the existing `ivanti_todo_queue` schema. Each finding becomes its own row, identical to what the single-add endpoint creates.
2. **SQLite transaction for atomicity** — all findings in a batch are inserted inside `db.serialize()` with `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the entire batch is rolled back. This satisfies the all-or-nothing requirement (Req 3.7, 3.8, 3.11).
3. **Selection state lives in the VulnerabilityTriagePage component** — a `Set<string>` of finding IDs managed via `useState`. This keeps the selection co-located with the existing `findings`, `sorted`, `filtered`, and `queueItems` state. No new context or global store needed.
4. **Dual-mode checkbox behavior** — when no findings are selected, clicking a checkbox opens the existing `AddToQueuePopover` (preserving the single-select flow per Req 5). Once one or more findings are selected, subsequent checkbox clicks toggle selection instead. This is the simplest UX that satisfies both Req 1 and Req 5.
5. **Selection Toolbar as inline sticky bar** — rendered between the table header controls and the `<table>` element, using `position: sticky` to stay visible during scroll. This avoids portal complexity and keeps the toolbar visually anchored to the table.
6. **200-item batch limit** — prevents oversized payloads and keeps SQLite transaction time reasonable. The findings table typically has 200-800 rows, so this covers most realistic batch sizes.
## Components and Interfaces
### Backend
#### `POST /api/ivanti/todo-queue/batch`
Added to the existing `createIvantiTodoQueueRouter` factory in `backend/routes/ivantiTodoQueue.js`.
**Request body:**
```json
{
"findings": [
{
"finding_id": "FID-12345",
"finding_title": "OpenSSL vulnerability",
"cves": ["CVE-2024-0001"],
"ip_address": "10.0.1.50"
}
],
"workflow_type": "FP",
"vendor": "Juniper"
}
```
**Validation rules:**
- `findings` — array, 1200 items
- Each item: `finding_id` required, non-empty string; `finding_title`, `cves`, `ip_address` optional
- `workflow_type` — must be `FP`, `Archer`, or `CARD`
- `vendor` — required non-empty string (≤200 chars) for FP/Archer; ignored for CARD
- If any finding fails validation, reject entire batch with 400
**Auth:** `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
**Response (201):**
```json
{
"items": [
{
"id": 42,
"user_id": 1,
"finding_id": "FID-12345",
"finding_title": "OpenSSL vulnerability",
"cves_json": "[\"CVE-2024-0001\"]",
"ip_address": "10.0.1.50",
"vendor": "Juniper",
"workflow_type": "FP",
"status": "pending",
"created_at": "2025-01-15 12:00:00",
"updated_at": "2025-01-15 12:00:00",
"cves": ["CVE-2024-0001"]
}
]
}
```
**Error responses:**
- `400` — validation failure (descriptive message)
- `401` — not authenticated
- `403` — insufficient permissions
- `500` — database transaction failure (all inserts rolled back)
### Frontend
#### Selection State (in VulnerabilityTriagePage)
New state variables added to the main component:
```javascript
const [selectedIds, setSelectedIds] = useState(new Set()); // Set<string> of finding IDs
const [lastClickedId, setLastClickedId] = useState(null); // for shift-click range select
const [batchSubmitting, setBatchSubmitting] = useState(false); // loading state
const [batchError, setBatchError] = useState(null); // error message from failed batch
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
const [batchVendor, setBatchVendor] = useState('');
```
#### Checkbox Click Logic
```
onClick(finding, event):
if finding is already queued → return (no-op)
if selectedIds.size === 0 AND not shift-click:
→ open AddToQueuePopover (existing single-select flow)
else:
if shift-click AND lastClickedId exists:
→ range-select all visible findings between lastClickedId and finding.id
else:
→ toggle finding.id in selectedIds
set lastClickedId = finding.id
```
#### SelectionToolbar Component
Rendered inline above the table when `selectedIds.size > 0`. Contains:
- Selected count badge
- "Clear Selection" button
- Workflow type toggle buttons (FP / Archer / CARD) with existing color scheme
- Vendor text input (hidden when CARD selected)
- "Add to Queue" submit button (disabled until valid)
- Error message display area
#### Selection Persistence Across Filters
When `columnFilters`, `actionFilter`, or `excFilter` change, the selection set is pruned to only include IDs that remain in the `filtered` array. This is done via a `useEffect` that intersects `selectedIds` with the current filtered finding IDs.
#### Select All / Deselect All
The checkbox column header renders a "Select All" control when `selectedIds.size > 0` or as a standard header otherwise. Clicking it:
- If not all visible non-queued findings are selected → selects all visible non-queued findings
- If all are already selected → deselects all
## Data Models
### Database Schema (unchanged)
The `ivanti_todo_queue` table is reused as-is:
```sql
CREATE TABLE ivanti_todo_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
finding_id TEXT NOT NULL,
finding_title TEXT,
cves_json TEXT,
ip_address TEXT,
vendor TEXT NOT NULL,
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
```
Each batch-added finding creates one row, identical to single-add. The `vendor` and `workflow_type` are shared across all findings in a batch (set once in the toolbar).
### API Request Schema
```
BatchAddRequest {
findings: Array<{
finding_id: string (required, non-empty, trimmed)
finding_title: string | null (max 500 chars)
cves: string[] | null
ip_address: string | null (max 64 chars)
}> (1200 items)
workflow_type: "FP" | "Archer" | "CARD"
vendor: string (required for FP/Archer, ≤200 chars; empty/absent for CARD)
}
```
### Frontend State Shape
```
Selection State:
selectedIds: Set<string> — finding IDs currently selected
lastClickedId: string | null — last checkbox clicked (for shift-range)
batchSubmitting: boolean — true while POST /batch in flight
batchError: string | null — error message from last failed batch
batchWorkflowType: "FP" | "Archer" | "CARD"
batchVendor: string
```
## 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: Selection pruning preserves only visible findings
*For any* set of selected finding IDs and any set of currently visible (filtered) finding IDs, pruning the selection after a filter change should produce exactly the intersection of the two sets — every ID in the result is both selected and visible, and no visible selected ID is lost.
**Validates: Requirements 1.4**
### Property 2: Select-all produces the complete visible non-queued set
*For any* list of visible findings and any set of queued finding IDs, the select-all operation should produce a set containing exactly the IDs of visible findings that are not in the queued set — no queued findings included, no non-queued visible findings omitted.
**Validates: Requirements 1.6**
### Property 3: Submit button enabled state matches validation rule
*For any* workflow type (FP, Archer, CARD) and any vendor string, the "Add to Queue" button should be enabled if and only if the workflow type is CARD, or the vendor string trimmed is non-empty. No other combination should enable the button.
**Validates: Requirements 2.7**
### Property 4: Batch size validation accepts only 1200 items
*For any* integer N representing the number of findings in a batch request, the endpoint should accept the request (assuming all other fields are valid) if and only if 1 ≤ N ≤ 200. Arrays of size 0 or greater than 200 should be rejected with a 400 response.
**Validates: Requirements 3.2**
### Property 5: Vendor validation is conditional on workflow type
*For any* workflow type and any vendor string, the batch endpoint should require a non-empty vendor of 200 characters or fewer when workflow_type is FP or Archer, and should accept any vendor value (including empty or absent) when workflow_type is CARD.
**Validates: Requirements 3.5, 3.6**
### Property 6: One invalid finding rejects the entire batch
*For any* valid batch of findings, if exactly one finding is replaced with an invalid finding (empty finding_id, missing finding_id, or non-string finding_id) at any position in the array, the entire batch should be rejected with a 400 response and zero rows should be inserted.
**Validates: Requirements 3.3, 3.8**
### Property 7: Successful batch response matches request
*For any* valid batch request of N findings, the 201 response should contain exactly N items, each with a unique numeric `id`, and the set of `finding_id` values in the response should equal the set of `finding_id` values in the request.
**Validates: Requirements 3.9**
### Property 8: Shift-click range select covers exactly the between range
*For any* sorted list of visible findings, any last-clicked index, and any current-click index, the shift-click range select should produce a set containing exactly the non-queued findings between those two indices (inclusive), regardless of which index is larger.
**Validates: Requirements 6.1**
## Error Handling
### Backend Errors
| Scenario | Response | Behavior |
|----------|----------|----------|
| Empty findings array or > 200 items | 400 | `{ error: "findings array must contain 1-200 items." }` |
| Any finding missing/empty finding_id | 400 | `{ error: "Each finding must have a non-empty finding_id string." }` |
| Invalid workflow_type | 400 | `{ error: "workflow_type must be FP, Archer, or CARD." }` |
| Missing vendor for FP/Archer | 400 | `{ error: "vendor is required for FP and Archer workflows." }` |
| Vendor exceeds 200 chars | 400 | `{ error: "vendor must be under 200 chars." }` |
| Not authenticated | 401 | Standard auth middleware response |
| Insufficient permissions (Read_Only) | 403 | Standard group middleware response |
| SQLite transaction failure | 500 | Transaction rolled back, `{ error: "Internal server error." }` |
### Frontend Errors
| Scenario | Behavior |
|----------|----------|
| Batch POST returns 4xx/5xx | Display error message in Selection Toolbar, keep selection intact |
| Network failure during batch POST | Display "Network error — please try again" in toolbar, keep selection |
| Batch POST timeout | Same as network failure handling |
### Edge Cases
- **Duplicate finding_ids in batch**: Allowed — the same finding could appear on multiple hosts. The backend does not enforce uniqueness on finding_id within a batch.
- **Finding already in queue**: The frontend prevents selecting already-queued findings (checkbox is disabled), so duplicates should not reach the API. No server-side duplicate check is added to keep the endpoint simple.
- **Concurrent batch submissions**: The SQLite transaction serializes writes. If two users submit overlapping batches, both succeed independently (each user has their own queue scoped by user_id).
- **Selection of 0 findings**: The "Add to Queue" button is only rendered when selectedIds.size > 0, so this state cannot be reached through the UI. The backend still validates for it.
## Testing Strategy
### Unit Tests
Focus on specific examples and edge cases:
- **Backend validation**: Test each validation rule with concrete valid/invalid inputs (empty array, 201 items, missing finding_id, invalid workflow_type, vendor edge cases)
- **Transaction rollback**: Mock a database error mid-insert, verify no rows are committed
- **Frontend checkbox dual-mode**: Test that clicking with empty selection opens popover, clicking with existing selection toggles selection
- **Toolbar visibility**: Test toolbar appears/disappears based on selection state
- **Clear selection**: Test that clear button empties selection
- **Escape key**: Test that Escape clears selection
- **Select-all toggle**: Test select-all and deselect-all behavior
- **Queue panel update**: Test that successful batch updates queueItems state
### Property-Based Tests
Using [fast-check](https://github.com/dubzzz/fast-check) for JavaScript property-based testing.
Each property test runs a minimum of 100 iterations with randomly generated inputs. Tests are tagged with their corresponding design property.
| Property | What's Generated | What's Verified |
|----------|-----------------|-----------------|
| Property 1: Selection pruning | Random sets of selected IDs and filtered IDs | Result = intersection of both sets |
| Property 2: Select-all | Random finding lists and queued ID sets | Result = visible IDs minus queued IDs |
| Property 3: Submit enabled | Random workflow types and vendor strings | Enabled iff CARD or non-empty vendor |
| Property 4: Batch size | Random integers 0300 | Accepted iff 1 ≤ N ≤ 200 |
| Property 5: Vendor validation | Random workflow types and vendor strings (0300 chars) | Conditional acceptance rule |
| Property 6: Invalid finding rejection | Valid batches with one injected invalid item | Entire batch rejected, 0 rows inserted |
| Property 7: Response shape | Valid batches of 150 findings | Response count matches, IDs match |
| Property 8: Range select | Random sorted lists and two index positions | Correct range of non-queued findings |
### Integration Tests
- End-to-end batch submission: POST valid batch, verify rows in database, verify response shape
- Auth enforcement: Verify 401 for unauthenticated, 403 for Read_Only users
- Transaction atomicity: Verify rollback on database error
- Frontend → Backend: Mock API, verify correct request payload from toolbar submit

View File

@@ -0,0 +1,97 @@
# Requirements Document
## Introduction
The Batch Finding Disposition feature adds multi-select capability to the Vulnerability Triage page's findings table, allowing engineers to select multiple findings at once and add them all to the Ivanti Queue with a shared workflow type and vendor in a single operation. Currently, each finding must be individually clicked, configured via a popover, and submitted — a repetitive process that slows down triage when working through many findings. This feature replaces that one-at-a-time flow with a batch selection toolbar and a bulk-add API endpoint.
## Glossary
- **Findings_Table**: The sortable, filterable table of Ivanti host findings rendered in the VulnerabilityTriagePage component (`ReportingPage.js`), where each row represents one finding.
- **Selection_Toolbar**: A floating toolbar that appears above the Findings_Table when one or more findings are selected via their row checkboxes, displaying the count of selected findings and batch action controls.
- **Batch_Add_Panel**: The inline panel within the Selection_Toolbar that provides workflow type selection (FP, Archer, CARD), an optional vendor input, and a submit button for adding all selected findings to the queue in one operation.
- **Todo_Queue_API**: The backend Express router at `/api/ivanti/todo-queue` that manages CRUD operations on the `ivanti_todo_queue` table.
- **Queue_Panel**: The existing right-side slide-out panel (`QueuePanel` component) that displays the user's current queue items grouped by vendor.
- **Workflow_Type**: One of three disposition categories: FP (false positive), Archer (risk acceptance), or CARD (remediation card). Each finding added to the queue is assigned exactly one Workflow_Type.
- **Finding**: A single Ivanti host vulnerability record containing an ID, title, CVEs, IP address, severity, and other metadata.
## Requirements
### Requirement 1: Multi-Select Findings via Row Checkboxes
**User Story:** As an engineer, I want to select multiple findings using checkboxes so that I can batch-process them instead of handling each one individually.
#### Acceptance Criteria
1. THE Findings_Table SHALL render a checkbox in the first column of each finding row that is not already in the queue.
2. WHEN a user clicks a finding row's checkbox, THE Findings_Table SHALL toggle that Finding's selected state without opening the AddToQueuePopover.
3. WHEN one or more findings are selected, THE Findings_Table SHALL visually distinguish selected rows from unselected rows using a highlighted background.
4. THE Findings_Table SHALL maintain the selected findings set across sort and filter changes, removing only findings that are no longer visible after filtering.
5. WHEN a finding is already in the queue, THE Findings_Table SHALL display that row's checkbox as checked and disabled, preventing re-selection.
6. WHILE findings are selected, THE Findings_Table SHALL display a "Select All (visible)" control in the checkbox column header that selects all visible, non-queued findings.
7. WHEN the "Select All" control is clicked while all visible non-queued findings are already selected, THE Findings_Table SHALL deselect all findings.
### Requirement 2: Selection Toolbar with Batch Actions
**User Story:** As an engineer, I want a toolbar that appears when I have findings selected so that I can see how many are selected and take batch actions on them.
#### Acceptance Criteria
1. WHEN one or more findings are selected, THE Selection_Toolbar SHALL appear as a sticky bar above the Findings_Table header row.
2. THE Selection_Toolbar SHALL display the count of currently selected findings.
3. THE Selection_Toolbar SHALL provide a "Clear Selection" button that deselects all findings and hides the Selection_Toolbar.
4. THE Selection_Toolbar SHALL provide workflow type toggle buttons for FP, Archer, and CARD, matching the existing color scheme (FP: amber, Archer: blue, CARD: green).
5. WHEN the selected Workflow_Type is FP or Archer, THE Selection_Toolbar SHALL display a vendor text input field.
6. WHEN the selected Workflow_Type is CARD, THE Selection_Toolbar SHALL hide the vendor input field and display a "No vendor required" indicator.
7. THE Selection_Toolbar SHALL provide an "Add to Queue" submit button that is enabled only when a Workflow_Type is selected and vendor is provided (for FP/Archer) or Workflow_Type is CARD.
8. THE Selection_Toolbar SHALL follow the existing dark theme design system (monospace fonts, dark gradient backgrounds, accent-colored borders).
### Requirement 3: Bulk Add to Queue API Endpoint
**User Story:** As an engineer, I want the backend to accept multiple findings in a single request so that batch additions are processed efficiently.
#### Acceptance Criteria
1. THE Todo_Queue_API SHALL expose a `POST /api/ivanti/todo-queue/batch` endpoint that accepts an array of finding objects with a shared workflow_type and vendor.
2. THE Todo_Queue_API SHALL validate that the findings array contains between 1 and 200 items.
3. THE Todo_Queue_API SHALL validate that each finding object contains a non-empty finding_id string.
4. THE Todo_Queue_API SHALL validate that workflow_type is one of FP, Archer, or CARD.
5. WHEN workflow_type is FP or Archer, THE Todo_Queue_API SHALL validate that vendor is a non-empty string of 200 characters or fewer.
6. WHEN workflow_type is CARD, THE Todo_Queue_API SHALL accept an empty or absent vendor field.
7. THE Todo_Queue_API SHALL insert all valid findings into the `ivanti_todo_queue` table within a single database transaction.
8. IF any finding in the batch fails validation, THEN THE Todo_Queue_API SHALL reject the entire batch and return a 400 response with a descriptive error message.
9. THE Todo_Queue_API SHALL return a 201 response containing the array of newly created queue items with their assigned IDs.
10. THE Todo_Queue_API SHALL require authentication and the Admin or Standard_User group.
11. IF a database error occurs during the transaction, THEN THE Todo_Queue_API SHALL roll back all inserts and return a 500 response.
### Requirement 4: Frontend Batch Submission Flow
**User Story:** As an engineer, I want clicking "Add to Queue" on the toolbar to submit all selected findings at once so that I save time during triage.
#### Acceptance Criteria
1. WHEN the user clicks "Add to Queue" on the Selection_Toolbar, THE Findings_Table SHALL send a single POST request to `POST /api/ivanti/todo-queue/batch` containing all selected findings with the chosen workflow_type and vendor.
2. WHILE the batch request is in progress, THE Selection_Toolbar SHALL disable the "Add to Queue" button and display a loading indicator.
3. WHEN the batch request succeeds, THE Findings_Table SHALL add all returned queue items to the local queue state, clear the selection, and hide the Selection_Toolbar.
4. WHEN the batch request succeeds, THE Findings_Table SHALL update each newly queued finding's row checkbox to show the checked-and-disabled (already queued) state.
5. IF the batch request fails, THEN THE Selection_Toolbar SHALL display the error message returned by the API and keep the current selection intact.
6. WHEN the batch request succeeds and the Queue_Panel is open, THE Queue_Panel SHALL reflect the newly added items immediately without requiring a manual refresh.
### Requirement 5: Preserve Single-Select Popover Flow
**User Story:** As an engineer, I want to still be able to add a single finding to the queue quickly without going through the batch flow, so that simple one-off additions remain fast.
#### Acceptance Criteria
1. WHEN no findings are currently selected and a user clicks a finding row's checkbox, THE Findings_Table SHALL open the existing AddToQueuePopover for that single finding.
2. WHEN one or more findings are already selected and a user clicks another finding row's checkbox, THE Findings_Table SHALL add that finding to the selection set instead of opening the AddToQueuePopover.
3. THE AddToQueuePopover SHALL continue to use the existing single-item `POST /api/ivanti/todo-queue` endpoint for individual additions.
### Requirement 6: Keyboard Accessibility for Multi-Select
**User Story:** As an engineer, I want to use keyboard shortcuts to speed up multi-select so that I can triage even faster.
#### Acceptance Criteria
1. WHEN a user holds Shift and clicks a finding row's checkbox, THE Findings_Table SHALL select all visible findings between the last clicked checkbox and the current checkbox (range select).
2. THE Selection_Toolbar SHALL be navigable via keyboard Tab order, with all interactive elements (workflow buttons, vendor input, submit button) reachable by Tab key.
3. WHEN the Escape key is pressed while the Selection_Toolbar is visible, THE Findings_Table SHALL clear the selection and hide the Selection_Toolbar.

View File

@@ -0,0 +1,116 @@
# Implementation Plan: Batch Finding Disposition
## Overview
Add multi-select capability to the Vulnerability Triage findings table with a batch-add-to-queue API endpoint. The backend gets a new `POST /api/ivanti/todo-queue/batch` route in `ivantiTodoQueue.js`. The frontend gets selection state, checkbox dual-mode logic, a SelectionToolbar component, shift-click range select, select-all, and Escape-to-clear — all within `ReportingPage.js`.
## Tasks
- [x] 1. Add `POST /api/ivanti/todo-queue/batch` endpoint
- [x] 1.1 Add batch route handler to `backend/routes/ivantiTodoQueue.js`
- Add `POST /batch` route inside `createIvantiTodoQueueRouter`, before the `POST /` route
- Apply `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')` middleware
- Validate request body: `findings` array (1200 items), each with non-empty `finding_id` string
- Validate `workflow_type` is one of `FP`, `Archer`, `CARD`
- Validate `vendor`: required non-empty string ≤200 chars for FP/Archer; ignored for CARD
- If any validation fails, return 400 with descriptive error message and reject entire batch
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.8, 3.10_
- [x] 1.2 Implement transactional batch insert with SQLite
- Use `db.serialize()` with `BEGIN TRANSACTION` / `COMMIT` to insert all findings atomically
- For each finding: insert row into `ivanti_todo_queue` with `user_id`, `finding_id`, `finding_title`, `cves_json`, `ip_address`, `vendor`, `workflow_type`
- On success: fetch all inserted rows, parse `cves_json` back to arrays, return 201 with `{ items: [...] }`
- On any DB error: `ROLLBACK` the transaction and return 500
- _Requirements: 3.7, 3.8, 3.9, 3.11_
- [x] 1.3 Add audit logging for batch additions
- After successful commit, call `logAudit(db, { ... })` with action `'batch_add_to_queue'`, entityType `'ivanti_todo_queue'`, and details including the count and workflow_type
- Import `logAudit` from `../helpers/auditLog`
- _Requirements: 3.7_
- [x] 2. Checkpoint — Verify backend endpoint
- Ensure the batch endpoint is syntactically correct and the route file has no errors. Ask the user if questions arise.
- [x] 3. Add multi-select state and checkbox dual-mode logic to `ReportingPage.js`
- [x] 3.1 Add selection state variables to `VulnerabilityTriagePage`
- Add `selectedIds` (`new Set()`), `lastClickedId` (null), `batchSubmitting` (false), `batchError` (null), `batchWorkflowType` ('FP'), `batchVendor` ('') as new `useState` hooks
- _Requirements: 1.1, 2.1_
- [x] 3.2 Implement checkbox dual-mode click handler
- Replace the existing `<td>` onClick in the checkbox cell with new logic:
- If finding is already queued → no-op (existing behavior)
- If `selectedIds.size === 0` AND not shift-click → open `AddToQueuePopover` (preserves single-select flow)
- If shift-click AND `lastClickedId` exists → range-select all visible non-queued findings between `lastClickedId` and current finding in the `sorted` array
- Otherwise → toggle finding.id in `selectedIds`
- Always update `lastClickedId` when toggling selection
- _Requirements: 1.1, 1.2, 5.1, 5.2, 6.1_
- [x] 3.3 Add visual highlighting for selected rows
- When a finding's ID is in `selectedIds`, apply a highlighted background (e.g. `rgba(14,165,233,0.12)`) to the row
- Override the existing alternating row background and hover for selected rows
- _Requirements: 1.3_
- [x] 3.4 Disable checkbox for already-queued findings
- Keep existing behavior: queued findings show checked + disabled checkbox, preventing re-selection
- Ensure queued findings are excluded from shift-click range select and select-all
- _Requirements: 1.5_
- [x] 4. Implement Select All / Deselect All in column header
- Modify the checkbox column `<th>` to render a clickable "Select All" checkbox when `selectedIds.size > 0` or when the user interacts with it
- Click behavior: if not all visible non-queued findings are selected → select all visible non-queued; if all are selected → deselect all
- _Requirements: 1.6, 1.7_
- [x] 5. Add selection pruning on filter changes
- Add a `useEffect` that watches `filtered` (the filtered findings array) and prunes `selectedIds` to only include IDs still present in the filtered set
- This ensures selection stays consistent when `columnFilters`, `actionFilter`, or `excFilter` change
- _Requirements: 1.4_
- [x] 6. Implement SelectionToolbar component
- [x] 6.1 Create the `SelectionToolbar` inline component in `ReportingPage.js`
- Render between the panel header controls and the `<table>` element, only when `selectedIds.size > 0`
- Use `position: sticky` with appropriate `top` value to stay visible during scroll
- Follow the dark theme design system: monospace fonts, dark gradient background, accent-colored borders
- _Requirements: 2.1, 2.8_
- [x] 6.2 Add toolbar controls: count badge, Clear Selection, workflow toggles, vendor input, submit button
- Display selected count badge (e.g. "12 selected")
- "Clear Selection" button that empties `selectedIds` and hides toolbar
- Workflow type toggle buttons (FP / Archer / CARD) using existing color scheme: FP = amber (`#F59E0B`), Archer = blue (`#0EA5E9`), CARD = green (`#10B981`)
- Vendor text input (hidden when CARD is selected, show "No vendor required" indicator for CARD)
- "Add to Queue" submit button — enabled only when workflow_type is CARD, or vendor is non-empty
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_
- [x] 7. Implement batch submission flow
- [x] 7.1 Add `submitBatch` async function to `VulnerabilityTriagePage`
- Build request payload from `selectedIds` (map each ID to its finding object from `sorted`/`filtered` for `finding_id`, `finding_title`, `cves`, `ip_address`), plus `batchWorkflowType` and `batchVendor`
- POST to `${API_BASE}/ivanti/todo-queue/batch` with `credentials: 'include'`
- Set `batchSubmitting = true` before request, `false` after
- _Requirements: 4.1, 4.2_
- [x] 7.2 Handle batch success response
- On 201: merge returned items into `queueItems` state (sorted by vendor then id, matching existing pattern)
- Clear `selectedIds`, reset `batchWorkflowType` to 'FP', reset `batchVendor` to '', clear `batchError`
- The newly queued findings will automatically show as checked+disabled via the existing `isQueued()` helper
- _Requirements: 4.3, 4.4, 4.6_
- [x] 7.3 Handle batch error response
- On 4xx/5xx: parse error message from response JSON, set `batchError` to display in toolbar
- On network failure: set `batchError` to "Network error — please try again"
- Keep selection intact on error so user can retry
- _Requirements: 4.5_
- [x] 8. Add Escape key handler to clear selection
- Add a `useEffect` with a `keydown` listener for Escape that clears `selectedIds` when the SelectionToolbar is visible (i.e. `selectedIds.size > 0`)
- Ensure it doesn't conflict with the existing Escape handler on `AddToQueuePopover`
- _Requirements: 6.3_
- [x] 9. Ensure keyboard Tab accessibility for SelectionToolbar
- Verify all interactive elements in the toolbar (workflow buttons, vendor input, submit button, clear button) are focusable via Tab key
- Use native `<button>` and `<input>` elements (which are inherently tabbable) rather than `<div>` with onClick
- _Requirements: 6.2_
- [x] 10. Final checkpoint — Full integration verification
- Ensure all files have no syntax errors or diagnostic issues
- Verify the checkbox dual-mode logic: no selection → popover, existing selection → toggle
- Verify the SelectionToolbar renders/hides correctly based on selection state
- Verify batch submit wires through to the backend endpoint and updates queue state
- Ensure all tests pass, ask the user if questions arise.
## Notes
- No new database migration needed — batch insert reuses the existing `ivanti_todo_queue` schema
- The batch endpoint must be registered before `POST /` in the router to avoid Express route conflicts
- All testing is done on the dev server after push — no local test tasks included
- Each task references specific acceptance criteria from the requirements document for traceability

View File

@@ -1,6 +1,7 @@
// routes/ivantiTodoQueue.js
const express = require('express');
const { requireGroup } = require('../middleware/auth');
const logAudit = require('../helpers/auditLog');
const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD'];
const VALID_STATUSES = ['pending', 'complete'];
@@ -14,8 +15,16 @@ function isValidVendor(vendor) {
function createIvantiTodoQueueRouter(db, requireAuth) {
const router = express.Router();
// GET /api/ivanti/todo-queue
// Fetch current user's queue items, ordered by vendor then created_at
/**
* GET /api/ivanti/todo-queue
*
* Fetch the current user's queue items, ordered by vendor then created_at.
*
* @returns {Array<Object>} 200 - Array of queue items, each with:
* id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
* @returns {Object} 500 - { error: string } on database error
*/
router.get('/', requireAuth(db), (req, res) => {
db.all(
`SELECT * FROM ivanti_todo_queue
@@ -37,8 +46,168 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
);
});
// POST /api/ivanti/todo-queue
// Add a finding to the queue
/**
* POST /api/ivanti/todo-queue/batch
*
* Add multiple findings to the current user's queue in a single transaction.
*
* @body {Object[]} findings - Required array of 1200 finding objects
* @body {string} findings[].finding_id - Required, non-empty finding identifier
* @body {string} [findings[].finding_title] - Optional finding title (max 500 chars)
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
*
* @returns {Object} 201 - { items: Array<Object> } array of created queue items,
* each with: id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves (parsed array)
* @returns {Object} 400 - { error: string } on validation failure
* @returns {Object} 500 - { error: string } on database/transaction error (all inserts rolled back)
*/
router.post('/batch', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { findings, workflow_type, vendor } = req.body;
// --- Validation ---
if (!Array.isArray(findings) || findings.length < 1 || findings.length > 200) {
return res.status(400).json({ error: 'findings array must contain 1-200 items.' });
}
for (let i = 0; i < findings.length; i++) {
const f = findings[i];
if (!f || typeof f.finding_id !== 'string' || f.finding_id.trim().length === 0) {
return res.status(400).json({ error: 'Each finding must have a non-empty finding_id string.' });
}
}
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();
const userId = req.user.id;
// --- Transactional batch insert ---
// Prepare all row values upfront
const rows = findings.map((f) => {
const findingId = f.finding_id.trim();
const title = f.finding_title && typeof f.finding_title === 'string'
? f.finding_title.slice(0, 500)
: null;
const cvesJson = Array.isArray(f.cves) ? JSON.stringify(f.cves) : null;
const ipVal = f.ip_address && typeof f.ip_address === 'string'
? f.ip_address.trim().slice(0, 64)
: null;
return [userId, findingId, title, cvesJson, ipVal, vendorVal, workflow_type];
});
const insertedIds = [];
let insertError = null;
let remaining = rows.length;
db.serialize(() => {
db.run('BEGIN TRANSACTION');
rows.forEach((params) => {
db.run(
`INSERT INTO ivanti_todo_queue
(user_id, finding_id, finding_title, cves_json, ip_address, vendor, workflow_type)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
params,
function (err) {
if (err && !insertError) {
insertError = err;
} else if (!err) {
insertedIds.push(this.lastID);
}
remaining--;
// After all insert callbacks have fired, commit or rollback
if (remaining === 0) {
if (insertError) {
db.run('ROLLBACK', () => {
console.error('Batch insert error:', insertError);
return res.status(500).json({ error: 'Internal server error.' });
});
} else {
db.run('COMMIT', (commitErr) => {
if (commitErr) {
console.error('Batch commit error:', commitErr);
db.run('ROLLBACK', () => {});
return res.status(500).json({ error: 'Internal server error.' });
}
// Fetch all inserted rows
const placeholders = insertedIds.map(() => '?').join(',');
db.all(
`SELECT * FROM ivanti_todo_queue WHERE id IN (${placeholders})`,
insertedIds,
(fetchErr, fetchedRows) => {
if (fetchErr) {
console.error('Error fetching inserted batch rows:', fetchErr);
return res.status(500).json({ error: 'Internal server error.' });
}
const items = (fetchedRows || []).map((r) => ({
...r,
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
}));
// Audit log (fire-and-forget)
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'batch_add_to_queue',
entityType: 'ivanti_todo_queue',
entityId: null,
details: {
count: insertedIds.length,
workflow_type: workflow_type,
finding_ids: findings.map((f) => f.finding_id.trim()),
},
ipAddress: req.ip,
});
return res.status(201).json({ items });
}
);
});
}
}
}
);
});
});
});
/**
* POST /api/ivanti/todo-queue
*
* Add a single finding to the current user's queue.
*
* @body {string} finding_id - Required, non-empty finding identifier
* @body {string} [finding_title] - Optional finding title (max 500 chars)
* @body {string[]} [cves] - Optional array of CVE identifiers
* @body {string} [ip_address] - Optional IP address (max 64 chars)
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
*
* @returns {Object} 201 - Created queue item with parsed cves array:
* id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves
* @returns {Object} 400 - { error: string } on validation failure
* @returns {Object} 500 - { error: string } on database error
*/
router.post('/', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { finding_id, finding_title, cves, ip_address, vendor, workflow_type } = req.body;
@@ -87,8 +256,23 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
);
});
// PUT /api/ivanti/todo-queue/:id
// Update vendor, workflow_type, or status — scoped to current user
/**
* PUT /api/ivanti/todo-queue/:id
*
* Update vendor, workflow_type, or status on a queue item — scoped to current user.
*
* @param {string} id - Queue item ID (URL parameter)
* @body {string} [vendor] - New vendor string (max 200 chars)
* @body {string} [workflow_type] - One of 'FP', 'Archer', 'CARD'
* @body {string} [status] - One of 'pending', 'complete'
*
* @returns {Object} 200 - Updated queue item with parsed cves array:
* id, user_id, finding_id, finding_title, cves_json, ip_address,
* vendor, workflow_type, status, created_at, updated_at, cves
* @returns {Object} 400 - { error: string } on validation failure or no fields to update
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.put('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;
const { vendor, workflow_type, status } = req.body;
@@ -162,9 +346,15 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
);
});
// DELETE /api/ivanti/todo-queue/completed
// Bulk-delete all completed items for the current user
// IMPORTANT: This route must be registered BEFORE DELETE /:id
/**
* DELETE /api/ivanti/todo-queue/completed
*
* Bulk-delete all completed items for the current user.
* IMPORTANT: This route must be registered BEFORE DELETE /:id.
*
* @returns {Object} 200 - { message: string, deleted: number }
* @returns {Object} 500 - { error: string } on database error
*/
router.delete('/completed', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
db.run(
"DELETE FROM ivanti_todo_queue WHERE user_id = ? AND status = 'complete'",
@@ -179,8 +369,17 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
);
});
// DELETE /api/ivanti/todo-queue/:id
// Delete a single item — scoped to current user
/**
* DELETE /api/ivanti/todo-queue/:id
*
* Delete a single queue item — scoped to current user.
*
* @param {string} id - Queue item ID (URL parameter)
*
* @returns {Object} 200 - { message: string }
* @returns {Object} 404 - { error: string } if item not found for current user
* @returns {Object} 500 - { error: string } on database error
*/
router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
const { id } = req.params;

View File

@@ -2113,6 +2113,139 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
);
}
// ---------------------------------------------------------------------------
// SelectionToolbar — batch action bar for multi-selected findings
// ---------------------------------------------------------------------------
function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWorkflowChange, onVendorChange, onSubmit, onClear }) {
const isCard = workflowType === 'CARD';
const canSubmit = !submitting && (isCard || vendor.trim().length > 0);
return (
<div style={{
position: 'sticky', top: 0, zIndex: 20,
display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap',
padding: '0.625rem 1rem',
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: '1px solid rgba(14,165,233,0.25)',
borderRadius: '0.375rem',
marginBottom: '0.5rem',
}}>
{/* Count badge */}
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '700', color: '#E2E8F0',
}}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: '22px', height: '22px', padding: '0 6px',
background: 'rgba(14,165,233,0.2)', border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '999px', fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '700', color: '#0EA5E9',
}}>
{count}
</span>
selected
</span>
{/* Workflow type toggles */}
<div style={{ display: 'flex', gap: '0.25rem' }}>
{[
{ type: 'FP', color: '#F59E0B', rgb: '245,158,11' },
{ type: 'Archer', color: '#0EA5E9', rgb: '14,165,233' },
{ type: 'CARD', color: '#10B981', rgb: '16,185,129' },
].map(({ type, color, rgb }) => {
const active = workflowType === type;
return (
<button
key={type}
onClick={() => onWorkflowChange(type)}
style={{
padding: '0.25rem 0.5rem',
background: active ? `rgba(${rgb},0.2)` : 'transparent',
border: `1px solid rgba(${rgb},${active ? '0.5' : '0.15'})`,
borderRadius: '0.25rem',
color: active ? color : '#475569',
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700',
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
{type}
</button>
);
})}
</div>
{/* Vendor input or CARD indicator */}
{isCard ? (
<span style={{
fontFamily: 'monospace', fontSize: '0.68rem', color: '#10B981',
padding: '0.25rem 0.5rem',
background: 'rgba(16,185,129,0.06)', border: '1px solid rgba(16,185,129,0.2)',
borderRadius: '0.25rem',
}}>
No vendor required
</span>
) : (
<input
type="text"
value={vendor}
onChange={(e) => onVendorChange(e.target.value)}
placeholder="Vendor / Platform"
style={{
width: '160px', boxSizing: 'border-box',
background: 'rgba(14,165,233,0.05)', border: '1px solid rgba(14,165,233,0.2)',
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
color: '#CBD5E1', fontSize: '0.75rem', fontFamily: 'monospace', outline: 'none',
}}
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) onSubmit(); }}
/>
)}
{/* Add to Queue button */}
<button
onClick={onSubmit}
disabled={!canSubmit}
style={{
padding: '0.3rem 0.75rem',
background: canSubmit ? 'rgba(14,165,233,0.15)' : 'transparent',
border: `1px solid rgba(14,165,233,${canSubmit ? '0.4' : '0.1'})`,
borderRadius: '0.25rem',
color: canSubmit ? '#0EA5E9' : '#334155',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
cursor: canSubmit ? 'pointer' : 'not-allowed',
textTransform: 'uppercase', letterSpacing: '0.05em',
transition: 'all 0.12s',
}}
>
{submitting ? 'Adding…' : 'Add to Queue'}
</button>
{/* Clear selection */}
<button
onClick={onClear}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#475569', padding: '4px', lineHeight: 1,
}}
title="Clear selection"
>
<X style={{ width: '16px', height: '16px' }} />
</button>
{/* Error message */}
{error && (
<span style={{
fontFamily: 'monospace', fontSize: '0.68rem', color: '#EF4444',
display: 'flex', alignItems: 'center', gap: '0.25rem',
}}>
<AlertCircle style={{ width: '12px', height: '12px' }} />
{error}
</span>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main ReportingPage
// ---------------------------------------------------------------------------
@@ -2138,6 +2271,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
const [actionFilter, setActionFilter] = useState(null);
const [excFilter, setExcFilter] = useState(filterEXC || null);
const [selectedIds, setSelectedIds] = useState(new Set());
const [lastClickedId, setLastClickedId] = useState(null);
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [batchError, setBatchError] = useState(null);
const [batchWorkflowType, setBatchWorkflowType] = useState('FP');
const [batchVendor, setBatchVendor] = useState('');
const updateColumns = useCallback((newOrder) => {
setColumnOrder(newOrder);
saveColumnOrder(newOrder);
@@ -2216,6 +2356,29 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
fetchQueue();
}, []); // eslint-disable-line
// Prune selection when filters change — keep only IDs still in filtered set
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const visibleIds = new Set(filtered.map((f) => f.id));
const next = new Set([...prev].filter((id) => visibleIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [filtered]);
// Escape key clears selection
useEffect(() => {
if (selectedIds.size === 0) return;
const handler = (e) => {
if (e.key === 'Escape' && selectedIds.size > 0 && !addPopover) {
setSelectedIds(new Set());
setBatchError(null);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [selectedIds, addPopover]);
// Set/clear a single column filter
const setColFilter = useCallback((colKey, vals) => {
setColumnFilters((prev) => {
@@ -2361,6 +2524,50 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
setQueueForm({ vendor: '', workflowType: 'FP' });
}, [addPopover, queueForm]);
const submitBatch = useCallback(async () => {
if (selectedIds.size === 0) return;
setBatchSubmitting(true);
setBatchError(null);
try {
const findingsPayload = [...selectedIds].map((id) => {
const f = findings.find((ff) => ff.id === id);
return f ? {
finding_id: f.id,
finding_title: f.title || null,
cves: f.cves || [],
ip_address: f.ipAddress || null,
} : { finding_id: id };
});
const res = await fetch(`${API_BASE}/ivanti/todo-queue/batch`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
findings: findingsPayload,
workflow_type: batchWorkflowType,
vendor: batchWorkflowType === 'CARD' ? '' : batchVendor.trim(),
}),
});
const data = await res.json();
if (res.ok) {
setQueueItems((prev) => [...prev, ...(data.items || [])].sort((a, b) =>
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
));
setSelectedIds(new Set());
setBatchWorkflowType('FP');
setBatchVendor('');
setBatchError(null);
} else {
setBatchError(data.error || 'Failed to add findings to queue.');
}
} catch (e) {
console.error('Error in batch add:', e);
setBatchError('Network error — please try again.');
} finally {
setBatchSubmitting(false);
}
}, [selectedIds, findings, batchWorkflowType, batchVendor]);
const updateQueueItem = useCallback(async (id, changes) => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${id}`, {
@@ -2786,6 +2993,19 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</div>
) : (
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 420px)', minHeight: '200px', marginTop: '0.75rem' }}>
{selectedIds.size > 0 && canWrite() && (
<SelectionToolbar
count={selectedIds.size}
workflowType={batchWorkflowType}
vendor={batchVendor}
submitting={batchSubmitting}
error={batchError}
onWorkflowChange={setBatchWorkflowType}
onVendorChange={setBatchVendor}
onSubmit={submitBatch}
onClear={() => { setSelectedIds(new Set()); setBatchError(null); }}
/>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
@@ -2796,8 +3016,31 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
background: 'rgb(10, 20, 36)',
position: 'sticky', top: 0, zIndex: 10,
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
textAlign: 'center',
}}
/>
>
{canWrite() && (
<input
type="checkbox"
checked={sorted.length > 0 && sorted.filter((f) => !isQueued(f.id)).length > 0 && sorted.filter((f) => !isQueued(f.id)).every((f) => selectedIds.has(f.id))}
onChange={() => {
const nonQueued = sorted.filter((f) => !isQueued(f.id));
const allSelected = nonQueued.length > 0 && nonQueued.every((f) => selectedIds.has(f.id));
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(nonQueued.map((f) => f.id)));
}
}}
style={{
accentColor: '#0EA5E9',
width: '13px', height: '13px',
cursor: 'pointer',
}}
title="Select all visible findings"
/>
)}
</th>
{visibleCols.map((col) => {
const def = COLUMN_DEFS[col.key];
const active = sort.field === col.key;
@@ -2849,31 +3092,60 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
</thead>
<tbody>
{sorted.map((finding, idx) => {
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
const isSelected = selectedIds.has(finding.id);
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
const queued = isQueued(finding.id);
return (
<tr
key={finding.id}
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
>
{/* Checkbox cell */}
<td
style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}
onClick={(e) => {
if (queued) return;
const rect = e.currentTarget.getBoundingClientRect();
setAddPopover({ finding, anchorRect: rect });
setQueueForm({ vendor: '', workflowType: 'FP' });
// If nothing selected and not shift-click, open single-add popover
if (selectedIds.size === 0 && !e.shiftKey) {
const rect = e.currentTarget.getBoundingClientRect();
setAddPopover({ finding, anchorRect: rect });
setQueueForm({ vendor: '', workflowType: 'FP' });
return;
}
// Shift-click range select
if (e.shiftKey && lastClickedId) {
const lastIdx = sorted.findIndex((f) => f.id === lastClickedId);
const currIdx = sorted.findIndex((f) => f.id === finding.id);
if (lastIdx !== -1 && currIdx !== -1) {
const start = Math.min(lastIdx, currIdx);
const end = Math.max(lastIdx, currIdx);
setSelectedIds((prev) => {
const next = new Set(prev);
for (let i = start; i <= end; i++) {
if (!isQueued(sorted[i].id)) next.add(sorted[i].id);
}
return next;
});
}
} else {
// Toggle selection
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id);
return next;
});
}
setLastClickedId(finding.id);
}}
>
<input
type="checkbox"
readOnly
checked={queued}
checked={queued || isSelected}
style={{
accentColor: '#0EA5E9',
accentColor: queued ? '#10B981' : '#0EA5E9',
width: '13px', height: '13px',
cursor: queued ? 'default' : 'pointer',
pointerEvents: 'none',