From 0fe8e94d511c4aa8ffcead213dd7ad5a2234efad Mon Sep 17 00:00:00 2001 From: jramos Date: Tue, 14 Apr 2026 15:33:19 -0600 Subject: [PATCH] feat: add GRANITE as fourth workflow type in Ivanti queue - Add GRANITE to VALID_WORKFLOW_TYPES in backend (no vendor required, same as CARD) - Update vendor validation and error messages across all endpoints (single add, batch, PUT, redirect) - Add GRANITE option to RedirectModal with warm slate color (#A1887F) - Rename QueuePanel CARD section to Inventory, group CARD + GRANITE with sub-divider - Add GRANITE to AddToQueuePopover and SelectionToolbar - Update spec docs (requirements, design, tasks) --- .kiro/specs/ivanti-queue-redirect/design.md | 194 ++++++++-- .../ivanti-queue-redirect/requirements.md | 55 ++- .kiro/specs/ivanti-queue-redirect/tasks.md | 122 +++--- backend/routes/ivantiTodoQueue.js | 39 +- frontend/src/components/RedirectModal.js | 7 +- .../src/components/pages/ReportingPage.js | 351 ++++++++++-------- 6 files changed, 503 insertions(+), 265 deletions(-) diff --git a/.kiro/specs/ivanti-queue-redirect/design.md b/.kiro/specs/ivanti-queue-redirect/design.md index 59aae5f..d91e261 100644 --- a/.kiro/specs/ivanti-queue-redirect/design.md +++ b/.kiro/specs/ivanti-queue-redirect/design.md @@ -2,12 +2,16 @@ ## 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 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, where an item was assigned to the wrong workflow initially, or where a CARD item with a high asset score (90+) needs to go through the GRANITE program for reassignment or deletion. -The feature consists of three parts: +The feature consists of five 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 +2. GRANITE added as a fourth valid workflow type across all backend endpoints (`VALID_WORKFLOW_TYPES` constant) +3. A redirect modal component in the frontend for collecting target workflow type and vendor +4. A redirect button on completed queue items in the existing QueuePanel +5. Updated QueuePanel grouping: CARD and GRANITE items grouped under an "Inventory" section, with GRANITE also available in the AddToQueue popover + +There are four workflow types: FP, Archer, CARD, and GRANITE. FP and Archer require a vendor string; CARD and GRANITE do not. Any completed item can redirect to any other workflow type — there is no fixed ordering between types. 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. @@ -35,10 +39,39 @@ sequenceDiagram 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). +No new database tables or schema changes are required. The redirect creates a standard `ivanti_todo_queue` row using the existing schema. Backend changes outside the new endpoint include: adding GRANITE to `VALID_WORKFLOW_TYPES`, updating all error messages to list four valid types, and treating GRANITE like CARD for vendor validation (no vendor required). + +### QueuePanel Grouping (Layout) + +```mermaid +graph TD + subgraph QueuePanel + subgraph Inventory Section + A[CARD items] + B[Sub-divider - only when both exist] + C[GRANITE items] + end + subgraph Vendor Groups + D[Vendor A - FP/Archer items] + E[Vendor B - FP/Archer items] + end + end +``` + +The QueuePanel groups items into two categories: +- **Inventory section** (top): Contains both CARD and GRANITE items under a single "Inventory" heading. CARD items appear first, followed by a subtle sub-divider (only shown when both types are present), then GRANITE items. Each item retains its workflow type badge (CARD in green, GRANITE in warm slate). +- **Vendor groups** (below): FP and Archer items grouped by vendor, same as current behavior. ## Components and Interfaces +### Backend: VALID_WORKFLOW_TYPES Constant + +Updated from `['FP', 'Archer', 'CARD']` to `['FP', 'Archer', 'CARD', 'GRANITE']`. + +All endpoints that reference this constant (batch add, single add, PUT, redirect) automatically accept GRANITE. The vendor validation condition changes from `workflow_type !== 'CARD'` to `workflow_type !== 'CARD' && workflow_type !== 'GRANITE'` (or equivalently, checking if the type is FP or Archer). The `vendorVal` assignment similarly treats GRANITE like CARD: `vendorVal = (workflow_type === 'CARD' || workflow_type === 'GRANITE') ? '' : vendor.trim()`. + +All error messages for invalid workflow_type are updated to: `"workflow_type must be FP, Archer, CARD, or GRANITE."`. + ### Backend: Redirect Endpoint Added to `backend/routes/ivantiTodoQueue.js` inside the existing `createIvantiTodoQueueRouter` factory function. @@ -50,8 +83,8 @@ POST /api/ivanti/todo-queue/:id/redirect **Request body:** ```json { - "workflow_type": "FP" | "Archer" | "CARD", - "vendor": "string (required for FP/Archer, omitted for CARD)" + "workflow_type": "FP" | "Archer" | "CARD" | "GRANITE", + "vendor": "string (required for FP/Archer, omitted for CARD/GRANITE)" } ``` @@ -85,18 +118,43 @@ POST /api/ivanti/todo-queue/:id/redirect **Auth:** `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')` +### Backend: Vendor Validation Logic + +The vendor requirement is conditional on workflow type across all endpoints: + +| Workflow Type | Vendor Required | vendorVal | +|---------------|----------------|-----------| +| FP | Yes — non-empty, ≤ 200 chars | `vendor.trim()` | +| Archer | Yes — non-empty, ≤ 200 chars | `vendor.trim()` | +| CARD | No | `''` (empty string) | +| GRANITE | No | `''` (empty string) | + +The condition for requiring vendor changes from `workflow_type !== 'CARD'` to `workflow_type === 'FP' || workflow_type === 'Archer'` (or equivalently `!['CARD', 'GRANITE'].includes(workflow_type)`). + ### 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."`. +In the existing PUT `/:id` handler, the error message for invalid `workflow_type` is updated to `"workflow_type must be FP, Archer, CARD, or GRANITE."`. The same update applies to the batch add, single add, and redirect endpoints. ### 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) +A modal component rendered inside the QueuePanel. It receives the item being redirected and collects: +- Target workflow type (radio buttons: FP, Archer, CARD, GRANITE) - 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. +**WORKFLOW_OPTIONS constant** (updated to include GRANITE): +```js +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' }, + { key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }, +]; +``` + +The `needsVendor` condition changes from `workflowType === 'FP' || workflowType === 'Archer'` — this remains the same since GRANITE, like CARD, does not need vendor. + Props: ```js { @@ -108,9 +166,80 @@ Props: ### 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 +#### Grouping Logic + +The current grouping logic filters `workflow_type === 'CARD'` into a separate top section. This changes to group both CARD and GRANITE into an "Inventory" section: + +```js +const grouped = useMemo(() => { + const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE'); + const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD'); + const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE'); + const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE'); + + // Vendor groups for FP/Archer items + const map = {}; + otherItems.forEach((item) => { + const v = item.vendor || 'Unknown'; + if (!map[v]) map[v] = []; + map[v].push(item); + }); + const vendorGroups = Object.keys(map).sort().map((vendor) => ({ + key: vendor, label: vendor, items: map[vendor], isInventory: false, + })); + + return inventoryItems.length > 0 + ? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, items: inventoryItems, isInventory: true }, ...vendorGroups] + : vendorGroups; +}, [items]); +``` + +#### Inventory Section Rendering + +The Inventory section header uses the existing accent color for the section label. Within the section: +1. CARD items render first +2. A subtle sub-divider appears only when both CARD and GRANITE items exist +3. GRANITE items render below the sub-divider + +Each item retains its individual workflow type badge with distinct colors. + +#### Workflow Type Color Mapping + +Updated to include GRANITE: + +```js +const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' } + : item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' } + : item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' } + : { col: '#10B981', rgb: '16,185,129' }; +``` + +#### GRANITE Item Rendering + +GRANITE items render identically to CARD items — showing hostname and ip_address fields (not CVEs), since GRANITE is also an inventory-category workflow. The condition changes from `isCardItem` to `isInventoryItem`: + +```js +const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE'; +``` + +#### Redirect Button + +A redirect icon button (`CornerUpRight` from lucide-react) on each completed queue item row, next to the existing delete button. Visible only when `item.status === 'complete'` and `canWrite` is true. + +### Frontend: AddToQueue Popover + +The AddToQueue popover (defined inline in `ReportingPage.js`) adds GRANITE as a fourth workflow type button: + +```js +const QUEUE_WORKFLOW_OPTIONS = [ + { key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' }, + { key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' }, + { key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' }, + { key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }, +]; +``` + +When GRANITE is selected, no vendor field is required — same behavior as CARD. The submit logic uses the same condition: `workflow_type === 'FP' || workflow_type === 'Archer'` to determine if vendor is needed. ### Frontend: API Call @@ -135,8 +264,8 @@ No schema changes. The redirect creates a standard `ivanti_todo_queue` row: | 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 | +| vendor | From request body (FP/Archer) or empty string (CARD/GRANITE) | +| workflow_type | From request body (FP, Archer, CARD, or GRANITE) | | status | `'pending'` (always) | The original completed item remains unchanged. @@ -148,15 +277,15 @@ The original completed item remains unchanged. ### 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. +*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 (FP, Archer, CARD, or GRANITE), 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. +*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" or "GRANITE", the request SHALL be accepted regardless of whether vendor is provided. -**Validates: Requirements 1.2, 1.3** +**Validates: Requirements 1.2, 1.3, 6.2** ### Property 3: Successful redirect produces correct audit entry @@ -170,13 +299,13 @@ The original completed item remains unchanged. |----------|-------------|---------------|----------| | 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 | +| Invalid workflow_type | 400 | "workflow_type must be FP, Archer, CARD, or GRANITE." | Same message across all endpoints | | 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. +The redirect endpoint reuses the existing `isValidVendor()` helper and `VALID_WORKFLOW_TYPES` constant from `ivantiTodoQueue.js` for consistent validation. All error messages for invalid workflow_type now list all four valid options: FP, Archer, CARD, and GRANITE. Audit logging uses the existing fire-and-forget pattern — a failed audit log write does not block or fail the redirect response. @@ -187,22 +316,35 @@ Audit logging uses the existing fire-and-forget pattern — a failed audit log w 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 completed FP item to GRANITE without vendor → 201, new item returned +- Redirect a completed GRANITE item to Archer with vendor → 201, new item returned - Redirect a pending item → 400 - Redirect another user's item → 404 -- Redirect with invalid workflow_type → 400 +- Redirect with invalid workflow_type → 400 with message listing FP, Archer, CARD, GRANITE - 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 +- PUT with invalid workflow_type returns error message "workflow_type must be FP, Archer, CARD, or GRANITE." +- Batch add with workflow_type GRANITE and no vendor → 201 +- Single add with workflow_type GRANITE and no vendor → 201 - Verify audit log is called with correct fields on successful redirect +- Verify VALID_WORKFLOW_TYPES includes all four types 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 shows all four workflow type options (FP, Archer, CARD, GRANITE) +- Modal shows vendor field for FP/Archer, hides for CARD and GRANITE - 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 +- QueuePanel groups CARD and GRANITE items under "Inventory" section +- Sub-divider shown only when both CARD and GRANITE items exist in Inventory section +- "Inventory" heading shown even when only one sub-type present +- GRANITE badge uses warm slate color (#A1887F) +- CARD badge uses green color (#10B981) +- AddToQueue popover shows GRANITE as fourth option with warm slate color +- Selecting GRANITE in AddToQueue popover does not require vendor ### Property-Based Tests @@ -210,11 +352,11 @@ 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". +- **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 from all four types (FP, Archer, CARD, GRANITE). 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. +- **Property 2**: Generate random (workflow_type, vendor) pairs where workflow_type is drawn from all four 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: FP/Archer require non-empty vendor ≤ 200 chars; CARD/GRANITE accept without vendor. - 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. +- **Property 3**: Generate random successful redirect scenarios with varying item data and all four 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` diff --git a/.kiro/specs/ivanti-queue-redirect/requirements.md b/.kiro/specs/ivanti-queue-redirect/requirements.md index b848864..275dfda 100644 --- a/.kiro/specs/ivanti-queue-redirect/requirements.md +++ b/.kiro/specs/ivanti-queue-redirect/requirements.md @@ -2,17 +2,20 @@ ## 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. +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. Additionally, CARD items with high asset scores (90+) that cannot be edited in CARD need to go through the GRANITE program for reassignment or deletion — GRANITE is a first-class workflow type alongside FP, Archer, and CARD. 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. Any completed item can redirect to GRANITE, and any completed GRANITE item can redirect to any other type — there is no fixed ordering between workflow types. ## 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. +- **Queue_Panel**: The slide-out panel in the frontend that displays the user's Ivanti todo queue items. Items are grouped into an Inventory section (containing CARD and GRANITE sub-groups) at the top, followed by vendor-grouped sections for FP and Archer items. +- **Queue_Item**: A row in the `ivanti_todo_queue` table representing a finding assigned to a workflow type (FP, Archer, CARD, or GRANITE) 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. +- **Workflow_Type**: One of four processing tracks for a finding: FP (False Positive), Archer (risk acceptance), CARD (inventory correction), or GRANITE (high-asset-score reassignment/deletion). +- **GRANITE**: A workflow type for findings with high asset scores (90+) that cannot be edited in CARD and require reassignment or deletion through the GRANITE program. GRANITE behaves like CARD for validation — no vendor is required. +- **Vendor**: The vendor string associated with a Queue_Item. Required for FP and Archer workflow types, not required for CARD or GRANITE. - **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. +- **Inventory_Section**: The top section of the Queue_Panel that groups both CARD and GRANITE items under the heading "Inventory", with a sub-divider separating CARD items (first) from GRANITE items (below). +- **AddToQueue_Popover**: The frontend popover that allows users to add findings to the queue by selecting a workflow type. ## Requirements @@ -24,10 +27,10 @@ The Ivanti Queue Redirect feature gives users the option to redirect any complet 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. +3. WHEN a user submits a redirect request with a target Workflow_Type of "CARD" or "GRANITE", 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. +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, CARD, or GRANITE. 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 @@ -55,9 +58,9 @@ The Ivanti Queue Redirect feature gives users the option to redirect any complet #### Acceptance Criteria -1. THE Redirect_Modal SHALL display a workflow type selector with options FP, Archer, and CARD. +1. THE Redirect_Modal SHALL display a workflow type selector with options FP, Archer, CARD, and GRANITE. 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. +3. WHEN the user selects CARD or GRANITE 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. @@ -69,4 +72,36 @@ The Ivanti Queue Redirect feature gives users the option to redirect any complet #### 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". +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, CARD, or GRANITE". + +### Requirement 6: GRANITE Backend Validation Support + +**User Story:** As a developer, I want GRANITE recognized as a valid workflow type across all backend endpoints, so that users can add, update, and redirect GRANITE items through the existing API. + +#### Acceptance Criteria + +1. THE Redirect_API SHALL include "GRANITE" in the VALID_WORKFLOW_TYPES constant alongside "FP", "Archer", and "CARD". +2. WHEN a user submits a request with Workflow_Type "GRANITE" to the batch add, single add, PUT, or redirect endpoints, THE Redirect_API SHALL accept the request without requiring a vendor, using the same validation rules as "CARD". +3. WHEN any endpoint returns an error for an invalid Workflow_Type, THE Redirect_API SHALL list all four valid options: FP, Archer, CARD, and GRANITE. + +### Requirement 7: Inventory Section Grouping in Queue Panel + +**User Story:** As a user, I want CARD and GRANITE items grouped together under an "Inventory" heading in the Queue_Panel, so that I can see all inventory-category work in one place while distinguishing between the two sub-types. + +#### Acceptance Criteria + +1. THE Queue_Panel SHALL display a top section labeled "Inventory" that contains both CARD and GRANITE Queue_Items. +2. WHILE the Inventory_Section contains both CARD and GRANITE items, THE Queue_Panel SHALL display a subtle sub-divider separating CARD items (listed first) from GRANITE items (listed below). +3. THE Queue_Panel SHALL display a workflow type badge on each item showing "CARD" or "GRANITE" with distinct badge colors. +4. THE Queue_Panel SHALL use a warm slate color (#A1887F or #8D6E63) for the GRANITE badge, distinct from the CARD green (#10B981). +5. WHILE the Inventory_Section contains only CARD items or only GRANITE items, THE Queue_Panel SHALL still display the "Inventory" section heading without a sub-divider. + +### Requirement 8: GRANITE Support in AddToQueue Popover + +**User Story:** As a user, I want to add findings to the queue as GRANITE items directly from the AddToQueue_Popover, so that I can assign high-asset-score findings to the GRANITE workflow without needing to redirect from another type. + +#### Acceptance Criteria + +1. THE AddToQueue_Popover SHALL display GRANITE as a fourth workflow type button alongside FP, Archer, and CARD. +2. WHEN the user selects GRANITE in the AddToQueue_Popover, THE AddToQueue_Popover SHALL submit the request without requiring a vendor field, using the same behavior as CARD. +3. THE AddToQueue_Popover SHALL use the warm slate color (#A1887F or #8D6E63) for the GRANITE button, consistent with the GRANITE badge color in the Queue_Panel. diff --git a/.kiro/specs/ivanti-queue-redirect/tasks.md b/.kiro/specs/ivanti-queue-redirect/tasks.md index 37032d5..f6f0af2 100644 --- a/.kiro/specs/ivanti-queue-redirect/tasks.md +++ b/.kiro/specs/ivanti-queue-redirect/tasks.md @@ -4,6 +4,8 @@ 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. +Additionally, GRANITE is added as a fourth workflow type across the entire stack — backend validation, RedirectModal, QueuePanel grouping (Inventory section), and AddToQueue popover. + ## Tasks - [x] 1. Fix PUT endpoint validation message @@ -25,27 +27,6 @@ Implement a redirect action for completed Ivanti queue items. The feature adds a - 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. @@ -63,15 +44,8 @@ Implement a redirect action for completed Ivanti queue items. The feature adds a - 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`) + - [x] 5.1 Add redirect button to completed items in QueuePanel (inside `frontend/src/components/pages/ReportingPage.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_ @@ -84,22 +58,86 @@ Implement a redirect action for completed Ivanti queue items. The feature adds a - Import and render `` 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. +- [ ] 7. Add GRANITE to backend validation + - [ ] 7.1 Update `VALID_WORKFLOW_TYPES` constant in `backend/routes/ivantiTodoQueue.js` + - Change from `['FP', 'Archer', 'CARD']` to `['FP', 'Archer', 'CARD', 'GRANITE']` + - _Requirements: 6.1_ + + - [ ] 7.2 Update vendor validation condition in POST `/` (single add) endpoint + - Change `workflow_type !== 'CARD'` to `workflow_type === 'FP' || workflow_type === 'Archer'` (or `!['CARD', 'GRANITE'].includes(workflow_type)`) for the vendor-required check + - Change `workflow_type === 'CARD' ? '' : vendor.trim()` to `(workflow_type === 'CARD' || workflow_type === 'GRANITE') ? '' : vendor.trim()` for vendorVal assignment + - _Requirements: 6.2_ + + - [ ] 7.3 Update vendor validation condition in POST `/batch` endpoint + - Change `workflow_type !== 'CARD'` to `workflow_type === 'FP' || workflow_type === 'Archer'` for the vendor-required check + - Change `workflow_type === 'CARD' ? '' : vendor.trim()` to `(workflow_type === 'CARD' || workflow_type === 'GRANITE') ? '' : vendor.trim()` for vendorVal assignment + - _Requirements: 6.2_ + + - [ ] 7.4 Update vendor validation condition in POST `/:id/redirect` endpoint + - Change `workflow_type !== 'CARD'` to `workflow_type === 'FP' || workflow_type === 'Archer'` for the vendor-required check + - Change `workflow_type === 'CARD' ? '' : vendor.trim()` to `(workflow_type === 'CARD' || workflow_type === 'GRANITE') ? '' : vendor.trim()` for vendorVal assignment + - _Requirements: 6.2_ + + - [ ] 7.5 Update all error messages across all endpoints + - Change `"workflow_type must be FP, Archer, or CARD."` to `"workflow_type must be FP, Archer, CARD, or GRANITE."` in POST `/`, POST `/batch`, PUT `/:id`, and POST `/:id/redirect` + - _Requirements: 5.1, 6.3_ + +- [ ] 8. Add GRANITE to RedirectModal + - [ ] 8.1 Update `WORKFLOW_OPTIONS` in `frontend/src/components/RedirectModal.js` + - Add `{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }` as the fourth option + - Vendor field already hidden for non-FP/Archer types via `needsVendor` check — no change needed there + - _Requirements: 4.1, 4.3_ + +- [ ] 9. Update QueuePanel grouping for Inventory section + - [ ] 9.1 Update the `grouped` useMemo in QueuePanel (`frontend/src/components/pages/ReportingPage.js`) + - Change `items.filter((i) => i.workflow_type === 'CARD')` to filter both CARD and GRANITE into inventory items + - Split inventory items into `cardItems` and `graniteItems` sub-arrays + - Change `otherItems` filter from `i.workflow_type !== 'CARD'` to exclude both CARD and GRANITE + - Rename group key from `__CARD__` to `__INVENTORY__`, label from `'CARD'` to `'Inventory'`, and `isCard` to `isInventory` + - Include `cardItems` and `graniteItems` as separate properties on the inventory group object + - _Requirements: 7.1, 7.5_ + + - [ ] 9.2 Update the QueuePanel rendering to handle the Inventory section + - Update the `.map()` destructuring from `isCard` to `isInventory` + - Update group header border and label color to use `isInventory` instead of `isCard` + - For the Inventory group, render CARD items first, then a subtle sub-divider (only when both `cardItems.length > 0` and `graniteItems.length > 0`), then GRANITE items + - _Requirements: 7.1, 7.2, 7.5_ + + - [ ] 9.3 Update the workflow type color mapping in QueuePanel item rendering + - Add GRANITE to the `wfColor` ternary: `item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' }` before the default CARD fallback + - _Requirements: 7.3, 7.4_ + + - [ ] 9.4 Update `isCardItem` to `isInventoryItem` in QueuePanel item rendering + - Change `const isCardItem = item.workflow_type === 'CARD'` to `const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE'` + - Update the conditional rendering that uses `isCardItem` to use `isInventoryItem` (hostname/ip_address display vs CVE display) + - _Requirements: 7.1_ + +- [ ] 10. Add GRANITE to AddToQueuePopover + - [ ] 10.1 Update workflow type buttons in `AddToQueuePopover` (`frontend/src/components/pages/ReportingPage.js`) + - Add `{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }` as the fourth button in the workflow type toggle array + - _Requirements: 8.1, 8.3_ + + - [ ] 10.2 Update `isCard` condition in `AddToQueuePopover` to include GRANITE + - Change `const isCard = queueForm.workflowType === 'CARD'` to `const isCard = queueForm.workflowType === 'CARD' || queueForm.workflowType === 'GRANITE'` (or rename to `isInventory`) + - This controls the "No vendor required" message and hides the vendor input for GRANITE + - _Requirements: 8.2_ + + - [ ] 10.3 Update `SelectionToolbar` component to include GRANITE + - Add `{ type: 'GRANITE', color: '#A1887F', rgb: '161,136,127' }` as the fourth button in the workflow type toggles array + - Change `const isCard = workflowType === 'CARD'` to include GRANITE: `const isCard = workflowType === 'CARD' || workflowType === 'GRANITE'` + - _Requirements: 8.1, 8.2, 8.3_ + +- [ ] 11. Final checkpoint — Verify all GRANITE changes + - Ensure all changes compile and render correctly, ask the user if questions arise. + ## Notes -- Tasks marked with `*` are optional and can be skipped for faster MVP +- Tasks 1–6 are the original redirect feature tasks, all completed +- Tasks 7–11 are the new GRANITE workflow type additions +- No test tasks included per user request — testing will be done manually on the dev server - 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 +- The QueuePanel component is defined inside `ReportingPage.js`, not a separate file +- The project uses plain JavaScript (no TypeScript) diff --git a/backend/routes/ivantiTodoQueue.js b/backend/routes/ivantiTodoQueue.js index 288ab6c..d8aa977 100644 --- a/backend/routes/ivantiTodoQueue.js +++ b/backend/routes/ivantiTodoQueue.js @@ -3,7 +3,7 @@ const express = require('express'); const { requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); -const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD']; +const VALID_WORKFLOW_TYPES = ['FP', 'Archer', 'CARD', 'GRANITE']; const VALID_STATUSES = ['pending', 'complete']; function isValidVendor(vendor) { @@ -64,8 +64,8 @@ function createIvantiTodoQueueRouter(db, requireAuth) { * @body {string[]} [findings[].cves] - Optional array of CVE identifiers * @body {string} [findings[].ip_address] - Optional IP address (max 64 chars) * @body {string} [findings[].hostname] - Optional hostname (max 255 chars) - * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD' - * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD + * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE' + * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE * * @returns {Object} 201 - { items: Array } array of created queue items, * each with: id, user_id, finding_id, finding_title, cves_json, ip_address, @@ -89,10 +89,10 @@ function createIvantiTodoQueueRouter(db, requireAuth) { } if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); } - if (workflow_type !== 'CARD') { + if (!['CARD', 'GRANITE'].includes(workflow_type)) { if (!isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } @@ -102,7 +102,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } - const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim(); + const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); const userId = req.user.id; // --- Transactional batch insert --- @@ -219,8 +219,9 @@ function createIvantiTodoQueueRouter(db, requireAuth) { * @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} [hostname] - Optional hostname (max 255 chars) * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD - * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD' + * @body {string} [hostname] - Optional hostname (max 255 chars) + * @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE' + * @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE * * @returns {Object} 201 - Created queue item with parsed cves array: * id, user_id, finding_id, finding_title, cves_json, ip_address, @@ -235,17 +236,17 @@ function createIvantiTodoQueueRouter(db, requireAuth) { return res.status(400).json({ error: 'finding_id is required.' }); } if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); } - // Vendor is required for FP and Archer, optional for CARD - if (workflow_type !== 'CARD' && !isValidVendor(vendor)) { + // Vendor is required for FP and Archer, optional for CARD/GRANITE + if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } - const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim(); + const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); const cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null; const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : null; const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null; @@ -294,7 +295,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { * * @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} [workflow_type] - One of 'FP', 'Archer', 'CARD', 'GRANITE' * @body {string} [status] - One of 'pending', 'complete' * * @returns {Object} 200 - Updated queue item with parsed cves array: @@ -312,7 +313,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, Archer, or CARD.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); } if (status !== undefined && !VALID_STATUSES.includes(status)) { return res.status(400).json({ error: 'status must be pending or complete.' }); @@ -394,8 +395,8 @@ function createIvantiTodoQueueRouter(db, requireAuth) { * 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 + * @body {string} workflow_type - Target workflow type: 'FP', 'Archer', 'CARD', or 'GRANITE' + * @body {string} [vendor] - Required for FP/Archer (max 200 chars); ignored for CARD/GRANITE * * @returns {Object} 201 - Newly created queue item with parsed cves array * @returns {Object} 400 - { error: string } on validation failure or item not complete @@ -408,10 +409,10 @@ function createIvantiTodoQueueRouter(db, requireAuth) { // --- Validation --- if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) { - return res.status(400).json({ error: 'workflow_type must be FP, Archer, or CARD.' }); + return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' }); } - if (workflow_type !== 'CARD') { + if (!['CARD', 'GRANITE'].includes(workflow_type)) { if (!isValidVendor(vendor)) { return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' }); } @@ -421,7 +422,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) { return res.status(400).json({ error: 'vendor must be under 200 chars.' }); } - const vendorVal = workflow_type === 'CARD' ? '' : vendor.trim(); + const vendorVal = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim(); // --- Fetch original item scoped to current user --- db.get( diff --git a/frontend/src/components/RedirectModal.js b/frontend/src/components/RedirectModal.js index 4e6e9a4..68f4d8f 100644 --- a/frontend/src/components/RedirectModal.js +++ b/frontend/src/components/RedirectModal.js @@ -4,9 +4,10 @@ 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' }, + { key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' }, + { key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' }, + { key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' }, + { key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }, ]; export default function RedirectModal({ item, onClose, onRedirect }) { diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 970a867..51ea0bf 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1135,7 +1135,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd return () => document.removeEventListener('keydown', handler); }, [onCancel]); - const isCard = queueForm.workflowType === 'CARD'; + const isCard = queueForm.workflowType === 'CARD' || queueForm.workflowType === 'GRANITE'; const canSubmit = isCard || queueForm.vendor.trim().length > 0; return ReactDOM.createPortal( @@ -1201,9 +1201,10 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
{[ - { key: 'FP', col: '#F59E0B', rgb: '245,158,11' }, - { key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' }, - { key: 'CARD', col: '#10B981', rgb: '16,185,129' }, + { key: 'FP', col: '#F59E0B', rgb: '245,158,11' }, + { key: 'Archer', col: '#0EA5E9', rgb: '14,165,233' }, + { key: 'CARD', col: '#10B981', rgb: '16,185,129' }, + { key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }, ].map(({ key, col, rgb }) => { const active = queueForm.workflowType === key; return ( @@ -1303,10 +1304,164 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on setTimeout(() => setRedirectSuccess(null), 3000); }; - // CARD items are their own top section; everything else groups by vendor + // Render a single queue item row + const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => { + const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' } + : item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' } + : item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' } + : { col: '#10B981', rgb: '16,185,129' }; + const cves = item.cves || []; + const cveDisplay = cves.length > 0 + ? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '') + : '—'; + const isInventoryItem = item.workflow_type === 'CARD' || item.workflow_type === 'GRANITE'; + return ( +
+ {/* Selection checkbox — for bulk delete */} + toggleSelect(item.id)} + style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }} + title="Select for deletion" + /> + + {/* Complete checkbox */} + onUpdate(item.id, { status: done ? 'pending' : 'complete' })} + style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }} + /> + + {/* Content */} +
+
+ {item.finding_id} +
+ {isInventoryItem ? ( + <> + {item.hostname && ( +
+ {item.hostname} +
+ )} + {item.ip_address && ( +
+ {item.ip_address} +
+ )} + + ) : ( + <> + {cves.length > 0 && ( +
+ {cveDisplay} +
+ )} + {item.hostname && ( +
+ {item.hostname} +
+ )} + {item.ip_address && ( +
+ {item.ip_address} +
+ )} + + )} +
+ + {/* Workflow type badge */} + + {item.workflow_type} + + + {/* Redirect button — completed items only */} + {canWrite && done && ( + + )} + + {/* Delete button */} + +
+ ); + }; + + // Inventory items (CARD + GRANITE) are their own top section; everything else groups by vendor const grouped = useMemo(() => { - const cardItems = items.filter((i) => i.workflow_type === 'CARD'); - const otherItems = items.filter((i) => i.workflow_type !== 'CARD'); + const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE'); + const cardItems = inventoryItems.filter((i) => i.workflow_type === 'CARD'); + const graniteItems = inventoryItems.filter((i) => i.workflow_type === 'GRANITE'); + const otherItems = items.filter((i) => i.workflow_type !== 'CARD' && i.workflow_type !== 'GRANITE'); const map = {}; otherItems.forEach((item) => { @@ -1315,11 +1470,11 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on map[v].push(item); }); const vendorGroups = Object.keys(map).sort().map((vendor) => ({ - key: vendor, label: vendor, items: map[vendor], isCard: false, + key: vendor, label: vendor, items: map[vendor], isInventory: false, })); - return cardItems.length > 0 - ? [{ key: '__CARD__', label: 'CARD', items: cardItems, isCard: true }, ...vendorGroups] + return inventoryItems.length > 0 + ? [{ key: '__INVENTORY__', label: 'Inventory', cardItems, graniteItems, items: inventoryItems, isInventory: true }, ...vendorGroups] : vendorGroups; }, [items]); @@ -1393,15 +1548,15 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on Check a row in the findings table to add it.
- ) : grouped.map(({ key, label, items: groupItems, isCard }) => ( + ) : grouped.map(({ key, label, items: groupItems, isInventory, cardItems, graniteItems }) => (
{/* Group header */}
- + {label} @@ -1409,157 +1564,22 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
- {/* Items */} - {groupItems.map((item) => { - const done = item.status === 'complete'; - const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' } - : item.workflow_type === 'Archer' ? { col: '#0EA5E9', rgb: '14,165,233' } - : { col: '#10B981', rgb: '16,185,129' }; - const cves = item.cves || []; - const cveDisplay = cves.length > 0 - ? cves.slice(0, 3).join(', ') + (cves.length > 3 ? ` +${cves.length - 3}` : '') - : '—'; - const isCardItem = item.workflow_type === 'CARD'; - return ( -
- {/* Selection checkbox — for bulk delete */} - toggleSelect(item.id)} - style={{ accentColor: '#EF4444', width: '12px', height: '12px', flexShrink: 0, marginTop: '3px', cursor: 'pointer', opacity: selectedIds.has(item.id) ? 1 : 0.35 }} - title="Select for deletion" - /> - - {/* Complete checkbox */} - onUpdate(item.id, { status: done ? 'pending' : 'complete' })} - style={{ accentColor: '#10B981', width: '14px', height: '14px', flexShrink: 0, marginTop: '2px', cursor: 'pointer' }} - /> - - {/* Content */} -
-
- {item.finding_id} -
- {isCardItem ? ( - <> - {item.hostname && ( -
- {item.hostname} -
- )} - {item.ip_address && ( -
- {item.ip_address} -
- )} - - ) : ( - <> - {cves.length > 0 && ( -
- {cveDisplay} -
- )} - {item.hostname && ( -
- {item.hostname} -
- )} - {item.ip_address && ( -
- {item.ip_address} -
- )} - - )} -
- - {/* Workflow type badge */} - - {item.workflow_type} - - - {/* Redirect button — completed items only */} - {canWrite && done && ( - - )} - - {/* Delete button */} - -
- ); - })} + {/* Items — Inventory section renders CARD then GRANITE with optional sub-divider */} + {isInventory ? ( + <> + {cardItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))} + {cardItems.length > 0 && graniteItems.length > 0 && ( +
+ )} + {graniteItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))} + + ) : ( + groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })) + )}
))}
@@ -2803,7 +2823,7 @@ function FpEditModal({ open, onClose, submission, queueItems, 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 isCard = workflowType === 'CARD' || workflowType === 'GRANITE'; const canSubmit = !submitting && (isCard || vendor.trim().length > 0); return ( @@ -2838,6 +2858,7 @@ function SelectionToolbar({ count, workflowType, vendor, submitting, error, onWo { type: 'FP', color: '#F59E0B', rgb: '245,158,11' }, { type: 'Archer', color: '#0EA5E9', rgb: '14,165,233' }, { type: 'CARD', color: '#10B981', rgb: '16,185,129' }, + { type: 'GRANITE', color: '#A1887F', rgb: '161,136,127' }, ].map(({ type, color, rgb }) => { const active = workflowType === type; return (