Compare commits
33 Commits
feature/cv
...
feature/mu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f141fa58a1 | ||
|
|
e1b0236874 | ||
|
|
ed48522932 | ||
|
|
938dda400a | ||
|
|
732873dd6a | ||
|
|
0fe8e94d51 | ||
|
|
28bce28fc9 | ||
|
|
72fd79ea42 | ||
|
|
f63c286458 | ||
|
|
93c144576f | ||
|
|
fa3b045a2f | ||
|
|
4583d09750 | ||
|
|
75ac8c823a | ||
|
|
68e36b4bac | ||
|
|
d24b45b404 | ||
|
|
d64eb7eec4 | ||
|
|
6cb65fddc1 | ||
|
|
0ca83c6736 | ||
|
|
06268880da | ||
|
|
b4f0ddcb78 | ||
|
|
55e3e074a5 | ||
|
|
66bbeb84a5 | ||
|
|
4578f8cd85 | ||
|
|
5469a86e6e | ||
|
|
2b6db1f903 | ||
|
|
7c97bc3a84 | ||
|
|
835fbf26e7 | ||
|
|
c4aaeff2a1 | ||
|
|
df30430956 | ||
|
|
57f11c362b | ||
|
|
4df83d36dd | ||
|
|
0a7a7c2827 | ||
|
|
1963faf9b8 |
1
.kiro/specs/archive-finding-clarity/.config.kiro
Normal file
1
.kiro/specs/archive-finding-clarity/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "acff93bd-0045-4fcd-b948-cc52c7cc5ec6", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
196
.kiro/specs/archive-finding-clarity/design.md
Normal file
196
.kiro/specs/archive-finding-clarity/design.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Design Document: Archive Finding Clarity
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature enhances the Ivanti Archive Findings panel on the STEAM Security Dashboard homepage to provide clearer context for archived findings. The changes span both backend (related active finding detection) and frontend (card rendering improvements).
|
||||||
|
|
||||||
|
The core additions are:
|
||||||
|
1. **Finding ID display** — Show the Ivanti finding ID on each archive card for cross-referencing
|
||||||
|
2. **Historical severity labeling** — Prefix severity with "Last seen:" to clarify it's a snapshot
|
||||||
|
3. **Related active finding detection** — Server-side matching of archived findings against the current findings cache by hostname + title
|
||||||
|
4. **Visual status indicators** — Icon and border color distinctions based on whether a related active finding exists
|
||||||
|
|
||||||
|
All matching is performed server-side in a single pass to avoid per-card API calls and keep the archive panel responsive.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The feature touches two layers:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Backend
|
||||||
|
A[GET /api/ivanti/archive?state=X] --> B[Query ivanti_finding_archives]
|
||||||
|
B --> C[Parse ivanti_findings_cache JSON once]
|
||||||
|
C --> D[Match each archive record against active findings]
|
||||||
|
D --> E[Return archives with related_active field]
|
||||||
|
end
|
||||||
|
subgraph Frontend
|
||||||
|
E --> F[App.js archiveList.map]
|
||||||
|
F --> G[Render enhanced Archive Cards]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decision:** The related finding lookup is embedded in the existing `GET /api/ivanti/archive` endpoint rather than exposed as a separate endpoint. This avoids N+1 API calls from the frontend and keeps the archive panel's fetch pattern unchanged (single request per state filter click).
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. User clicks a state card in `ArchiveSummaryBar` → triggers `handleArchiveStateClick(state)` in `App.js`
|
||||||
|
2. Frontend calls `GET /api/ivanti/archive?state={state}`
|
||||||
|
3. Backend queries `ivanti_finding_archives` for matching state
|
||||||
|
4. Backend reads `ivanti_findings_cache` row (id=1), parses `findings_json` once
|
||||||
|
5. For each archive record, backend runs the matching function against the parsed active findings
|
||||||
|
6. Backend returns `{ archives: [...], total: N }` where each archive object now includes a `related_active` field
|
||||||
|
7. Frontend renders each archive card with the new fields: finding ID, "Last seen:" severity, optional badge, icon/border
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend: Modified Archive Route (`backend/routes/ivantiArchive.js`)
|
||||||
|
|
||||||
|
**Changes to `GET /` handler:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// New matching function added to the module
|
||||||
|
function findRelatedActive(archive, activeFindings) {
|
||||||
|
// Returns { id, title, severity } or null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`findRelatedActive(archive, activeFindings)` logic:**
|
||||||
|
- Input: one archive record, array of parsed active findings
|
||||||
|
- Filter active findings where:
|
||||||
|
- `hostName` exactly matches `archive.host_name` (case-sensitive, matching existing DB convention)
|
||||||
|
- AND the archive's `finding_title` is a case-insensitive substring of the active finding's `title`, OR vice versa
|
||||||
|
- AND the active finding's `id` is NOT equal to `archive.finding_id`
|
||||||
|
- If multiple matches, return the one with the highest `severity`
|
||||||
|
- If no matches, return `null`
|
||||||
|
|
||||||
|
**Modified response shape:**
|
||||||
|
```javascript
|
||||||
|
// Before
|
||||||
|
{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, ... }
|
||||||
|
|
||||||
|
// After — same fields plus:
|
||||||
|
{ ...existing, related_active: null | { id: string, title: string, severity: number } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend: Modified Archive Card Rendering (`frontend/src/App.js`)
|
||||||
|
|
||||||
|
The `archiveList.map()` block in `App.js` is updated to render:
|
||||||
|
|
||||||
|
1. **Finding title** (existing, unchanged)
|
||||||
|
2. **Finding ID** — new line below title, monospace, muted color (`#64748B`), font size `0.6rem`. Truncated with ellipsis at 20 characters, full value in `title` attribute for tooltip.
|
||||||
|
3. **Severity badge** — changed from raw number to "Last seen: X.X" format. Null/zero shows "Last seen: —".
|
||||||
|
4. **Related active badge** — conditional. When `related_active` is non-null, shows "Similar finding active" with the related finding's ID and severity, styled with accent color (`#0EA5E9`).
|
||||||
|
5. **Icon** — `AlertTriangle` (from lucide-react) when `related_active` is non-null, `CheckCircle` when null.
|
||||||
|
6. **Left border** — `#F59E0B` (amber) when `related_active` is non-null, `#10B981` (green) when null.
|
||||||
|
|
||||||
|
### No New Components
|
||||||
|
|
||||||
|
The archive card is rendered inline in `App.js` (not a separate component), consistent with the existing pattern. The changes modify the existing `archiveList.map()` JSX block. No new React components are introduced.
|
||||||
|
|
||||||
|
### No New API Endpoints
|
||||||
|
|
||||||
|
The related finding detection is added to the existing `GET /api/ivanti/archive` route. The `ArchiveSummaryBar` component and its `/stats` endpoint are unchanged.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Existing Tables (unchanged)
|
||||||
|
|
||||||
|
**`ivanti_finding_archives`**
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER PK | Auto-increment row ID |
|
||||||
|
| finding_id | TEXT UNIQUE | Ivanti finding identifier |
|
||||||
|
| finding_title | TEXT | Finding title at archive time |
|
||||||
|
| host_name | TEXT | Hostname |
|
||||||
|
| ip_address | TEXT | IP address |
|
||||||
|
| current_state | TEXT | ARCHIVED, RETURNED, or CLOSED |
|
||||||
|
| last_severity | REAL | Severity at last transition |
|
||||||
|
| first_archived_at | DATETIME | First archive timestamp |
|
||||||
|
| last_transition_at | DATETIME | Last state change timestamp |
|
||||||
|
|
||||||
|
**`ivanti_findings_cache`** (row id=1)
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| findings_json | TEXT | JSON array of active findings |
|
||||||
|
| total | INTEGER | Count of cached findings |
|
||||||
|
|
||||||
|
Each entry in `findings_json` has the shape produced by `extractFinding()` in `ivantiFindings.js`:
|
||||||
|
```javascript
|
||||||
|
{ id, title, severity, vrrGroup, hostName, ipAddress, dns, status, slaStatus, dueDate, lastFoundOn, buOwnership, cves, workflow }
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Schema Changes
|
||||||
|
|
||||||
|
This feature requires no database migrations. All data needed for the matching logic already exists in the two tables above.
|
||||||
|
|
||||||
|
## 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: Finding ID display with truncation
|
||||||
|
|
||||||
|
*For any* archive record, the rendered card SHALL display the finding_id. If the finding_id is longer than 20 characters, the displayed text SHALL be truncated to 20 characters followed by an ellipsis. If the finding_id is 20 characters or fewer, it SHALL be displayed in full.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1, 1.2**
|
||||||
|
|
||||||
|
### Property 2: Historical severity labeling
|
||||||
|
|
||||||
|
*For any* archive record, the rendered severity display SHALL contain the text "Last seen:" followed by the severity value formatted to one decimal place. When the severity is null or zero, the display SHALL show "Last seen: —".
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.3**
|
||||||
|
|
||||||
|
### Property 3: API response structure — related_active always present
|
||||||
|
|
||||||
|
*For any* request to the archive API and *for any* archive record in the response, the record SHALL contain a `related_active` field that is either `null` or an object with `id` (string), `title` (string), and `severity` (number) properties.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.1, 3.4**
|
||||||
|
|
||||||
|
### Property 4: Matching logic — hostname and title substring
|
||||||
|
|
||||||
|
*For any* archived finding and *for any* active finding, the active finding is a related match if and only if: (a) the active finding's hostname exactly equals the archive's hostname, AND (b) the archive's title is a case-insensitive substring of the active finding's title OR the active finding's title is a case-insensitive substring of the archive's title, AND (c) the active finding's ID is not equal to the archive's finding_id.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.2, 3.5**
|
||||||
|
|
||||||
|
### Property 5: Highest severity selection
|
||||||
|
|
||||||
|
*For any* archived finding with multiple matching active findings, the `related_active` field SHALL contain the match with the highest severity value.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.3**
|
||||||
|
|
||||||
|
### Property 6: Badge visibility matches related_active presence
|
||||||
|
|
||||||
|
*For any* archive record, the "Similar finding active" badge SHALL be displayed if and only if the `related_active` field is non-null. When displayed, the badge SHALL include the related finding's ID and severity.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1, 4.3**
|
||||||
|
|
||||||
|
### Property 7: Icon and border determined by related_active, not lifecycle state
|
||||||
|
|
||||||
|
*For any* archive record, regardless of its lifecycle state (ARCHIVED, RETURNED, or CLOSED), the icon and left border color SHALL be determined solely by whether `related_active` is non-null (alert icon + amber border) or null (check icon + green border).
|
||||||
|
|
||||||
|
**Validates: Requirements 5.1, 5.2, 5.3**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|----------|----------|
|
||||||
|
| `findings_json` is malformed or unparseable | Catch JSON.parse error, log warning, treat as empty array (all `related_active` fields become `null`) |
|
||||||
|
| `findings_json` column is NULL | Default to empty array |
|
||||||
|
| `ivanti_findings_cache` row missing (id=1) | Default to empty array — no related matches |
|
||||||
|
| Database query failure on archive records | Return 500 with `{ error: 'Failed to fetch archive records' }` (existing behavior) |
|
||||||
|
| Database query failure on findings cache | Log error, continue with empty active findings (graceful degradation) |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|----------|----------|
|
||||||
|
| `related_active` field missing from response | Treat as `null` (no badge, green/check styling) |
|
||||||
|
| `finding_id` is empty string | Display finding title only (existing fallback behavior) |
|
||||||
|
| `last_severity` is undefined | Display "Last seen: —" |
|
||||||
|
| API returns error | Existing error handling in `handleArchiveStateClick` already catches and shows empty state |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Testing is performed manually on the dev server. No automated tests are required for this feature.
|
||||||
82
.kiro/specs/archive-finding-clarity/requirements.md
Normal file
82
.kiro/specs/archive-finding-clarity/requirements.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Ivanti Archive Findings panel on the STEAM Security Dashboard homepage displays findings that have transitioned through the archive lifecycle (Active, Archived, Returned, Closed). The current archive cards show the finding title, hostname, IP address, and a raw severity number — but lack clarity in several areas. Users cannot see the Ivanti finding ID for cross-referencing, the severity score appears to be a current value when it is actually a historical snapshot, and there is no indication when a related finding with the same title still exists on the same host under a different Ivanti finding ID.
|
||||||
|
|
||||||
|
This feature improves archive card clarity by adding finding IDs, labeling severity as historical, introducing a "related active finding" indicator, and using visual icon distinctions to communicate resolution status at a glance.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Archive_Card**: A single rendered entry in the archive findings list on the homepage, representing one row from the `ivanti_finding_archives` table.
|
||||||
|
- **Archive_Panel**: The section of the homepage that contains the ArchiveSummaryBar stat cards and the expandable archive findings list.
|
||||||
|
- **Finding_ID**: The stable Ivanti-assigned identifier for a host finding (stored as `finding_id` in `ivanti_finding_archives`). Finding IDs do not change with score drift or rescoring.
|
||||||
|
- **Last_Severity**: The severity score recorded at the time a finding was archived or last transitioned between states. It is a historical snapshot, not a live risk assessment.
|
||||||
|
- **Current_Findings_Cache**: The `ivanti_findings_cache` table containing the latest synced findings as a JSON array. Each cached finding has fields including `id`, `title`, `severity`, `hostName`, and `ipAddress`.
|
||||||
|
- **Related_Active_Finding**: A finding in the Current_Findings_Cache that shares the same hostname and a similar title with an archived finding but has a different Finding_ID, indicating a genuinely distinct but related finding is still open on the same host.
|
||||||
|
- **Archive_API**: The backend endpoint `GET /api/ivanti/archive` that returns archive records filtered by lifecycle state.
|
||||||
|
- **Related_Findings_Endpoint**: A new backend endpoint that accepts archived finding details and returns matching active findings from the Current_Findings_Cache.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Display Finding ID on Archive Cards
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want to see the Ivanti finding ID on each archive card, so that I can cross-reference archived findings with the Reporting page.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Archive_Card SHALL display the Finding_ID in monospace font below the finding title.
|
||||||
|
2. WHEN the Finding_ID is longer than 20 characters, THE Archive_Card SHALL truncate the Finding_ID with an ellipsis and display the full value in a tooltip on hover.
|
||||||
|
3. THE Archive_Card SHALL render the Finding_ID with a visually distinct style (muted color, smaller font size) so it is clearly secondary to the finding title.
|
||||||
|
|
||||||
|
### Requirement 2: Historical Severity Labeling
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the severity score on archive cards to be clearly labeled as a historical value, so that I do not mistake it for a current risk assessment.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Archive_Card SHALL display the Last_Severity with a "Last seen:" prefix label (e.g., "Last seen: 9.4").
|
||||||
|
2. THE Archive_Card SHALL render the severity label in a muted, secondary style that visually distinguishes it from live severity badges used elsewhere in the dashboard.
|
||||||
|
3. WHEN the Last_Severity value is null or zero, THE Archive_Card SHALL display "Last seen: —" as a placeholder.
|
||||||
|
|
||||||
|
### Requirement 3: Related Active Finding Detection API
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the system to detect when an archived finding has a related active finding on the same host, so that I can understand whether similar risk still exists.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Archive_API SHALL return a `related_active` field for each archive record, containing either `null` (no match) or an object with the matching active finding's `id`, `title`, and `severity`.
|
||||||
|
2. WHEN matching archived findings to active findings, THE Related_Findings_Endpoint SHALL compare by exact hostname match AND case-insensitive substring containment of the archived finding title within the active finding title (or vice versa).
|
||||||
|
3. WHEN multiple active findings match a single archived finding, THE Related_Findings_Endpoint SHALL return the match with the highest severity.
|
||||||
|
4. IF the Current_Findings_Cache contains no findings or is empty, THEN THE Related_Findings_Endpoint SHALL return `null` for all `related_active` fields.
|
||||||
|
5. THE Related_Findings_Endpoint SHALL exclude matches where the active finding's `id` is identical to the archived finding's Finding_ID.
|
||||||
|
|
||||||
|
### Requirement 4: Related Active Finding Indicator on Archive Cards
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want to see a visual indicator on archive cards when a related active finding exists on the same host, so that I can quickly identify findings that may still represent active risk.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN an archive record has a non-null `related_active` field, THE Archive_Card SHALL display a badge reading "Similar finding active" with the related finding's ID and current severity.
|
||||||
|
2. THE Archive_Card SHALL render the related active badge using the dashboard accent color (#0EA5E9) to distinguish it from the archive card's own severity display.
|
||||||
|
3. WHEN an archive record has a null `related_active` field, THE Archive_Card SHALL not display any related-finding badge.
|
||||||
|
|
||||||
|
### Requirement 5: Visual Icon Distinction by Resolution Status
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want archive cards to use different icons and border colors based on whether a related active finding exists, so that I can scan the list and quickly distinguish fully resolved findings from those with ongoing similar risk.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN an archive record has a non-null `related_active` field, THE Archive_Card SHALL display an alert-style icon (e.g., `AlertTriangle` from lucide-react) and use a warning-toned left border color (#F59E0B).
|
||||||
|
2. WHEN an archive record has a null `related_active` field, THE Archive_Card SHALL display a check-style icon (e.g., `CheckCircle` from lucide-react) and use a success-toned left border color (#10B981).
|
||||||
|
3. THE Archive_Card SHALL apply the icon and border color consistently regardless of the archive lifecycle state (ARCHIVED, RETURNED, or CLOSED).
|
||||||
|
|
||||||
|
### Requirement 6: Performance of Related Finding Lookup
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the archive panel to load promptly even when checking for related active findings, so that the feature does not degrade the homepage experience.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Archive_API SHALL compute related active finding matches server-side within the existing archive list query, avoiding separate per-card API calls from the frontend.
|
||||||
|
2. WHEN the Current_Findings_Cache JSON is parsed for matching, THE Archive_API SHALL parse the cache once per request and reuse the parsed result across all archive records in the response.
|
||||||
|
3. THE Archive_API response time for a filtered archive list SHALL remain under 500ms for up to 200 archive records.
|
||||||
70
.kiro/specs/archive-finding-clarity/tasks.md
Normal file
70
.kiro/specs/archive-finding-clarity/tasks.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Implementation Plan: Archive Finding Clarity
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Enhance the Ivanti Archive Findings panel to display finding IDs, label severity as historical, detect related active findings server-side, and apply visual icon/border distinctions based on resolution status. Changes span `backend/routes/ivantiArchive.js` (matching logic + enriched response) and `frontend/src/App.js` (card rendering updates). No new components, endpoints, or migrations.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Add `findRelatedActive` function and enrich the GET `/` handler in `backend/routes/ivantiArchive.js`
|
||||||
|
- [x] 1.1 Add the `findRelatedActive(archive, activeFindings)` helper function
|
||||||
|
- Add function above `createIvantiArchiveRouter` or inside the module scope
|
||||||
|
- Filter active findings where `hostName` exactly matches `archive.host_name`
|
||||||
|
- AND the archive's `finding_title` is a case-insensitive substring of the active finding's `title`, or vice versa
|
||||||
|
- AND the active finding's `id` is NOT equal to `archive.finding_id`
|
||||||
|
- If multiple matches, return the one with the highest `severity` as `{ id, title, severity }`
|
||||||
|
- If no matches, return `null`
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.5_
|
||||||
|
|
||||||
|
- [x] 1.2 Modify the `GET /` handler to parse findings cache and enrich archive records
|
||||||
|
- After fetching archive rows, query `ivanti_findings_cache` (id=1) for `findings_json`
|
||||||
|
- Parse `findings_json` once with `JSON.parse`; default to empty array if NULL, missing row, or parse error
|
||||||
|
- Log a warning on parse failure, do not throw
|
||||||
|
- For each archive record, call `findRelatedActive(archive, parsedFindings)` and attach the result as `related_active`
|
||||||
|
- Return the enriched archives array in the existing `{ archives, total }` response shape
|
||||||
|
- _Requirements: 3.1, 3.4, 6.1, 6.2_
|
||||||
|
|
||||||
|
- [x] 2. Checkpoint — Verify backend changes
|
||||||
|
- Ensure the backend starts without errors, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 3. Update archive card rendering in `frontend/src/App.js`
|
||||||
|
- [x] 3.1 Add `AlertTriangle` and `CheckCircle` to the lucide-react import
|
||||||
|
- Locate the existing lucide-react import statement in `App.js`
|
||||||
|
- Add `AlertTriangle` and `CheckCircle` if not already imported
|
||||||
|
- _Requirements: 5.1, 5.2_
|
||||||
|
|
||||||
|
- [x] 3.2 Add Finding ID display below the finding title
|
||||||
|
- Inside the `archiveList.map()` block, add a new line below the title `<span>`
|
||||||
|
- Render `a.finding_id` in monospace font, `0.6rem` size, muted color `#64748B`
|
||||||
|
- If `finding_id` length exceeds 20 characters, truncate displayed text to 20 chars + ellipsis
|
||||||
|
- Set the full `finding_id` as the `title` attribute for hover tooltip
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3_
|
||||||
|
|
||||||
|
- [x] 3.3 Change severity badge to "Last seen: X.X" format
|
||||||
|
- In the severity `<span>` within the archive card, replace `{a.last_severity?.toFixed(1) ?? '—'}` with `Last seen: {a.last_severity?.toFixed(1) ?? '—'}`
|
||||||
|
- Null or zero severity displays as "Last seen: —"
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3_
|
||||||
|
|
||||||
|
- [x] 3.4 Add conditional "Similar finding active" badge
|
||||||
|
- When `a.related_active` is non-null, render a badge below the host info line
|
||||||
|
- Badge text: "Similar finding active" with the related finding's ID and severity
|
||||||
|
- Style with accent color `#0EA5E9`, monospace font, `0.6rem` size
|
||||||
|
- When `a.related_active` is null, render nothing
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3_
|
||||||
|
|
||||||
|
- [x] 3.5 Add icon and left border color based on `related_active`
|
||||||
|
- When `a.related_active` is non-null: render `AlertTriangle` icon and set left border to `3px solid #F59E0B` (amber)
|
||||||
|
- When `a.related_active` is null: render `CheckCircle` icon and set left border to `3px solid #10B981` (green)
|
||||||
|
- Place the icon at the left side of the card header row, before the title
|
||||||
|
- Apply consistently regardless of archive lifecycle state (ARCHIVED, RETURNED, CLOSED)
|
||||||
|
- _Requirements: 5.1, 5.2, 5.3_
|
||||||
|
|
||||||
|
- [x] 4. Final checkpoint — Verify full feature
|
||||||
|
- Ensure the frontend compiles without errors, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No automated tests — feature is validated manually on the dev server per user preference
|
||||||
|
- No new components, endpoints, or database migrations required
|
||||||
|
- The `findRelatedActive` function parses the findings cache once per request for performance (Requirement 6.2)
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
1
.kiro/specs/compliance-multi-metric-notes/.config.kiro
Normal file
1
.kiro/specs/compliance-multi-metric-notes/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "8ec01dea-8d5c-40c1-8778-ec2992adb37f", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
290
.kiro/specs/compliance-multi-metric-notes/design.md
Normal file
290
.kiro/specs/compliance-multi-metric-notes/design.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# Design Document: Multi-Metric Notes for Compliance Detail Panel
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature extends the compliance notes system so that a single note can be associated with multiple metrics in one action. Today, the `ComplianceDetailPanel` uses a single-select `<select>` dropdown to pick one metric before adding a note. When a remediation action covers several metrics on the same device, the analyst must repeat the note for each metric individually.
|
||||||
|
|
||||||
|
The change touches three layers:
|
||||||
|
|
||||||
|
1. **Database** — add a `group_id` column to `compliance_notes` so notes created together can be identified as a batch.
|
||||||
|
2. **API** — extend `POST /api/compliance/notes` to accept `metric_ids` (array) alongside the existing `metric_id` (string), inserting one row per metric inside a transaction.
|
||||||
|
3. **Frontend** — replace the single-select dropdown with a multi-select chip-based selector, add Select All / Deselect All, and group notes by `group_id` in the display.
|
||||||
|
|
||||||
|
Backward compatibility is preserved: the existing `metric_id` field continues to work, and notes created before this feature (which lack a `group_id`) render exactly as they do today.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The feature follows the existing compliance module architecture. No new files or route modules are introduced — changes are scoped to the existing `compliance.js` route file and `ComplianceDetailPanel.js` component.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant DetailPanel as ComplianceDetailPanel
|
||||||
|
participant API as POST /api/compliance/notes
|
||||||
|
participant DB as SQLite (compliance_notes)
|
||||||
|
|
||||||
|
User->>DetailPanel: Select multiple metrics via chip selector
|
||||||
|
User->>DetailPanel: Type note text, click Send
|
||||||
|
DetailPanel->>API: POST { hostname, metric_ids: [...], note }
|
||||||
|
API->>API: Validate inputs (note text, metric IDs)
|
||||||
|
API->>API: Generate group_id (UUID)
|
||||||
|
API->>DB: BEGIN TRANSACTION
|
||||||
|
loop For each metric_id in metric_ids
|
||||||
|
API->>DB: INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
|
||||||
|
end
|
||||||
|
API->>DB: COMMIT
|
||||||
|
API->>DetailPanel: 201 { notes: [...created rows] }
|
||||||
|
DetailPanel->>DetailPanel: Group notes by group_id, refresh display
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
**Modified: `POST /api/compliance/notes`**
|
||||||
|
|
||||||
|
Request body accepts either format:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// New multi-metric format
|
||||||
|
{ hostname: "SERVER01", metric_ids: ["2.1.1", "2.3.2", "4.1.1"], note: "Vendor ticket VT-1234 opened" }
|
||||||
|
|
||||||
|
// Legacy single-metric format (still supported)
|
||||||
|
{ hostname: "SERVER01", metric_id: "2.1.1", note: "Vendor ticket VT-1234 opened" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Precedence: if both `metric_id` and `metric_ids` are present, `metric_ids` wins.
|
||||||
|
|
||||||
|
Validation rules:
|
||||||
|
- `hostname` — required, string, 1–300 chars, matches `/^[a-zA-Z0-9._-]+$/` (unchanged)
|
||||||
|
- `metric_ids` — array of strings, each non-empty and ≤50 chars, at least one entry
|
||||||
|
- `note` — required, non-empty after trimming, max 1000 chars (unchanged)
|
||||||
|
|
||||||
|
On success, the endpoint returns all created rows (with `username` joined from `users`) so the frontend can update without a separate fetch.
|
||||||
|
|
||||||
|
**New: Migration script `backend/migrations/add_compliance_notes_group_id.js`**
|
||||||
|
|
||||||
|
Adds the `group_id` column and backfills existing rows:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE compliance_notes ADD COLUMN group_id TEXT;
|
||||||
|
CREATE INDEX idx_compliance_notes_group ON compliance_notes(group_id);
|
||||||
|
-- Backfill: each existing row gets its own unique group_id
|
||||||
|
UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
The backfill ensures every row has a `group_id`, so the frontend grouping logic works uniformly without null checks.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
**Modified: `ComplianceDetailPanel.js`**
|
||||||
|
|
||||||
|
The notes section is updated with three changes:
|
||||||
|
|
||||||
|
1. **Multi-select metric selector** — replaces the `<select>` dropdown with a chip-based toggle list. Each active metric is rendered as a clickable `MetricChip`. Selected chips get a highlighted border/background. A "Select All" / "Deselect All" toggle appears when there are 2+ active metrics.
|
||||||
|
|
||||||
|
2. **Submission logic** — `handleAddNote` sends `metric_ids` (array of selected metric IDs) instead of `metric_id` (single string). The submit button is disabled when no metrics are selected or note text is empty.
|
||||||
|
|
||||||
|
3. **Note display grouping** — notes are grouped by `group_id` before rendering. Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges. Notes without a `group_id` (pre-migration legacy) render as individual entries, same as today.
|
||||||
|
|
||||||
|
**Component structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
ComplianceDetailPanel
|
||||||
|
├── Header (hostname, IP, device type, team)
|
||||||
|
├── Section: Failing Metrics
|
||||||
|
│ └── MetricRow (per active metric)
|
||||||
|
├── Section: Resolved Metrics
|
||||||
|
│ └── MetricRow (per resolved metric)
|
||||||
|
├── Section: History
|
||||||
|
│ └── MetricChip + seen count (per active metric)
|
||||||
|
└── Section: Notes
|
||||||
|
├── NoteCard (per group_id group, shows multiple MetricChips if multi-metric)
|
||||||
|
└── Add Note Form
|
||||||
|
├── MetricChipSelector (multi-select chip toggles)
|
||||||
|
│ ├── MetricChip (per active metric, clickable)
|
||||||
|
│ └── Select All / Deselect All toggle
|
||||||
|
├── Textarea (note text)
|
||||||
|
└── Send button (disabled when no metrics selected or text empty)
|
||||||
|
```
|
||||||
|
|
||||||
|
**MetricChipSelector behavior:**
|
||||||
|
|
||||||
|
| State | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| 1 active metric | Chip is pre-selected and non-removable. No Select All toggle. |
|
||||||
|
| 2+ active metrics, panel just opened | First metric pre-selected. Select All toggle visible. |
|
||||||
|
| User clicks unselected chip | Chip added to selection |
|
||||||
|
| User clicks selected chip (2+ selected) | Chip removed from selection |
|
||||||
|
| User clicks selected chip (only 1 selected, 2+ metrics exist) | No-op — at least one must remain selected |
|
||||||
|
| Select All clicked | All active metrics selected, toggle label changes to "Deselect All" |
|
||||||
|
| Deselect All clicked | All metrics deselected except the first (to maintain minimum selection) |
|
||||||
|
|
||||||
|
**Design rationale — minimum selection of 1:** The submit button is disabled when no metrics are selected (Requirement 3.4). Rather than allowing the user to reach an empty state and see a disabled button, "Deselect All" keeps the first metric selected. This matches the current UX where a metric is always selected.
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### compliance_notes table (modified)
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER PK | Auto-increment row ID |
|
||||||
|
| `hostname` | TEXT NOT NULL | Device hostname |
|
||||||
|
| `metric_id` | TEXT NOT NULL | Compliance metric ID |
|
||||||
|
| `note` | TEXT NOT NULL | Note text (max 1000 chars) |
|
||||||
|
| `group_id` | TEXT | Batch identifier — rows from the same submission share this value |
|
||||||
|
| `created_by` | INTEGER FK | User ID of the note author |
|
||||||
|
| `created_at` | DATETIME | Timestamp of creation |
|
||||||
|
|
||||||
|
The `group_id` is a UUID v4 string generated server-side via `crypto.randomUUID()`. Single-metric submissions also receive a `group_id` so the frontend grouping logic is uniform.
|
||||||
|
|
||||||
|
**Index:** `idx_compliance_notes_group ON compliance_notes(group_id)` — supports the frontend's grouping query.
|
||||||
|
|
||||||
|
### API Response Shape
|
||||||
|
|
||||||
|
`POST /api/compliance/notes` response (201):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"hostname": "SERVER01",
|
||||||
|
"metric_id": "2.1.1",
|
||||||
|
"note": "Vendor ticket VT-1234 opened",
|
||||||
|
"group_id": "a1b2c3d4-...",
|
||||||
|
"created_at": "2025-01-15 14:30:00",
|
||||||
|
"created_by": "jsmith"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"hostname": "SERVER01",
|
||||||
|
"metric_id": "2.3.2",
|
||||||
|
"note": "Vendor ticket VT-1234 opened",
|
||||||
|
"group_id": "a1b2c3d4-...",
|
||||||
|
"created_at": "2025-01-15 14:30:00",
|
||||||
|
"created_by": "jsmith"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/compliance/items/:hostname` response — the existing `notes` array now includes `group_id`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notes": [
|
||||||
|
{ "id": 43, "metric_id": "2.3.2", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
|
||||||
|
{ "id": 42, "metric_id": "2.1.1", "note": "...", "group_id": "a1b2c3d4-...", "created_at": "...", "created_by": "jsmith" },
|
||||||
|
{ "id": 10, "metric_id": "2.1.1", "note": "...", "group_id": "legacy-10", "created_at": "...", "created_by": "admin" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend groups consecutive notes by `group_id` to render multi-metric notes as a single card.
|
||||||
|
|
||||||
|
## 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: Select All / Deselect All round-trip
|
||||||
|
|
||||||
|
*For any* set of active metrics with size > 1, clicking "Select All" should result in all metrics being selected, and then clicking "Deselect All" should result in only the first metric remaining selected (minimum selection invariant).
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.2**
|
||||||
|
|
||||||
|
### Property 2: Toggle label reflects selection state
|
||||||
|
|
||||||
|
*For any* set of active metrics, if the user manually selects every metric one by one, the toggle label should read "Deselect All" — the label is a pure function of whether all metrics are selected, regardless of how that state was reached.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.3**
|
||||||
|
|
||||||
|
### Property 3: Multi-metric submission creates correct rows with shared group_id
|
||||||
|
|
||||||
|
*For any* valid hostname, non-empty note text, and non-empty array of valid metric IDs, submitting a note should create exactly N rows in `compliance_notes` (where N = length of the metric IDs array), all sharing the same `note` text, `created_by` user, `created_at` timestamp, and `group_id` value.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.1, 3.2, 5.3, 5.7, 6.1**
|
||||||
|
|
||||||
|
### Property 4: Whitespace-only notes are rejected
|
||||||
|
|
||||||
|
*For any* string composed entirely of whitespace characters (spaces, tabs, newlines, or combinations thereof), the Notes API should reject the submission with a 400 error and create zero rows in the database.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.3**
|
||||||
|
|
||||||
|
### Property 5: Atomic validation — invalid metric IDs reject the entire batch
|
||||||
|
|
||||||
|
*For any* array of metric IDs where at least one entry is invalid (empty string, exceeds 50 characters, or non-string), the Notes API should reject the entire request with a 400 error and insert zero rows, even if all other entries are valid.
|
||||||
|
|
||||||
|
**Validates: Requirements 5.2, 5.6**
|
||||||
|
|
||||||
|
### Property 6: Note grouping display
|
||||||
|
|
||||||
|
*For any* set of notes where multiple notes share the same `group_id`, the Detail Panel should render them as a single note entry displaying all associated Metric Chips, rather than as separate entries.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1, 4.2, 6.4**
|
||||||
|
|
||||||
|
### Property 7: Reverse chronological note ordering
|
||||||
|
|
||||||
|
*For any* set of notes with varying `created_at` timestamps and group sizes, the Detail Panel should display note groups in reverse chronological order (newest `created_at` first), regardless of how many metrics each group covers.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.3**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
| Scenario | Response | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| Empty or whitespace-only note text | 400 `{ error: "Note cannot be empty" }` | No rows inserted |
|
||||||
|
| `metric_ids` is empty array | 400 `{ error: "At least one metric ID is required" }` | No rows inserted |
|
||||||
|
| Any metric ID in array is empty or >50 chars | 400 `{ error: "Invalid metric_id at index N" }` | No rows inserted (atomic rejection) |
|
||||||
|
| `metric_ids` is not an array (when provided) | 400 `{ error: "metric_ids must be an array" }` | Falls back to checking `metric_id` |
|
||||||
|
| Neither `metric_id` nor `metric_ids` provided | 400 `{ error: "metric_id or metric_ids is required" }` | No rows inserted |
|
||||||
|
| Database error during transaction | 500 `{ error: "Failed to save note" }` | Transaction rolled back, no partial inserts |
|
||||||
|
| Invalid hostname format | 400 `{ error: "Invalid hostname format" }` | No rows inserted (unchanged) |
|
||||||
|
|
||||||
|
Transaction safety: all inserts for a multi-metric note happen inside `BEGIN TRANSACTION` / `COMMIT`. If any insert fails, the transaction is rolled back and no rows are persisted.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| API returns 400 validation error | Display error message below the note input (existing `noteError` state) |
|
||||||
|
| API returns 500 server error | Display error message below the note input |
|
||||||
|
| Network failure | Display "Failed to save note" error |
|
||||||
|
| No metrics selected | Submit button is disabled, no API call made |
|
||||||
|
| Successful submission | Clear note text, refresh notes list, retain metric selection |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (example-based)
|
||||||
|
|
||||||
|
- **Backend:**
|
||||||
|
- Legacy `metric_id` field still creates a single note row (backward compatibility)
|
||||||
|
- Both `metric_id` and `metric_ids` provided — `metric_ids` takes precedence
|
||||||
|
- Single active metric pre-selects and is non-removable
|
||||||
|
- Response shape includes all created rows with `group_id` and `username`
|
||||||
|
|
||||||
|
- **Frontend:**
|
||||||
|
- MetricChipSelector renders correct number of chips for given active metrics
|
||||||
|
- Clicking a chip toggles its selection state
|
||||||
|
- Submit button disabled when note text is empty or no metrics selected
|
||||||
|
- Notes without `group_id` (legacy) render as individual entries
|
||||||
|
- Single active metric auto-selects and hides Select All toggle
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
Property-based tests use `fast-check` (JavaScript PBT library) with a minimum of 100 iterations per property.
|
||||||
|
|
||||||
|
Each property test is tagged with a comment referencing the design property:
|
||||||
|
- **Feature: compliance-multi-metric-notes, Property 3: Multi-metric submission creates correct rows with shared group_id**
|
||||||
|
- **Feature: compliance-multi-metric-notes, Property 4: Whitespace-only notes are rejected**
|
||||||
|
- **Feature: compliance-multi-metric-notes, Property 5: Atomic validation — invalid metric IDs reject the entire batch**
|
||||||
|
|
||||||
|
Backend properties (3, 4, 5) are tested against the route handler using a test SQLite database. Frontend properties (1, 2, 6, 7) are tested against the component rendering/grouping logic using React Testing Library with generated inputs.
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- End-to-end flow: open detail panel → select multiple metrics → submit note → verify grouped display
|
||||||
|
- Migration script: verify `group_id` column exists and legacy rows are backfilled
|
||||||
|
- Backward compatibility: existing `GET /items/:hostname` response includes `group_id` field on notes
|
||||||
85
.kiro/specs/compliance-multi-metric-notes/requirements.md
Normal file
85
.kiro/specs/compliance-multi-metric-notes/requirements.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The compliance detail panel currently allows users to add notes to a single metric at a time via a dropdown selector. When a remediation action, vendor ticket, or status update applies to multiple metrics on the same device, users must repeat the same note for each metric individually. This feature adds multi-metric selection to the note creation flow so that a single note can be associated with multiple metrics in one action, while preserving the existing per-metric note history and display.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Detail_Panel**: The slide-out panel (`ComplianceDetailPanel.js`) that opens when a user clicks a device row on the Compliance page. It displays failing metrics, resolved metrics, upload history, and notes for a single hostname.
|
||||||
|
- **Note**: A timestamped, user-attributed text entry stored in the `compliance_notes` table, keyed on `(hostname, metric_id)`. Notes persist across uploads and form a historical record.
|
||||||
|
- **Metric_Selector**: The UI control in the Detail_Panel's notes section that allows the user to choose which metric(s) a note applies to. Currently a single-select dropdown; this feature replaces it with a multi-select control.
|
||||||
|
- **Metric_Chip**: A small colored badge displaying a metric ID, used throughout the compliance UI to visually identify metrics by category color.
|
||||||
|
- **Notes_API**: The `POST /api/compliance/notes` endpoint that persists a note to the database.
|
||||||
|
- **Active_Metric**: A compliance item with `status = 'active'` for the selected hostname — these are the metrics currently failing.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Multi-Metric Selection UI
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want to select multiple metrics when adding a note, so that I can document a single remediation action that covers several metrics without repeating myself.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Detail_Panel is open for a hostname with more than one Active_Metric, THE Metric_Selector SHALL display all Active_Metrics as individually selectable options.
|
||||||
|
2. WHEN the user interacts with the Metric_Selector, THE Metric_Selector SHALL allow the user to select one or more Active_Metrics simultaneously.
|
||||||
|
3. WHEN the Detail_Panel is open for a hostname with exactly one Active_Metric, THE Metric_Selector SHALL pre-select that metric and remain visible as a single non-removable selection.
|
||||||
|
4. WHEN the Detail_Panel first opens for a hostname with multiple Active_Metrics, THE Metric_Selector SHALL pre-select the first Active_Metric by default.
|
||||||
|
5. THE Metric_Selector SHALL display each option using the Metric_Chip component with the metric's category color, so that metrics are visually distinguishable.
|
||||||
|
|
||||||
|
### Requirement 2: Select All / Deselect All
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want a quick way to select or deselect all metrics, so that I can efficiently apply a note to every failing metric on a device.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the hostname has more than one Active_Metric, THE Metric_Selector SHALL display a "Select All" toggle that selects all Active_Metrics when activated.
|
||||||
|
2. WHEN all Active_Metrics are already selected, THE "Select All" toggle SHALL change to "Deselect All" and deselect all Active_Metrics when activated.
|
||||||
|
3. WHEN the user manually selects all Active_Metrics one by one, THE toggle label SHALL update to "Deselect All" to reflect the current state.
|
||||||
|
|
||||||
|
### Requirement 3: Multi-Metric Note Submission
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want the system to save my note against all selected metrics in one action, so that the historical record accurately reflects which metrics the note covers.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user submits a note with multiple metrics selected, THE Notes_API SHALL create one `compliance_notes` row per selected metric, all sharing the same note text, `created_by`, and `created_at` timestamp.
|
||||||
|
2. WHEN the user submits a note with a single metric selected, THE Notes_API SHALL create exactly one `compliance_notes` row, preserving backward compatibility with the existing behavior.
|
||||||
|
3. IF the note text is empty or contains only whitespace, THEN THE Notes_API SHALL reject the submission and return a validation error.
|
||||||
|
4. IF no metrics are selected, THEN THE Detail_Panel SHALL disable the submit button and prevent submission.
|
||||||
|
5. WHEN a multi-metric note is successfully saved, THE Detail_Panel SHALL clear the note text field, refresh the notes list, and retain the current metric selection.
|
||||||
|
|
||||||
|
### Requirement 4: Multi-Metric Note Display
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want to see which metrics a note was applied to, so that I can understand the scope of past remediation actions.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a note was submitted for multiple metrics simultaneously, THE Detail_Panel SHALL display all associated Metric_Chips together on that note entry, visually grouped.
|
||||||
|
2. WHEN a note was submitted for a single metric, THE Detail_Panel SHALL continue to display a single Metric_Chip on that note entry, matching the current behavior.
|
||||||
|
3. THE Detail_Panel SHALL display notes in reverse chronological order, with the newest note first, regardless of how many metrics each note covers.
|
||||||
|
|
||||||
|
### Requirement 5: Backend Multi-Metric Notes Endpoint
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the notes API to accept an array of metric IDs, so that the frontend can submit a note for multiple metrics in a single request.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Notes_API SHALL accept a `metric_ids` field (array of strings) in the request body as an alternative to the existing `metric_id` field (single string).
|
||||||
|
2. WHEN `metric_ids` is provided, THE Notes_API SHALL validate that each entry is a non-empty string of 50 characters or fewer.
|
||||||
|
3. WHEN `metric_ids` is provided, THE Notes_API SHALL insert one `compliance_notes` row per metric ID, all within the same database transaction, sharing the same `created_at` timestamp.
|
||||||
|
4. WHEN the legacy `metric_id` field is provided instead of `metric_ids`, THE Notes_API SHALL continue to function as before, inserting a single row.
|
||||||
|
5. IF both `metric_id` and `metric_ids` are provided, THEN THE Notes_API SHALL use `metric_ids` and ignore `metric_id`.
|
||||||
|
6. IF any metric ID in the `metric_ids` array fails validation, THEN THE Notes_API SHALL reject the entire request and return a 400 error without inserting any rows.
|
||||||
|
7. THE Notes_API SHALL return all created note rows in the response, so the frontend can update the display without a separate fetch.
|
||||||
|
|
||||||
|
### Requirement 6: Note Grouping Identifier
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want notes that were created together to share a group identifier, so that the frontend can visually group multi-metric notes in the display.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN multiple notes are created from a single submission, THE Notes_API SHALL assign the same `group_id` value to all rows in that batch.
|
||||||
|
2. WHEN a single note is created, THE Notes_API SHALL assign a unique `group_id` to that row.
|
||||||
|
3. THE `group_id` SHALL be stored as a text column in the `compliance_notes` table.
|
||||||
|
4. THE Detail_Panel SHALL use the `group_id` to visually group notes that were submitted together, displaying them as a single note entry with multiple Metric_Chips rather than as separate entries.
|
||||||
105
.kiro/specs/compliance-multi-metric-notes/tasks.md
Normal file
105
.kiro/specs/compliance-multi-metric-notes/tasks.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Implementation Plan: Multi-Metric Notes for Compliance Detail Panel
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Extend the compliance notes system so a single note can be associated with multiple metrics in one action. Changes span three layers: a new migration script adding `group_id` to `compliance_notes`, updates to the `POST /notes` endpoint in `backend/routes/compliance.js` to accept `metric_ids` (array) and insert rows transactionally, and frontend changes in `ComplianceDetailPanel.js` to replace the single-select dropdown with a multi-select chip selector and group notes by `group_id` in the display.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Create database migration for `group_id` column
|
||||||
|
- [x] 1.1 Create `backend/migrations/add_compliance_notes_group_id.js`
|
||||||
|
- Add `group_id TEXT` column to `compliance_notes` table via `ALTER TABLE`
|
||||||
|
- Create index `idx_compliance_notes_group` on `compliance_notes(group_id)`
|
||||||
|
- Backfill existing rows: `UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`
|
||||||
|
- Follow the existing migration pattern (sqlite3, serialize, console logging)
|
||||||
|
- _Requirements: 6.1, 6.2, 6.3_
|
||||||
|
|
||||||
|
- [x] 2. Update `POST /notes` endpoint to support multi-metric submissions
|
||||||
|
- [x] 2.1 Modify the `POST /notes` handler in `backend/routes/compliance.js` to accept `metric_ids` array
|
||||||
|
- Accept `metric_ids` (array of strings) as an alternative to `metric_id` (single string)
|
||||||
|
- When both are provided, `metric_ids` takes precedence
|
||||||
|
- When neither is provided, return 400 with `"metric_id or metric_ids is required"`
|
||||||
|
- When `metric_ids` is provided but is not an array, return 400 with `"metric_ids must be an array"`
|
||||||
|
- Normalize single `metric_id` into a one-element array internally so the rest of the logic is uniform
|
||||||
|
- _Requirements: 5.1, 5.4, 5.5_
|
||||||
|
|
||||||
|
- [x] 2.2 Add validation for `metric_ids` array entries
|
||||||
|
- Validate that `metric_ids` has at least one entry; return 400 with `"At least one metric ID is required"` if empty
|
||||||
|
- Validate each entry is a non-empty string of 50 characters or fewer; return 400 with `"Invalid metric_id at index N"` on failure
|
||||||
|
- Reject the entire request if any entry fails validation (atomic rejection, no partial inserts)
|
||||||
|
- _Requirements: 5.2, 5.6_
|
||||||
|
|
||||||
|
- [x] 2.3 Implement transactional multi-row insert with `group_id`
|
||||||
|
- Generate a `group_id` using `crypto.randomUUID()` for each submission (single or multi)
|
||||||
|
- Wrap all inserts in `BEGIN TRANSACTION` / `COMMIT` with `ROLLBACK` on error
|
||||||
|
- Insert one `compliance_notes` row per metric ID, all sharing the same `note`, `group_id`, `created_by`, and `created_at`
|
||||||
|
- _Requirements: 3.1, 3.2, 5.3, 6.1, 6.2_
|
||||||
|
|
||||||
|
- [x] 2.4 Update the response to return all created note rows
|
||||||
|
- After commit, query all created rows (joined with `users` for `username`) and return as `{ notes: [...] }`
|
||||||
|
- Each row includes `id`, `hostname`, `metric_id`, `note`, `group_id`, `created_at`, `created_by`
|
||||||
|
- Return HTTP 201 status
|
||||||
|
- _Requirements: 5.7_
|
||||||
|
|
||||||
|
- [x] 3. Update `GET /items/:hostname` to include `group_id` in notes response
|
||||||
|
- Add `cn.group_id` to the SELECT in the notes query within the `GET /items/:hostname` handler
|
||||||
|
- The existing query already fetches notes for the hostname; just add the column
|
||||||
|
- No other changes to this endpoint
|
||||||
|
- _Requirements: 6.3, 6.4_
|
||||||
|
|
||||||
|
- [x] 4. Checkpoint — Verify backend changes
|
||||||
|
- Ensure all backend changes are syntactically correct, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 5. Replace single-select dropdown with multi-select MetricChipSelector in `ComplianceDetailPanel.js`
|
||||||
|
- [x] 5.1 Replace `noteMetric` (string) state with `selectedMetrics` (array) state
|
||||||
|
- Initialize `selectedMetrics` with the first active metric's ID when detail loads (matching current default behavior)
|
||||||
|
- When there is exactly one active metric, pre-select it as a non-removable selection
|
||||||
|
- _Requirements: 1.3, 1.4_
|
||||||
|
|
||||||
|
- [x] 5.2 Build the multi-select chip-based metric selector UI
|
||||||
|
- Replace the existing `<select>` dropdown with a row of clickable `MetricChip` components
|
||||||
|
- Each active metric renders as a chip; selected chips get a highlighted border/background
|
||||||
|
- Clicking an unselected chip adds it to `selectedMetrics`
|
||||||
|
- Clicking a selected chip removes it, unless it is the only selected chip (minimum 1 selection)
|
||||||
|
- Only show the chip selector when there are 2+ active metrics (single metric is auto-selected)
|
||||||
|
- Style chips using existing `MetricChip` component patterns and category colors
|
||||||
|
- _Requirements: 1.1, 1.2, 1.5_
|
||||||
|
|
||||||
|
- [x] 5.3 Add Select All / Deselect All toggle
|
||||||
|
- Show a text toggle above or beside the chip row when there are 2+ active metrics
|
||||||
|
- "Select All" selects all active metrics; label changes to "Deselect All"
|
||||||
|
- "Deselect All" deselects all except the first metric (minimum selection invariant)
|
||||||
|
- Toggle label is a pure function of whether all metrics are selected
|
||||||
|
- Hide the toggle when there is only one active metric
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3_
|
||||||
|
|
||||||
|
- [x] 6. Update note submission logic to send `metric_ids` array
|
||||||
|
- Modify `handleAddNote` to send `{ hostname, metric_ids: selectedMetrics, note }` instead of `{ hostname, metric_id: noteMetric, note }`
|
||||||
|
- Disable the submit button when `selectedMetrics` is empty or note text is empty
|
||||||
|
- On success, clear note text, refresh the detail panel, and retain the current metric selection
|
||||||
|
- Handle the new response shape (`{ notes: [...] }`) from the updated API
|
||||||
|
- _Requirements: 3.1, 3.4, 3.5_
|
||||||
|
|
||||||
|
- [x] 7. Update note display to group by `group_id`
|
||||||
|
- [x] 7.1 Add note grouping logic
|
||||||
|
- Group the `detail.notes` array by `group_id` before rendering
|
||||||
|
- Notes sharing a `group_id` are displayed as a single card with multiple `MetricChip` badges
|
||||||
|
- Notes without a `group_id` (pre-migration legacy, should not occur after backfill) render as individual entries
|
||||||
|
- Maintain reverse chronological order (newest `created_at` first) across groups
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 6.4_
|
||||||
|
|
||||||
|
- [x] 7.2 Update the note card rendering
|
||||||
|
- For grouped notes, display all associated `MetricChip` components in the card header
|
||||||
|
- For single-metric notes, display one `MetricChip` (matching current behavior)
|
||||||
|
- Preserve existing note card styling (background, border, padding, typography)
|
||||||
|
- _Requirements: 4.1, 4.2_
|
||||||
|
|
||||||
|
- [x] 8. Final checkpoint — Verify full feature
|
||||||
|
- Ensure frontend compiles without errors, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No automated tests — feature is validated manually per user preference
|
||||||
|
- No new components or route modules required; all changes are scoped to existing files plus one migration
|
||||||
|
- The `group_id` backfill ensures legacy notes render correctly without null checks
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
1
.kiro/specs/fp-attachment-library/.config.kiro
Normal file
1
.kiro/specs/fp-attachment-library/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "acff93bd-0045-4fcd-b948-cc52c7cc5ec6", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
362
.kiro/specs/fp-attachment-library/design.md
Normal file
362
.kiro/specs/fp-attachment-library/design.md
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# Design Document: FP Attachment Library
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature extends the FP submission workflow to let users attach documents from the existing CVE document library (the `documents` table) alongside traditional local file uploads. The core change is a new **Attachment Source Picker** component shared by both the create and edit modals, backed by a new **Document Search API** endpoint. On submission, the backend reads library files from disk and sends them to the Ivanti API identically to local uploads.
|
||||||
|
|
||||||
|
The design prioritizes minimal disruption to the existing codebase: one new GET endpoint, modifications to two existing POST endpoints, and a shared React component inserted into both modals.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Frontend
|
||||||
|
A[FpWorkflowModal] --> C[AttachmentSourcePicker]
|
||||||
|
B[FpEditModal] --> C
|
||||||
|
C -->|local files| D[File objects in state]
|
||||||
|
C -->|library docs| E[Document IDs in state]
|
||||||
|
end
|
||||||
|
subgraph Backend
|
||||||
|
F[GET /api/documents/search] -->|SQLite| G[(documents table)]
|
||||||
|
H[POST /api/ivanti/fp-workflow] -->|reads disk| I[uploads/]
|
||||||
|
H -->|multipart| J[Ivanti API]
|
||||||
|
K[POST .../attachments] -->|reads disk| I
|
||||||
|
K -->|multipart| J
|
||||||
|
end
|
||||||
|
C -->|fetch| F
|
||||||
|
A -->|FormData + libraryDocIds| H
|
||||||
|
B -->|FormData + libraryDocIds| K
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
|
||||||
|
1. User opens FP Create or Edit modal
|
||||||
|
2. Attachment Source Picker renders with two mode tabs: **Local Upload** and **Library**
|
||||||
|
3. In Library mode, user types a search query → frontend debounces 300ms → calls `GET /api/documents/search?q=...`
|
||||||
|
4. User selects library documents and/or local files
|
||||||
|
5. On submit:
|
||||||
|
- Frontend sends `FormData` with local files in `attachments` field and library document IDs in a `libraryDocIds` JSON field
|
||||||
|
- Backend parses both, looks up library documents in the `documents` table, reads their files from disk
|
||||||
|
- Backend combines local file buffers and library file buffers into a single `files` array
|
||||||
|
- Backend calls `ivantiFormPost` with all files in one multipart request
|
||||||
|
- Backend records results in `attachment_results_json` with a `source` field (`"local"` or `"library"`)
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
1. **Single shared component**: The `AttachmentSourcePicker` is used in both modals to avoid duplication. It receives callbacks for state management and renders the mode toggle, search UI, and unified attachment list.
|
||||||
|
|
||||||
|
2. **Library doc IDs sent as JSON field**: Rather than changing the multipart structure, library document IDs are sent as a JSON-encoded string field (`libraryDocIds`) alongside the existing `attachments` file field. This keeps the existing local upload path unchanged.
|
||||||
|
|
||||||
|
3. **Backend reads files from disk**: Library documents are read from disk using `fs.readFileSync(file_path)` at submission time. This avoids storing duplicate file buffers and keeps the Ivanti API call identical for both sources.
|
||||||
|
|
||||||
|
4. **No new database tables**: The feature uses the existing `documents` table for search and the existing `ivanti_fp_submissions` table for recording results. The only schema-level change is adding a `source` field to the JSON objects in `attachment_results_json`.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### New API Endpoint
|
||||||
|
|
||||||
|
#### `GET /api/documents/search`
|
||||||
|
|
||||||
|
Added to `backend/routes/ivantiFpWorkflow.js` (or as a new route in `server.js` alongside existing document routes).
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `q` | query string | No | Search term matched against `name`, `cve_id`, `vendor` using SQL `LIKE` |
|
||||||
|
|
||||||
|
**Response** (200):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"cve_id": "CVE-2024-1234",
|
||||||
|
"vendor": "Microsoft",
|
||||||
|
"name": "advisory-2024-1234.pdf",
|
||||||
|
"type": "Advisory",
|
||||||
|
"file_size": "245760",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"uploaded_at": "2024-11-15T10:30:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```javascript
|
||||||
|
// Pseudocode
|
||||||
|
router.get('/documents/search', requireAuth(db), (req, res) => {
|
||||||
|
const q = (req.query.q || '').trim();
|
||||||
|
let sql, params;
|
||||||
|
if (q) {
|
||||||
|
const like = `%${q}%`;
|
||||||
|
sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at
|
||||||
|
FROM documents
|
||||||
|
WHERE name LIKE ? OR cve_id LIKE ? OR vendor LIKE ?
|
||||||
|
ORDER BY uploaded_at DESC
|
||||||
|
LIMIT 50`;
|
||||||
|
params = [like, like, like];
|
||||||
|
} else {
|
||||||
|
sql = `SELECT id, cve_id, vendor, name, type, file_size, mime_type, uploaded_at
|
||||||
|
FROM documents
|
||||||
|
ORDER BY uploaded_at DESC
|
||||||
|
LIMIT 50`;
|
||||||
|
params = [];
|
||||||
|
}
|
||||||
|
db.all(sql, params, (err, rows) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Database error.' });
|
||||||
|
res.json(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified API Endpoints
|
||||||
|
|
||||||
|
#### `POST /api/ivanti/fp-workflow` (create)
|
||||||
|
|
||||||
|
**New field in multipart body**:
|
||||||
|
- `libraryDocIds` — JSON-encoded array of document IDs (integers) from the `documents` table
|
||||||
|
|
||||||
|
**Backend changes**:
|
||||||
|
1. Parse `libraryDocIds` from `req.body` (default to `[]`)
|
||||||
|
2. Validate each ID is a positive integer
|
||||||
|
3. Query `documents` table for matching records
|
||||||
|
4. Validate all IDs were found (400 if any missing)
|
||||||
|
5. Read each file from disk using `file_path` (error if file missing on disk)
|
||||||
|
6. Combine local file buffers (`req.files`) and library file buffers into a single `formFiles` array
|
||||||
|
7. Pass combined array to `ivantiFormPost`
|
||||||
|
8. Record results with `source: "local"` or `source: "library"` in `attachment_results_json`
|
||||||
|
|
||||||
|
#### `POST /api/ivanti/fp-workflow/submissions/:id/attachments` (edit)
|
||||||
|
|
||||||
|
Same changes as the create endpoint — accepts `libraryDocIds` alongside `attachments` files.
|
||||||
|
|
||||||
|
### New Frontend Component
|
||||||
|
|
||||||
|
#### `AttachmentSourcePicker`
|
||||||
|
|
||||||
|
Defined inline in `ReportingPage.js` (consistent with existing component patterns).
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `files` | `File[]` | Current local file attachments |
|
||||||
|
| `onFilesChange` | `(files: File[]) => void` | Callback when local files change |
|
||||||
|
| `libraryDocs` | `object[]` | Current selected library documents |
|
||||||
|
| `onLibraryDocsChange` | `(docs: object[]) => void` | Callback when library selections change |
|
||||||
|
| `disabled` | `boolean` | Disables all interactions (for approved submissions) |
|
||||||
|
|
||||||
|
**Internal state**:
|
||||||
|
- `mode` — `'local'` or `'library'` (default: `'local'`)
|
||||||
|
- `searchQuery` — current search input value
|
||||||
|
- `searchResults` — array of document records from API
|
||||||
|
- `searching` — loading state for search
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
- Mode toggle renders two tab-style buttons at the top
|
||||||
|
- Local mode shows the existing drag-and-drop zone
|
||||||
|
- Library mode shows a search input + scrollable results list
|
||||||
|
- Search is debounced at 300ms using `setTimeout`/`clearTimeout`
|
||||||
|
- Selected library docs are tracked by `id` to prevent duplicates
|
||||||
|
- Already-selected docs appear disabled/checked in search results
|
||||||
|
- Unified attachment list below shows all attachments with source badges
|
||||||
|
- Each attachment row shows: source badge, filename, file size, remove button
|
||||||
|
- Library attachment rows additionally show CVE ID and vendor
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> LocalMode
|
||||||
|
LocalMode --> LibraryMode: Click "Library" tab
|
||||||
|
LibraryMode --> LocalMode: Click "Local Upload" tab
|
||||||
|
|
||||||
|
state LibraryMode {
|
||||||
|
[*] --> Idle
|
||||||
|
Idle --> Searching: User types (after 300ms debounce)
|
||||||
|
Searching --> ResultsShown: API responds
|
||||||
|
ResultsShown --> Searching: User types again
|
||||||
|
ResultsShown --> DocSelected: User clicks result
|
||||||
|
DocSelected --> ResultsShown: Doc added to list
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
#### FpWorkflowModal (create)
|
||||||
|
|
||||||
|
- Replace the current file upload section with `<AttachmentSourcePicker>`
|
||||||
|
- Add `libraryDocs` state array alongside existing `files` state
|
||||||
|
- On submit, append `libraryDocIds` as JSON string to `FormData`:
|
||||||
|
```javascript
|
||||||
|
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FpEditModal (edit — attachments tab)
|
||||||
|
|
||||||
|
- Replace the static "upload in Ivanti" message with `<AttachmentSourcePicker>`
|
||||||
|
- Keep existing attachment display above the picker
|
||||||
|
- On submit, build `FormData` with both local files and `libraryDocIds`
|
||||||
|
- Disable picker when `lifecycle_status === 'approved'`
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Existing: `documents` table (no changes)
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | INTEGER PK | Auto-increment ID |
|
||||||
|
| `cve_id` | VARCHAR(20) | Associated CVE identifier |
|
||||||
|
| `vendor` | VARCHAR(100) | Vendor name |
|
||||||
|
| `name` | VARCHAR(255) | Original filename |
|
||||||
|
| `type` | VARCHAR(50) | Document type (Advisory, Patch, etc.) |
|
||||||
|
| `file_path` | VARCHAR(500) | Relative path under `uploads/` |
|
||||||
|
| `file_size` | VARCHAR(20) | Human-readable or byte size |
|
||||||
|
| `mime_type` | VARCHAR(100) | MIME type |
|
||||||
|
| `uploaded_at` | TIMESTAMP | Upload timestamp |
|
||||||
|
| `notes` | TEXT | Optional notes |
|
||||||
|
|
||||||
|
### Modified: `attachment_results_json` shape
|
||||||
|
|
||||||
|
Current format per entry:
|
||||||
|
```json
|
||||||
|
{ "filename": "report.pdf", "success": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
New format per entry:
|
||||||
|
```json
|
||||||
|
{ "filename": "report.pdf", "success": true, "source": "local" }
|
||||||
|
```
|
||||||
|
or:
|
||||||
|
```json
|
||||||
|
{ "filename": "advisory-2024.pdf", "success": true, "source": "library", "documentId": 42 }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `source` field is added to distinguish attachment origins. The `documentId` field is included for library documents to enable traceability. Existing records without a `source` field are treated as `"local"` by the frontend for backward compatibility.
|
||||||
|
|
||||||
|
### Frontend State: Library Document Selection
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Shape of a selected library document in component state
|
||||||
|
{
|
||||||
|
id: 42, // documents.id
|
||||||
|
cve_id: "CVE-2024-1234",
|
||||||
|
vendor: "Microsoft",
|
||||||
|
name: "advisory-2024-1234.pdf",
|
||||||
|
file_size: "245760",
|
||||||
|
mime_type: "application/pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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: Search results match query
|
||||||
|
|
||||||
|
*For any* non-empty search query string `q` and any set of documents in the database, every document returned by the Document Search API SHALL have `q` as a case-insensitive substring of its `name`, `cve_id`, or `vendor` field.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1**
|
||||||
|
|
||||||
|
### Property 2: Default results are ordered by recency
|
||||||
|
|
||||||
|
*For any* set of documents in the database, when the Document Search API is called with no query, the returned results SHALL be ordered by `uploaded_at` descending (most recent first).
|
||||||
|
|
||||||
|
**Validates: Requirements 1.2**
|
||||||
|
|
||||||
|
### Property 3: Result set size is bounded
|
||||||
|
|
||||||
|
*For any* search query (including empty), the Document Search API SHALL return at most 50 records.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.3**
|
||||||
|
|
||||||
|
### Property 4: Library document ID validation rejects non-positive-integers
|
||||||
|
|
||||||
|
*For any* value that is not a positive integer (e.g., negative numbers, zero, floats, non-numeric strings, null), the backend validation SHALL reject it as an invalid library document ID.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.5**
|
||||||
|
|
||||||
|
### Property 5: Combined attachments are all sent to Ivanti
|
||||||
|
|
||||||
|
*For any* combination of local file uploads and library document references in a submission, the backend SHALL produce a files array for the Ivanti API call whose length equals the count of local files plus the count of valid library documents, and each library file buffer SHALL match the content read from the document's `file_path`.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1, 4.2**
|
||||||
|
|
||||||
|
### Property 6: Attachment results record source and filename correctly
|
||||||
|
|
||||||
|
*For any* mix of local and library attachments processed by the backend, each entry in `attachment_results_json` SHALL have a `source` field of `"local"` or `"library"`, and for library entries the `filename` SHALL equal the `name` field from the corresponding `documents` record.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.6, 4.7**
|
||||||
|
|
||||||
|
### Property 7: No duplicate library documents in attachment list
|
||||||
|
|
||||||
|
*For any* sequence of library document selections applied to the Attachment Source Picker, the resulting attachment list SHALL contain at most one entry per document `id`.
|
||||||
|
|
||||||
|
**Validates: Requirements 5.1**
|
||||||
|
|
||||||
|
### Property 8: Attachment list displays all required fields per type
|
||||||
|
|
||||||
|
*For any* attachment in the list (local or library), the rendered display SHALL include the filename, file size, source indicator, and a remove action. *For any* library attachment, the display SHALL additionally include the CVE ID and vendor name.
|
||||||
|
|
||||||
|
**Validates: Requirements 6.1, 6.2, 6.3, 6.4**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Document search DB error | Return 500 with `{ error: 'Database error.' }` |
|
||||||
|
| Invalid `libraryDocIds` JSON | Return 400 with `{ error: 'libraryDocIds must be a valid JSON array.' }` |
|
||||||
|
| Non-positive-integer document ID | Return 400 identifying the invalid ID |
|
||||||
|
| Document ID not found in DB | Return 400 identifying the missing document ID |
|
||||||
|
| Library file missing from disk | Log warning, skip that attachment, include `{ success: false, error: 'File not found on disk' }` in attachment results, continue with remaining files |
|
||||||
|
| Ivanti API failure for attachment upload | Record `{ success: false, error: '...' }` per file in results, return partial success if some files succeeded |
|
||||||
|
| Network error calling Document Search API (frontend) | Show inline error message in search results area, allow retry |
|
||||||
|
| Empty search results | Show "No documents found" message with suggestion to refine search |
|
||||||
|
| Unauthenticated request to search endpoint | Return 401 (handled by existing `requireAuth` middleware) |
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
- Existing `attachment_results_json` entries without a `source` field are treated as `"local"` by the frontend
|
||||||
|
- The `libraryDocIds` field is optional in both create and edit endpoints — omitting it preserves current behavior exactly
|
||||||
|
- No database migrations required — the `documents` table already exists
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Property-Based Tests (fast-check)
|
||||||
|
|
||||||
|
The project uses plain JavaScript with React 19. Property-based tests will use [fast-check](https://github.com/dubzzz/fast-check) with the existing `react-scripts test` runner (Jest).
|
||||||
|
|
||||||
|
Each property test runs a minimum of 100 iterations and is tagged with a comment referencing its design property.
|
||||||
|
|
||||||
|
**Configuration**: `npm install --save-dev fast-check` in the frontend package (or backend if testing backend logic separately).
|
||||||
|
|
||||||
|
**Properties to test**:
|
||||||
|
- Property 1: Search relevance — generate random documents and queries, verify all results match
|
||||||
|
- Property 2: Default ordering — generate random documents, verify descending order
|
||||||
|
- Property 3: Result limit — generate >50 documents, verify max 50 returned
|
||||||
|
- Property 4: ID validation — generate random non-positive-integer values, verify rejection
|
||||||
|
- Property 5: Combined attachment handling — generate random mixes, verify file array correctness
|
||||||
|
- Property 6: Result record shape — generate random mixes, verify source and filename fields
|
||||||
|
- Property 7: Duplicate prevention — generate random selection sequences, verify uniqueness
|
||||||
|
- Property 8: Display completeness — generate random attachment lists, verify rendered fields
|
||||||
|
|
||||||
|
**Tag format**: `// Feature: fp-attachment-library, Property N: <property text>`
|
||||||
|
|
||||||
|
### Unit Tests (example-based)
|
||||||
|
|
||||||
|
- Authentication guard on search endpoint (1.5)
|
||||||
|
- DB error handling returns 500 (1.6)
|
||||||
|
- Mode toggle renders correctly in both modals (2.1, 2.2, 2.3)
|
||||||
|
- Debounce behavior with fake timers (2.4)
|
||||||
|
- Library doc selection adds to list with indicator (2.5)
|
||||||
|
- Remove works for both types (2.6)
|
||||||
|
- Mixed attachments in same submission (2.7)
|
||||||
|
- Library doc displays name, size, CVE ID (2.8)
|
||||||
|
- Edit modal replaces static message (3.1)
|
||||||
|
- Existing attachments shown above picker (3.4)
|
||||||
|
- Approved submission disables picker (3.5)
|
||||||
|
- Missing file on disk returns error (4.3)
|
||||||
|
- Invalid document ID returns 400 (4.4)
|
||||||
|
- Already-selected docs shown as disabled (5.2)
|
||||||
|
- Removed doc re-enabled in results (5.3)
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- End-to-end create flow with mixed local + library attachments
|
||||||
|
- End-to-end edit flow adding library attachments to existing submission
|
||||||
|
- Search endpoint with real SQLite database
|
||||||
94
.kiro/specs/fp-attachment-library/requirements.md
Normal file
94
.kiro/specs/fp-attachment-library/requirements.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The FP Attachment Library feature extends the FP submission workflow (both create and edit flows) to allow users to attach existing documents from the CVE document library stored in the `documents` table, in addition to the current local file upload capability. This eliminates the need to re-download and re-upload files that already exist in the system, streamlining the attachment workflow for FP submissions.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Dashboard**: The STEAM Security Dashboard application
|
||||||
|
- **FP_Create_Modal**: The FpWorkflowModal component used to create new FP workflow submissions (in ReportingPage.js)
|
||||||
|
- **FP_Edit_Modal**: The FpEditModal component used to edit existing FP workflow submissions (in ReportingPage.js)
|
||||||
|
- **Document_Library**: The collection of files stored in the `documents` table, organized by CVE ID and vendor, with files on disk under `uploads/{cve_id}/{vendor}/`
|
||||||
|
- **Attachment_Source_Picker**: The UI component that lets users choose between uploading a local file or selecting an existing document from the Document_Library
|
||||||
|
- **Document_Search_API**: The backend endpoint that searches and returns documents from the Document_Library for selection
|
||||||
|
- **Library_Document**: A document record from the `documents` table, containing id, cve_id, vendor, name, type, file_path, file_size, mime_type, uploaded_at, and notes
|
||||||
|
- **Ivanti_API**: The external Ivanti/RiskSense API that receives FP workflow submissions and file attachments
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Document Search API
|
||||||
|
|
||||||
|
**User Story:** As an editor, I want to search the document library from within the FP workflow, so that I can find and attach existing documents without leaving the modal.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a search query is provided, THE Document_Search_API SHALL return Library_Document records whose name, cve_id, or vendor fields contain the query string
|
||||||
|
2. WHEN no search query is provided, THE Document_Search_API SHALL return the most recent Library_Document records ordered by uploaded_at descending
|
||||||
|
3. THE Document_Search_API SHALL limit results to a maximum of 50 records per request
|
||||||
|
4. THE Document_Search_API SHALL return each Library_Document with its id, cve_id, vendor, name, type, file_size, mime_type, and uploaded_at fields
|
||||||
|
5. THE Document_Search_API SHALL require an authenticated session before returning results
|
||||||
|
6. IF the database query fails, THEN THE Document_Search_API SHALL return an error response with a 500 status code
|
||||||
|
|
||||||
|
### Requirement 2: Attachment Source Picker in FP Create Modal
|
||||||
|
|
||||||
|
**User Story:** As an editor, I want to choose between uploading a local file or selecting a document from the library when creating an FP submission, so that I can attach evidence without re-uploading files that already exist in the system.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE FP_Create_Modal SHALL display the Attachment_Source_Picker with two modes: local file upload and library document selection
|
||||||
|
2. WHEN the user selects local file upload mode, THE FP_Create_Modal SHALL display the existing drag-and-drop zone and file picker
|
||||||
|
3. WHEN the user selects library document selection mode, THE FP_Create_Modal SHALL display a search input and a scrollable list of matching Library_Document records
|
||||||
|
4. WHEN the user types in the library search input, THE FP_Create_Modal SHALL query the Document_Search_API and display matching results within 300 milliseconds of the last keystroke (debounced)
|
||||||
|
5. WHEN the user selects a Library_Document from the search results, THE FP_Create_Modal SHALL add the document to the attachment list with a visual indicator distinguishing it from locally uploaded files
|
||||||
|
6. THE FP_Create_Modal SHALL allow the user to remove any attachment from the list, whether it is a local file or a Library_Document
|
||||||
|
7. THE FP_Create_Modal SHALL allow mixing local file uploads and Library_Document selections in the same submission
|
||||||
|
8. THE FP_Create_Modal SHALL display the file name, file size, and CVE ID for each selected Library_Document in the attachment list
|
||||||
|
|
||||||
|
### Requirement 3: Attachment Source Picker in FP Edit Modal
|
||||||
|
|
||||||
|
**User Story:** As an editor, I want to attach existing library documents to an FP submission I am editing, so that I can add supporting evidence after the initial submission without re-uploading files.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE FP_Edit_Modal SHALL replace the static "upload in Ivanti" message on the attachments tab with the Attachment_Source_Picker
|
||||||
|
2. WHEN the user selects library document selection mode, THE FP_Edit_Modal SHALL display a search input and a scrollable list of matching Library_Document records
|
||||||
|
3. WHEN the user selects local file upload mode, THE FP_Edit_Modal SHALL display a drag-and-drop zone and file picker for local files
|
||||||
|
4. THE FP_Edit_Modal SHALL continue to display existing attachments from the initial submission above the Attachment_Source_Picker
|
||||||
|
5. WHILE the submission lifecycle_status is "approved", THE FP_Edit_Modal SHALL disable the Attachment_Source_Picker and prevent adding new attachments
|
||||||
|
6. THE FP_Edit_Modal SHALL allow the user to upload or attach selected documents by clicking a submit action button
|
||||||
|
|
||||||
|
### Requirement 4: Backend Handling of Library Document Attachments
|
||||||
|
|
||||||
|
**User Story:** As an editor, I want library documents to be sent to the Ivanti API the same way as local uploads, so that all attachments appear correctly on the Ivanti workflow.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the FP submission includes Library_Document references, THE Dashboard backend SHALL read the referenced files from disk using the file_path stored in the documents table
|
||||||
|
2. WHEN the FP submission includes both local files and Library_Document references, THE Dashboard backend SHALL send all attachments to the Ivanti_API in a single multipart request
|
||||||
|
3. IF a referenced Library_Document file_path does not exist on disk, THEN THE Dashboard backend SHALL return an error identifying the missing file and skip that attachment
|
||||||
|
4. IF a referenced Library_Document id does not exist in the documents table, THEN THE Dashboard backend SHALL return a 400 error identifying the invalid document ID
|
||||||
|
5. THE Dashboard backend SHALL validate that each referenced Library_Document id is a positive integer before querying the database
|
||||||
|
6. THE Dashboard backend SHALL include Library_Document attachments in the attachment_results_json field of the submission record, with a source indicator distinguishing them from local uploads
|
||||||
|
7. WHEN recording attachment results, THE Dashboard backend SHALL store the original document name from the Library_Document record as the filename
|
||||||
|
|
||||||
|
### Requirement 5: Duplicate Attachment Prevention
|
||||||
|
|
||||||
|
**User Story:** As an editor, I want the system to prevent me from attaching the same library document twice, so that I do not create redundant attachments on the Ivanti workflow.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user selects a Library_Document that is already in the attachment list, THE Attachment_Source_Picker SHALL not add a duplicate entry
|
||||||
|
2. THE Attachment_Source_Picker SHALL visually indicate Library_Document records that are already attached by showing them as disabled or checked in the search results
|
||||||
|
3. WHEN the user removes a previously selected Library_Document from the attachment list, THE Attachment_Source_Picker SHALL re-enable that document in the search results
|
||||||
|
|
||||||
|
### Requirement 6: Attachment List Display
|
||||||
|
|
||||||
|
**User Story:** As an editor, I want to clearly distinguish between local uploads and library documents in the attachment list, so that I know the source of each attachment before submitting.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Attachment_Source_Picker SHALL display a source badge or icon next to each attachment indicating whether it is a "Local Upload" or a "Library Document"
|
||||||
|
2. THE Attachment_Source_Picker SHALL display the file name and file size for all attachments regardless of source
|
||||||
|
3. WHEN displaying a Library_Document attachment, THE Attachment_Source_Picker SHALL also display the associated CVE ID and vendor name
|
||||||
|
4. THE Attachment_Source_Picker SHALL display a remove button for each attachment in the list
|
||||||
95
.kiro/specs/fp-attachment-library/tasks.md
Normal file
95
.kiro/specs/fp-attachment-library/tasks.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Implementation Plan: FP Attachment Library
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plan implements the FP Attachment Library feature, which allows users to attach existing CVE document library files to FP workflow submissions alongside traditional local file uploads. The implementation adds a new Document Search API endpoint, modifies two existing backend endpoints to handle library document references, and creates a shared AttachmentSourcePicker component used in both the create and edit modals.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Add Document Search API endpoint
|
||||||
|
- [x] 1.1 Add `GET /api/documents/search` route in `backend/routes/ivantiFpWorkflow.js`
|
||||||
|
- Add a new GET route handler for `/documents/search` inside `createIvantiFpWorkflowRouter`
|
||||||
|
- Accept optional `q` query parameter for search term
|
||||||
|
- When `q` is provided, query the `documents` table with `LIKE` matching against `name`, `cve_id`, and `vendor` columns (case-insensitive)
|
||||||
|
- When `q` is empty or missing, return the most recent documents ordered by `uploaded_at DESC`
|
||||||
|
- Limit results to 50 records maximum
|
||||||
|
- Return each record with fields: `id`, `cve_id`, `vendor`, `name`, `type`, `file_size`, `mime_type`, `uploaded_at`
|
||||||
|
- Protect with `requireAuth(db)` middleware
|
||||||
|
- Return 500 with `{ error: 'Database error.' }` on DB failure
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
|
||||||
|
|
||||||
|
- [x] 2. Modify backend to handle library document attachments on create
|
||||||
|
- [x] 2.1 Update `POST /api/ivanti/fp-workflow` in `backend/routes/ivantiFpWorkflow.js` to accept `libraryDocIds`
|
||||||
|
- Parse `libraryDocIds` from `req.body` as a JSON-encoded array (default to `[]` if absent)
|
||||||
|
- Return 400 if `libraryDocIds` is not valid JSON
|
||||||
|
- Validate each ID is a positive integer; return 400 identifying any invalid ID
|
||||||
|
- Query the `documents` table for all referenced IDs; return 400 if any ID is not found
|
||||||
|
- Read each library file from disk using `fs.readFileSync(file_path)`; if a file is missing on disk, log a warning and include `{ success: false, error: 'File not found on disk', source: 'library', documentId: id }` in attachment results, skip that file
|
||||||
|
- Combine local file buffers (`req.files`) and library file buffers into a single `formFiles` array passed to `ivantiFormPost`
|
||||||
|
- Record attachment results with `source: "local"` for uploaded files and `source: "library"` plus `documentId` for library files
|
||||||
|
- Use the `name` field from the `documents` record as the `filename` in attachment results for library files
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_
|
||||||
|
|
||||||
|
- [x] 3. Modify backend to handle library document attachments on edit
|
||||||
|
- [x] 3.1 Update `POST /api/ivanti/fp-workflow/submissions/:id/attachments` in `backend/routes/ivantiFpWorkflow.js` to accept `libraryDocIds`
|
||||||
|
- Apply the same `libraryDocIds` parsing, validation, disk-read, and combined upload logic as task 2.1
|
||||||
|
- Combine local file buffers and library file buffers into a single `formFiles` array for the Ivanti API call
|
||||||
|
- Record attachment results with `source` and `documentId` fields matching the create endpoint behavior
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_
|
||||||
|
|
||||||
|
- [x] 4. Checkpoint — Verify backend changes
|
||||||
|
- Ensure all backend changes are syntactically correct and consistent with existing patterns. Ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 5. Create AttachmentSourcePicker component
|
||||||
|
- [x] 5.1 Implement `AttachmentSourcePicker` inline in `frontend/src/components/pages/ReportingPage.js`
|
||||||
|
- Define the component above `FpWorkflowModal` in the file
|
||||||
|
- Accept props: `files`, `onFilesChange`, `libraryDocs`, `onLibraryDocsChange`, `disabled`
|
||||||
|
- Implement a mode toggle with two tab-style buttons: "Local Upload" and "Library" (default to "Local Upload")
|
||||||
|
- In Local Upload mode, render the existing drag-and-drop zone with file input, file validation (extension + size), and file list
|
||||||
|
- In Library mode, render a search input that queries `GET /api/documents/search?q=...` with 300ms debounce using `setTimeout`/`clearTimeout`
|
||||||
|
- Display search results in a scrollable list showing document name, CVE ID, vendor, and file size
|
||||||
|
- Show already-selected library documents as disabled/checked in search results to prevent duplicates
|
||||||
|
- When a search result is clicked, add it to `libraryDocs` via `onLibraryDocsChange` (skip if already selected by `id`)
|
||||||
|
- When a library doc is removed from the attachment list, re-enable it in search results
|
||||||
|
- Render a unified attachment list below the mode-specific UI showing all attachments (local + library)
|
||||||
|
- Each attachment row displays: source badge ("Local" or "Library"), filename, file size, and a remove button (Trash2 icon)
|
||||||
|
- Library attachment rows additionally display CVE ID and vendor name
|
||||||
|
- Disable all interactions when `disabled` prop is true
|
||||||
|
- Style consistently with existing modal components using inline style objects, monospace font, dark theme colors from DESIGN_SYSTEM.md
|
||||||
|
- Handle network errors on search by showing an inline error message in the results area
|
||||||
|
- Show "No documents found" when search returns empty results
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 5.1, 5.2, 5.3, 6.1, 6.2, 6.3, 6.4_
|
||||||
|
|
||||||
|
- [x] 6. Integrate AttachmentSourcePicker into FpWorkflowModal (create flow)
|
||||||
|
- [x] 6.1 Replace the file upload section in `FpWorkflowModal` with `AttachmentSourcePicker`
|
||||||
|
- Add `libraryDocs` state (`useState([])`) alongside existing `files` state
|
||||||
|
- Reset `libraryDocs` to `[]` when modal opens (in the existing `useEffect` on `open`)
|
||||||
|
- Replace the current drag-and-drop zone and file list section with `<AttachmentSourcePicker>` passing `files`, `setFiles`, `libraryDocs`, `setLibraryDocs`, and `disabled={submitting}`
|
||||||
|
- Remove the inline `addFiles`, `removeFile`, `handleDrop`, `handleDragOver` functions and `fileInputRef`/`dropRef` refs (these are now handled inside AttachmentSourcePicker)
|
||||||
|
- On submit, append `libraryDocIds` as a JSON string to the FormData: `formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)))`
|
||||||
|
- Update the progress message to reflect combined attachment count
|
||||||
|
- Update the result view to show source badges on attachment results (use `source` field, default to `"local"` for backward compatibility)
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.5, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 7. Integrate AttachmentSourcePicker into FpEditModal (edit flow)
|
||||||
|
- [x] 7.1 Replace the static "upload in Ivanti" message on the attachments tab with `AttachmentSourcePicker`
|
||||||
|
- Add `libraryDocs` state (`useState([])`) alongside existing `files` state
|
||||||
|
- Reset `libraryDocs` to `[]` when submission changes (in the existing `useEffect` on `submission`)
|
||||||
|
- Keep the existing attachment display section (showing attachments from initial submission) above the picker
|
||||||
|
- Render `<AttachmentSourcePicker>` below existing attachments, passing `files`, `setFiles`, `libraryDocs`, `setLibraryDocs`, and `disabled={isApproved}`
|
||||||
|
- Update `handleUploadAttachments` to build FormData with both local files and `libraryDocIds` JSON field
|
||||||
|
- Enable the upload button when either `files.length > 0` or `libraryDocs.length > 0`
|
||||||
|
- Disable the picker when `lifecycle_status === 'approved'`
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||||
|
|
||||||
|
- [x] 8. Final checkpoint — Verify all changes
|
||||||
|
- Ensure all changes are complete and consistent across backend and frontend. Ensure no hanging or orphaned code. Ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No testing tasks included per user request — testing will be done on the dev server
|
||||||
|
- The project uses plain JavaScript (no TypeScript) throughout
|
||||||
|
- All frontend styling uses inline style objects consistent with the existing dark theme design system
|
||||||
|
- The `documents` table already exists — no database migrations are needed
|
||||||
|
- The `libraryDocIds` field is optional in both endpoints, preserving full backward compatibility
|
||||||
|
- Existing `attachment_results_json` entries without a `source` field are treated as `"local"` by the frontend
|
||||||
1
.kiro/specs/fp-submission-editing/.config.kiro
Normal file
1
.kiro/specs/fp-submission-editing/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "a7e2c1f8-9b34-4d6a-b5e0-8f1c3a2d7e90", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
428
.kiro/specs/fp-submission-editing/design.md
Normal file
428
.kiro/specs/fp-submission-editing/design.md
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
# Design Document: FP Submission Editing
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature extends the existing FP workflow submission system to support viewing, editing, and resubmitting False Positive submissions. It adds lifecycle status tracking, an edit modal triggered from clickable workflow badges in the Reporting Table and from a submissions list in the Queue Panel, backend endpoints that proxy update/map/attach operations to the Ivanti API, and a submission history audit trail.
|
||||||
|
|
||||||
|
The design builds on the existing `ivantiFpWorkflow.js` route, `FpWorkflowModal` component, and `ivanti_fp_submissions` table. It follows the same conventions: factory-pattern Express routes, inline React components with the dark tactical theme, Multer for file uploads, and the `ivantiFormPost()` / `ivantiPost()` helpers for Ivanti API calls.
|
||||||
|
|
||||||
|
Key Ivanti API endpoints used for editing:
|
||||||
|
- `POST /workflowBatch/falsePositive/update` — update workflow metadata
|
||||||
|
- `POST /workflowBatch/falsePositive/{uuid}/map` — add findings to existing workflow
|
||||||
|
- `POST /workflowBatch/falsePositive/{uuid}/attach` — upload additional attachments
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant FE as React Frontend
|
||||||
|
participant BE as Express Backend
|
||||||
|
participant IV as Ivanti API
|
||||||
|
participant DB as SQLite
|
||||||
|
|
||||||
|
Note over U,FE: Entry Point A: Clickable Workflow Badge
|
||||||
|
U->>FE: Click Reworked/Rejected/Expired badge in Reporting Table
|
||||||
|
FE->>FE: Look up FP_Submission by workflow batch ID
|
||||||
|
FE->>FE: Open FpEditModal pre-populated with submission data
|
||||||
|
|
||||||
|
Note over U,FE: Entry Point B: Queue Panel Submissions List
|
||||||
|
U->>FE: Click submission in Queue Panel submissions list
|
||||||
|
FE->>FE: Open FpEditModal pre-populated with submission data
|
||||||
|
|
||||||
|
Note over U,DB: Load Submission Data
|
||||||
|
FE->>BE: GET /api/ivanti/fp-submissions
|
||||||
|
BE->>DB: SELECT from ivanti_fp_submissions
|
||||||
|
DB-->>BE: Submission records
|
||||||
|
BE-->>FE: JSON array of submissions
|
||||||
|
|
||||||
|
Note over U,IV: Edit Form Fields
|
||||||
|
U->>FE: Modify name/reason/description/expiration, click Save
|
||||||
|
FE->>BE: PUT /api/ivanti/fp-submissions/:id
|
||||||
|
BE->>BE: Validate input
|
||||||
|
BE->>IV: POST /workflowBatch/falsePositive/update
|
||||||
|
IV-->>BE: 200 OK
|
||||||
|
BE->>DB: UPDATE ivanti_fp_submissions
|
||||||
|
BE->>DB: INSERT ivanti_fp_submission_history
|
||||||
|
BE->>DB: INSERT audit_log
|
||||||
|
BE-->>FE: 200 + updated record
|
||||||
|
|
||||||
|
Note over U,IV: Add Findings
|
||||||
|
U->>FE: Select additional FP queue items, click Add
|
||||||
|
FE->>BE: POST /api/ivanti/fp-submissions/:id/findings
|
||||||
|
BE->>IV: POST /workflowBatch/falsePositive/{uuid}/map
|
||||||
|
IV-->>BE: 200 OK
|
||||||
|
BE->>DB: UPDATE finding_ids_json
|
||||||
|
BE->>DB: UPDATE queue items → complete
|
||||||
|
BE->>DB: INSERT history + audit
|
||||||
|
BE-->>FE: 200 + updated record
|
||||||
|
|
||||||
|
Note over U,IV: Add Attachments
|
||||||
|
U->>FE: Upload files, click Attach
|
||||||
|
FE->>BE: POST /api/ivanti/fp-submissions/:id/attachments (multipart)
|
||||||
|
loop Each file
|
||||||
|
BE->>IV: POST /workflowBatch/falsePositive/{uuid}/attach
|
||||||
|
IV-->>BE: 200 OK
|
||||||
|
end
|
||||||
|
BE->>DB: UPDATE attachment_count, attachment_results_json
|
||||||
|
BE->>DB: INSERT history + audit
|
||||||
|
BE-->>FE: 200 + attachment results
|
||||||
|
|
||||||
|
Note over U,DB: Status Transition
|
||||||
|
U->>FE: Change lifecycle status
|
||||||
|
FE->>BE: PATCH /api/ivanti/fp-submissions/:id/status
|
||||||
|
BE->>DB: UPDATE lifecycle_status, INSERT history + audit
|
||||||
|
BE-->>FE: 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### Extended Route Module: `backend/routes/ivantiFpWorkflow.js`
|
||||||
|
|
||||||
|
Extends the existing `createIvantiFpWorkflowRouter(db, requireAuth)` with five new endpoints. All endpoints use `requireAuth(db)` and `requireGroup('Admin', 'Standard_User')`, and verify the authenticated user owns the submission (returning 403 otherwise).
|
||||||
|
|
||||||
|
**Endpoint: `GET /api/ivanti/fp-submissions`**
|
||||||
|
|
||||||
|
Returns the authenticated user's FP submission records.
|
||||||
|
|
||||||
|
- Auth: `requireAuth(db)`, any authenticated user (viewers get read-only list)
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": 5,
|
||||||
|
"username": "jdoe",
|
||||||
|
"ivanti_workflow_batch_id": 33418832,
|
||||||
|
"ivanti_workflow_batch_uuid": "abc-123-def",
|
||||||
|
"workflow_name": "FP - CVE-2024-1234",
|
||||||
|
"reason": "Scanner false positive",
|
||||||
|
"description": "Confirmed by manual review",
|
||||||
|
"expiration_date": "2026-12-31",
|
||||||
|
"scope_override": "Authorized",
|
||||||
|
"finding_ids_json": "[\"2283734550\",\"2283734551\"]",
|
||||||
|
"queue_item_ids_json": "[1,2]",
|
||||||
|
"attachment_count": 2,
|
||||||
|
"attachment_results_json": "[{\"filename\":\"evidence.pdf\",\"success\":true}]",
|
||||||
|
"status": "success",
|
||||||
|
"lifecycle_status": "rework",
|
||||||
|
"error_message": null,
|
||||||
|
"created_at": "2026-04-08T18:16:08",
|
||||||
|
"updated_at": "2026-04-10T12:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endpoint: `PUT /api/ivanti/fp-submissions/:id`**
|
||||||
|
|
||||||
|
Updates form fields and proxies to Ivanti update endpoint.
|
||||||
|
|
||||||
|
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Ownership: verified via `user_id` match
|
||||||
|
- Lifecycle guard: rejects if `lifecycle_status === 'approved'`
|
||||||
|
- Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Updated FP - CVE-2024-1234",
|
||||||
|
"reason": "Updated reason",
|
||||||
|
"description": "Updated description",
|
||||||
|
"expirationDate": "2027-06-01",
|
||||||
|
"scopeOverride": "Authorized"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Validation: same rules as creation form (`validateFpWorkflowForm`)
|
||||||
|
- Ivanti call: `POST /client/{clientId}/workflowBatch/falsePositive/update` with JSON body containing `workflowBatchId` and updated fields
|
||||||
|
- On success: updates local record, inserts history row, logs audit, sets `lifecycle_status` to `resubmitted` if previous status was `rejected` or `rework`
|
||||||
|
- Response: `{ success: true, submission: { ...updatedRecord } }`
|
||||||
|
|
||||||
|
**Endpoint: `POST /api/ivanti/fp-submissions/:id/findings`**
|
||||||
|
|
||||||
|
Maps additional findings to the existing workflow batch.
|
||||||
|
|
||||||
|
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Ownership: verified
|
||||||
|
- Lifecycle guard: rejects if `lifecycle_status === 'approved'`
|
||||||
|
- Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"findingIds": ["2283734552", "2283734553"],
|
||||||
|
"queueItemIds": [3, 4]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Validates queue items belong to user, are FP type, and pending
|
||||||
|
- Ivanti call: `POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/map` with `subjectFilterRequest` containing the new finding IDs
|
||||||
|
- On success: appends new IDs to `finding_ids_json`, marks queue items complete, inserts history + audit
|
||||||
|
- Response: `{ success: true, addedFindings: [...], queueItemsUpdated: 2 }`
|
||||||
|
|
||||||
|
**Endpoint: `POST /api/ivanti/fp-submissions/:id/attachments`**
|
||||||
|
|
||||||
|
Uploads additional files to the existing workflow batch.
|
||||||
|
|
||||||
|
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Content-Type: `multipart/form-data` (Multer)
|
||||||
|
- Ownership: verified
|
||||||
|
- Lifecycle guard: rejects if `lifecycle_status === 'approved'`
|
||||||
|
- File constraints: same as creation (10 MB, allowed extensions)
|
||||||
|
- Ivanti call: for each file, `POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/attach`
|
||||||
|
- On success: updates `attachment_count` and `attachment_results_json`, inserts history + audit
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"attachmentResults": [
|
||||||
|
{ "filename": "new-evidence.pdf", "success": true },
|
||||||
|
{ "filename": "screenshot.png", "success": false, "error": "Upload failed" }
|
||||||
|
],
|
||||||
|
"status": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endpoint: `PATCH /api/ivanti/fp-submissions/:id/status`**
|
||||||
|
|
||||||
|
Updates the lifecycle status of a submission.
|
||||||
|
|
||||||
|
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Ownership: verified
|
||||||
|
- Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lifecycle_status": "rejected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Validates status is one of: `submitted`, `approved`, `rejected`, `rework`, `resubmitted`
|
||||||
|
- Validates transition is allowed (cannot transition FROM `approved`)
|
||||||
|
- On success: updates `lifecycle_status` and `updated_at`, inserts history row with previous and new status, logs audit
|
||||||
|
- Response: `{ success: true, previousStatus: "submitted", newStatus: "rejected" }`
|
||||||
|
|
||||||
|
#### Pure Helper Functions (exported for testing)
|
||||||
|
|
||||||
|
The following pure functions are extracted for testability:
|
||||||
|
|
||||||
|
- `validateFpWorkflowForm(body)` — already exists, reused for edit validation
|
||||||
|
- `isAllowedFileExtension(filename)` — already exists, reused
|
||||||
|
- `buildSubjectFilterRequest(findingIds)` — already exists, reused for map endpoint
|
||||||
|
- `validateLifecycleTransition(currentStatus, newStatus)` — new, returns `{ valid: boolean, error?: string }`
|
||||||
|
- `mergeFindings(existingJson, newIds)` — new, merges finding ID arrays, deduplicates, returns JSON string
|
||||||
|
- `buildSubmissionHistoryEntry(changeType, details, userId, username)` — new, constructs a history record object
|
||||||
|
|
||||||
|
#### Ivanti API Calls
|
||||||
|
|
||||||
|
Uses existing helpers from `backend/helpers/ivantiApi.js`:
|
||||||
|
|
||||||
|
- **Update workflow**: `ivantiPost()` to `POST /client/{clientId}/workflowBatch/falsePositive/update` with JSON body
|
||||||
|
- **Map findings**: `ivantiFormPost()` to `POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/map` with `subjectFilterRequest`
|
||||||
|
- **Attach file**: `ivantiMultipartPost()` to `POST /client/{clientId}/workflowBatch/falsePositive/{uuid}/attach` with file buffer
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### New Component: `FpEditModal`
|
||||||
|
|
||||||
|
Defined inline in `frontend/src/components/pages/ReportingPage.js`, following the existing `FpWorkflowModal` pattern.
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `open` (boolean) — controls visibility
|
||||||
|
- `onClose` (function) — close handler
|
||||||
|
- `submission` (object) — the FP_Submission record to edit (null when closed)
|
||||||
|
- `queueItems` (array) — user's current queue items (for adding findings)
|
||||||
|
- `onSuccess` (function) — callback after successful edit, triggers data refresh
|
||||||
|
|
||||||
|
**State:**
|
||||||
|
- `name`, `reason`, `description`, `expirationDate`, `scopeOverride` — editable form fields, initialized from `submission`
|
||||||
|
- `files` — array of new File objects for upload
|
||||||
|
- `additionalFindingIds` — selected queue items to add as findings
|
||||||
|
- `saving` — boolean, disables form during save
|
||||||
|
- `errors` — validation error map
|
||||||
|
- `result` — operation result (success/failure)
|
||||||
|
- `activeTab` — current tab: 'details' | 'findings' | 'attachments' | 'history'
|
||||||
|
|
||||||
|
**UI Layout:**
|
||||||
|
- Modal overlay with dark backdrop (matching `FpWorkflowModal`)
|
||||||
|
- Header: "Edit FP Workflow — {workflow_name}" with lifecycle status badge and close button
|
||||||
|
- Tab bar: Details | Findings | Attachments | History
|
||||||
|
- Details tab: editable form fields (name, reason, description, expiration, scope override) with Save button
|
||||||
|
- Findings tab: current finding IDs list (read-only) + mechanism to select and add FP queue items
|
||||||
|
- Attachments tab: existing attachments list + file upload area for new attachments
|
||||||
|
- History tab: chronological list of changes from `ivanti_fp_submission_history`
|
||||||
|
- Footer: contextual action buttons per tab
|
||||||
|
- Approved submissions: all fields read-only with "This submission is finalized" message
|
||||||
|
|
||||||
|
#### Workflow Badge Modifications (Reporting Table)
|
||||||
|
|
||||||
|
The workflow column renderer (lines 1044–1070 of `ReportingPage.js`) is modified:
|
||||||
|
|
||||||
|
- For badges with state `reworked`, `rejected`, or `expired`:
|
||||||
|
- Add `cursor: 'pointer'` and `onClick` handler
|
||||||
|
- Append a small pencil icon (lucide `Edit3`, 10px) after the state text
|
||||||
|
- On hover: increase border opacity and brighten background
|
||||||
|
- On click: look up matching FP_Submission by `wf.id` (workflow batch ID), open `FpEditModal`
|
||||||
|
- For badges with state `requested` or `approved`:
|
||||||
|
- No changes — remain non-interactive (no cursor, no icon, no click handler)
|
||||||
|
|
||||||
|
#### QueuePanel Modifications
|
||||||
|
|
||||||
|
- Add a "Submissions" section below the existing queue items list
|
||||||
|
- Fetches submissions via `GET /api/ivanti/fp-submissions` on panel open
|
||||||
|
- Each submission row shows: workflow name, batch ID, lifecycle status badge, finding count, created date
|
||||||
|
- Lifecycle status badges use color coding: submitted (sky blue), approved (emerald), rejected (red), rework (amber), resubmitted (sky blue)
|
||||||
|
- Clicking a submission row opens `FpEditModal` with that submission's data
|
||||||
|
- Viewers see the list but cannot click to edit
|
||||||
|
|
||||||
|
#### Lifecycle Status Badge Component
|
||||||
|
|
||||||
|
Inline helper function `lifecycleStatusBadge(status)` returning style object:
|
||||||
|
|
||||||
|
| Status | Border | Background | Text |
|
||||||
|
|--------|--------|------------|------|
|
||||||
|
| submitted | `rgba(14,165,233,0.4)` | `rgba(14,165,233,0.12)` | `#0EA5E9` |
|
||||||
|
| approved | `rgba(16,185,129,0.4)` | `rgba(16,185,129,0.12)` | `#10B981` |
|
||||||
|
| rejected | `rgba(239,68,68,0.4)` | `rgba(239,68,68,0.12)` | `#EF4444` |
|
||||||
|
| rework | `rgba(245,158,11,0.4)` | `rgba(245,158,11,0.12)` | `#F59E0B` |
|
||||||
|
| resubmitted | `rgba(14,165,233,0.4)` | `rgba(14,165,233,0.12)` | `#0EA5E9` |
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Schema Changes to `ivanti_fp_submissions`
|
||||||
|
|
||||||
|
Three new columns added to the existing table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE ivanti_fp_submissions ADD COLUMN lifecycle_status TEXT NOT NULL DEFAULT 'submitted'
|
||||||
|
CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted'));
|
||||||
|
|
||||||
|
ALTER TABLE ivanti_fp_submissions ADD COLUMN ivanti_workflow_batch_uuid TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE ivanti_fp_submissions ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Table: `ivanti_fp_submission_history`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
submission_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
change_type TEXT NOT NULL CHECK(change_type IN (
|
||||||
|
'created', 'fields_updated', 'findings_added',
|
||||||
|
'attachments_added', 'status_changed'
|
||||||
|
)),
|
||||||
|
change_details_json TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**change_details_json examples:**
|
||||||
|
|
||||||
|
- `fields_updated`: `{"changed": {"name": {"from": "old", "to": "new"}, "reason": {"from": "old", "to": "new"}}}`
|
||||||
|
- `findings_added`: `{"addedFindingIds": ["123", "456"], "queueItemIds": [3, 4]}`
|
||||||
|
- `attachments_added`: `{"files": [{"filename": "evidence.pdf", "success": true}]}`
|
||||||
|
- `status_changed`: `{"from": "submitted", "to": "rejected"}`
|
||||||
|
- `created`: `{"workflowBatchId": 33418832, "findingCount": 3, "attachmentCount": 1}`
|
||||||
|
|
||||||
|
### Migration Script: `backend/migrations/add_fp_submission_editing.js`
|
||||||
|
|
||||||
|
Applies all schema changes idempotently using `ALTER TABLE ... ADD COLUMN` wrapped in try/catch (SQLite throws if column already exists) and `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 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.*
|
||||||
|
|
||||||
|
Note: Properties for `validateFpWorkflowForm` and `isAllowedFileExtension` are already covered by the existing `ivanti-fp-workflow-submission` spec and are reused without modification. The properties below cover new pure functions introduced by this feature.
|
||||||
|
|
||||||
|
### Property 1: Finding Merge Preserves All IDs and Deduplicates
|
||||||
|
|
||||||
|
*For any* existing finding IDs JSON string (valid JSON array of strings) and any array of new finding ID strings, `mergeFindings(existingJson, newIds)` should produce a JSON string that, when parsed, contains every ID from the original array and every ID from the new array, contains no duplicate entries, and has a length less than or equal to the sum of the original and new array lengths.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.3**
|
||||||
|
|
||||||
|
### Property 2: Lifecycle Transition Validation
|
||||||
|
|
||||||
|
*For any* pair of lifecycle status values (currentStatus, newStatus) drawn from the set {submitted, approved, rejected, rework, resubmitted}, `validateLifecycleTransition(currentStatus, newStatus)` should return `{ valid: false }` whenever currentStatus is "approved" (no transitions allowed from finalized state), and should return `{ valid: true }` for all other currentStatus values when newStatus is a valid lifecycle status. Additionally, when currentStatus is "rejected" or "rework" and newStatus is "resubmitted", the transition should always be valid.
|
||||||
|
|
||||||
|
**Validates: Requirements 5.4, 5.5**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Ivanti API Errors
|
||||||
|
|
||||||
|
| HTTP Status | Endpoint | User-Facing Message | System Behavior |
|
||||||
|
|-------------|----------|---------------------|-----------------|
|
||||||
|
| 401 | All | "Ivanti API key is invalid or missing. Contact your administrator." | Log error, preserve form state |
|
||||||
|
| 419 | All | "API key lacks permissions for this operation." | Log error, preserve form state |
|
||||||
|
| 429 | All | "Ivanti API rate limit reached. Please try again in a few minutes." | Log error, preserve form state |
|
||||||
|
| 5xx | All | "Ivanti API is temporarily unavailable. Please try again later." | Log error, preserve form state |
|
||||||
|
| Other | All | "Operation failed: {status} — {message}" | Log error with full response, preserve form state |
|
||||||
|
|
||||||
|
### Partial Failure (Attachment Upload)
|
||||||
|
|
||||||
|
When some attachment uploads succeed and others fail:
|
||||||
|
- Response includes per-file success/failure details
|
||||||
|
- Successfully uploaded files are recorded in `attachment_results_json`
|
||||||
|
- Failed files are reported to the user with retry option
|
||||||
|
- The submission record is updated with the successful uploads only
|
||||||
|
|
||||||
|
### Lifecycle Guard Errors
|
||||||
|
|
||||||
|
- Attempting to edit an "approved" submission returns 400: `"This submission is finalized and cannot be edited."`
|
||||||
|
- Attempting an invalid status transition returns 400: `"Cannot transition from {current} to {new}."`
|
||||||
|
|
||||||
|
### Ownership Errors
|
||||||
|
|
||||||
|
- All edit endpoints verify `user_id` matches the authenticated user
|
||||||
|
- Mismatch returns 403: `"You can only edit your own submissions."`
|
||||||
|
|
||||||
|
### Local Database Errors
|
||||||
|
|
||||||
|
- If history INSERT fails: log error, still return success (the Ivanti operation succeeded)
|
||||||
|
- If audit log INSERT fails: fire-and-forget (existing `logAudit()` pattern)
|
||||||
|
- If submission record UPDATE fails: return 500 with error message
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Property-Based Testing
|
||||||
|
|
||||||
|
Use `fast-check` as the property-based testing library. Each correctness property maps to a single property-based test with a minimum of 100 iterations.
|
||||||
|
|
||||||
|
Property tests focus on the new pure functions:
|
||||||
|
- `mergeFindings(existingJson, newIds)` — Property 1
|
||||||
|
- `validateLifecycleTransition(currentStatus, newStatus)` — Property 2
|
||||||
|
|
||||||
|
Tag format: **Feature: fp-submission-editing, Property {number}: {title}**
|
||||||
|
|
||||||
|
Test file: `backend/__tests__/fpSubmissionEditing.property.test.js`
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
Unit tests cover specific examples, edge cases, and integration points:
|
||||||
|
|
||||||
|
- **Validation reuse**: verify `validateFpWorkflowForm` is called correctly in the PUT endpoint
|
||||||
|
- **Lifecycle badge styles**: verify each of the 5 statuses maps to the correct color scheme
|
||||||
|
- **Clickable badge logic**: verify reworked/rejected/expired states produce clickable badges, requested/approved do not
|
||||||
|
- **Ownership verification**: verify 403 when non-owner attempts edit
|
||||||
|
- **Role guard**: verify non-Admin/Standard_User users are rejected
|
||||||
|
- **Approved guard**: verify 400 when editing an approved submission
|
||||||
|
- **Error mapping**: verify each Ivanti HTTP status maps to the correct error message
|
||||||
|
- **History recording**: verify correct `change_type` and `change_details_json` for each operation type
|
||||||
|
- **Migration idempotency**: verify migration can run multiple times without error
|
||||||
|
|
||||||
|
Test file: `backend/__tests__/fpSubmissionEditing.test.js`
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
Integration tests verify the full request/response cycle with mocked Ivanti API:
|
||||||
|
|
||||||
|
- GET submissions returns correct records for authenticated user
|
||||||
|
- PUT update proxies to Ivanti and updates local record
|
||||||
|
- POST findings maps to Ivanti and merges finding IDs
|
||||||
|
- POST attachments uploads to Ivanti and updates attachment records
|
||||||
|
- PATCH status updates lifecycle and creates history entry
|
||||||
|
- Queue items marked complete after successful finding addition
|
||||||
|
|
||||||
|
Test file: `backend/__tests__/fpSubmissionEditing.integration.test.js`
|
||||||
122
.kiro/specs/fp-submission-editing/requirements.md
Normal file
122
.kiro/specs/fp-submission-editing/requirements.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This feature adds the ability to view, edit, and resubmit existing False Positive (FP) workflow submissions in the STEAM Security Dashboard. Users need to update FP workflows when assets or findings must be added, when supporting documentation needs to be supplemented, or when submissions are rejected or returned for rework by Ivanti reviewers. The feature introduces lifecycle status tracking for FP submissions, an edit modal that loads existing submission data, and backend endpoints that proxy update, map, and attach operations to the Ivanti API.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Dashboard**: The STEAM Security Dashboard application
|
||||||
|
- **FP_Submission**: A local database record in the `ivanti_fp_submissions` table tracking a False Positive workflow submission, including its Ivanti workflow batch ID, form data, finding IDs, attachment history, and lifecycle status
|
||||||
|
- **Ivanti_API**: The Ivanti/RiskSense REST API at https://platform4.risksense.com/api/v1, authenticated via x-api-key header
|
||||||
|
- **Workflow_Batch**: An Ivanti API resource representing a group of findings submitted together under a single FP workflow request, identified by a numeric ID and a UUID
|
||||||
|
- **Lifecycle_Status**: The current state of an FP submission in its review lifecycle: submitted, approved, rejected, rework, or resubmitted
|
||||||
|
- **Edit_Modal**: The UI modal that loads an existing FP submission's data and allows the user to modify form fields, add findings, and upload additional attachments
|
||||||
|
- **Submission_History**: A chronological log of changes made to an FP submission, including edits, finding additions, attachment uploads, and status transitions
|
||||||
|
- **Queue_Panel**: The slide-out panel in the Reporting Page that displays the user's Ivanti todo queue items
|
||||||
|
- **Workflow_Badge**: The colored status badge displayed in the Workflow column of the Reporting Page findings table, showing the workflow ID and state (e.g., "FP#12345 REWORKED"). States include: expired (red), rejected (red), reworked (amber), actionable (amber), requested (sky blue)
|
||||||
|
- **Reporting_Table**: The findings table on the Reporting Page that displays host findings with columns including a Workflow column showing Workflow_Badges
|
||||||
|
- **Ivanti_Update_Endpoint**: The Ivanti API endpoint `POST /workflowBatch/falsePositive/update` used to modify workflow batch metadata (name, reason, description, expiration date)
|
||||||
|
- **Ivanti_Map_Endpoint**: The Ivanti API endpoint `POST /workflowBatch/falsePositive/{workflowBatchUuid}/map` used to add additional findings to an existing workflow batch
|
||||||
|
- **Ivanti_Attach_Endpoint**: The Ivanti API endpoint `POST /workflowBatch/falsePositive/{workflowBatchUuid}/attach` used to upload additional file attachments to an existing workflow batch
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: View and Access Existing FP Submissions
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to access my existing FP workflow submissions from the reporting table's workflow badges and from a submissions list, so that I can quickly identify and edit submissions that need attention.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL display a list of FP_Submissions for the authenticated user in the Queue_Panel, showing workflow name, Ivanti workflow batch ID, Lifecycle_Status, finding count, attachment count, and submission date
|
||||||
|
2. WHEN the user clicks on an FP_Submission in the list, THE Dashboard SHALL open the Edit_Modal pre-populated with the submission's current data including form fields, associated finding IDs, and attachment history
|
||||||
|
3. THE Dashboard SHALL visually distinguish FP_Submissions by Lifecycle_Status using color-coded status badges: submitted (sky blue), approved (emerald), rejected (red), rework (amber), resubmitted (sky blue)
|
||||||
|
4. WHILE the user has the viewer role, THE Dashboard SHALL display the FP_Submission list in read-only mode with the edit action disabled
|
||||||
|
5. WHEN a finding in the Reporting_Table has a Workflow_Badge with state "reworked", "rejected", or "expired", THE Dashboard SHALL render the Workflow_Badge as a clickable element with a pointer cursor and a subtle edit icon (pencil) appended to the badge
|
||||||
|
6. WHEN the user clicks a clickable Workflow_Badge in the Reporting_Table, THE Dashboard SHALL look up the matching FP_Submission by the workflow batch ID displayed in the badge and open the Edit_Modal pre-populated with that submission's data
|
||||||
|
7. WHEN the user hovers over a clickable Workflow_Badge, THE Dashboard SHALL display a hover effect (increased border opacity and slight background brightening) to indicate the badge is interactive
|
||||||
|
8. WHILE a Workflow_Badge has state "requested" or "approved", THE Dashboard SHALL render the badge as non-interactive (no pointer cursor, no edit icon, no click handler) since those states do not require user action
|
||||||
|
|
||||||
|
### Requirement 2: Edit FP Workflow Form Fields
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to update the name, reason, description, and expiration date of an existing FP submission, so that I can correct or supplement the justification when a submission is returned for rework.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Edit_Modal is opened for an existing FP_Submission, THE Dashboard SHALL load and display the current values for workflow name, reason, description, expiration date, and scope override authorization in editable form fields
|
||||||
|
2. THE Dashboard SHALL apply the same validation rules to edited fields as the creation form: workflow name required and max 255 characters, reason required, description optional and max 2000 characters, expiration date required and must be a future date
|
||||||
|
3. WHEN the user modifies form fields and clicks Save, THE Dashboard SHALL send the updated fields to the Ivanti_Update_Endpoint to modify the workflow batch metadata in the Ivanti platform
|
||||||
|
4. IF the Ivanti_Update_Endpoint returns an error, THEN THE Dashboard SHALL display the error message and preserve the user's edits so the user can retry without re-entering data
|
||||||
|
5. WHEN a form field update completes successfully, THE Dashboard SHALL update the local FP_Submission record with the new field values and record the change in Submission_History
|
||||||
|
|
||||||
|
### Requirement 3: Add Findings to Existing FP Submission
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to add additional findings or assets to an existing FP submission, so that I can expand the scope of a false positive workflow when new related findings are identified.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Edit_Modal SHALL display the current list of finding IDs associated with the FP_Submission and provide a mechanism to add additional findings from the user's Ivanti queue
|
||||||
|
2. WHEN the user selects additional FP-type queue items to add, THE Dashboard SHALL send the new finding IDs to the Ivanti_Map_Endpoint to map the findings to the existing Workflow_Batch
|
||||||
|
3. WHEN findings are mapped successfully, THE Dashboard SHALL update the local FP_Submission record's finding_ids_json to include the newly added finding IDs
|
||||||
|
4. WHEN findings are mapped successfully, THE Dashboard SHALL mark the corresponding queue items as complete and refresh the Queue_Panel
|
||||||
|
5. IF the Ivanti_Map_Endpoint returns an error, THEN THE Dashboard SHALL display the error message and leave the queue items in their current status
|
||||||
|
|
||||||
|
### Requirement 4: Add Attachments to Existing FP Submission
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to upload additional files and screenshots to an existing FP submission, so that I can provide supplementary evidence when reviewers request more documentation.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Edit_Modal SHALL display the list of previously uploaded attachments (filename and upload status) and provide a file upload area for adding new attachments
|
||||||
|
2. THE Dashboard SHALL apply the same file constraints as the creation form: maximum 10 MB per file, allowed extensions .pdf, .png, .jpg, .jpeg, .gif, .doc, .docx, .xlsx, .csv, .txt, .zip
|
||||||
|
3. WHEN the user uploads new files, THE Dashboard SHALL send each file to the Ivanti_Attach_Endpoint to attach the file to the existing Workflow_Batch
|
||||||
|
4. WHEN an attachment upload completes successfully, THE Dashboard SHALL update the local FP_Submission record's attachment_count and attachment_results_json to include the new attachment
|
||||||
|
5. IF an attachment upload fails, THEN THE Dashboard SHALL report which attachments failed and allow the user to retry the failed uploads without re-uploading successful attachments
|
||||||
|
|
||||||
|
### Requirement 5: FP Submission Lifecycle Status Tracking
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want the Dashboard to track the lifecycle status of my FP submissions, so that I can see which submissions are pending review, approved, rejected, or need rework.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL store a Lifecycle_Status field for each FP_Submission with allowed values: submitted, approved, rejected, rework, resubmitted
|
||||||
|
2. WHEN a new FP workflow is created, THE Dashboard SHALL set the initial Lifecycle_Status to "submitted"
|
||||||
|
3. WHEN the user manually updates the Lifecycle_Status of an FP_Submission (e.g., marking it as rejected or rework after receiving notification), THE Dashboard SHALL record the status change with a timestamp in Submission_History
|
||||||
|
4. WHEN an FP_Submission with Lifecycle_Status "rejected" or "rework" is edited and resubmitted, THE Dashboard SHALL update the Lifecycle_Status to "resubmitted"
|
||||||
|
5. THE Dashboard SHALL prevent editing of FP_Submissions with Lifecycle_Status "approved" and display a message indicating the submission is finalized
|
||||||
|
|
||||||
|
### Requirement 6: Submission History and Audit Trail
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to see a history of changes made to an FP submission, so that I can track what was modified and when for audit purposes.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Edit_Modal SHALL display a Submission_History section showing a chronological list of changes made to the FP_Submission, including: initial creation, form field edits, finding additions, attachment uploads, and status transitions
|
||||||
|
2. WHEN any modification is made to an FP_Submission, THE Dashboard SHALL log an audit entry with action "ivanti_fp_submission_edited", entity type "ivanti_workflow", the workflow batch ID as entity ID, and details including the type of change and changed values
|
||||||
|
3. WHEN a Lifecycle_Status transition occurs, THE Dashboard SHALL log an audit entry with action "ivanti_fp_status_changed", entity type "ivanti_workflow", and details including the previous status and new status
|
||||||
|
|
||||||
|
### Requirement 7: Backend API Endpoints for FP Editing
|
||||||
|
|
||||||
|
**User Story:** As a system component, the backend needs API endpoints to retrieve, update, and extend existing FP submissions, so that the frontend can perform edit operations securely.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL provide a GET /api/ivanti/fp-submissions endpoint that returns the authenticated user's FP_Submission records with all stored fields and Lifecycle_Status
|
||||||
|
2. THE Dashboard SHALL provide a PUT /api/ivanti/fp-submissions/:id endpoint that accepts updated form fields, validates the input, proxies the update to the Ivanti_Update_Endpoint, and updates the local record
|
||||||
|
3. THE Dashboard SHALL provide a POST /api/ivanti/fp-submissions/:id/findings endpoint that accepts additional finding IDs, proxies the map operation to the Ivanti_Map_Endpoint, and updates the local record
|
||||||
|
4. THE Dashboard SHALL provide a POST /api/ivanti/fp-submissions/:id/attachments endpoint that accepts file uploads, proxies each file to the Ivanti_Attach_Endpoint, and updates the local record
|
||||||
|
5. THE Dashboard SHALL provide a PATCH /api/ivanti/fp-submissions/:id/status endpoint that accepts a new Lifecycle_Status value and updates the local record with the status transition
|
||||||
|
6. THE Dashboard SHALL restrict all FP submission editing endpoints to users with "Admin" or "Standard_User" group membership
|
||||||
|
7. THE Dashboard SHALL verify that the authenticated user owns the FP_Submission before allowing any edit operation, returning a 403 status if ownership verification fails
|
||||||
|
|
||||||
|
### Requirement 8: Database Schema Updates for Editing Support
|
||||||
|
|
||||||
|
**User Story:** As a system component, the database needs additional fields and tables to support FP submission editing, lifecycle tracking, and change history.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Dashboard SHALL add a lifecycle_status column to the ivanti_fp_submissions table with allowed values: submitted, approved, rejected, rework, resubmitted, defaulting to "submitted"
|
||||||
|
2. THE Dashboard SHALL add an ivanti_workflow_batch_uuid column to the ivanti_fp_submissions table to store the UUID required by the Ivanti map and attach endpoints
|
||||||
|
3. THE Dashboard SHALL add an updated_at column to the ivanti_fp_submissions table that is set to the current timestamp on each modification
|
||||||
|
4. THE Dashboard SHALL create an ivanti_fp_submission_history table with columns: id, submission_id (foreign key), user_id, username, change_type, change_details_json, and created_at
|
||||||
|
5. THE Dashboard SHALL provide a migration script at backend/migrations/add_fp_submission_editing.js that applies the schema changes idempotently
|
||||||
182
.kiro/specs/fp-submission-editing/tasks.md
Normal file
182
.kiro/specs/fp-submission-editing/tasks.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Implementation Plan: FP Submission Editing
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Extends the existing FP workflow system with lifecycle status tracking, edit/resubmit capabilities, and a submission history audit trail. Implementation proceeds bottom-up: database migration → pure helpers → backend endpoints → frontend components → wiring and integration.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Database migration and schema changes
|
||||||
|
- [x] 1.1 Create migration script `backend/migrations/add_fp_submission_editing.js`
|
||||||
|
- Add `lifecycle_status` column to `ivanti_fp_submissions` with CHECK constraint and default `'submitted'`
|
||||||
|
- Add `ivanti_workflow_batch_uuid` TEXT column to `ivanti_fp_submissions`
|
||||||
|
- Add `updated_at` DATETIME column to `ivanti_fp_submissions` with default CURRENT_TIMESTAMP
|
||||||
|
- Create `ivanti_fp_submission_history` table with columns: id, submission_id (FK), user_id, username, change_type (CHECK constraint), change_details_json, created_at
|
||||||
|
- Create index `idx_fp_history_submission` on submission_id
|
||||||
|
- Wrap ALTER TABLE statements in try/catch for idempotency; use CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS
|
||||||
|
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5_
|
||||||
|
|
||||||
|
- [x] 2. Implement pure helper functions in `backend/routes/ivantiFpWorkflow.js`
|
||||||
|
- [x] 2.1 Implement `validateLifecycleTransition(currentStatus, newStatus)`
|
||||||
|
- Accept two status strings from the set {submitted, approved, rejected, rework, resubmitted}
|
||||||
|
- Return `{ valid: false, error }` when currentStatus is `'approved'` (finalized, no transitions allowed)
|
||||||
|
- Return `{ valid: false, error }` when newStatus is not in the allowed set
|
||||||
|
- Return `{ valid: true }` for all other valid transitions
|
||||||
|
- Export from module for testing
|
||||||
|
- _Requirements: 5.4, 5.5_
|
||||||
|
|
||||||
|
- [x] 2.2 Implement `mergeFindings(existingJson, newIds)`
|
||||||
|
- Parse existingJson (JSON array of strings), concatenate with newIds array
|
||||||
|
- Deduplicate by converting to Set, return JSON.stringify of the merged array
|
||||||
|
- Handle edge cases: empty existing array, empty newIds, overlapping IDs
|
||||||
|
- Export from module for testing
|
||||||
|
- _Requirements: 3.3_
|
||||||
|
|
||||||
|
- [x] 2.3 Implement `buildSubmissionHistoryEntry(changeType, details, userId, username)`
|
||||||
|
- Construct and return an object with: submission_id (to be set by caller), user_id, username, change_type, change_details_json (JSON.stringify of details), created_at (ISO string)
|
||||||
|
- Export from module for testing
|
||||||
|
- _Requirements: 6.1, 6.2_
|
||||||
|
|
||||||
|
- [ ]* 2.4 Write property test for `mergeFindings` — Property 1: Finding Merge Preserves All IDs and Deduplicates
|
||||||
|
- **Property 1: Finding Merge Preserves All IDs and Deduplicates**
|
||||||
|
- **Validates: Requirements 3.3**
|
||||||
|
- Use fast-check to generate arbitrary arrays of string IDs for existing and new
|
||||||
|
- Assert: parsed result contains every ID from both inputs, no duplicates, length ≤ sum of input lengths
|
||||||
|
- Test file: `backend/__tests__/fpSubmissionEditing.property.test.js`
|
||||||
|
|
||||||
|
- [ ]* 2.5 Write property test for `validateLifecycleTransition` — Property 2: Lifecycle Transition Validation
|
||||||
|
- **Property 2: Lifecycle Transition Validation**
|
||||||
|
- **Validates: Requirements 5.4, 5.5**
|
||||||
|
- Use fast-check to generate pairs from {submitted, approved, rejected, rework, resubmitted}
|
||||||
|
- Assert: always invalid when currentStatus is 'approved'; always valid for other currentStatus values with valid newStatus; rejected/rework → resubmitted is always valid
|
||||||
|
- Test file: `backend/__tests__/fpSubmissionEditing.property.test.js`
|
||||||
|
|
||||||
|
- [ ] 3. Checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Implement backend API endpoints in `backend/routes/ivantiFpWorkflow.js`
|
||||||
|
- [x] 4.1 Implement `GET /api/ivanti/fp-submissions`
|
||||||
|
- Add route with `requireAuth(db)` — any authenticated user
|
||||||
|
- Query `ivanti_fp_submissions` filtered by `req.user.id`
|
||||||
|
- Return JSON array of submission records including lifecycle_status and updated_at
|
||||||
|
- _Requirements: 7.1, 1.1_
|
||||||
|
|
||||||
|
- [x] 4.2 Implement `PUT /api/ivanti/fp-submissions/:id`
|
||||||
|
- Add route with `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Verify ownership (user_id match → 403 if not)
|
||||||
|
- Lifecycle guard: reject if lifecycle_status is 'approved' → 400
|
||||||
|
- Validate body with existing `validateFpWorkflowForm`
|
||||||
|
- Proxy to Ivanti update endpoint via `ivantiPost()`
|
||||||
|
- On success: UPDATE local record fields + updated_at, INSERT history row (change_type: 'fields_updated'), log audit
|
||||||
|
- If previous status was 'rejected' or 'rework', set lifecycle_status to 'resubmitted'
|
||||||
|
- _Requirements: 7.2, 2.1, 2.2, 2.3, 2.4, 2.5, 5.4, 5.5, 7.6, 7.7_
|
||||||
|
|
||||||
|
- [x] 4.3 Implement `POST /api/ivanti/fp-submissions/:id/findings`
|
||||||
|
- Add route with `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Verify ownership, lifecycle guard (reject if approved)
|
||||||
|
- Validate findingIds and queueItemIds from body; verify queue items belong to user, are FP type, and pending
|
||||||
|
- Proxy to Ivanti map endpoint via `ivantiFormPost()` using `buildSubjectFilterRequest`
|
||||||
|
- On success: merge finding IDs with `mergeFindings()`, mark queue items complete, INSERT history + audit
|
||||||
|
- _Requirements: 7.3, 3.1, 3.2, 3.3, 3.4, 3.5_
|
||||||
|
|
||||||
|
- [x] 4.4 Implement `POST /api/ivanti/fp-submissions/:id/attachments`
|
||||||
|
- Add route with `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`, Multer middleware
|
||||||
|
- Verify ownership, lifecycle guard (reject if approved)
|
||||||
|
- Validate file constraints (10 MB, allowed extensions)
|
||||||
|
- Loop each file: call `ivantiMultipartPost()` to Ivanti attach endpoint
|
||||||
|
- Collect per-file success/failure results
|
||||||
|
- Update attachment_count and attachment_results_json, INSERT history + audit
|
||||||
|
- _Requirements: 7.4, 4.1, 4.2, 4.3, 4.4, 4.5_
|
||||||
|
|
||||||
|
- [x] 4.5 Implement `PATCH /api/ivanti/fp-submissions/:id/status`
|
||||||
|
- Add route with `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Verify ownership
|
||||||
|
- Validate new status is in allowed set
|
||||||
|
- Use `validateLifecycleTransition()` to check transition validity
|
||||||
|
- UPDATE lifecycle_status and updated_at, INSERT history row (change_type: 'status_changed'), log audit
|
||||||
|
- _Requirements: 7.5, 5.1, 5.2, 5.3, 7.6, 7.7_
|
||||||
|
|
||||||
|
- [ ]* 4.6 Write unit tests for backend endpoints
|
||||||
|
- Test ownership verification returns 403 for non-owner
|
||||||
|
- Test lifecycle guard returns 400 for approved submissions
|
||||||
|
- Test role guard rejects non-Admin/Standard_User
|
||||||
|
- Test Ivanti error status mapping (401, 419, 429, 5xx)
|
||||||
|
- Test history recording produces correct change_type and change_details_json
|
||||||
|
- Test migration idempotency (can run multiple times without error)
|
||||||
|
- Test file: `backend/__tests__/fpSubmissionEditing.test.js`
|
||||||
|
- _Requirements: 7.6, 7.7, 5.5_
|
||||||
|
|
||||||
|
- [ ]* 4.7 Write integration tests for backend endpoints
|
||||||
|
- Test GET returns correct records for authenticated user
|
||||||
|
- Test PUT proxies to Ivanti and updates local record
|
||||||
|
- Test POST findings maps to Ivanti and merges finding IDs
|
||||||
|
- Test POST attachments uploads to Ivanti and updates attachment records
|
||||||
|
- Test PATCH status updates lifecycle and creates history entry
|
||||||
|
- Test queue items marked complete after successful finding addition
|
||||||
|
- Test file: `backend/__tests__/fpSubmissionEditing.integration.test.js`
|
||||||
|
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
||||||
|
|
||||||
|
- [ ] 5. Checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 6. Register new endpoints in `backend/server.js`
|
||||||
|
- Wire the updated `ivantiFpWorkflow` router so the new GET/PUT/POST/PATCH routes are accessible under `/api/ivanti/fp-submissions`
|
||||||
|
- Verify the existing POST `/api/ivanti/fp-workflow` route continues to work
|
||||||
|
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
|
||||||
|
|
||||||
|
- [x] 7. Implement frontend components in `frontend/src/components/pages/ReportingPage.js`
|
||||||
|
- [x] 7.1 Implement `lifecycleStatusBadge(status)` helper function
|
||||||
|
- Return inline style object with border, background, and text color per status
|
||||||
|
- Color mapping: submitted/resubmitted (sky blue), approved (emerald), rejected (red), rework (amber)
|
||||||
|
- _Requirements: 1.3_
|
||||||
|
|
||||||
|
- [x] 7.2 Implement `FpEditModal` component
|
||||||
|
- Props: open, onClose, submission, queueItems, onSuccess
|
||||||
|
- State: form fields initialized from submission, activeTab, saving, errors, result
|
||||||
|
- Tab bar with 4 tabs: Details, Findings, Attachments, History
|
||||||
|
- Details tab: editable form fields (name, reason, description, expirationDate, scopeOverride) with Save button; calls PUT endpoint
|
||||||
|
- Findings tab: read-only current finding IDs list + mechanism to select and add FP queue items; calls POST findings endpoint
|
||||||
|
- Attachments tab: existing attachments list + file upload area; calls POST attachments endpoint
|
||||||
|
- History tab: chronological list fetched from submission history (included in GET response or separate query)
|
||||||
|
- Approved submissions: all fields read-only with finalized message
|
||||||
|
- Dark tactical theme matching existing FpWorkflowModal
|
||||||
|
- _Requirements: 1.2, 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2, 3.4, 3.5, 4.1, 4.2, 4.3, 4.4, 4.5, 5.5, 6.1_
|
||||||
|
|
||||||
|
- [x] 7.3 Modify workflow badge renderer for clickable badges
|
||||||
|
- In the workflow column renderer (~lines 1044–1070), for badges with state reworked/rejected/expired:
|
||||||
|
- Add `cursor: 'pointer'` and `onClick` handler
|
||||||
|
- Append pencil icon (lucide `Edit3`, 10px) after state text
|
||||||
|
- On hover: increase border opacity and brighten background
|
||||||
|
- On click: look up matching FP_Submission by workflow batch ID, open FpEditModal
|
||||||
|
- For badges with state requested/approved: no changes (remain non-interactive)
|
||||||
|
- _Requirements: 1.5, 1.6, 1.7, 1.8_
|
||||||
|
|
||||||
|
- [x] 7.4 Add submissions list section to QueuePanel
|
||||||
|
- Fetch submissions via GET /api/ivanti/fp-submissions on panel open
|
||||||
|
- Display each submission: workflow name, batch ID, lifecycle status badge, finding count, created date
|
||||||
|
- Clicking a submission row opens FpEditModal with that submission's data
|
||||||
|
- Viewers see the list but cannot click to edit
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
||||||
|
|
||||||
|
- [x] 8. Wire frontend state and data flow
|
||||||
|
- [x] 8.1 Add submissions state and fetch logic to ReportingPage
|
||||||
|
- Add state for submissions array and selected submission
|
||||||
|
- Fetch submissions on page load and after successful edits (onSuccess callback)
|
||||||
|
- Pass submissions and queueItems to FpEditModal and QueuePanel
|
||||||
|
- _Requirements: 1.1, 1.2_
|
||||||
|
|
||||||
|
- [x] 8.2 Connect FpEditModal callbacks to refresh data
|
||||||
|
- On successful edit/findings/attachments/status change, call onSuccess to refresh submissions list, queue items, and reporting table data
|
||||||
|
- _Requirements: 2.5, 3.4, 4.4, 5.3_
|
||||||
|
|
||||||
|
- [ ] 9. Final checkpoint — Ensure all tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests validate universal correctness properties from the design document
|
||||||
|
- The project uses plain JavaScript (no TypeScript) — all code should follow existing conventions
|
||||||
|
- All new endpoints follow the existing factory-pattern router in `ivantiFpWorkflow.js`
|
||||||
1
.kiro/specs/ivanti-queue-redirect/.config.kiro
Normal file
1
.kiro/specs/ivanti-queue-redirect/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "b8855eb4-3949-426e-86ac-36fe069a6bb1", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
362
.kiro/specs/ivanti-queue-redirect/design.md
Normal file
362
.kiro/specs/ivanti-queue-redirect/design.md
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# Design Document: Ivanti Queue Redirect
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Ivanti Queue Redirect feature adds an optional redirect action to completed queue items, allowing users to create a new pending queue item under a different workflow type from an existing completed item. This supports the common scenario where a CARD inventory fix is done but the finding still needs FP or Archer processing, 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 five parts:
|
||||||
|
1. A new backend API endpoint (`POST /api/ivanti/todo-queue/:id/redirect`) added to the existing `ivantiTodoQueue.js` route module
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The feature follows the existing patterns in the codebase:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant QP as QueuePanel
|
||||||
|
participant RM as RedirectModal
|
||||||
|
participant API as POST /todo-queue/:id/redirect
|
||||||
|
participant DB as SQLite (ivanti_todo_queue)
|
||||||
|
participant AL as Audit Log
|
||||||
|
|
||||||
|
U->>QP: Clicks redirect button on completed item
|
||||||
|
QP->>RM: Opens modal with item context
|
||||||
|
U->>RM: Selects target workflow type + vendor
|
||||||
|
RM->>API: POST /api/ivanti/todo-queue/:id/redirect
|
||||||
|
API->>DB: SELECT original item (verify ownership + complete status)
|
||||||
|
API->>DB: INSERT new pending item with target workflow_type
|
||||||
|
API->>AL: logAudit (fire-and-forget)
|
||||||
|
API-->>RM: 201 + new item JSON
|
||||||
|
RM->>QP: Adds new item to list, closes modal, shows success
|
||||||
|
```
|
||||||
|
|
||||||
|
No new database tables or schema changes are required. The redirect creates a standard `ivanti_todo_queue` row using the existing schema. 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.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/ivanti/todo-queue/:id/redirect
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workflow_type": "FP" | "Archer" | "CARD" | "GRANITE",
|
||||||
|
"vendor": "string (required for FP/Archer, omitted for CARD/GRANITE)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"user_id": 1,
|
||||||
|
"finding_id": "12345",
|
||||||
|
"finding_title": "...",
|
||||||
|
"cves_json": "[...]",
|
||||||
|
"ip_address": "10.0.0.1",
|
||||||
|
"hostname": "host.example.com",
|
||||||
|
"vendor": "Cisco",
|
||||||
|
"workflow_type": "FP",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "...",
|
||||||
|
"updated_at": "...",
|
||||||
|
"cves": ["CVE-2024-1234"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error responses:**
|
||||||
|
| Status | Condition |
|
||||||
|
|--------|-----------|
|
||||||
|
| 400 | Item not in "complete" status |
|
||||||
|
| 400 | Invalid workflow_type |
|
||||||
|
| 400 | Missing/invalid vendor for FP/Archer |
|
||||||
|
| 404 | Item not found or belongs to different user |
|
||||||
|
| 500 | Database error |
|
||||||
|
|
||||||
|
**Auth:** `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
|
||||||
|
### Backend: 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` 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 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
|
||||||
|
{
|
||||||
|
item: Object, // The completed queue item being redirected
|
||||||
|
onClose: Function, // Close the modal
|
||||||
|
onRedirect: Function // Callback with the new item after successful redirect
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend: QueuePanel Changes
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${itemId}/redirect`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ workflow_type, vendor })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
No schema changes. The redirect creates a standard `ivanti_todo_queue` row:
|
||||||
|
|
||||||
|
| Column | Source |
|
||||||
|
|--------|--------|
|
||||||
|
| user_id | `req.user.id` (current user) |
|
||||||
|
| finding_id | Copied from original item |
|
||||||
|
| finding_title | Copied from original item |
|
||||||
|
| cves_json | Copied from original item |
|
||||||
|
| ip_address | Copied from original item |
|
||||||
|
| hostname | Copied from original item |
|
||||||
|
| vendor | From request body (FP/Archer) or empty string (CARD/GRANITE) |
|
||||||
|
| workflow_type | From request body (FP, Archer, CARD, or GRANITE) |
|
||||||
|
| status | `'pending'` (always) |
|
||||||
|
|
||||||
|
The original completed item remains unchanged.
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Redirect preserves finding data
|
||||||
|
|
||||||
|
*For any* completed queue item with arbitrary finding_id, finding_title, cves_json, ip_address, and hostname values, and *for any* valid target workflow type (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" or "GRANITE", the request SHALL be accepted regardless of whether vendor is provided.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.2, 1.3, 6.2**
|
||||||
|
|
||||||
|
### Property 3: Successful redirect produces correct audit entry
|
||||||
|
|
||||||
|
*For any* successful redirect operation, the audit log entry SHALL contain action "queue_item_redirected", entityType "ivanti_todo_queue", the original item's ID as entityId, and details including the original workflow_type, the target workflow_type, the new item's ID, and the vendor.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.2**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | HTTP Status | Error Message | Behavior |
|
||||||
|
|----------|-------------|---------------|----------|
|
||||||
|
| Item not found or belongs to another user | 404 | "Queue item not found." | Consistent with existing DELETE/PUT pattern |
|
||||||
|
| Item status is not "complete" | 400 | "Only completed queue items can be redirected." | Prevents redirecting pending items |
|
||||||
|
| Invalid workflow_type | 400 | "workflow_type must be FP, Archer, 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. 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.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (Example-Based)
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- Redirect a completed CARD item to FP with vendor → 201, new item returned
|
||||||
|
- Redirect a completed FP item to CARD without vendor → 201, new item returned
|
||||||
|
- Redirect a 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 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 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 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
|
||||||
|
|
||||||
|
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 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 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 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`
|
||||||
107
.kiro/specs/ivanti-queue-redirect/requirements.md
Normal file
107
.kiro/specs/ivanti-queue-redirect/requirements.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Ivanti Queue Redirect feature gives users the option to redirect any completed queue item to a different workflow type. Not every completed item needs a redirect — many items are fully resolved once their workflow completes. However, some findings require further action under a different workflow. The primary use case is CARD items where the inventory fix is done but the finding still needs an FP or Archer workflow. It also supports correcting items that were assigned to the wrong team by redirecting them after a CARD fix. 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. 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 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
|
||||||
|
|
||||||
|
### Requirement 1: Redirect a Completed Queue Item via API
|
||||||
|
|
||||||
|
**User Story:** As an editor or admin, I want to redirect a completed queue item to a different workflow type, so that I can continue processing a finding under the correct workflow after initial work is done.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user submits a redirect request for a completed Queue_Item, THE Redirect_API SHALL create a new Queue_Item with status "pending", the specified target Workflow_Type, and the same finding_id, finding_title, cves_json, ip_address, and hostname as the original Queue_Item.
|
||||||
|
2. WHEN a user submits a redirect request with a target Workflow_Type of "FP" or "Archer", THE Redirect_API SHALL require a non-empty vendor string of 200 characters or fewer.
|
||||||
|
3. WHEN a user submits a redirect request with a target Workflow_Type of "CARD" 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, 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
|
||||||
|
|
||||||
|
**User Story:** As an admin, I want redirect actions to be recorded in the audit log, so that I can track workflow changes for compliance and accountability.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a redirect is successfully completed, THE Redirect_API SHALL log an audit entry with action "queue_item_redirected", the user's ID and username, the original Queue_Item ID as entityId, and details including the original Workflow_Type, the target Workflow_Type, the new Queue_Item ID, and the vendor.
|
||||||
|
2. THE Redirect_API SHALL use entityType "ivanti_todo_queue" for all redirect audit entries.
|
||||||
|
|
||||||
|
### Requirement 3: Redirect UI in the Queue Panel
|
||||||
|
|
||||||
|
**User Story:** As a user, I want a redirect button on completed queue items, so that I can easily initiate a redirect without leaving the Queue_Panel.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHILE a Queue_Item has status "complete", THE Queue_Panel SHALL display a redirect button on that item.
|
||||||
|
2. WHILE a Queue_Item has status "pending", THE Queue_Panel SHALL hide the redirect button on that item.
|
||||||
|
3. WHEN the user clicks the redirect button on a completed Queue_Item, THE Queue_Panel SHALL open the Redirect_Modal pre-populated with the finding details from the selected item.
|
||||||
|
|
||||||
|
### Requirement 4: Redirect Modal Workflow
|
||||||
|
|
||||||
|
**User Story:** As a user, I want a modal dialog to select the target workflow type and vendor when redirecting, so that I can confirm the redirect details before submitting.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Redirect_Modal SHALL display a workflow type selector with options FP, Archer, 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 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.
|
||||||
|
7. WHEN the redirect succeeds, THE Redirect_Modal SHALL close and THE Queue_Panel SHALL display a success notification.
|
||||||
|
|
||||||
|
### Requirement 5: Fix PUT Endpoint Validation Message
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the PUT endpoint validation message to accurately list all valid workflow types, so that API consumers receive correct error guidance.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user submits an invalid workflow_type to the PUT /api/ivanti/todo-queue/:id endpoint, THE Redirect_API SHALL return an error message stating "workflow_type must be FP, Archer, 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.
|
||||||
143
.kiro/specs/ivanti-queue-redirect/tasks.md
Normal file
143
.kiro/specs/ivanti-queue-redirect/tasks.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Implementation Plan: Ivanti Queue Redirect
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement a redirect action for completed Ivanti queue items. The feature adds a `POST /api/ivanti/todo-queue/:id/redirect` endpoint to the existing route module, fixes the PUT validation message, creates a RedirectModal frontend component, and wires a redirect button into the QueuePanel for completed items. Tasks are ordered: backend bug fix, backend endpoint, frontend modal, frontend integration, with property tests alongside each layer.
|
||||||
|
|
||||||
|
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
|
||||||
|
- [x] 1.1 Update PUT `/:id` workflow_type error message in `backend/routes/ivantiTodoQueue.js`
|
||||||
|
- Change `"workflow_type must be FP or Archer."` to `"workflow_type must be FP, Archer, or CARD."`
|
||||||
|
- _Requirements: 5.1_
|
||||||
|
|
||||||
|
- [x] 2. Add redirect endpoint to backend
|
||||||
|
- [x] 2.1 Add `POST /:id/redirect` route in `backend/routes/ivantiTodoQueue.js`
|
||||||
|
- Place inside the existing `createIvantiTodoQueueRouter` factory, before the DELETE routes
|
||||||
|
- Auth: `requireAuth(db)`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Validate `workflow_type` against existing `VALID_WORKFLOW_TYPES` constant
|
||||||
|
- For FP/Archer: validate vendor using existing `isValidVendor()` helper; also check length ≤ 200
|
||||||
|
- For CARD: accept without vendor
|
||||||
|
- Fetch original item with `db.get()` scoped to `req.user.id`; return 404 if not found
|
||||||
|
- Return 400 if original item status is not `"complete"`
|
||||||
|
- INSERT new row copying finding_id, finding_title, cves_json, ip_address, hostname from original; set status `"pending"`, workflow_type and vendor from request body
|
||||||
|
- Fetch the inserted row, parse cves_json, return 201 with the new item
|
||||||
|
- Call `logAudit(db, ...)` fire-and-forget with action `"queue_item_redirected"`, entityType `"ivanti_todo_queue"`, entityId = original item ID, details: `{ original_workflow_type, target_workflow_type, new_item_id, vendor }`
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.1, 2.2_
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint — Verify backend changes
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Create RedirectModal frontend component
|
||||||
|
- [x] 4.1 Create `frontend/src/components/RedirectModal.js`
|
||||||
|
- Props: `item` (the completed queue item), `onClose` (function), `onRedirect` (function called with new item)
|
||||||
|
- Display read-only context: finding title, finding ID, current workflow type
|
||||||
|
- Workflow type selector (radio buttons or select) with options FP, Archer, CARD
|
||||||
|
- Vendor text input shown only when FP or Archer is selected; required for those types
|
||||||
|
- Submit button calls `POST /api/ivanti/todo-queue/${item.id}/redirect` with `credentials: 'include'`
|
||||||
|
- On success: call `onRedirect(newItem)`, close modal
|
||||||
|
- On error: display error message from API response, keep modal open
|
||||||
|
- Loading state on submit button to prevent double-clicks
|
||||||
|
- Style with inline style objects following DESIGN_SYSTEM.md (dark theme, accent borders, gradient backgrounds)
|
||||||
|
- Use lucide-react icons (e.g., `CornerUpRight` or `ArrowRightLeft`)
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_
|
||||||
|
|
||||||
|
- [x] 5. Integrate redirect button and modal into QueuePanel
|
||||||
|
- [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_
|
||||||
|
|
||||||
|
- [x] 5.2 Wire RedirectModal state and rendering in QueuePanel
|
||||||
|
- Add `redirectItem` state (null or the item being redirected)
|
||||||
|
- Clicking the redirect button sets `redirectItem` to that item, opening the modal
|
||||||
|
- On successful redirect (`onRedirect` callback): append the new item to the queue items list, show a success notification, clear `redirectItem`
|
||||||
|
- On close: clear `redirectItem`
|
||||||
|
- Import and render `<RedirectModal>` conditionally when `redirectItem` is set
|
||||||
|
- _Requirements: 3.3, 4.5, 4.7_
|
||||||
|
|
||||||
|
- [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 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
|
||||||
|
- The QueuePanel component is defined inside `ReportingPage.js`, not a separate file
|
||||||
|
- The project uses plain JavaScript (no TypeScript)
|
||||||
1
.kiro/specs/reporting-row-visibility/.config.kiro
Normal file
1
.kiro/specs/reporting-row-visibility/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "acff93bd-0045-4fcd-b948-cc52c7cc5ec6", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
400
.kiro/specs/reporting-row-visibility/design.md
Normal file
400
.kiro/specs/reporting-row-visibility/design.md
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
# Design Document: Reporting Row Visibility
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds row-level visibility controls to the Reporting page's findings table, allowing security analysts to temporarily hide specific rows from view. Hidden rows are excluded from the visible table, the Action Coverage donut chart, and CSV/XLSX exports. The feature is entirely frontend — no backend changes are needed. All state is persisted in browser localStorage.
|
||||||
|
|
||||||
|
The feature consists of five interconnected pieces:
|
||||||
|
|
||||||
|
1. **Per-row hide button** — An `EyeOff` icon button on each table row that hides that finding
|
||||||
|
2. **Bulk select and hide** — Checkboxes on each row, a select-all checkbox, and a bulk action toolbar for hiding multiple rows at once
|
||||||
|
3. **Row Visibility Manager** — A toolbar popover (modeled after the existing `ColumnManager`) for viewing and restoring hidden rows
|
||||||
|
4. **Chart integration** — The Action Coverage donut chart recalculates using only visible (non-hidden) findings
|
||||||
|
5. **Export integration** — CSV and XLSX exports include only visible rows
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
- **localStorage over backend storage**: Row visibility is a personal view preference, not shared team state. Storing it in localStorage keeps the feature zero-cost on the backend and avoids auth/permission complexity. This mirrors how column visibility is already handled via `STORAGE_KEY`.
|
||||||
|
- **Hide-before-filter pipeline**: Hidden rows are removed from the dataset before column filters, action coverage filters, and EXC filters are applied. This ensures hidden rows never appear in filter dropdowns or affect filter counts.
|
||||||
|
- **Stale IDs are retained silently**: If a hidden Finding_ID no longer exists after an Ivanti sync, the ID stays in localStorage. This avoids the need for cleanup logic and ensures the finding is re-hidden if it reappears in a future sync.
|
||||||
|
- **Selection state is transient**: The checkbox selection state (`Row_Selection_State`) is not persisted. It resets on page reload and clears after a bulk hide operation.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The feature is contained entirely within `frontend/src/components/pages/ReportingPage.js`. No new files are created. The changes integrate into the existing component hierarchy and data flow.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph ReportingPage State
|
||||||
|
A[findings - raw from API] --> B[hiddenRowIds - from localStorage]
|
||||||
|
B --> C[visibleFindings = findings minus hiddenRowIds]
|
||||||
|
C --> D[filtered - column/action/EXC filters applied]
|
||||||
|
D --> E[sorted - sort order applied]
|
||||||
|
E --> F[Table rows rendered]
|
||||||
|
C --> G[ActionCoverageDonut receives visibleFindings]
|
||||||
|
E --> H[Export functions use sorted visible rows]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph New Components
|
||||||
|
I[RowVisibilityManager popover]
|
||||||
|
J[BulkActionToolbar]
|
||||||
|
K[Selection checkboxes]
|
||||||
|
L[Per-row hide button]
|
||||||
|
end
|
||||||
|
|
||||||
|
I -->|restore| B
|
||||||
|
J -->|bulk hide| B
|
||||||
|
L -->|single hide| B
|
||||||
|
K -->|toggle selection| M[selectedRowIds - transient state]
|
||||||
|
M --> J
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow Pipeline
|
||||||
|
|
||||||
|
The existing filtering pipeline in `VulnerabilityTriagePage` is:
|
||||||
|
|
||||||
|
```
|
||||||
|
findings → columnFilters → actionFilter → excFilter → sorted → rendered/exported
|
||||||
|
```
|
||||||
|
|
||||||
|
The new pipeline inserts row hiding as the first step:
|
||||||
|
|
||||||
|
```
|
||||||
|
findings → HIDE (remove hiddenRowIds) → columnFilters → actionFilter → excFilter → sorted → rendered/exported
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures hidden rows are excluded before any other filtering logic runs.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### 1. Hidden Row State Management
|
||||||
|
|
||||||
|
New state and helpers added to `VulnerabilityTriagePage`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const HIDDEN_ROWS_KEY = 'steam_findings_hidden_rows';
|
||||||
|
|
||||||
|
// Load hidden row IDs from localStorage
|
||||||
|
function loadHiddenRows() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(HIDDEN_ROWS_KEY) || 'null');
|
||||||
|
if (saved && Array.isArray(saved)) return new Set(saved);
|
||||||
|
} catch { /* corrupted — treat as empty */ }
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist hidden row IDs to localStorage
|
||||||
|
function saveHiddenRows(hiddenSet) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HIDDEN_ROWS_KEY, JSON.stringify([...hiddenSet]));
|
||||||
|
} catch { /* localStorage unavailable — degrade silently */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State declarations:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const [hiddenRowIds, setHiddenRowIds] = useState(loadHiddenRows);
|
||||||
|
const [selectedRowIds, setSelectedRowIds] = useState(new Set());
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hide/restore operations:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Hide a single row
|
||||||
|
const hideRow = useCallback((findingId) => {
|
||||||
|
setHiddenRowIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(String(findingId));
|
||||||
|
saveHiddenRows(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restore a single row
|
||||||
|
const restoreRow = useCallback((findingId) => {
|
||||||
|
setHiddenRowIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(String(findingId));
|
||||||
|
saveHiddenRows(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restore all hidden rows
|
||||||
|
const restoreAllRows = useCallback(() => {
|
||||||
|
setHiddenRowIds(new Set());
|
||||||
|
saveHiddenRows(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Bulk hide selected rows
|
||||||
|
const hideSelectedRows = useCallback(() => {
|
||||||
|
setHiddenRowIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
selectedRowIds.forEach(id => next.add(String(id)));
|
||||||
|
saveHiddenRows(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedRowIds(new Set());
|
||||||
|
}, [selectedRowIds]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Modified Filtering Pipeline
|
||||||
|
|
||||||
|
The existing `filtered` useMemo is modified to exclude hidden rows first:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// New: visible findings (hidden rows removed) — fed to ActionCoverageDonut
|
||||||
|
const visibleFindings = useMemo(() => {
|
||||||
|
if (hiddenRowIds.size === 0) return findings;
|
||||||
|
return findings.filter(f => !hiddenRowIds.has(String(f.id)));
|
||||||
|
}, [findings, hiddenRowIds]);
|
||||||
|
|
||||||
|
// Modified: filtered now starts from visibleFindings instead of findings
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let result = visibleFindings;
|
||||||
|
// ... existing column filter, action filter, EXC filter logic unchanged
|
||||||
|
}, [visibleFindings, columnFilters, actionFilter, excFilter]);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ActionCoverageDonut` receives `visibleFindings` instead of `findings`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<ActionCoverageDonut
|
||||||
|
findings={visibleFindings} // was: findings
|
||||||
|
activeSegment={actionFilter}
|
||||||
|
onSegmentClick={...}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Selection State Management
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Clear selection when visible rows change (filter changes)
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRowIds(prev => {
|
||||||
|
const visibleIds = new Set(sorted.map(f => String(f.id)));
|
||||||
|
const next = new Set([...prev].filter(id => visibleIds.has(id)));
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
}, [sorted]);
|
||||||
|
|
||||||
|
// Toggle a single row's selection
|
||||||
|
const toggleRowSelection = useCallback((findingId) => {
|
||||||
|
setSelectedRowIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
const id = String(findingId);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Select/deselect all visible rows
|
||||||
|
const toggleSelectAll = useCallback(() => {
|
||||||
|
const allVisibleIds = sorted.map(f => String(f.id));
|
||||||
|
setSelectedRowIds(prev => {
|
||||||
|
if (prev.size === allVisibleIds.length) return new Set(); // deselect all
|
||||||
|
return new Set(allVisibleIds); // select all
|
||||||
|
});
|
||||||
|
}, [sorted]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. RowVisibilityManager Component
|
||||||
|
|
||||||
|
A new component following the same pattern as `ColumnManager`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll }) {
|
||||||
|
// Props:
|
||||||
|
// hiddenRowIds: Set<string> — currently hidden finding IDs
|
||||||
|
// findings: Array — full findings array (to look up titles for hidden IDs)
|
||||||
|
// onRestore: (findingId: string) => void
|
||||||
|
// onRestoreAll: () => void
|
||||||
|
//
|
||||||
|
// State:
|
||||||
|
// open: boolean — popover visibility
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Button shows EyeOff icon + "Hidden (N)" count
|
||||||
|
// - Popover lists hidden findings by ID and title
|
||||||
|
// - Each entry has an Eye icon restore button
|
||||||
|
// - "Restore All" button at the bottom
|
||||||
|
// - When hiddenRowIds.size === 0, popover shows "No rows hidden" message
|
||||||
|
// - Closes on outside click (same pattern as ColumnManager)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placement:** Rendered in the toolbar `div` adjacent to the `ColumnManager` button, between the ColumnManager and the Sync button.
|
||||||
|
|
||||||
|
### 5. BulkActionToolbar Component
|
||||||
|
|
||||||
|
Rendered above the table when `selectedRowIds.size > 0`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function BulkHideToolbar({ count, onHide, onClear }) {
|
||||||
|
// Props:
|
||||||
|
// count: number — selected row count
|
||||||
|
// onHide: () => void — hide all selected rows
|
||||||
|
// onClear: () => void — clear selection
|
||||||
|
//
|
||||||
|
// Renders:
|
||||||
|
// "{count} rows selected" label
|
||||||
|
// "Hide Selected" button with EyeOff icon
|
||||||
|
// "Clear" button to deselect all
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placement:** Rendered inside the table scroll container, above the `<table>` element, in the same position as the existing `SelectionToolbar` for batch FP submissions. The bulk hide toolbar appears alongside (or replaces) the existing selection toolbar depending on context.
|
||||||
|
|
||||||
|
### 6. Per-Row Hide Button and Selection Checkbox
|
||||||
|
|
||||||
|
Two new fixed columns are added to the table, before the existing checkbox column:
|
||||||
|
|
||||||
|
| Column | Width | Content | Position |
|
||||||
|
|--------|-------|---------|----------|
|
||||||
|
| Selection checkbox | 36px | `Square` / `CheckSquare` icon (lucide-react) | First column |
|
||||||
|
| Hide button | 36px | `EyeOff` icon button | Second column |
|
||||||
|
|
||||||
|
Both columns are fixed (not managed by `ColumnManager`) and use sticky positioning in the header.
|
||||||
|
|
||||||
|
### 7. Select All Checkbox
|
||||||
|
|
||||||
|
Rendered in the table header for the selection column:
|
||||||
|
|
||||||
|
- **Unchecked** (`Square` icon): No rows selected
|
||||||
|
- **Checked** (`CheckSquare` icon): All visible rows selected
|
||||||
|
- **Indeterminate** (`MinusSquare` icon): Some but not all visible rows selected
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### localStorage Schema
|
||||||
|
|
||||||
|
**Key:** `steam_findings_hidden_rows`
|
||||||
|
|
||||||
|
**Value:** JSON array of Finding ID strings
|
||||||
|
|
||||||
|
```json
|
||||||
|
["12345", "67890", "11111"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- Finding IDs are stored as strings for consistent comparison
|
||||||
|
- The array may contain IDs that no longer exist in the current findings dataset (stale IDs are retained)
|
||||||
|
- An empty array `[]` or missing key both mean "no rows hidden"
|
||||||
|
- If the stored value fails JSON parsing, it is treated as empty (all rows visible)
|
||||||
|
|
||||||
|
### Component State
|
||||||
|
|
||||||
|
| State Variable | Type | Persisted | Description |
|
||||||
|
|---------------|------|-----------|-------------|
|
||||||
|
| `hiddenRowIds` | `Set<string>` | Yes (localStorage) | Finding IDs currently hidden |
|
||||||
|
| `selectedRowIds` | `Set<string>` | No (transient) | Finding IDs currently selected via checkboxes |
|
||||||
|
|
||||||
|
### Derived Data
|
||||||
|
|
||||||
|
| Variable | Derivation | Used By |
|
||||||
|
|----------|-----------|---------|
|
||||||
|
| `visibleFindings` | `findings.filter(f => !hiddenRowIds.has(f.id))` | ActionCoverageDonut, filter pipeline |
|
||||||
|
| `filtered` | `visibleFindings` → column filters → action filter → EXC filter | Sort, table render |
|
||||||
|
| `sorted` | `filtered` → sort comparator | Table render, export |
|
||||||
|
|
||||||
|
|
||||||
|
## 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: Hidden row filtering invariant
|
||||||
|
|
||||||
|
*For any* array of findings and *any* set of hidden Finding_IDs, the `visibleFindings` array SHALL never contain a finding whose ID is in the hidden set.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1, 1.2, 4.1, 4.3, 5.1, 5.2, 6.1, 6.2**
|
||||||
|
|
||||||
|
### Property 2: localStorage round-trip preserves hidden row state
|
||||||
|
|
||||||
|
*For any* set of valid Finding_ID strings, calling `saveHiddenRows(set)` followed by `loadHiddenRows()` SHALL return a set containing exactly the same elements.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.2**
|
||||||
|
|
||||||
|
### Property 3: Corrupted localStorage produces empty set
|
||||||
|
|
||||||
|
*For any* string that is not a valid JSON array of strings, `loadHiddenRows()` SHALL return an empty set, and no error SHALL be thrown.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.4**
|
||||||
|
|
||||||
|
### Property 4: Restore removes exactly the specified ID
|
||||||
|
|
||||||
|
*For any* non-empty set of hidden Finding_IDs and *any* ID in that set, calling `restoreRow(id)` SHALL produce a new hidden set that is equal to the original set minus that single ID.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.3**
|
||||||
|
|
||||||
|
### Property 5: Bulk hide produces the union of hidden and selected sets
|
||||||
|
|
||||||
|
*For any* set of currently hidden Finding_IDs and *any* set of selected Finding_IDs, calling `hideSelectedRows()` SHALL produce a hidden set equal to the union of both sets, and the selection set SHALL be empty afterward.
|
||||||
|
|
||||||
|
**Validates: Requirements 8.5, 8.6**
|
||||||
|
|
||||||
|
### Property 6: Selection is always a subset of visible rows
|
||||||
|
|
||||||
|
*For any* set of selected Finding_IDs and *any* change to the visible row set (via filter changes or row hiding), the resulting selection set SHALL be a subset of the current visible row IDs.
|
||||||
|
|
||||||
|
**Validates: Requirements 8.8**
|
||||||
|
|
||||||
|
### Property 7: Select all produces exactly the visible row ID set
|
||||||
|
|
||||||
|
*For any* array of currently visible (sorted) findings, calling `toggleSelectAll` when no rows are selected SHALL produce a selection set equal to the set of all visible Finding_IDs. Calling `toggleSelectAll` when all rows are selected SHALL produce an empty selection set.
|
||||||
|
|
||||||
|
**Validates: Requirements 8.2, 8.3**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| localStorage unavailable (private browsing, quota exceeded) | `saveHiddenRows` fails silently via try/catch. `loadHiddenRows` returns empty set. All rows remain visible. Feature degrades to session-only (hidden state lost on reload). |
|
||||||
|
| Corrupted localStorage value | `loadHiddenRows` catches JSON parse error and returns empty set. No error shown to user. |
|
||||||
|
| Stale Finding_ID in hidden set (ID no longer in findings after sync) | ID is retained in localStorage. The `filter()` call simply doesn't match any finding, so no visible effect. If the finding reappears in a future sync, it will be hidden again automatically. |
|
||||||
|
| Empty findings array | `visibleFindings` is empty. `selectedRowIds` is empty. Charts show "No data" state. Export produces headers only. All controls render but have no actionable items. |
|
||||||
|
| Bulk hide with empty selection | The "Hide Selected" button is only shown when `selectedRowIds.size > 0`, so this state is unreachable via the UI. If called programmatically, `hideSelectedRows` is a no-op (union with empty set). |
|
||||||
|
| Select all with no visible rows | `toggleSelectAll` produces an empty set (no rows to select). |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
The feature's core logic — set operations, filtering, localStorage serialization — is well-suited for property-based testing. The functions under test are pure or near-pure (localStorage can be mocked), and the input space (sets of string IDs, arrays of finding objects) is large.
|
||||||
|
|
||||||
|
**Library:** [fast-check](https://github.com/dubzzz/fast-check) — the standard PBT library for JavaScript/React projects.
|
||||||
|
|
||||||
|
**Configuration:** Each property test runs a minimum of 100 iterations.
|
||||||
|
|
||||||
|
**Tag format:** Each test is tagged with a comment: `// Feature: reporting-row-visibility, Property N: <property text>`
|
||||||
|
|
||||||
|
**Properties to implement:**
|
||||||
|
|
||||||
|
| Property | Test Description | Key Generators |
|
||||||
|
|----------|-----------------|----------------|
|
||||||
|
| 1 | Filter findings by hidden set, verify no hidden ID in output | `fc.array(findingArb)`, `fc.uniqueArray(fc.string())` |
|
||||||
|
| 2 | Save then load hidden rows, verify round-trip equality | `fc.uniqueArray(fc.stringOf(fc.constantFrom(...digits), {minLength: 1}))` |
|
||||||
|
| 3 | Set corrupted string in localStorage, verify loadHiddenRows returns empty set | `fc.string()` filtered to exclude valid JSON arrays |
|
||||||
|
| 4 | Remove one ID from hidden set, verify set difference | `fc.uniqueArray(fc.string(), {minLength: 1})`, pick random element |
|
||||||
|
| 5 | Union hidden + selected sets, verify result and empty selection | `fc.uniqueArray(fc.string())` × 2 |
|
||||||
|
| 6 | Generate selection + filter change, verify selection ⊆ visible | `fc.uniqueArray(fc.string())` for selection and visible sets |
|
||||||
|
| 7 | Select all from visible set, verify equality; toggle again, verify empty | `fc.array(findingArb)` |
|
||||||
|
|
||||||
|
### Unit Tests (Example-Based)
|
||||||
|
|
||||||
|
Unit tests cover specific scenarios, UI rendering, and edge cases that don't benefit from randomized input:
|
||||||
|
|
||||||
|
- **Hide button renders on each row** (Req 1.3) — verify EyeOff icon in fixed column
|
||||||
|
- **Hide button visible for viewer role** (Req 1.4) — render with read-only auth context
|
||||||
|
- **localStorage write on hide/restore** (Req 2.3) — mock localStorage, verify setItem called
|
||||||
|
- **Row Visibility Manager button shows count** (Req 3.1) — verify "Hidden (N)" text
|
||||||
|
- **Row Visibility Manager popover lists hidden findings** (Req 3.2) — click button, verify list
|
||||||
|
- **Restore All clears all hidden rows** (Req 3.4) — verify empty set after restoreAll
|
||||||
|
- **"Hidden (0)" when no rows hidden** (Req 3.5) — verify button text and empty message
|
||||||
|
- **Chart re-renders after hide** (Req 4.2) — verify ActionCoverageDonut receives updated findings
|
||||||
|
- **Hidden state preserved across sync** (Req 5.3) — simulate sync, verify hiddenRowIds unchanged
|
||||||
|
- **Stale IDs retained silently** (Req 5.4) — hide ID, remove from findings, verify no error
|
||||||
|
- **Bulk action toolbar appears with count** (Req 8.4) — select rows, verify toolbar renders
|
||||||
|
- **Indeterminate checkbox state** (Req 8.9) — partial selection, verify MinusSquare icon
|
||||||
|
- **Toolbar hidden when no selection** (Req 8.10) — empty selection, verify no toolbar
|
||||||
|
- **Styling consistency** (Req 7.1, 7.2, 7.3, 8.11) — snapshot tests for visual consistency
|
||||||
115
.kiro/specs/reporting-row-visibility/requirements.md
Normal file
115
.kiro/specs/reporting-row-visibility/requirements.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Reporting page in the STEAM Security Dashboard displays a table of Ivanti host findings with columns for finding ID, severity, title, CVEs, hostname, IP address, DNS, due date, SLA status, BU ownership, workflow, last found date, and notes. Some findings have manually entered notes such as "NOT STEAM/ACCESS", "MongoDB Update", or other free-text annotations indicating that work is being done outside of the automated FP or Archer exception workflows. These manually-noted findings are classified as "pending" in the Action Coverage donut chart, inflating the pending count even though they represent active remediation efforts.
|
||||||
|
|
||||||
|
Users need the ability to temporarily hide specific rows from the table view — similar to how columns can already be hidden via the ColumnManager popover. Hidden rows should be excluded from the visible table and from the Action Coverage chart calculations, but the underlying data must remain intact. The feature should persist across page reloads and provide a clear mechanism to reveal hidden rows or restore them individually.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Reporting_Table**: The findings data table rendered on the Reporting page, displaying one row per Ivanti host finding with sortable, filterable columns.
|
||||||
|
- **Row_Visibility_State**: A client-side record of which finding IDs have been hidden by the user. Stored in browser localStorage for persistence across sessions.
|
||||||
|
- **Hidden_Row**: A finding whose ID is present in the Row_Visibility_State hidden set. Hidden rows are excluded from the visible table and from chart metric calculations.
|
||||||
|
- **ColumnManager**: The existing popover component on the Reporting page that allows users to show/hide columns and reorder them via drag-and-drop. The row-hiding feature follows a similar UX pattern.
|
||||||
|
- **Action_Coverage_Chart**: The donut chart on the Reporting page that classifies open findings into three categories — FP Request, Archer Exception, and Pending — based on workflow status and note content.
|
||||||
|
- **Row_Visibility_Manager**: A new UI component that provides controls for viewing and restoring hidden rows, analogous to the ColumnManager for columns.
|
||||||
|
- **Finding_ID**: The unique Ivanti-assigned identifier for each host finding, used as the key for tracking hidden rows.
|
||||||
|
- **Row_Selection_State**: A transient client-side record of which Finding_IDs are currently selected via checkboxes. This state is not persisted and resets on page reload or after a bulk action completes.
|
||||||
|
- **Selection_Checkbox**: A checkbox control rendered in a fixed column on each visible row, used to toggle that row's inclusion in the Row_Selection_State.
|
||||||
|
- **Select_All_Checkbox**: A checkbox control rendered in the table header that toggles selection of all currently visible (non-hidden, post-filter) rows.
|
||||||
|
- **Bulk_Action_Toolbar**: A contextual toolbar that appears above the Reporting_Table when one or more rows are selected, displaying the count of selected rows and bulk action controls.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Hide Individual Rows from the Reporting Table
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want to hide specific rows in the Reporting table by clicking a hide control on each row, so that I can remove manually-handled findings from view without deleting them.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Reporting_Table SHALL display a hide button on each row that, when clicked, adds the row's Finding_ID to the Row_Visibility_State hidden set.
|
||||||
|
2. WHEN a row's Finding_ID is added to the Row_Visibility_State hidden set, THE Reporting_Table SHALL immediately remove that row from the visible table without a page reload.
|
||||||
|
3. THE hide button SHALL be rendered as an icon button (using the `EyeOff` icon from lucide-react) in a fixed column that is not managed by the ColumnManager.
|
||||||
|
4. WHEN the user has no write permissions (viewer role), THE Reporting_Table SHALL still display the hide button, as row visibility is a personal view preference and not a data modification.
|
||||||
|
|
||||||
|
### Requirement 2: Persist Hidden Row State Across Sessions
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want my hidden row selections to persist when I navigate away and return to the Reporting page, so that I do not have to re-hide the same rows every session.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Row_Visibility_State SHALL be stored in browser localStorage under a dedicated key (e.g., `steam_findings_hidden_rows`).
|
||||||
|
2. WHEN the Reporting page loads, THE Reporting_Table SHALL read the Row_Visibility_State from localStorage and exclude hidden Finding_IDs from the visible table.
|
||||||
|
3. WHEN the Row_Visibility_State changes (row hidden or restored), THE Reporting_Table SHALL write the updated state to localStorage immediately.
|
||||||
|
4. IF localStorage is unavailable or the stored value is corrupted, THEN THE Reporting_Table SHALL treat all rows as visible and continue operating without error.
|
||||||
|
|
||||||
|
### Requirement 3: Row Visibility Manager Panel
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want a panel that shows me which rows are currently hidden and lets me restore them, so that I can manage my hidden rows and bring back findings I no longer want to hide.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Row_Visibility_Manager SHALL be accessible via a toolbar button placed adjacent to the existing ColumnManager button, using the `EyeOff` icon and displaying a count of currently hidden rows (e.g., "Hidden (3)").
|
||||||
|
2. WHEN the Row_Visibility_Manager button is clicked, THE Row_Visibility_Manager SHALL open a popover panel listing all currently hidden findings by Finding_ID and title.
|
||||||
|
3. THE Row_Visibility_Manager panel SHALL provide a restore button (using the `Eye` icon) next to each hidden finding entry that, when clicked, removes that Finding_ID from the Row_Visibility_State and returns the row to the visible table.
|
||||||
|
4. THE Row_Visibility_Manager panel SHALL provide a "Restore All" button that clears the entire Row_Visibility_State and returns all hidden rows to the visible table.
|
||||||
|
5. WHEN no rows are hidden, THE Row_Visibility_Manager button SHALL display "Hidden (0)" and the popover panel SHALL display a message indicating no rows are hidden.
|
||||||
|
|
||||||
|
### Requirement 4: Exclude Hidden Rows from Action Coverage Metrics
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want hidden rows to be excluded from the Action Coverage donut chart, so that manually-handled findings I have hidden do not inflate the "Pending" count.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Action_Coverage_Chart SHALL compute its FP Request, Archer Exception, and Pending counts using only visible (non-hidden) findings.
|
||||||
|
2. WHEN a row is hidden or restored, THE Action_Coverage_Chart SHALL recalculate and re-render immediately to reflect the updated visible finding set.
|
||||||
|
3. THE Action_Coverage_Chart segment click filtering SHALL operate only on visible findings, so clicking a segment filters within the non-hidden set.
|
||||||
|
|
||||||
|
### Requirement 5: Hidden Row Interaction with Existing Filters
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want row hiding to work correctly alongside column filters, sort order, and the action coverage chart filter, so that hiding rows does not interfere with other table controls.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Reporting_Table SHALL apply row hiding before column filters, so that hidden rows are excluded from the dataset before any column filter, sort, or action coverage filter is applied.
|
||||||
|
2. WHEN a finding is hidden and a column filter is active, THE Reporting_Table SHALL not include the hidden finding in filter value dropdowns or filter counts.
|
||||||
|
3. WHEN findings are synced from Ivanti (Sync button), THE Row_Visibility_State SHALL be preserved — previously hidden Finding_IDs remain hidden if they still exist in the refreshed dataset.
|
||||||
|
4. IF a hidden Finding_ID no longer exists in the synced findings data, THEN THE Row_Visibility_State SHALL retain the ID silently (no error) so that it is automatically re-hidden if the finding reappears in a future sync.
|
||||||
|
|
||||||
|
### Requirement 6: Export Behavior for Hidden Rows
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want CSV and XLSX exports to include only visible rows by default, so that my exports reflect the same filtered view I see on screen.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the user exports data via CSV or XLSX, THE Reporting_Table SHALL export only the currently visible (non-hidden, post-filter) rows.
|
||||||
|
2. THE export SHALL respect all active filters (column filters, action coverage filter, EXC filter) in addition to row hiding, exporting only the intersection of all active view constraints.
|
||||||
|
|
||||||
|
### Requirement 7: Visual Styling Consistency
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want the row-hiding controls to match the existing dashboard aesthetic, so that the feature feels native to the application.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE hide button on each row SHALL use the same icon size (13px), color palette (muted slate for default, accent blue on hover), and monospace font styling as existing toolbar controls.
|
||||||
|
2. THE Row_Visibility_Manager popover SHALL use the same panel styling (dark gradient background, accent border, box shadow) as the existing ColumnManager popover.
|
||||||
|
3. THE Row_Visibility_Manager toolbar button SHALL use the same button styling (padding, border radius, font size, uppercase text) as the existing ColumnManager and Queue toolbar buttons.
|
||||||
|
|
||||||
|
### Requirement 8: Bulk Hide Rows via Multi-Select
|
||||||
|
|
||||||
|
**User Story:** As a security analyst, I want to select multiple rows and hide them all at once, so that I can quickly clear out batches of manually-handled findings without clicking hide on each row individually.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Reporting_Table SHALL display a Selection_Checkbox on each visible row in a fixed column that is not managed by the ColumnManager, positioned before the hide button column.
|
||||||
|
2. THE Reporting_Table SHALL display a Select_All_Checkbox in the table header of the selection column that, when checked, adds all currently visible (non-hidden, post-filter) Finding_IDs to the Row_Selection_State.
|
||||||
|
3. WHEN the Select_All_Checkbox is unchecked, THE Reporting_Table SHALL remove all Finding_IDs from the Row_Selection_State.
|
||||||
|
4. WHEN one or more Finding_IDs are present in the Row_Selection_State, THE Bulk_Action_Toolbar SHALL appear above the Reporting_Table displaying the count of selected rows (e.g., "3 rows selected") and a "Hide Selected" button using the `EyeOff` icon.
|
||||||
|
5. WHEN the "Hide Selected" button is clicked, THE Reporting_Table SHALL add all Finding_IDs in the Row_Selection_State to the Row_Visibility_State hidden set in a single operation.
|
||||||
|
6. WHEN a bulk hide operation completes, THE Reporting_Table SHALL clear the Row_Selection_State so that no rows remain selected.
|
||||||
|
7. WHEN a bulk hide operation completes, THE Action_Coverage_Chart SHALL recalculate and re-render immediately to reflect the updated visible finding set.
|
||||||
|
8. WHEN column filters or the action coverage filter change the set of visible rows, THE Row_Selection_State SHALL remove any Finding_IDs that are no longer visible, so that the selection always reflects the current filtered view.
|
||||||
|
9. THE Select_All_Checkbox SHALL display an indeterminate state when some but not all visible rows are selected.
|
||||||
|
10. WHEN no rows are selected, THE Bulk_Action_Toolbar SHALL not be displayed.
|
||||||
|
11. THE Selection_Checkbox, Select_All_Checkbox, and Bulk_Action_Toolbar SHALL use the same color palette (muted slate for default, accent blue for checked/active state), monospace font styling, and dark gradient background as existing toolbar controls defined in the design system.
|
||||||
127
.kiro/specs/reporting-row-visibility/tasks.md
Normal file
127
.kiro/specs/reporting-row-visibility/tasks.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Implementation Plan: Reporting Row Visibility
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plan implements row-level visibility controls for the Reporting page's findings table. All changes are contained within `frontend/src/components/pages/ReportingPage.js` — no new files, no backend changes. The implementation adds hidden row state management (localStorage-persisted), a visibility filtering step in the data pipeline, selection checkboxes with bulk hide, a Row Visibility Manager popover, chart/export integration, and per-row hide buttons. Each task builds incrementally on the previous one, wiring everything together by the final step.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Add hidden row state management and localStorage helpers
|
||||||
|
- Add the `HIDDEN_ROWS_KEY` constant (`'steam_findings_hidden_rows'`)
|
||||||
|
- Implement `loadHiddenRows()` function that reads from localStorage, parses JSON, returns a `Set<string>` (empty set on parse failure or missing key)
|
||||||
|
- Implement `saveHiddenRows(hiddenSet)` function that serializes the set to a JSON array and writes to localStorage (silent catch on failure)
|
||||||
|
- Add `hiddenRowIds` state initialized via `useState(loadHiddenRows)`
|
||||||
|
- Implement `hideRow(findingId)` callback that adds a string ID to the set and persists
|
||||||
|
- Implement `restoreRow(findingId)` callback that removes a string ID from the set and persists
|
||||||
|
- Implement `restoreAllRows()` callback that clears the set and persists an empty set
|
||||||
|
- _Requirements: 1.1, 2.1, 2.2, 2.3, 2.4_
|
||||||
|
|
||||||
|
- [ ]* 1.1 Write property test: localStorage round-trip preserves hidden row state
|
||||||
|
- **Property 2: localStorage round-trip preserves hidden row state**
|
||||||
|
- Generate arbitrary sets of valid Finding_ID strings, call `saveHiddenRows` then `loadHiddenRows`, assert the returned set contains exactly the same elements
|
||||||
|
- **Validates: Requirements 2.1, 2.2**
|
||||||
|
|
||||||
|
- [ ]* 1.2 Write property test: corrupted localStorage produces empty set
|
||||||
|
- **Property 3: Corrupted localStorage produces empty set**
|
||||||
|
- Generate arbitrary strings that are not valid JSON arrays of strings, set them in localStorage under the hidden rows key, call `loadHiddenRows`, assert the result is an empty set and no error is thrown
|
||||||
|
- **Validates: Requirements 2.4**
|
||||||
|
|
||||||
|
- [x] 2. Insert visibility filtering into the data pipeline
|
||||||
|
- Add `visibleFindings` useMemo that filters `findings` by excluding any finding whose `String(f.id)` is in `hiddenRowIds` (short-circuit when set is empty)
|
||||||
|
- Modify the existing `filtered` useMemo to start from `visibleFindings` instead of `findings`
|
||||||
|
- Ensure column filter dropdowns, action filter, and EXC filter all operate on the post-hide dataset
|
||||||
|
- _Requirements: 1.2, 5.1, 5.2_
|
||||||
|
|
||||||
|
- [ ]* 2.1 Write property test: hidden row filtering invariant
|
||||||
|
- **Property 1: Hidden row filtering invariant**
|
||||||
|
- Generate arbitrary arrays of finding objects and arbitrary sets of hidden Finding_IDs, compute `visibleFindings`, assert no finding in the output has an ID present in the hidden set
|
||||||
|
- **Validates: Requirements 1.1, 1.2, 4.1, 4.3, 5.1, 5.2, 6.1, 6.2**
|
||||||
|
|
||||||
|
- [x] 3. Integrate hidden rows with chart and export
|
||||||
|
- Pass `visibleFindings` (instead of `findings`) to the `ActionCoverageDonut` component's `findings` prop
|
||||||
|
- Modify the CSV export function to use the sorted/filtered visible rows (already derived from `visibleFindings` via the pipeline)
|
||||||
|
- Modify the XLSX export function to use the sorted/filtered visible rows
|
||||||
|
- Verify that chart segment click filtering operates on the visible set
|
||||||
|
- _Requirements: 4.1, 4.2, 4.3, 6.1, 6.2_
|
||||||
|
|
||||||
|
- [x] 4. Checkpoint — Verify core hide/restore pipeline
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 5. Add selection state and bulk hide logic
|
||||||
|
- Add `selectedRowIds` state as `useState(new Set())`
|
||||||
|
- Implement `toggleRowSelection(findingId)` callback that adds/removes a string ID from the selection set
|
||||||
|
- Implement `toggleSelectAll()` callback that selects all visible sorted row IDs when not all are selected, or clears selection when all are selected
|
||||||
|
- Implement `hideSelectedRows()` callback that unions `selectedRowIds` into `hiddenRowIds`, persists, and clears the selection set
|
||||||
|
- Add a `useEffect` that prunes `selectedRowIds` to only include IDs present in the current `sorted` array whenever `sorted` changes
|
||||||
|
- _Requirements: 8.1, 8.2, 8.3, 8.5, 8.6, 8.8_
|
||||||
|
|
||||||
|
- [ ]* 5.1 Write property test: bulk hide produces union of hidden and selected sets
|
||||||
|
- **Property 5: Bulk hide produces the union of hidden and selected sets**
|
||||||
|
- Generate two arbitrary sets of Finding_ID strings (hidden and selected), simulate `hideSelectedRows`, assert the resulting hidden set equals the union and the selection set is empty
|
||||||
|
- **Validates: Requirements 8.5, 8.6**
|
||||||
|
|
||||||
|
- [ ]* 5.2 Write property test: selection is always a subset of visible rows
|
||||||
|
- **Property 6: Selection is always a subset of visible rows**
|
||||||
|
- Generate arbitrary selection and visible row sets, simulate the pruning effect, assert the resulting selection is a subset of visible row IDs
|
||||||
|
- **Validates: Requirements 8.8**
|
||||||
|
|
||||||
|
- [ ]* 5.3 Write property test: select all produces exactly the visible row ID set
|
||||||
|
- **Property 7: Select all produces exactly the visible row ID set**
|
||||||
|
- Generate an arbitrary array of finding objects representing sorted visible rows, simulate `toggleSelectAll` from empty selection, assert the selection equals the full visible ID set; toggle again, assert empty
|
||||||
|
- **Validates: Requirements 8.2, 8.3**
|
||||||
|
|
||||||
|
- [ ]* 5.4 Write property test: restore removes exactly the specified ID
|
||||||
|
- **Property 4: Restore removes exactly the specified ID**
|
||||||
|
- Generate a non-empty set of hidden Finding_IDs, pick a random element, simulate `restoreRow`, assert the result equals the original set minus that single ID
|
||||||
|
- **Validates: Requirements 3.3**
|
||||||
|
|
||||||
|
- [x] 6. Add selection checkbox column and select-all checkbox to the table
|
||||||
|
- Import `Square`, `CheckSquare`, and `MinusSquare` icons from lucide-react
|
||||||
|
- Add a fixed 36px selection checkbox column as the first column in the table header and body
|
||||||
|
- Render `Select_All_Checkbox` in the header: `CheckSquare` when all selected, `MinusSquare` when partially selected, `Square` when none selected; onClick calls `toggleSelectAll`
|
||||||
|
- Render `Selection_Checkbox` on each row: `CheckSquare` when selected, `Square` when not; onClick calls `toggleRowSelection(finding.id)`
|
||||||
|
- Style checkboxes with muted slate default color, accent blue when checked/active, matching existing icon sizing
|
||||||
|
- _Requirements: 8.1, 8.2, 8.3, 8.9, 8.11_
|
||||||
|
|
||||||
|
- [x] 7. Add per-row hide button column
|
||||||
|
- Add a fixed 36px hide button column as the second column (after selection checkbox) in the table header and body
|
||||||
|
- Render an `EyeOff` icon button on each row; onClick calls `hideRow(finding.id)`
|
||||||
|
- Style the button with 13px icon size, muted slate default color, accent blue on hover, matching existing toolbar icon patterns
|
||||||
|
- The column header cell is empty (no label)
|
||||||
|
- _Requirements: 1.1, 1.3, 1.4, 7.1_
|
||||||
|
|
||||||
|
- [x] 8. Implement BulkHideToolbar component
|
||||||
|
- Create inline `BulkHideToolbar` component accepting `count`, `onHide`, and `onClear` props
|
||||||
|
- Render "{count} rows selected" label, "Hide Selected" button with `EyeOff` icon, and "Clear" button
|
||||||
|
- Style with dark gradient background, accent border, monospace font, matching existing toolbar patterns
|
||||||
|
- Render the toolbar above the table inside the scroll container, only when `selectedRowIds.size > 0`
|
||||||
|
- Wire `onHide` to `hideSelectedRows` and `onClear` to clearing the selection set
|
||||||
|
- _Requirements: 8.4, 8.5, 8.6, 8.7, 8.10, 8.11_
|
||||||
|
|
||||||
|
- [x] 9. Checkpoint — Verify selection and bulk hide UI
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 10. Implement RowVisibilityManager popover component
|
||||||
|
- Create inline `RowVisibilityManager` component accepting `hiddenRowIds`, `findings`, `onRestore`, and `onRestoreAll` props
|
||||||
|
- Add `open` state for popover visibility, with outside-click-to-close behavior (same pattern as existing `ColumnManager`)
|
||||||
|
- Render a toolbar button with `EyeOff` icon and "Hidden (N)" count text, styled to match the existing ColumnManager and Queue toolbar buttons (same padding, border radius, font size, uppercase text)
|
||||||
|
- When open, render a popover panel listing hidden findings by Finding_ID and title (looked up from the full `findings` array)
|
||||||
|
- Each entry has an `Eye` icon restore button that calls `onRestore(findingId)`
|
||||||
|
- Include a "Restore All" button at the bottom that calls `onRestoreAll`
|
||||||
|
- When `hiddenRowIds.size === 0`, show "No rows hidden" message in the popover
|
||||||
|
- Use dark gradient background, accent border, and box shadow matching the ColumnManager popover
|
||||||
|
- Place the button in the toolbar div adjacent to the ColumnManager button
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 7.2, 7.3_
|
||||||
|
|
||||||
|
- [x] 11. Final checkpoint — Verify complete feature integration
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- All changes are contained within `frontend/src/components/pages/ReportingPage.js` — no new files needed
|
||||||
|
- The design uses JavaScript throughout; fast-check is the PBT library
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests validate the 7 correctness properties defined in the design document
|
||||||
|
- Unit tests validate specific UI rendering scenarios and edge cases
|
||||||
31
README.md
31
README.md
@@ -145,10 +145,12 @@ node migrations/add_ivanti_findings_tables.js
|
|||||||
node migrations/add_ivanti_todo_queue_table.js
|
node migrations/add_ivanti_todo_queue_table.js
|
||||||
node migrations/add_card_workflow_type.js
|
node migrations/add_card_workflow_type.js
|
||||||
node migrations/add_todo_queue_ip_address.js
|
node migrations/add_todo_queue_ip_address.js
|
||||||
|
node migrations/add_todo_queue_hostname.js
|
||||||
node migrations/add_compliance_tables.js
|
node migrations/add_compliance_tables.js
|
||||||
node migrations/add_finding_archive_tables.js
|
node migrations/add_finding_archive_tables.js
|
||||||
node migrations/add_archer_tickets_timestamps.js
|
node migrations/add_archer_tickets_timestamps.js
|
||||||
node migrations/add_ivanti_counts_history_table.js
|
node migrations/add_ivanti_counts_history_table.js
|
||||||
|
node migrations/add_fp_submissions_table.js
|
||||||
node migrations/add_user_groups.js
|
node migrations/add_user_groups.js
|
||||||
node migrations/add_created_by_columns.js
|
node migrations/add_created_by_columns.js
|
||||||
```
|
```
|
||||||
@@ -354,6 +356,8 @@ Each row represents a single Ivanti host finding.
|
|||||||
|
|
||||||
**Inline editing:** Click a Host or DNS cell to override the Ivanti value. An amber dot (●) marks overridden cells; use the revert button (↻) to restore the original. Overrides survive re-syncs. Requires Admin or Standard_User group.
|
**Inline editing:** Click a Host or DNS cell to override the Ivanti value. An amber dot (●) marks overridden cells; use the revert button (↻) to restore the original. Overrides survive re-syncs. Requires Admin or Standard_User group.
|
||||||
|
|
||||||
|
**CVE Tooltips:** Hover over any CVE badge in the table to see a tooltip with the CVE description and severity (if the CVE exists in the local database). Tooltips appear after a 300ms delay, are cached in memory for the session, and auto-position to stay within the viewport.
|
||||||
|
|
||||||
**Filtering:** Click ⊙ on any column header for multi-select filtering. The `— empty —` option filters to findings with no value in that column. Multiple filters are ANDed. The Action Coverage chart also acts as a filter.
|
**Filtering:** Click ⊙ on any column header for multi-select filtering. The `— empty —` option filters to findings with no value in that column. Multiple filters are ANDed. The Action Coverage chart also acts as a filter.
|
||||||
|
|
||||||
**Column management:** Toggle visibility and drag to reorder via the **Columns** button. Order and visibility persist to `localStorage`.
|
**Column management:** Toggle visibility and drag to reorder via the **Columns** button. Order and visibility persist to `localStorage`.
|
||||||
@@ -381,6 +385,14 @@ A personal staging list for batch-processing FP, Archer, and CARD workflows with
|
|||||||
- Check the green checkbox on an item to mark it complete (strikethrough at reduced opacity)
|
- Check the green checkbox on an item to mark it complete (strikethrough at reduced opacity)
|
||||||
- Delete individual items with the trash icon, or select multiple and use **Delete (N)**
|
- Delete individual items with the trash icon, or select multiple and use **Delete (N)**
|
||||||
- **Clear Completed** removes all marked-complete items at once
|
- **Clear Completed** removes all marked-complete items at once
|
||||||
|
- **Create FP Workflow** — select pending FP items and click to open the FP Workflow modal, which submits a False Positive workflow batch directly to the Ivanti API with form fields, file attachments, and scope override. Successful submission marks the queue items as complete and records the submission locally.
|
||||||
|
|
||||||
|
**Redirecting completed items:**
|
||||||
|
- Completed items show a redirect button (↱) next to the delete icon
|
||||||
|
- Click redirect to open a modal where you select the target workflow type (FP, Archer, or CARD) and vendor (required for FP/Archer)
|
||||||
|
- Redirecting creates a new pending queue item with the same finding data under the new workflow type — the original completed item is preserved
|
||||||
|
- This is useful when a CARD inventory fix is done but the finding still needs an FP or Archer workflow, or when an item was assigned to the wrong workflow initially
|
||||||
|
- Not every completed item needs a redirect — it's an optional action for items that require further processing
|
||||||
|
|
||||||
Queue items are stored in the database, are **personal to your login**, and persist across sessions and page refreshes.
|
Queue items are stored in the database, are **personal to your login**, and persist across sessions and page refreshes.
|
||||||
|
|
||||||
@@ -563,6 +575,7 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
| GET | `/api/cves/distinct-ids` | Any | All distinct CVE IDs (used by NVD sync) |
|
| GET | `/api/cves/distinct-ids` | Any | All distinct CVE IDs (used by NVD sync) |
|
||||||
| GET | `/api/cves/:cveId/vendors` | Any | All vendor entries for a specific CVE ID |
|
| GET | `/api/cves/:cveId/vendors` | Any | All vendor entries for a specific CVE ID |
|
||||||
| GET | `/api/cves/compliance` | Any | Document compliance status view |
|
| GET | `/api/cves/compliance` | Any | Document compliance status view |
|
||||||
|
| GET | `/api/cves/:cveId/tooltip` | Any | Get CVE description and severity for tooltip display (truncated to 300 chars) |
|
||||||
|
|
||||||
### Documents
|
### Documents
|
||||||
|
|
||||||
@@ -606,13 +619,21 @@ All endpoints are prefixed with `/api`. All endpoints except `/api/auth/login` a
|
|||||||
| GET | `/api/ivanti/workflows` | Any | Get cached workflow data |
|
| GET | `/api/ivanti/workflows` | Any | Get cached workflow data |
|
||||||
| POST | `/api/ivanti/workflows/sync` | Admin, Standard_User | Trigger an immediate workflow sync |
|
| POST | `/api/ivanti/workflows/sync` | Admin, Standard_User | Trigger an immediate workflow sync |
|
||||||
|
|
||||||
|
### Ivanti — FP Workflow Submission
|
||||||
|
|
||||||
|
| Method | Path | Group | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/ivanti/fp-workflow` | Admin, Standard_User | Submit an FP workflow batch to Ivanti API (multipart/form-data with attachments) |
|
||||||
|
|
||||||
### Ivanti — Todo Queue
|
### Ivanti — Todo Queue
|
||||||
|
|
||||||
| Method | Path | Group | Description |
|
| Method | Path | Group | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/ivanti/todo-queue` | Any | Get all queue items for the current user |
|
| GET | `/api/ivanti/todo-queue` | Any | Get all queue items for the current user |
|
||||||
| POST | `/api/ivanti/todo-queue` | Admin, Standard_User | Add a finding to the queue |
|
| POST | `/api/ivanti/todo-queue` | Admin, Standard_User | Add a finding to the queue |
|
||||||
|
| POST | `/api/ivanti/todo-queue/batch` | Admin, Standard_User | Batch-add multiple findings to the queue |
|
||||||
| PUT | `/api/ivanti/todo-queue/:id` | Admin, Standard_User | Update a queue item (mark complete, edit vendor/type) |
|
| PUT | `/api/ivanti/todo-queue/:id` | Admin, Standard_User | Update a queue item (mark complete, edit vendor/type) |
|
||||||
|
| POST | `/api/ivanti/todo-queue/:id/redirect` | Admin, Standard_User | Redirect a completed item to a different workflow type |
|
||||||
| DELETE | `/api/ivanti/todo-queue/:id` | Admin, Standard_User | Delete a single queue item |
|
| DELETE | `/api/ivanti/todo-queue/:id` | Admin, Standard_User | Delete a single queue item |
|
||||||
| DELETE | `/api/ivanti/todo-queue/completed` | Admin, Standard_User | Delete all completed queue items |
|
| DELETE | `/api/ivanti/todo-queue/completed` | Admin, Standard_User | Delete all completed queue items |
|
||||||
|
|
||||||
@@ -736,6 +757,8 @@ cve-dashboard/
|
|||||||
├── NvdSyncModal.js # Bulk NVD sync dialog
|
├── NvdSyncModal.js # Bulk NVD sync dialog
|
||||||
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
├── KnowledgeBaseModal.js # Knowledge base upload/list modal
|
||||||
├── KnowledgeBaseViewer.js # Inline document viewer (sandboxed iframe, sanitized markdown)
|
├── KnowledgeBaseViewer.js # Inline document viewer (sandboxed iframe, sanitized markdown)
|
||||||
|
├── CveTooltip.js # Hover tooltip for CVE badges (portal-rendered, cached)
|
||||||
|
├── RedirectModal.js # Queue item redirect modal (workflow type + vendor selection)
|
||||||
└── pages/
|
└── pages/
|
||||||
├── ReportingPage.js # Host findings: charts, table, queue, export
|
├── ReportingPage.js # Host findings: charts, table, queue, export
|
||||||
├── CompliancePage.js # AEO compliance: metric cards, device table
|
├── CompliancePage.js # AEO compliance: metric cards, device table
|
||||||
@@ -784,7 +807,9 @@ cve-dashboard/
|
|||||||
|
|
||||||
**`ivanti_finding_overrides`** — Editor-applied overrides for `hostName` and `dns` fields. `UNIQUE(finding_id, field)`.
|
**`ivanti_finding_overrides`** — Editor-applied overrides for `hostName` and `dns` fields. `UNIQUE(finding_id, field)`.
|
||||||
|
|
||||||
**`ivanti_todo_queue`** — Personal per-user queue of findings staged for FP, Archer, or CARD processing. Keyed by `(user_id, finding_id)`.
|
**`ivanti_todo_queue`** — Personal per-user queue of findings staged for FP, Archer, or CARD processing. Keyed by `(user_id, finding_id)`. Completed items can be redirected to a different workflow type via `POST /:id/redirect`, which creates a new pending item preserving the original finding data.
|
||||||
|
|
||||||
|
**`ivanti_fp_submissions`** — Record of FP workflow submissions to the Ivanti API. Tracks user, workflow batch ID, form fields, finding IDs, queue item IDs, attachment results, and submission status (success/partial/failed).
|
||||||
|
|
||||||
**`compliance_uploads`** — Record of each compliance xlsx upload: filename, report date, uploader, timestamp, and new/resolved/recurring counts.
|
**`compliance_uploads`** — Record of each compliance xlsx upload: filename, report date, uploader, timestamp, and new/resolved/recurring counts.
|
||||||
|
|
||||||
@@ -897,10 +922,12 @@ node migrations/add_ivanti_findings_tables.js
|
|||||||
node migrations/add_ivanti_todo_queue_table.js
|
node migrations/add_ivanti_todo_queue_table.js
|
||||||
node migrations/add_card_workflow_type.js
|
node migrations/add_card_workflow_type.js
|
||||||
node migrations/add_todo_queue_ip_address.js
|
node migrations/add_todo_queue_ip_address.js
|
||||||
|
node migrations/add_todo_queue_hostname.js
|
||||||
node migrations/add_compliance_tables.js
|
node migrations/add_compliance_tables.js
|
||||||
node migrations/add_finding_archive_tables.js
|
node migrations/add_finding_archive_tables.js
|
||||||
node migrations/add_archer_tickets_timestamps.js
|
node migrations/add_archer_tickets_timestamps.js
|
||||||
node migrations/add_ivanti_counts_history_table.js
|
node migrations/add_ivanti_counts_history_table.js
|
||||||
|
node migrations/add_fp_submissions_table.js
|
||||||
node migrations/add_user_groups.js
|
node migrations/add_user_groups.js
|
||||||
node migrations/add_created_by_columns.js
|
node migrations/add_created_by_columns.js
|
||||||
cd ..
|
cd ..
|
||||||
@@ -935,10 +962,12 @@ node migrations/add_ivanti_findings_tables.js
|
|||||||
node migrations/add_ivanti_todo_queue_table.js
|
node migrations/add_ivanti_todo_queue_table.js
|
||||||
node migrations/add_card_workflow_type.js
|
node migrations/add_card_workflow_type.js
|
||||||
node migrations/add_todo_queue_ip_address.js
|
node migrations/add_todo_queue_ip_address.js
|
||||||
|
node migrations/add_todo_queue_hostname.js
|
||||||
node migrations/add_compliance_tables.js
|
node migrations/add_compliance_tables.js
|
||||||
node migrations/add_finding_archive_tables.js
|
node migrations/add_finding_archive_tables.js
|
||||||
node migrations/add_archer_tickets_timestamps.js
|
node migrations/add_archer_tickets_timestamps.js
|
||||||
node migrations/add_ivanti_counts_history_table.js
|
node migrations/add_ivanti_counts_history_table.js
|
||||||
|
node migrations/add_fp_submissions_table.js
|
||||||
node migrations/add_user_groups.js
|
node migrations/add_user_groups.js
|
||||||
node migrations/add_created_by_columns.js
|
node migrations/add_created_by_columns.js
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -109,11 +109,11 @@ function ivantiFormPost(urlPath, fields, files, apiKey, skipTls) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// File fields
|
// File fields
|
||||||
for (const { name, buffer, filename } of files) {
|
for (const { name, buffer, filename, contentType } of files) {
|
||||||
parts.push(Buffer.from(
|
parts.push(Buffer.from(
|
||||||
`--${boundary}\r\n` +
|
`--${boundary}\r\n` +
|
||||||
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
|
`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n` +
|
||||||
`Content-Type: application/octet-stream\r\n\r\n`
|
`Content-Type: ${contentType || 'application/octet-stream'}\r\n\r\n`
|
||||||
));
|
));
|
||||||
parts.push(buffer);
|
parts.push(buffer);
|
||||||
parts.push(Buffer.from('\r\n'));
|
parts.push(Buffer.from('\r\n'));
|
||||||
|
|||||||
29
backend/migrations/add_compliance_notes_group_id.js
Normal file
29
backend/migrations/add_compliance_notes_group_id.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Migration: Add group_id column to compliance_notes table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_compliance_notes_group_id migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`ALTER TABLE compliance_notes ADD COLUMN group_id TEXT`, (err) => {
|
||||||
|
if (err) console.error('Error adding group_id column:', err);
|
||||||
|
else console.log('✓ group_id column added to compliance_notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_compliance_notes_group ON compliance_notes(group_id)`, (err) => {
|
||||||
|
if (err) console.error('Error creating group_id index:', err);
|
||||||
|
else console.log('✓ idx_compliance_notes_group created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`UPDATE compliance_notes SET group_id = 'legacy-' || id WHERE group_id IS NULL`, (err) => {
|
||||||
|
if (err) console.error('Error backfilling group_id:', err);
|
||||||
|
else console.log('✓ Existing rows backfilled with legacy group_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
94
backend/migrations/add_fp_submission_editing.js
Normal file
94
backend/migrations/add_fp_submission_editing.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Migration: Add FP submission editing support (lifecycle status, batch UUID, history table)
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting FP submission editing migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
// Add lifecycle_status column to ivanti_fp_submissions
|
||||||
|
// Wrapped in try/catch style via callback — SQLite throws if column already exists
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE ivanti_fp_submissions ADD COLUMN lifecycle_status TEXT NOT NULL DEFAULT 'submitted' CHECK(lifecycle_status IN ('submitted', 'approved', 'rejected', 'rework', 'resubmitted'))`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.message.includes('duplicate column')) {
|
||||||
|
console.log('✓ lifecycle_status column already exists');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding lifecycle_status column:', err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ lifecycle_status column added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add ivanti_workflow_batch_uuid column
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE ivanti_fp_submissions ADD COLUMN ivanti_workflow_batch_uuid TEXT`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.message.includes('duplicate column')) {
|
||||||
|
console.log('✓ ivanti_workflow_batch_uuid column already exists');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding ivanti_workflow_batch_uuid column:', err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ ivanti_workflow_batch_uuid column added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add updated_at column (SQLite requires constant defaults for ALTER TABLE, so default to NULL)
|
||||||
|
db.run(
|
||||||
|
`ALTER TABLE ivanti_fp_submissions ADD COLUMN updated_at DATETIME DEFAULT NULL`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.message.includes('duplicate column')) {
|
||||||
|
console.log('✓ updated_at column already exists');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding updated_at column:', err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ updated_at column added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create submission history table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_fp_submission_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
submission_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
change_type TEXT NOT NULL CHECK(change_type IN (
|
||||||
|
'created', 'fields_updated', 'findings_added',
|
||||||
|
'attachments_added', 'status_changed'
|
||||||
|
)),
|
||||||
|
change_details_json TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (submission_id) REFERENCES ivanti_fp_submissions(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating history table:', err.message);
|
||||||
|
else console.log('✓ ivanti_fp_submission_history table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create index on submission_id for history lookups
|
||||||
|
db.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_fp_history_submission ON ivanti_fp_submission_history(submission_id)`,
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error creating history index:', err.message);
|
||||||
|
else console.log('✓ idx_fp_history_submission index created');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✓ Migration statements queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
80
backend/migrations/add_granite_workflow_type.js
Normal file
80
backend/migrations/add_granite_workflow_type.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Migration: Add GRANITE to workflow_type CHECK constraint on ivanti_todo_queue
|
||||||
|
// SQLite cannot ALTER a CHECK constraint, so this recreates the table.
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting add_granite_workflow_type migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run('PRAGMA foreign_keys = OFF', (err) => {
|
||||||
|
if (err) console.error('PRAGMA error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('BEGIN TRANSACTION', (err) => {
|
||||||
|
if (err) { console.error('BEGIN error:', err); return; }
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE ivanti_todo_queue_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
finding_title TEXT,
|
||||||
|
cves_json TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
hostname TEXT,
|
||||||
|
vendor TEXT NOT NULL,
|
||||||
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD', 'GRANITE')),
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating new table:', err);
|
||||||
|
else console.log('✓ ivanti_todo_queue_new created');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO ivanti_todo_queue_new SELECT id, user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type, status, created_at, updated_at FROM ivanti_todo_queue',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error copying data:', err);
|
||||||
|
else console.log('✓ Data copied');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run('DROP TABLE ivanti_todo_queue', (err) => {
|
||||||
|
if (err) console.error('Error dropping old table:', err);
|
||||||
|
else console.log('✓ Old table dropped');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'ALTER TABLE ivanti_todo_queue_new RENAME TO ivanti_todo_queue',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error renaming table:', err);
|
||||||
|
else console.log('✓ Table renamed');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
||||||
|
(err) => {
|
||||||
|
if (err) console.error('Error creating index:', err);
|
||||||
|
else console.log('✓ Index recreated');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
db.run('COMMIT', (err) => {
|
||||||
|
if (err) console.error('COMMIT error:', err);
|
||||||
|
else console.log('✓ Transaction committed');
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('PRAGMA foreign_keys = ON', () => {}); // FIXME: Callback does not handle the error parameter (should be `(err) => { if (err) ... }`)
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
@@ -8,12 +8,13 @@
|
|||||||
// GET /summary — metric health cards for a team (from latest upload)
|
// GET /summary — metric health cards for a team (from latest upload)
|
||||||
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
|
// GET /items — non-compliant devices grouped by hostname (?team=X&status=active)
|
||||||
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
|
// GET /items/:hostname — detail panel: all metrics + notes + upload history for a device
|
||||||
// POST /notes — add a note to a (hostname, metric_id) pair
|
// POST /notes — add a note to one or more (hostname, metric_id) pairs
|
||||||
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||||
@@ -488,7 +489,7 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
|||||||
|
|
||||||
// Notes (all metrics for this hostname, sorted newest first)
|
// Notes (all metrics for this hostname, sorted newest first)
|
||||||
const notes = await dbAll(db,
|
const notes = await dbAll(db,
|
||||||
`SELECT cn.id, cn.metric_id, cn.note, cn.created_at,
|
`SELECT cn.id, cn.metric_id, cn.note, cn.group_id, cn.created_at,
|
||||||
u.username AS created_by
|
u.username AS created_by
|
||||||
FROM compliance_notes cn
|
FROM compliance_notes cn
|
||||||
LEFT JOIN users u ON cn.created_by = u.id
|
LEFT JOIN users u ON cn.created_by = u.id
|
||||||
@@ -517,42 +518,82 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
|||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// POST /notes
|
// POST /notes
|
||||||
// Add a note to a (hostname, metric_id) pair.
|
// Add a note to one or more (hostname, metric_id) pairs.
|
||||||
// Body: { hostname, metric_id, note }
|
// Body: { hostname, metric_ids: [...], note } — or legacy { hostname, metric_id, note }
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.post('/notes', requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
const { hostname, metric_id, note } = req.body;
|
const { hostname, metric_id, metric_ids, note } = req.body;
|
||||||
|
|
||||||
if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) {
|
if (!hostname || typeof hostname !== 'string' || hostname.length > 300 || !/^[a-zA-Z0-9._-]+$/.test(hostname)) {
|
||||||
return res.status(400).json({ error: 'Invalid hostname format' });
|
return res.status(400).json({ error: 'Invalid hostname format' });
|
||||||
}
|
}
|
||||||
if (!metric_id || typeof metric_id !== 'string' || metric_id.length > 50) {
|
|
||||||
return res.status(400).json({ error: 'Invalid metric_id' });
|
// --- Resolve metric IDs: metric_ids takes precedence over metric_id ---
|
||||||
|
let resolvedIds;
|
||||||
|
if (metric_ids !== undefined) {
|
||||||
|
if (!Array.isArray(metric_ids)) {
|
||||||
|
return res.status(400).json({ error: 'metric_ids must be an array' });
|
||||||
|
}
|
||||||
|
resolvedIds = metric_ids;
|
||||||
|
} else if (metric_id !== undefined && metric_id !== null && metric_id !== '') {
|
||||||
|
if (typeof metric_id !== 'string' || metric_id.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Invalid metric_id' });
|
||||||
|
}
|
||||||
|
resolvedIds = [metric_id];
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'metric_id or metric_ids is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Validate resolved metric IDs ---
|
||||||
|
if (resolvedIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'At least one metric ID is required' });
|
||||||
|
}
|
||||||
|
for (let i = 0; i < resolvedIds.length; i++) {
|
||||||
|
const mid = resolvedIds[i];
|
||||||
|
if (!mid || typeof mid !== 'string' || mid.length === 0 || mid.length > 50) {
|
||||||
|
return res.status(400).json({ error: `Invalid metric_id at index ${i}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const noteText = String(note || '').trim().slice(0, 1000);
|
const noteText = String(note || '').trim().slice(0, 1000);
|
||||||
if (!noteText) {
|
if (!noteText) {
|
||||||
return res.status(400).json({ error: 'Note cannot be empty' });
|
return res.status(400).json({ error: 'Note cannot be empty' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const groupId = crypto.randomUUID();
|
||||||
const { lastID } = await dbRun(db,
|
const userId = req.user?.id || null;
|
||||||
`INSERT INTO compliance_notes (hostname, metric_id, note, created_by, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
|
||||||
[hostname, metric_id, noteText, req.user?.id || null]
|
|
||||||
);
|
|
||||||
|
|
||||||
const created = await dbGet(db,
|
try {
|
||||||
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.created_at,
|
await dbRun(db, 'BEGIN TRANSACTION');
|
||||||
|
|
||||||
|
const insertedIds = [];
|
||||||
|
for (const mid of resolvedIds) {
|
||||||
|
const { lastID } = await dbRun(db,
|
||||||
|
`INSERT INTO compliance_notes (hostname, metric_id, note, group_id, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
[hostname, mid, noteText, groupId, userId]
|
||||||
|
);
|
||||||
|
insertedIds.push(lastID);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbRun(db, 'COMMIT');
|
||||||
|
|
||||||
|
// Fetch all created rows with username
|
||||||
|
const placeholders = insertedIds.map(() => '?').join(', ');
|
||||||
|
const notes = await dbAll(db,
|
||||||
|
`SELECT cn.id, cn.hostname, cn.metric_id, cn.note, cn.group_id, cn.created_at,
|
||||||
u.username AS created_by
|
u.username AS created_by
|
||||||
FROM compliance_notes cn
|
FROM compliance_notes cn
|
||||||
LEFT JOIN users u ON cn.created_by = u.id
|
LEFT JOIN users u ON cn.created_by = u.id
|
||||||
WHERE cn.id = ?`,
|
WHERE cn.id IN (${placeholders})
|
||||||
[lastID]
|
ORDER BY cn.id ASC`,
|
||||||
|
insertedIds
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json(created);
|
res.status(201).json({ notes });
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await dbRun(db, 'ROLLBACK').catch(() => {});
|
||||||
console.error('[Compliance] POST /notes error:', err.message);
|
console.error('[Compliance] POST /notes error:', err.message);
|
||||||
res.status(500).json({ error: 'Failed to save note' });
|
res.status(500).json({ error: 'Failed to save note' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,37 @@ const express = require('express');
|
|||||||
|
|
||||||
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
|
const VALID_STATES = ['ACTIVE', 'ARCHIVED', 'RETURNED', 'CLOSED'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most severe active finding related to an archived finding.
|
||||||
|
*
|
||||||
|
* A match requires:
|
||||||
|
* - Exact hostname match (case-sensitive)
|
||||||
|
* - The archive title is a case-insensitive substring of the active title, or vice versa
|
||||||
|
* - The active finding ID differs from the archive's finding_id
|
||||||
|
*
|
||||||
|
* @param {Object} archive - Archive record from ivanti_finding_archives
|
||||||
|
* @param {Array} activeFindings - Parsed entries from ivanti_findings_cache
|
||||||
|
* @returns {{ id: string, title: string, severity: number } | null}
|
||||||
|
*/
|
||||||
|
function findRelatedActive(archive, activeFindings) {
|
||||||
|
const archiveTitle = (archive.finding_title || '').toLowerCase();
|
||||||
|
|
||||||
|
const matches = activeFindings.filter(f => {
|
||||||
|
if (f.hostName !== archive.host_name) return false;
|
||||||
|
if (f.id === archive.finding_id) return false;
|
||||||
|
|
||||||
|
const activeTitle = (f.title || '').toLowerCase();
|
||||||
|
if (!archiveTitle.includes(activeTitle) && !activeTitle.includes(archiveTitle)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
const best = matches.reduce((a, b) => (b.severity > a.severity ? b : a));
|
||||||
|
return { id: best.id, title: best.title, severity: best.severity };
|
||||||
|
}
|
||||||
|
|
||||||
function createIvantiArchiveRouter(db, requireAuth) {
|
function createIvantiArchiveRouter(db, requireAuth) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -45,7 +76,37 @@ function createIvantiArchiveRouter(db, requireAuth) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ archives, total: archives.length });
|
// Fetch and parse active findings cache for related-finding enrichment
|
||||||
|
let activeFindings = [];
|
||||||
|
try {
|
||||||
|
const cacheRow = await new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT findings_json FROM ivanti_findings_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cacheRow && cacheRow.findings_json) {
|
||||||
|
activeFindings = JSON.parse(cacheRow.findings_json);
|
||||||
|
}
|
||||||
|
} catch (cacheErr) {
|
||||||
|
console.warn('Failed to load findings cache for related-active matching:', cacheErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(activeFindings)) {
|
||||||
|
activeFindings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich each archive record with related active finding info
|
||||||
|
const enrichedArchives = archives.map(archive => ({
|
||||||
|
...archive,
|
||||||
|
related_active: findRelatedActive(archive, activeFindings)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ archives: enrichedArchives, total: enrichedArchives.length });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Archive list error:', err);
|
console.error('Archive list error:', err);
|
||||||
res.status(500).json({ error: 'Failed to fetch archive records' });
|
res.status(500).json({ error: 'Failed to fetch archive records' });
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ const express = require('express');
|
|||||||
const { requireGroup } = require('../middleware/auth');
|
const { requireGroup } = require('../middleware/auth');
|
||||||
const logAudit = require('../helpers/auditLog');
|
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'];
|
const VALID_STATUSES = ['pending', 'complete'];
|
||||||
|
|
||||||
function isValidVendor(vendor) {
|
function isValidVendor(vendor) {
|
||||||
@@ -27,20 +27,27 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
*/
|
*/
|
||||||
router.get('/', requireAuth(db), (req, res) => {
|
router.get('/', requireAuth(db), (req, res) => {
|
||||||
db.all(
|
db.all(
|
||||||
`SELECT * FROM ivanti_todo_queue
|
`SELECT q.*,
|
||||||
WHERE user_id = ?
|
o.value AS override_hostname
|
||||||
ORDER BY vendor ASC, created_at ASC`,
|
FROM ivanti_todo_queue q
|
||||||
|
LEFT JOIN ivanti_finding_overrides o
|
||||||
|
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||||
|
WHERE q.user_id = ?
|
||||||
|
ORDER BY q.vendor ASC, q.created_at ASC`,
|
||||||
[req.user.id],
|
[req.user.id],
|
||||||
(err, rows) => {
|
(err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Error fetching todo queue:', err);
|
console.error('Error fetching todo queue:', err);
|
||||||
return res.status(500).json({ error: 'Internal server error.' });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
// Parse cves_json back to array for each row
|
// Parse cves_json back to array; prefer overridden hostname
|
||||||
const parsed = rows.map((r) => ({
|
const parsed = rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
|
hostname: r.override_hostname || r.hostname,
|
||||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||||
}));
|
}));
|
||||||
|
// Clean up the extra column from the response
|
||||||
|
parsed.forEach((r) => delete r.override_hostname);
|
||||||
res.json(parsed);
|
res.json(parsed);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -57,8 +64,8 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
|
* @body {string[]} [findings[].cves] - Optional array of CVE identifiers
|
||||||
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
|
* @body {string} [findings[].ip_address] - Optional IP address (max 64 chars)
|
||||||
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
|
* @body {string} [findings[].hostname] - Optional hostname (max 255 chars)
|
||||||
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD'
|
* @body {string} workflow_type - One of 'FP', 'Archer', 'CARD', 'GRANITE'
|
||||||
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD
|
* @body {string} vendor - Required for FP/Archer (max 200 chars); optional for CARD/GRANITE
|
||||||
*
|
*
|
||||||
* @returns {Object} 201 - { items: Array<Object> } array of created queue items,
|
* @returns {Object} 201 - { items: Array<Object> } array of created queue items,
|
||||||
* each with: id, user_id, finding_id, finding_title, cves_json, ip_address,
|
* each with: id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
@@ -82,10 +89,10 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
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)) {
|
if (!isValidVendor(vendor)) {
|
||||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||||
}
|
}
|
||||||
@@ -95,7 +102,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
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;
|
const userId = req.user.id;
|
||||||
|
|
||||||
// --- Transactional batch insert ---
|
// --- Transactional batch insert ---
|
||||||
@@ -154,7 +161,11 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
// Fetch all inserted rows
|
// Fetch all inserted rows
|
||||||
const placeholders = insertedIds.map(() => '?').join(',');
|
const placeholders = insertedIds.map(() => '?').join(',');
|
||||||
db.all(
|
db.all(
|
||||||
`SELECT * FROM ivanti_todo_queue WHERE id IN (${placeholders})`,
|
`SELECT q.*, o.value AS override_hostname
|
||||||
|
FROM ivanti_todo_queue q
|
||||||
|
LEFT JOIN ivanti_finding_overrides o
|
||||||
|
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||||
|
WHERE q.id IN (${placeholders})`,
|
||||||
insertedIds,
|
insertedIds,
|
||||||
(fetchErr, fetchedRows) => {
|
(fetchErr, fetchedRows) => {
|
||||||
if (fetchErr) {
|
if (fetchErr) {
|
||||||
@@ -162,10 +173,15 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
return res.status(500).json({ error: 'Internal server error.' });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = (fetchedRows || []).map((r) => ({
|
const items = (fetchedRows || []).map((r) => {
|
||||||
...r,
|
const item = {
|
||||||
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
...r,
|
||||||
}));
|
hostname: r.override_hostname || r.hostname,
|
||||||
|
cves: r.cves_json ? JSON.parse(r.cves_json) : [],
|
||||||
|
};
|
||||||
|
delete item.override_hostname;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
// Audit log (fire-and-forget)
|
// Audit log (fire-and-forget)
|
||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
@@ -203,8 +219,9 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
* @body {string} [finding_title] - Optional finding title (max 500 chars)
|
* @body {string} [finding_title] - Optional finding title (max 500 chars)
|
||||||
* @body {string[]} [cves] - Optional array of CVE identifiers
|
* @body {string[]} [cves] - Optional array of CVE identifiers
|
||||||
* @body {string} [ip_address] - Optional IP address (max 64 chars)
|
* @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} [hostname] - Optional hostname (max 255 chars)
|
||||||
* @body {string} workflow_type - One of 'FP', 'Archer', '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 - Created queue item with parsed cves array:
|
* @returns {Object} 201 - Created queue item with parsed cves array:
|
||||||
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
* id, user_id, finding_id, finding_title, cves_json, ip_address,
|
||||||
@@ -219,17 +236,17 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
return res.status(400).json({ error: 'finding_id is required.' });
|
return res.status(400).json({ error: 'finding_id is required.' });
|
||||||
}
|
}
|
||||||
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
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
|
// Vendor is required for FP and Archer, optional for CARD/GRANITE
|
||||||
if (workflow_type !== 'CARD' && !isValidVendor(vendor)) {
|
if (!['CARD', 'GRANITE'].includes(workflow_type) && !isValidVendor(vendor)) {
|
||||||
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
return res.status(400).json({ error: 'vendor is required for FP and Archer workflows.' });
|
||||||
}
|
}
|
||||||
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
if (vendor !== undefined && vendor !== '' && !isValidVendor(vendor)) {
|
||||||
return res.status(400).json({ error: 'vendor must be under 200 chars.' });
|
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 cvesJson = Array.isArray(cves) ? JSON.stringify(cves) : null;
|
||||||
const ipVal = ip_address && typeof ip_address === 'string' ? ip_address.trim().slice(0, 64) : 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;
|
const hostVal = hostname && typeof hostname === 'string' ? hostname.trim().slice(0, 255) : null;
|
||||||
@@ -248,13 +265,23 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
return res.status(500).json({ error: 'Internal server error.' });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
db.get(
|
db.get(
|
||||||
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
`SELECT q.*, o.value AS override_hostname
|
||||||
|
FROM ivanti_todo_queue q
|
||||||
|
LEFT JOIN ivanti_finding_overrides o
|
||||||
|
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||||
|
WHERE q.id = ?`,
|
||||||
[this.lastID],
|
[this.lastID],
|
||||||
(err2, row) => {
|
(err2, row) => {
|
||||||
if (err2 || !row) {
|
if (err2 || !row) {
|
||||||
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
|
return res.status(201).json({ id: this.lastID, message: 'Added to queue.' });
|
||||||
}
|
}
|
||||||
res.status(201).json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
const result = {
|
||||||
|
...row,
|
||||||
|
hostname: row.override_hostname || row.hostname,
|
||||||
|
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||||
|
};
|
||||||
|
delete result.override_hostname;
|
||||||
|
res.status(201).json(result);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,7 +295,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
*
|
*
|
||||||
* @param {string} id - Queue item ID (URL parameter)
|
* @param {string} id - Queue item ID (URL parameter)
|
||||||
* @body {string} [vendor] - New vendor string (max 200 chars)
|
* @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'
|
* @body {string} [status] - One of 'pending', 'complete'
|
||||||
*
|
*
|
||||||
* @returns {Object} 200 - Updated queue item with parsed cves array:
|
* @returns {Object} 200 - Updated queue item with parsed cves array:
|
||||||
@@ -286,7 +313,7 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
return res.status(400).json({ error: 'vendor must be a non-empty string (max 200 chars).' });
|
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)) {
|
if (workflow_type !== undefined && !VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
return res.status(400).json({ error: 'workflow_type must be FP or Archer.' });
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||||
}
|
}
|
||||||
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||||
return res.status(400).json({ error: 'status must be pending or complete.' });
|
return res.status(400).json({ error: 'status must be pending or complete.' });
|
||||||
@@ -336,13 +363,134 @@ function createIvantiTodoQueueRouter(db, requireAuth) {
|
|||||||
return res.status(500).json({ error: 'Internal server error.' });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
db.get(
|
db.get(
|
||||||
'SELECT * FROM ivanti_todo_queue WHERE id = ?',
|
`SELECT q.*, o.value AS override_hostname
|
||||||
|
FROM ivanti_todo_queue q
|
||||||
|
LEFT JOIN ivanti_finding_overrides o
|
||||||
|
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||||
|
WHERE q.id = ?`,
|
||||||
[id],
|
[id],
|
||||||
(err3, row) => {
|
(err3, row) => {
|
||||||
if (err3 || !row) {
|
if (err3 || !row) {
|
||||||
return res.json({ message: 'Queue item updated.' });
|
return res.json({ message: 'Queue item updated.' });
|
||||||
}
|
}
|
||||||
res.json({ ...row, cves: row.cves_json ? JSON.parse(row.cves_json) : [] });
|
const result = {
|
||||||
|
...row,
|
||||||
|
hostname: row.override_hostname || row.hostname,
|
||||||
|
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||||
|
};
|
||||||
|
delete result.override_hostname;
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ivanti/todo-queue/:id/redirect
|
||||||
|
*
|
||||||
|
* Redirect a completed queue item to a different workflow type.
|
||||||
|
* Creates a new pending item copying finding data from the original.
|
||||||
|
*
|
||||||
|
* @param {string} id - Original queue item ID (URL parameter)
|
||||||
|
* @body {string} workflow_type - Target workflow type: 'FP', 'Archer', '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
|
||||||
|
* @returns {Object} 404 - { error: string } if item not found for current user
|
||||||
|
* @returns {Object} 500 - { error: string } on database error
|
||||||
|
*/
|
||||||
|
router.post('/:id/redirect', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { workflow_type, vendor } = req.body;
|
||||||
|
|
||||||
|
// --- Validation ---
|
||||||
|
if (!VALID_WORKFLOW_TYPES.includes(workflow_type)) {
|
||||||
|
return res.status(400).json({ error: 'workflow_type must be FP, Archer, CARD, or GRANITE.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['CARD', 'GRANITE'].includes(workflow_type)) {
|
||||||
|
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 = ['CARD', 'GRANITE'].includes(workflow_type) ? '' : vendor.trim();
|
||||||
|
|
||||||
|
// --- Fetch original item scoped to current user ---
|
||||||
|
db.get(
|
||||||
|
'SELECT * FROM ivanti_todo_queue WHERE id = ? AND user_id = ?',
|
||||||
|
[id, req.user.id],
|
||||||
|
(err, original) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching queue item for redirect:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
if (!original) {
|
||||||
|
return res.status(404).json({ error: 'Queue item not found.' });
|
||||||
|
}
|
||||||
|
if (original.status !== 'complete') {
|
||||||
|
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- INSERT new row copying finding data from original ---
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_todo_queue
|
||||||
|
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[req.user.id, original.finding_id, original.finding_title, original.cves_json, original.ip_address, original.hostname, vendorVal, workflow_type],
|
||||||
|
function (insertErr) {
|
||||||
|
if (insertErr) {
|
||||||
|
console.error('Error inserting redirected queue item:', insertErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newId = this.lastID;
|
||||||
|
|
||||||
|
// --- Fetch the inserted row ---
|
||||||
|
db.get(
|
||||||
|
`SELECT q.*, o.value AS override_hostname
|
||||||
|
FROM ivanti_todo_queue q
|
||||||
|
LEFT JOIN ivanti_finding_overrides o
|
||||||
|
ON o.finding_id = q.finding_id AND o.field = 'hostName'
|
||||||
|
WHERE q.id = ?`,
|
||||||
|
[newId],
|
||||||
|
(fetchErr, row) => {
|
||||||
|
if (fetchErr || !row) {
|
||||||
|
console.error('Error fetching redirected queue item:', fetchErr);
|
||||||
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log (fire-and-forget)
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'queue_item_redirected',
|
||||||
|
entityType: 'ivanti_todo_queue',
|
||||||
|
entityId: String(original.id),
|
||||||
|
details: {
|
||||||
|
original_workflow_type: original.workflow_type,
|
||||||
|
target_workflow_type: workflow_type,
|
||||||
|
new_item_id: newId,
|
||||||
|
vendor: vendorVal,
|
||||||
|
},
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...row,
|
||||||
|
hostname: row.override_hostname || row.hostname,
|
||||||
|
cves: row.cves_json ? JSON.parse(row.cves_json) : [],
|
||||||
|
};
|
||||||
|
delete result.override_hostname;
|
||||||
|
return res.status(201).json(result);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,26 +74,114 @@ Key details:
|
|||||||
|
|
||||||
Returns HTTP 200 or 202 (Accepted — async job creation). Response contains a numeric `id` (the workflow batch job ID) and `created` timestamp. No `generatedId` or `uuid` in this response.
|
Returns HTTP 200 or 202 (Accepted — async job creation). Response contains a numeric `id` (the workflow batch job ID) and `created` timestamp. No `generatedId` or `uuid` in this response.
|
||||||
|
|
||||||
|
### Map Findings to Existing Workflow (tested 2026-04-13)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /client/{clientId}/workflowBatch/falsePositive/{workflowBatchUuid}/map
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Maps additional host findings to an existing FP workflow batch. Used by the FP submission editing feature to add findings after initial creation.
|
||||||
|
|
||||||
|
**Critical: one finding per call.** The map endpoint only reliably maps one finding per request. Sending multiple finding IDs via the `IN` operator or comma-separated values results in only the first finding being mapped. The multipart/form-data format (used by the create endpoint) returns 500 on this endpoint.
|
||||||
|
|
||||||
|
#### Request body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject": "hostFinding",
|
||||||
|
"filterRequest": {
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"field": "id",
|
||||||
|
"exclusive": false,
|
||||||
|
"operator": "EXACT",
|
||||||
|
"value": "2283734550"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key details:
|
||||||
|
- Must be `application/json` (NOT multipart/form-data — returns 500)
|
||||||
|
- Use `EXACT` operator with a single finding ID per call
|
||||||
|
- `IN` operator with comma-separated IDs only maps the first finding
|
||||||
|
- Loop through findings and make one API call per finding
|
||||||
|
- The `workflowBatchUuid` in the URL is the UUID from the search endpoint (not the numeric batch ID from create)
|
||||||
|
|
||||||
|
#### Response (200)
|
||||||
|
|
||||||
|
Returns the updated workflow batch object on success.
|
||||||
|
|
||||||
|
#### UUID resolution
|
||||||
|
|
||||||
|
The `workflowBatchUuid` required in the URL is NOT returned by the create endpoint. To obtain it:
|
||||||
|
|
||||||
|
1. Search via `POST /client/{clientId}/workflowBatch/search` with `{ field: 'name', operator: 'EXACT', value: '<workflow_name>' }`
|
||||||
|
2. Use `projection: 'internal'` to get full batch objects
|
||||||
|
3. The UUID is in the `uuid` field of the returned batch object
|
||||||
|
4. Cache the UUID locally after first resolution (stored in `ivanti_fp_submissions.ivanti_workflow_batch_uuid`)
|
||||||
|
|
||||||
|
#### Implementation in dashboard
|
||||||
|
|
||||||
|
The `resolveWorkflowBatchUuid()` helper in `backend/routes/ivantiFpWorkflow.js` handles UUID resolution:
|
||||||
|
- Returns cached UUID if available in the local submission record
|
||||||
|
- Otherwise searches Ivanti by workflow name, extracts `batch.uuid`, and caches it for future use
|
||||||
|
|
||||||
|
The findings map loop in the `POST /submissions/:id/findings` endpoint:
|
||||||
|
- Iterates through each finding ID individually
|
||||||
|
- Makes one JSON POST per finding with `EXACT` operator
|
||||||
|
- Tracks which findings succeeded vs failed
|
||||||
|
- Only marks queue items as complete for successfully mapped findings
|
||||||
|
- Returns both `addedFindings` and `failedFindings` arrays in the response
|
||||||
|
|
||||||
### Other Workflow Endpoints (from Swagger)
|
### Other Workflow Endpoints (from Swagger)
|
||||||
|
|
||||||
These are available but not currently used by the dashboard:
|
These are available but not all are currently used by the dashboard:
|
||||||
|
|
||||||
| Endpoint | Purpose |
|
| Endpoint | Purpose | Status |
|
||||||
|----------|---------|
|
|----------|---------|--------|
|
||||||
| `/workflowBatch/acceptance/request` | Risk acceptance workflow |
|
| `/workflowBatch/acceptance/request` | Risk acceptance workflow | Not used |
|
||||||
| `/workflowBatch/remediation/request` | Remediation workflow |
|
| `/workflowBatch/remediation/request` | Remediation workflow | Not used |
|
||||||
| `/workflowBatch/severityChange/request` | Severity change workflow |
|
| `/workflowBatch/severityChange/request` | Severity change workflow | Not used |
|
||||||
| `/workflowBatch/{workflowType}/approve` | Approve a workflow (needs `workflowBatchUuid`) |
|
| `/workflowBatch/{workflowType}/approve` | Approve a workflow (needs `workflowBatchUuid`) | Not used |
|
||||||
| `/workflowBatch/{workflowType}/reject` | Reject a workflow |
|
| `/workflowBatch/{workflowType}/reject` | Reject a workflow | Not used |
|
||||||
| `/workflowBatch/{workflowType}/rework` | Send back for rework |
|
| `/workflowBatch/{workflowType}/rework` | Send back for rework | Not used |
|
||||||
| `/workflowBatch/{workflowType}/update` | Update a workflow |
|
| `/workflowBatch/{workflowType}/update` | Update a workflow | Not used |
|
||||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/map` | Map findings to workflow |
|
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/map` | Map findings to workflow | Used (FP editing) |
|
||||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/unmap` | Unmap findings |
|
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/unmap` | Unmap findings | Not used |
|
||||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/attach` | Attach file to existing workflow |
|
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/attach` | Attach file to existing workflow | **Broken — see note** |
|
||||||
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/detach` | Detach file |
|
| `/workflowBatch/{workflowType}/{workflowBatchUuid}/detach` | Detach file | Not used |
|
||||||
| `/workflowBatch/model` | Get model/schema |
|
| `/workflowBatch/model` | Get model/schema | Not used |
|
||||||
| `/workflowBatch/filter` | Get available filter fields |
|
| `/workflowBatch/filter` | Get available filter fields | Not used |
|
||||||
| `/workflowBatch/suggest` | Get suggested values for a filter field |
|
| `/workflowBatch/suggest` | Get suggested values for a filter field | Not used |
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
#### Attach endpoint does not work (tested 2026-04-13)
|
||||||
|
|
||||||
|
The `/workflowBatch/{workflowType}/{workflowBatchUuid}/attach` endpoint is listed in the Swagger spec but returns HTTP 400 (Bad Request) for all tested request formats:
|
||||||
|
|
||||||
|
- `multipart/form-data` with field name `file` (singular) — 400
|
||||||
|
- `multipart/form-data` with field name `files` (plural) — 400
|
||||||
|
- Tested with `Content-Type: application/octet-stream` and `image/png` — both 400
|
||||||
|
- Tested with both `ivantiMultipartPost` and `ivantiFormPost` helpers — both 400
|
||||||
|
|
||||||
|
The Ivanti response is a generic Spring Boot error with no detail message:
|
||||||
|
```json
|
||||||
|
{"timestamp":"...","status":400,"error":"Bad Request","path":"/api/v1/client/1550/workflowBatch/falsePositive/{uuid}/attach"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workaround:** File attachments can only be uploaded during the initial workflow creation (sent inline with the `/workflowBatch/falsePositive/request` endpoint). To add attachments to an existing workflow, users must upload them directly in the Ivanti platform UI.
|
||||||
|
|
||||||
|
#### Search by numeric batch ID does not work
|
||||||
|
|
||||||
|
The `/workflowBatch/search` endpoint does not support filtering by the numeric `id` returned from the create endpoint. Searching with `{ field: 'id', operator: 'EXACT', value: '33432541' }` returns 0 results. Searching by `name` field works and returns the workflow batch object including the `uuid` field needed for map/attach operations.
|
||||||
|
|
||||||
|
#### UUID not returned by create endpoint
|
||||||
|
|
||||||
|
The `/workflowBatch/falsePositive/request` create endpoint returns only `{ id: <number>, created: <timestamp> }`. The `uuid` needed for map/attach/approve/reject operations must be obtained separately via the search endpoint.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
|||||||
94
docs/kb-compliance-guide.md
Normal file
94
docs/kb-compliance-guide.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# AEO Compliance Tracking Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Compliance page tracks AEO security posture metrics for the STEAM and ACCESS-ENG teams. It processes weekly xlsx compliance reports, shows per-metric health cards, and tracks non-compliant devices down to the individual hostname level.
|
||||||
|
|
||||||
|
## Teams Tracked
|
||||||
|
|
||||||
|
Only two teams are monitored:
|
||||||
|
- **STEAM** (NTS-AEO-STEAM)
|
||||||
|
- **ACCESS-ENG** (NTS-AEO-ACCESS-ENG)
|
||||||
|
|
||||||
|
## Uploading a Compliance Report
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- You must have editor or admin access
|
||||||
|
- The report must be an `.xlsx` file (the standard NTS_AEO compliance export)
|
||||||
|
|
||||||
|
### Upload Process
|
||||||
|
|
||||||
|
1. Navigate to the **Compliance** page
|
||||||
|
2. Click the **Upload Report** button
|
||||||
|
3. Drag and drop the xlsx file or click to browse
|
||||||
|
4. The system parses the spreadsheet using a Python backend script and shows a **preview**:
|
||||||
|
- **New items**: Devices/metrics appearing for the first time
|
||||||
|
- **Recurring items**: Devices/metrics that were already non-compliant
|
||||||
|
- **Resolved items**: Previously non-compliant items no longer in the report
|
||||||
|
5. Review the diff summary
|
||||||
|
6. Click **Commit** to save the data
|
||||||
|
|
||||||
|
The upload is a two-step process (preview then commit) so you can verify the data before it's written to the database.
|
||||||
|
|
||||||
|
## Health Cards
|
||||||
|
|
||||||
|
After uploading, the page displays metric health cards for each team. Each card shows:
|
||||||
|
|
||||||
|
- **Metric ID** — the compliance metric identifier
|
||||||
|
- **Category** — the metric category (Vulnerability Management, Access & MFA, Logging & Monitoring, etc.)
|
||||||
|
- **Compliance %** — current compliance percentage
|
||||||
|
- **Target** — the required target percentage
|
||||||
|
- **Status** — color-coded:
|
||||||
|
- Green: Meets/Exceeds Target
|
||||||
|
- Amber: Within 15% of Target
|
||||||
|
- Red: Below 15% of Target
|
||||||
|
|
||||||
|
Click a health card to filter the device list to that specific metric.
|
||||||
|
|
||||||
|
## Metric Categories
|
||||||
|
|
||||||
|
| Category | Color |
|
||||||
|
|----------|-------|
|
||||||
|
| Vulnerability Management | Red |
|
||||||
|
| Access & MFA | Amber |
|
||||||
|
| Logging & Monitoring | Purple |
|
||||||
|
| End-of-Life OS | Orange |
|
||||||
|
| Decommissioned Assets | Slate |
|
||||||
|
| Asset Data Quality | Slate |
|
||||||
|
| Application Security | Blue |
|
||||||
|
| Disaster Recovery | Teal |
|
||||||
|
| Endpoint Protection | Orange |
|
||||||
|
|
||||||
|
## Device-Level Tracking
|
||||||
|
|
||||||
|
Below the health cards, the device list shows non-compliant devices grouped by hostname. Each device entry shows:
|
||||||
|
|
||||||
|
- Hostname and IP address
|
||||||
|
- Device type and team assignment
|
||||||
|
- Failing metrics with first-seen and last-seen dates
|
||||||
|
- Seen count (how many consecutive reports the device has been non-compliant)
|
||||||
|
|
||||||
|
### Device Detail Panel
|
||||||
|
|
||||||
|
Click a device to open the detail panel showing:
|
||||||
|
- All metrics the device is failing
|
||||||
|
- Upload history (when the device first appeared, when it was last seen)
|
||||||
|
- Per-metric notes with timestamps
|
||||||
|
|
||||||
|
### Adding Notes
|
||||||
|
|
||||||
|
You can add notes to any device/metric combination:
|
||||||
|
1. Open the device detail panel
|
||||||
|
2. Find the metric you want to annotate
|
||||||
|
3. Type your note and save
|
||||||
|
4. Notes are timestamped and attributed to the logged-in user
|
||||||
|
|
||||||
|
Notes are useful for tracking remediation progress, vendor ticket numbers, or explaining why a device is non-compliant.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. Weekly xlsx report is uploaded through the dashboard
|
||||||
|
2. Python parser extracts team metrics and non-compliant devices
|
||||||
|
3. Diff is computed against existing data (new/recurring/resolved)
|
||||||
|
4. On commit: new items are inserted, recurring items have their seen_count incremented, resolved items are marked with resolved_on date
|
||||||
|
5. Health cards and device lists update automatically
|
||||||
104
docs/kb-cve-tracking-guide.md
Normal file
104
docs/kb-cve-tracking-guide.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# CVE Tracking & NVD Sync Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Home page (CVE Management) is where you track individual CVEs across vendors, store supporting documentation, and link Archer risk acceptance tickets. It serves as the reference library for all vulnerability research and evidence.
|
||||||
|
|
||||||
|
## Adding a CVE
|
||||||
|
|
||||||
|
1. Click "Add CVE" on the Home page
|
||||||
|
2. Enter the **CVE ID** (format: CVE-YYYY-NNNNN, e.g., CVE-2024-6387)
|
||||||
|
3. Click the NVD lookup button to auto-populate fields from the National Vulnerability Database:
|
||||||
|
- Description
|
||||||
|
- Severity (Critical, High, Medium, Low)
|
||||||
|
- Published date
|
||||||
|
4. Select or type the **Vendor/Platform** (e.g., Cisco, Juniper, ADTRAN)
|
||||||
|
5. Review and adjust any fields as needed
|
||||||
|
6. Click Save
|
||||||
|
|
||||||
|
### NVD Auto-Population
|
||||||
|
|
||||||
|
The NVD lookup queries the NIST NVD 2.0 API and extracts:
|
||||||
|
- English description
|
||||||
|
- CVSS severity using a cascade: v3.1 → v3.0 → v2.0
|
||||||
|
- Published date
|
||||||
|
|
||||||
|
If the NVD API is rate-limited (429 response), wait a few seconds and try again. Having an NVD API key configured in the backend `.env` file increases the rate limit.
|
||||||
|
|
||||||
|
## CVE Details
|
||||||
|
|
||||||
|
Each CVE entry tracks:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| CVE ID | The CVE identifier (e.g., CVE-2024-6387) |
|
||||||
|
| Vendor | The affected vendor/platform |
|
||||||
|
| Severity | Critical, High, Medium, or Low |
|
||||||
|
| Description | Vulnerability description (from NVD or manual entry) |
|
||||||
|
| Published Date | When the CVE was published |
|
||||||
|
| Status | Open, In Progress, Addressed, or Resolved |
|
||||||
|
|
||||||
|
## Document Storage
|
||||||
|
|
||||||
|
Each CVE/vendor pair can have supporting documents attached. These serve as evidence for FP workflows, Archer tickets, and audit purposes.
|
||||||
|
|
||||||
|
### Uploading Documents
|
||||||
|
1. Open a CVE entry
|
||||||
|
2. Click "Upload Document"
|
||||||
|
3. Select the file (max 10 MB)
|
||||||
|
4. Documents are stored in `uploads/cves/{cveId}/{vendor}/` on the server
|
||||||
|
|
||||||
|
### Document Types
|
||||||
|
- **Advisory** — vendor security advisories
|
||||||
|
- **Email** — vendor communications or support ticket responses
|
||||||
|
- **Screenshot** — device screenshots showing version info
|
||||||
|
- **Patch** — patch notes or release documentation
|
||||||
|
- **Other** — any other supporting evidence
|
||||||
|
|
||||||
|
### Why Store Documents Here?
|
||||||
|
Documents uploaded to CVE entries can be reused across multiple FP workflows. When an FP expires and needs renewal, the evidence is already in the dashboard rather than having to track it down again.
|
||||||
|
|
||||||
|
## Archer Ticket Tracking
|
||||||
|
|
||||||
|
Archer risk acceptance tickets (EXC-XXXXX) are linked to CVE/vendor pairs.
|
||||||
|
|
||||||
|
### Adding an Archer Ticket
|
||||||
|
1. Open a CVE entry
|
||||||
|
2. Click "Add Archer Ticket"
|
||||||
|
3. Enter the EXC number (e.g., EXC-12345)
|
||||||
|
4. Optionally add the Archer URL and status
|
||||||
|
|
||||||
|
### EXC Badge Integration
|
||||||
|
Once an EXC number is entered:
|
||||||
|
- An EXC badge appears on the CVE card on the Home page
|
||||||
|
- Clicking the badge navigates to the Reporting page pre-filtered to findings with that EXC number in their notes
|
||||||
|
- The Action Coverage chart on the Reporting page classifies findings with EXC numbers as "Archer Exception"
|
||||||
|
|
||||||
|
## Vendor Tracking
|
||||||
|
|
||||||
|
CVEs can be tracked across multiple vendors. Each CVE/vendor combination is a separate entry, allowing you to:
|
||||||
|
- Track different remediation statuses per vendor
|
||||||
|
- Store vendor-specific documentation
|
||||||
|
- Link different Archer tickets per vendor
|
||||||
|
|
||||||
|
## Editing CVEs
|
||||||
|
|
||||||
|
1. Click the edit icon on a CVE card
|
||||||
|
2. Modify any fields
|
||||||
|
3. Use the NVD lookup button to refresh data from NVD if needed
|
||||||
|
4. Click Save
|
||||||
|
|
||||||
|
## Quick Check
|
||||||
|
|
||||||
|
The Quick Check feature on the Home page lets you look up a CVE ID without adding it to the database:
|
||||||
|
1. Type a CVE ID in the Quick Check field
|
||||||
|
2. Press Enter — the NVD data is fetched and displayed
|
||||||
|
3. If you want to track it, click "Add CVE" to create an entry
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Always upload screenshots and vendor advisories to the CVE entry before submitting an FP workflow — reviewers may ask for this evidence
|
||||||
|
- Use the status field to track progress: Open → In Progress → Addressed → Resolved
|
||||||
|
- Link Archer EXC numbers as soon as the ticket is created — this updates the Action Coverage chart immediately
|
||||||
|
- The search bar on the Home page searches across CVE ID, vendor, and description
|
||||||
|
- Filter by vendor or severity using the dropdowns to focus on specific areas
|
||||||
110
docs/kb-fp-submission-editing-guide.md
Normal file
110
docs/kb-fp-submission-editing-guide.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# FP Workflow Queue & Submission Editing Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The STEAM Security Dashboard allows you to create, track, and edit False Positive (FP) workflow submissions directly from the Reporting Page. This guide covers the full workflow from adding findings to the queue through editing and resubmitting FP workflows.
|
||||||
|
|
||||||
|
## Adding Findings to the Queue
|
||||||
|
|
||||||
|
1. On the Reporting Page, select findings by clicking the checkboxes in the findings table
|
||||||
|
2. Use Shift+Click to select a range of findings
|
||||||
|
3. In the selection toolbar that appears, choose the workflow type (FP, Archer, or CARD)
|
||||||
|
4. Enter the vendor name (not required for CARD)
|
||||||
|
5. Click "Add to Queue"
|
||||||
|
|
||||||
|
The findings will appear in the Ivanti Queue panel (click the "Queue" button in the top-right).
|
||||||
|
|
||||||
|
## Creating an FP Workflow
|
||||||
|
|
||||||
|
1. Open the Queue panel
|
||||||
|
2. Select the pending FP items you want to submit using the checkboxes
|
||||||
|
3. Click "Create FP Workflow" at the bottom of the panel
|
||||||
|
4. Fill in the required fields:
|
||||||
|
- **Workflow Name**: Use the format `FP — CVE-XXXX-XXXX — Vendor` (e.g., `FP — CVE-2024-6387 — Cisco_STEAM`)
|
||||||
|
- **Reason / Justification**: Explain why these findings are false positives
|
||||||
|
- **Description** (optional): Additional context
|
||||||
|
- **Expiration Date**: Must be a future date
|
||||||
|
- **Scope Override**: Leave as "Authorized" for standard FP workflows
|
||||||
|
5. Attach supporting files (screenshots, evidence) — up to 10 files, 10 MB each
|
||||||
|
6. Click Submit
|
||||||
|
|
||||||
|
The workflow is created in the Ivanti platform and the queue items are marked as complete.
|
||||||
|
|
||||||
|
## Viewing Submissions
|
||||||
|
|
||||||
|
Your FP submissions appear in the "Submissions" section at the bottom of the Queue panel. Each submission shows:
|
||||||
|
- Workflow name
|
||||||
|
- Ivanti batch ID
|
||||||
|
- Lifecycle status badge (color-coded)
|
||||||
|
- Finding count
|
||||||
|
- Submission date
|
||||||
|
|
||||||
|
Click any submission to open the Edit Modal.
|
||||||
|
|
||||||
|
## Lifecycle Status
|
||||||
|
|
||||||
|
Submissions go through these states:
|
||||||
|
|
||||||
|
| Status | Color | Meaning |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| Submitted | Sky Blue | Awaiting review |
|
||||||
|
| Rework | Amber | Reviewer sent it back — action needed |
|
||||||
|
| Rejected | Red | Reviewer denied the FP request |
|
||||||
|
| Resubmitted | Sky Blue | Edited and sent back for review |
|
||||||
|
| Approved | Green | FP accepted — no further action |
|
||||||
|
|
||||||
|
The status badge automatically syncs with the Ivanti platform state when findings data is refreshed.
|
||||||
|
|
||||||
|
## Editing an Existing Submission
|
||||||
|
|
||||||
|
Open a submission from the Queue panel to access the Edit Modal with four tabs:
|
||||||
|
|
||||||
|
### Details Tab
|
||||||
|
- Edit the workflow name, reason, description, expiration date, and scope override
|
||||||
|
- Click "Save Details" to push changes to the Ivanti platform
|
||||||
|
- If the submission was in Rework or Rejected status, saving automatically changes it to Resubmitted
|
||||||
|
|
||||||
|
### Findings Tab
|
||||||
|
- View the current list of finding IDs mapped to this workflow
|
||||||
|
- Add more findings from your pending FP queue items
|
||||||
|
- Select the items to add and click "Add Findings"
|
||||||
|
- Each finding is mapped individually to the Ivanti workflow
|
||||||
|
|
||||||
|
### Attachments Tab
|
||||||
|
- View files that were uploaded with the original submission
|
||||||
|
- **Note**: Adding attachments to an existing workflow is not supported via the Ivanti API. To add more files, upload them directly in the Ivanti platform.
|
||||||
|
|
||||||
|
### History Tab
|
||||||
|
- View a chronological log of all changes made to the submission
|
||||||
|
- Shows finding additions with the actual finding IDs
|
||||||
|
- Displays Ivanti reviewer notes (rework feedback, approval notes) pulled directly from the Ivanti platform
|
||||||
|
|
||||||
|
## Handling Rework Requests
|
||||||
|
|
||||||
|
When a submission comes back for rework:
|
||||||
|
|
||||||
|
1. Open the submission from the Queue panel — the status badge will show "Rework" (amber)
|
||||||
|
2. Go to the **History** tab to read the reviewer's notes explaining what needs to change
|
||||||
|
3. Common rework reasons:
|
||||||
|
- Need more screenshots showing remediation
|
||||||
|
- Need to verify specific software versions
|
||||||
|
- Missing evidence for some findings
|
||||||
|
4. Go to the **Findings** tab to add any additional findings if needed
|
||||||
|
5. Upload additional screenshots directly in the Ivanti platform (Attachments tab has a link)
|
||||||
|
6. Go to the **Details** tab to update the reason/description if needed
|
||||||
|
7. Click "Save Details" — the status automatically changes to Resubmitted
|
||||||
|
|
||||||
|
## Changing Status Manually
|
||||||
|
|
||||||
|
Use the status dropdown in the Edit Modal to manually change the lifecycle status. This is useful when:
|
||||||
|
- You receive notification outside the dashboard that a submission was rejected
|
||||||
|
- You want to mark a submission as approved after confirming in Ivanti
|
||||||
|
|
||||||
|
**Note**: Approved submissions are locked and cannot be edited.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Always include enough screenshots per audit guidance (e.g., 10 screenshots for 20-50 findings)
|
||||||
|
- Use the naming convention `FP — CVE-XXXX-XXXX — Vendor_Team` for easy identification
|
||||||
|
- Check the FP Workflow Status donut chart on the Reporting Page for an overview of all your FP ticket states
|
||||||
|
- The workflow column in the findings table shows the current Ivanti state for each finding
|
||||||
89
docs/kb-ivanti-queue-guide.md
Normal file
89
docs/kb-ivanti-queue-guide.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Ivanti Queue & Batch Operations Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Ivanti Queue is a personal staging area for batch-processing vulnerability findings. You select findings from the Reporting Page table, assign them a workflow type and vendor, and stage them in the queue. From there you can create FP workflows, track Archer exceptions, or manage CARD dispositions.
|
||||||
|
|
||||||
|
## Workflow Types
|
||||||
|
|
||||||
|
| Type | Color | Purpose | Vendor Required? |
|
||||||
|
|------|-------|---------|-----------------|
|
||||||
|
| FP | Amber | False Positive — finding is not actually a vulnerability | Yes |
|
||||||
|
| Archer | Blue | Risk Acceptance — vulnerability exists but can't be patched | Yes |
|
||||||
|
| CARD | Green | Asset disposition — device not owned by your BU | No |
|
||||||
|
|
||||||
|
## Adding Findings to the Queue
|
||||||
|
|
||||||
|
### Single Finding
|
||||||
|
1. In the findings table, click the checkbox area on a row (not the checkbox itself — click the cell)
|
||||||
|
2. A popover appears with:
|
||||||
|
- The finding ID
|
||||||
|
- Vendor/Platform input field (required for FP and Archer)
|
||||||
|
- Workflow type toggle (FP / Archer / CARD)
|
||||||
|
3. Enter the vendor name and select the workflow type
|
||||||
|
4. Click "Add to Queue"
|
||||||
|
|
||||||
|
### Batch Add (Multiple Findings)
|
||||||
|
1. Select multiple findings using checkboxes (Shift+Click for range selection)
|
||||||
|
2. The selection toolbar appears at the top of the table
|
||||||
|
3. Choose the workflow type (FP / Archer / CARD)
|
||||||
|
4. Enter the vendor name (not needed for CARD)
|
||||||
|
5. Click "Add to Queue" — all selected findings are added at once (up to 200 per batch)
|
||||||
|
|
||||||
|
## The Queue Panel
|
||||||
|
|
||||||
|
Click the **Queue** button (top right of the Reporting Page) to open the slide-out panel. The badge shows the count of pending items.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- Items are grouped by vendor (alphabetically)
|
||||||
|
- CARD items appear in their own green section at the top
|
||||||
|
- Each item shows: finding ID, CVEs, hostname, IP address, and workflow type badge
|
||||||
|
|
||||||
|
### Item Actions
|
||||||
|
|
||||||
|
| Action | How |
|
||||||
|
|--------|-----|
|
||||||
|
| Mark complete | Click the green checkbox |
|
||||||
|
| Mark pending | Uncheck the green checkbox |
|
||||||
|
| Select for deletion | Click the red checkbox (left side) |
|
||||||
|
| Delete selected | Click "Delete (N)" button in footer |
|
||||||
|
| Clear all completed | Click "Clear Completed" button in footer |
|
||||||
|
| Redirect workflow | Click the redirect arrow (↗) on completed items |
|
||||||
|
|
||||||
|
### Redirect Feature
|
||||||
|
|
||||||
|
When a finding is completed under one workflow type but needs to be processed under another:
|
||||||
|
1. Complete the item first
|
||||||
|
2. Click the redirect arrow (↗) icon
|
||||||
|
3. Choose the new workflow type
|
||||||
|
4. A new pending item is created with the same finding data but the new workflow type
|
||||||
|
|
||||||
|
Example: You submitted an FP but it was rejected. You now need to open an Archer ticket instead. Complete the FP item, then redirect it to Archer.
|
||||||
|
|
||||||
|
## Creating FP Workflows from the Queue
|
||||||
|
|
||||||
|
1. Open the Queue panel
|
||||||
|
2. Select pending FP items using the checkboxes
|
||||||
|
3. Click "Create FP Workflow" in the footer (only enabled when FP items are selected)
|
||||||
|
4. Fill in the workflow details (name, reason, description, expiration date)
|
||||||
|
5. Attach supporting files (screenshots, evidence)
|
||||||
|
6. Submit — the workflow is created in Ivanti and queue items are marked complete
|
||||||
|
|
||||||
|
See the [FP Submission Editing Guide](kb-fp-submission-editing-guide.md) for details on editing submitted workflows.
|
||||||
|
|
||||||
|
## FP Submissions Section
|
||||||
|
|
||||||
|
Below the queue items, a "Submissions" section shows your previously submitted FP workflows with:
|
||||||
|
- Workflow name and Ivanti batch ID
|
||||||
|
- Lifecycle status badge (Submitted, Rework, Rejected, Resubmitted, Approved)
|
||||||
|
- Finding count and submission date
|
||||||
|
|
||||||
|
Click any submission to open the Edit Modal for viewing details, adding findings, or reading reviewer notes.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Group related findings by vendor before adding to the queue — this makes it easier to create batch FP workflows
|
||||||
|
- Use CARD for findings on devices that belong to another team — no vendor entry needed
|
||||||
|
- The queue is per-user — other team members can't see or modify your queue items
|
||||||
|
- Completed items stay in the queue until you clear them, so you have a record of what was processed
|
||||||
|
- Use the redirect feature when a workflow type needs to change after initial processing
|
||||||
92
docs/kb-reporting-page-guide.md
Normal file
92
docs/kb-reporting-page-guide.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Reporting Page Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Reporting Page is the primary operational page in the STEAM Security Dashboard. It provides a live view of all open Ivanti host findings with filtering, sorting, inline editing, metric charts, and export capabilities.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Navigate to the Reporting page from the sidebar
|
||||||
|
2. Click **Sync** (top right) to pull the latest findings from Ivanti
|
||||||
|
3. The sync timestamp updates when complete — findings, charts, and counts all refresh together
|
||||||
|
|
||||||
|
## Metric Charts
|
||||||
|
|
||||||
|
Four donut charts appear at the top of the page:
|
||||||
|
|
||||||
|
### Open vs Closed
|
||||||
|
Shows the total count of open and closed findings across all synced data.
|
||||||
|
|
||||||
|
### Action Coverage
|
||||||
|
Breaks down open findings into three categories:
|
||||||
|
- **FP Request** (blue) — findings with an FP workflow ticket in Ivanti
|
||||||
|
- **Archer Exception** (amber) — findings with an EXC-XXXXX number in their notes
|
||||||
|
- **Pending** (red) — findings with no action taken yet
|
||||||
|
|
||||||
|
Click a chart segment to filter the table to that category. Click again or use "clear filter" to remove.
|
||||||
|
|
||||||
|
### FP Finding Status
|
||||||
|
Shows the distribution of findings across FP workflow states (Requested, Reworked, Actionable, Approved, Rejected, Expired).
|
||||||
|
|
||||||
|
### FP Workflow Status
|
||||||
|
Shows the count of unique FP ticket IDs per state — one FP ticket can cover many findings.
|
||||||
|
|
||||||
|
## Findings Table
|
||||||
|
|
||||||
|
### Columns
|
||||||
|
The table has 13 columns. All are visible by default:
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Finding ID | Ivanti host finding identifier |
|
||||||
|
| Severity | VRR score with severity group (Critical, High, Medium) |
|
||||||
|
| Title | Vulnerability title |
|
||||||
|
| CVEs | Associated CVE identifiers (hover for tooltip details) |
|
||||||
|
| Host | Hostname (inline editable) |
|
||||||
|
| IP Address | Device IP |
|
||||||
|
| DNS | DNS name (inline editable) |
|
||||||
|
| Due Date | SLA deadline — red if overdue, amber if within 30 days |
|
||||||
|
| SLA | SLA status (Overdue, At Risk, Within SLA) |
|
||||||
|
| BU | Business unit ownership (STEAM or ACCESS-ENG) |
|
||||||
|
| Workflow | FP workflow badge showing ticket ID and state |
|
||||||
|
| Last Found | Date the finding was last detected by scanner |
|
||||||
|
| Notes | Free-text notes field (inline editable) |
|
||||||
|
|
||||||
|
### Column Management
|
||||||
|
Click the **Columns** button (gear icon) to:
|
||||||
|
- Show/hide columns by clicking the eye icon
|
||||||
|
- Drag columns to reorder them
|
||||||
|
- Your column configuration is saved in your browser
|
||||||
|
|
||||||
|
### Sorting
|
||||||
|
Click any sortable column header to sort. Click again to reverse direction. The active sort column is highlighted in blue.
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
Click the filter icon on any filterable column header to open a dropdown with all unique values. Check/uncheck values to filter. Use "Select All" or "Clear" for bulk operations. A search box lets you find specific values quickly.
|
||||||
|
|
||||||
|
Active filters show as amber badges above the table. Click "Clear Filters" to remove all column filters at once.
|
||||||
|
|
||||||
|
### Inline Editing
|
||||||
|
|
||||||
|
Three columns support inline editing:
|
||||||
|
|
||||||
|
- **Host**: Click the hostname to edit. An amber dot appears when an override is active. Click the revert button (↻) to restore the original Ivanti value. Overrides survive re-syncs.
|
||||||
|
- **DNS**: Same behavior as Host.
|
||||||
|
- **Notes**: Click to type. Saves automatically on blur. Use notes to record EXC numbers (e.g., `EXC-12345`) — the Action Coverage chart will classify these as "Archer Exception".
|
||||||
|
|
||||||
|
## Selecting Findings
|
||||||
|
|
||||||
|
Check the checkbox on any row to select it. Use Shift+Click for range selection. The "select all" checkbox in the header selects all visible (non-queued) findings.
|
||||||
|
|
||||||
|
When findings are selected, a toolbar appears with:
|
||||||
|
- Workflow type toggle (FP / Archer / CARD)
|
||||||
|
- Vendor input field (not needed for CARD)
|
||||||
|
- "Add to Queue" button to stage findings for batch processing
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
Click the **Export** dropdown to download the current filtered/sorted view as:
|
||||||
|
- **CSV** — comma-separated values with UTF-8 BOM
|
||||||
|
- **Excel (.xlsx)** — formatted spreadsheet with auto-fit column widths
|
||||||
|
|
||||||
|
Only visible columns are included in the export.
|
||||||
106
docs/kb-user-management-guide.md
Normal file
106
docs/kb-user-management-guide.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# User Management & Roles Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The STEAM Security Dashboard uses role-based access control with four user groups. Only administrators can manage users. All user operations are logged in the audit trail.
|
||||||
|
|
||||||
|
## User Groups
|
||||||
|
|
||||||
|
| Group | Access Level | Description |
|
||||||
|
|-------|-------------|-------------|
|
||||||
|
| Admin | Full access | All operations including user management, delete, audit log |
|
||||||
|
| Standard_User | Operational access | Create, edit, limited delete (own resources only), exports |
|
||||||
|
| Leadership | Read-only + exports | View all data, download CSV/XLSX exports |
|
||||||
|
| Read_Only | View only | Read-only access to all pages, no modifications |
|
||||||
|
|
||||||
|
## Permission Matrix
|
||||||
|
|
||||||
|
| Action | Admin | Standard_User | Leadership | Read_Only |
|
||||||
|
|--------|-------|---------------|------------|-----------|
|
||||||
|
| View findings/CVEs | Yes | Yes | Yes | Yes |
|
||||||
|
| Sync Ivanti data | Yes | Yes | No | No |
|
||||||
|
| Edit hostname/DNS overrides | Yes | Yes | No | No |
|
||||||
|
| Edit notes | Yes | Yes | No | No |
|
||||||
|
| Add to queue | Yes | Yes | No | No |
|
||||||
|
| Create FP workflows | Yes | Yes | No | No |
|
||||||
|
| Edit FP submissions | Yes | Yes | No | No |
|
||||||
|
| Upload compliance reports | Yes | Yes | No | No |
|
||||||
|
| Add CVEs | Yes | Yes | No | No |
|
||||||
|
| Upload documents | Yes | Yes | No | No |
|
||||||
|
| Export CSV/XLSX | Yes | Yes | Yes | No |
|
||||||
|
| Delete CVEs/documents | Yes | Own only | No | No |
|
||||||
|
| Manage users | Yes | No | No | No |
|
||||||
|
| View audit log | Yes | No | No | No |
|
||||||
|
|
||||||
|
## Managing Users (Admin Only)
|
||||||
|
|
||||||
|
### Accessing User Management
|
||||||
|
1. Click the user icon in the top navigation bar
|
||||||
|
2. Select "User Management" from the menu
|
||||||
|
3. The user list shows all accounts with their group, status, and last login
|
||||||
|
|
||||||
|
### Creating a New User
|
||||||
|
1. Click "Add User"
|
||||||
|
2. Fill in the required fields:
|
||||||
|
- **Username** — must be unique
|
||||||
|
- **Email** — user's email address
|
||||||
|
- **Password** — initial password (user should change on first login)
|
||||||
|
- **Group** — select from Admin, Standard_User, Leadership, or Read_Only
|
||||||
|
3. Click Save
|
||||||
|
|
||||||
|
New users default to Read_Only if no group is specified.
|
||||||
|
|
||||||
|
### Editing a User
|
||||||
|
1. Click the edit icon on the user row
|
||||||
|
2. Modify username, email, or group
|
||||||
|
3. Optionally set a new password (leave blank to keep current)
|
||||||
|
4. Click Save
|
||||||
|
|
||||||
|
### Changing User Groups
|
||||||
|
When changing a user's group, a confirmation dialog appears. Extra warnings are shown when:
|
||||||
|
- Removing Admin privileges from a user
|
||||||
|
- Upgrading a user to Admin
|
||||||
|
|
||||||
|
Group changes are logged separately in the audit trail with the previous and new group recorded.
|
||||||
|
|
||||||
|
### Deactivating Users
|
||||||
|
Users can be deactivated rather than deleted. Deactivated users cannot log in but their data and audit history are preserved.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
- Sessions use httpOnly cookies with 24-hour expiry
|
||||||
|
- Passwords are hashed with bcryptjs
|
||||||
|
- All API endpoints (except login) require a valid session
|
||||||
|
- Failed login attempts are not rate-limited at the application level
|
||||||
|
|
||||||
|
## Audit Log
|
||||||
|
|
||||||
|
The audit log records all significant actions in the dashboard. Only admins can view it.
|
||||||
|
|
||||||
|
### What's Logged
|
||||||
|
- User creation, updates, group changes, deletion
|
||||||
|
- CVE creation, updates, deletion
|
||||||
|
- Document uploads and deletions
|
||||||
|
- Ivanti sync operations
|
||||||
|
- FP workflow submissions and edits
|
||||||
|
- Queue operations
|
||||||
|
- Compliance uploads
|
||||||
|
- Login/logout events
|
||||||
|
|
||||||
|
### Audit Entry Fields
|
||||||
|
Each entry includes:
|
||||||
|
- Timestamp
|
||||||
|
- User who performed the action
|
||||||
|
- Action type (e.g., user_create, ivanti_fp_workflow_created)
|
||||||
|
- Entity type and ID
|
||||||
|
- Details (JSON with specifics of what changed)
|
||||||
|
- IP address
|
||||||
|
|
||||||
|
## Default Admin Account
|
||||||
|
|
||||||
|
On first setup (`node setup.js`), a default admin account is created:
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: set during setup
|
||||||
|
- Group: `Admin`
|
||||||
|
|
||||||
|
Change the default password immediately after first login.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
|
import { Search, FileText, AlertCircle, AlertTriangle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity, Menu } from 'lucide-react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import UserMenu from './components/UserMenu';
|
import UserMenu from './components/UserMenu';
|
||||||
@@ -2313,16 +2313,38 @@ export default function App() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
<div style={{ maxHeight: '240px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
{archiveList.map((a) => (
|
{archiveList.map((a) => (
|
||||||
<div key={a.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))', border: '1px solid rgba(100, 116, 139, 0.25)', borderRadius: '0.375rem', padding: '0.5rem' }}>
|
<div key={a.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85), rgba(51, 65, 85, 0.75))', border: '1px solid rgba(100, 116, 139, 0.25)', borderLeft: a.related_active ? '3px solid #F59E0B' : '3px solid #10B981', borderRadius: '0.375rem', padding: '0.5rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#E2E8F0' }}>{a.finding_title || a.finding_id}</span>
|
<div style={{ display: 'flex', alignItems: 'start', gap: '0.375rem', flex: 1, minWidth: 0 }}>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
{a.related_active ? (
|
||||||
{a.last_severity?.toFixed(1) ?? '—'}
|
<AlertTriangle style={{ width: '13px', height: '13px', color: '#F59E0B', flexShrink: 0, marginTop: '1px' }} />
|
||||||
|
) : (
|
||||||
|
<CheckCircle style={{ width: '13px', height: '13px', color: '#10B981', flexShrink: 0, marginTop: '1px' }} />
|
||||||
|
)}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600', color: '#E2E8F0', display: 'block' }}>{a.finding_title || a.finding_id}</span>
|
||||||
|
{a.finding_id && (
|
||||||
|
<span
|
||||||
|
title={a.finding_id}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#64748B', display: 'block', marginTop: '0.1rem' }}
|
||||||
|
>
|
||||||
|
{a.finding_id.length > 20 ? a.finding_id.slice(0, 20) + '…' : a.finding_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.55rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
||||||
|
Last seen: {(a.last_severity && a.last_severity !== 0) ? a.last_severity.toFixed(1) : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B', marginLeft: '1.375rem' }}>
|
||||||
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
|
{a.host_name}{a.ip_address ? ` (${a.ip_address})` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
{a.related_active && (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#0EA5E9', marginTop: '0.35rem', marginLeft: '1.375rem', padding: '0.2rem 0.4rem', background: 'rgba(14, 165, 233, 0.1)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.25rem', display: 'inline-block' }}>
|
||||||
|
Similar finding active — ID: {a.related_active.id} ({a.related_active.severity?.toFixed(1) ?? '—'})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
320
frontend/src/components/RedirectModal.js
Normal file
320
frontend/src/components/RedirectModal.js
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { CornerUpRight, X, Loader, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const WORKFLOW_OPTIONS = [
|
||||||
|
{ key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' },
|
||||||
|
{ key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
|
||||||
|
{ key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||||
|
{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RedirectModal({ item, onClose, onRedirect }) {
|
||||||
|
const [workflowType, setWorkflowType] = useState('FP');
|
||||||
|
const [vendor, setVendor] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const needsVendor = workflowType === 'FP' || workflowType === 'Archer';
|
||||||
|
const canSubmit = !loading && (!needsVendor || vendor.trim().length > 0);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = { workflow_type: workflowType };
|
||||||
|
if (needsVendor) body.vendor = vendor.trim();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/redirect`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Redirect failed.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRedirect(data);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Network error.');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
background: 'rgba(10, 14, 39, 0.92)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 10000,
|
||||||
|
padding: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '100%', maxWidth: '460px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||||
|
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7), 0 0 28px rgba(14, 165, 233, 0.12)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top accent line */}
|
||||||
|
<div style={{
|
||||||
|
height: '2px',
|
||||||
|
background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)',
|
||||||
|
boxShadow: '0 0 8px rgba(14, 165, 233, 0.4)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||||
|
<CornerUpRight style={{ width: '18px', height: '18px', color: '#0EA5E9' }} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: '0.95rem', fontWeight: '700',
|
||||||
|
color: '#E2E8F0',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
}}>
|
||||||
|
Redirect Queue Item
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
<X style={{ width: '18px', height: '18px' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{/* Read-only context */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(15, 23, 42, 0.6)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '0.375rem',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
Finding Title
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.78rem', fontWeight: '600',
|
||||||
|
color: '#CBD5E1',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}} title={item.finding_title}>
|
||||||
|
{item.finding_title || '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1.5rem', marginTop: '0.25rem' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
Finding ID
|
||||||
|
</span>
|
||||||
|
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.72rem', color: '#94A3B8', marginTop: '2px' }}>
|
||||||
|
{item.finding_id || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||||
|
Current Type
|
||||||
|
</span>
|
||||||
|
<div style={{ marginTop: '2px' }}>
|
||||||
|
{(() => {
|
||||||
|
const opt = WORKFLOW_OPTIONS.find((o) => o.key === item.workflow_type) || WORKFLOW_OPTIONS[0];
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.1rem 0.4rem',
|
||||||
|
borderRadius: '0.2rem',
|
||||||
|
background: `rgba(${opt.rgb}, 0.12)`,
|
||||||
|
border: `1px solid rgba(${opt.rgb}, 0.3)`,
|
||||||
|
color: opt.col,
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.65rem', fontWeight: '700',
|
||||||
|
}}>
|
||||||
|
{item.workflow_type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow type selector */}
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||||
|
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}>
|
||||||
|
Target Workflow Type
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
{WORKFLOW_OPTIONS.map(({ key, label, col, rgb }) => {
|
||||||
|
const active = workflowType === key;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: '0.375rem',
|
||||||
|
padding: '0.45rem 0.5rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
background: active ? `rgba(${rgb}, 0.15)` : 'transparent',
|
||||||
|
border: `1.5px solid ${active ? `rgba(${rgb}, 0.5)` : 'rgba(255,255,255,0.08)'}`,
|
||||||
|
color: active ? col : '#475569',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.72rem', fontWeight: '700',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="redirect-workflow-type"
|
||||||
|
value={key}
|
||||||
|
checked={active}
|
||||||
|
onChange={() => setWorkflowType(key)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<span style={{
|
||||||
|
width: '8px', height: '8px', borderRadius: '50%',
|
||||||
|
background: active ? col : 'rgba(255,255,255,0.1)',
|
||||||
|
boxShadow: active ? `0 0 6px ${col}` : 'none',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vendor input — conditional */}
|
||||||
|
{needsVendor && (
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||||
|
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}>
|
||||||
|
Vendor <span style={{ color: '#EF4444' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={vendor}
|
||||||
|
onChange={(e) => setVendor(e.target.value)}
|
||||||
|
placeholder="e.g. Cisco, Juniper, ADTRAN…"
|
||||||
|
maxLength={200}
|
||||||
|
style={{
|
||||||
|
width: '100%', boxSizing: 'border-box',
|
||||||
|
background: 'rgba(30, 41, 59, 0.6)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8rem',
|
||||||
|
color: '#E2E8F0',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { e.target.style.borderColor = '#0EA5E9'; }}
|
||||||
|
onBlur={(e) => { e.target.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
}}>
|
||||||
|
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
||||||
|
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', color: '#FCA5A5' }}>
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'flex-end', gap: '0.625rem',
|
||||||
|
padding: '0.875rem 1.25rem',
|
||||||
|
borderTop: '1px solid rgba(14, 165, 233, 0.1)',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '0.45rem 1rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
padding: '0.45rem 1.1rem',
|
||||||
|
background: canSubmit
|
||||||
|
? 'linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(14, 165, 233, 0.1))'
|
||||||
|
: 'transparent',
|
||||||
|
border: `1.5px solid ${canSubmit ? '#0EA5E9' : 'rgba(255,255,255,0.06)'}`,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: canSubmit ? '#38BDF8' : '#334155',
|
||||||
|
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
||||||
|
) : (
|
||||||
|
<CornerUpRight style={{ width: '14px', height: '14px' }} />
|
||||||
|
)}
|
||||||
|
{loading ? 'Redirecting…' : 'Redirect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
const [noteMetric, setNoteMetric] = useState('');
|
const [selectedMetrics, setSelectedMetrics] = useState([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [noteError, setNoteError] = useState(null);
|
const [noteError, setNoteError] = useState(null);
|
||||||
|
|
||||||
@@ -55,9 +55,9 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
||||||
setDetail(data);
|
setDetail(data);
|
||||||
|
|
||||||
// Default note metric to first active failing metric
|
// Default selected metrics to first active failing metric
|
||||||
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||||
if (firstActive) setNoteMetric(firstActive.metric_id);
|
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,7 +68,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
||||||
|
|
||||||
const handleAddNote = async () => {
|
const handleAddNote = async () => {
|
||||||
if (!noteText.trim() || !noteMetric) return;
|
if (!noteText.trim() || selectedMetrics.length === 0) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setNoteError(null);
|
setNoteError(null);
|
||||||
try {
|
try {
|
||||||
@@ -76,7 +76,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
|
body: JSON.stringify({ hostname, metric_ids: selectedMetrics, note: noteText.trim() }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Failed to save note');
|
if (!res.ok) throw new Error(data.error || 'Failed to save note');
|
||||||
@@ -194,39 +194,115 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
{detail.notes.length === 0 && (
|
{detail.notes.length === 0 && (
|
||||||
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
|
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
|
||||||
)}
|
)}
|
||||||
{detail.notes.map(n => (
|
{(() => {
|
||||||
<div key={n.id} style={{
|
// Build a lookup map for metric categories (active + resolved)
|
||||||
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
|
const metricMap = {};
|
||||||
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
(detail.metrics || []).forEach(m => { metricMap[m.metric_id] = m.category; });
|
||||||
border: '1px solid rgba(255,255,255,0.05)',
|
|
||||||
}}>
|
// Group notes by group_id, preserving reverse chronological order
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
|
const grouped = [];
|
||||||
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
|
const seen = new Set();
|
||||||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
|
// detail.notes is already sorted newest-first from the API
|
||||||
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
|
for (const n of detail.notes) {
|
||||||
</span>
|
const gid = n.group_id;
|
||||||
|
if (!gid) {
|
||||||
|
// Legacy note without group_id — render individually
|
||||||
|
grouped.push({ key: `note-${n.id}`, notes: [n], note: n.note, created_by: n.created_by, created_at: n.created_at });
|
||||||
|
} else if (!seen.has(gid)) {
|
||||||
|
seen.add(gid);
|
||||||
|
const group = detail.notes.filter(x => x.group_id === gid);
|
||||||
|
grouped.push({ key: `group-${gid}`, notes: group, note: group[0].note, created_by: group[0].created_by, created_at: group[0].created_at });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped.map(g => (
|
||||||
|
<div key={g.key} style={{
|
||||||
|
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
|
||||||
|
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.3rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
|
{g.notes.map(n => (
|
||||||
|
<MetricChip key={n.id} metricId={n.metric_id} category={metricMap[n.metric_id] || ''} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', flexShrink: 0, marginLeft: '0.5rem', whiteSpace: 'nowrap' }}>
|
||||||
|
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{g.note}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
|
));
|
||||||
</div>
|
})()}
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Add note */}
|
{/* Add note */}
|
||||||
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
{activeMetrics.length > 1 && (
|
{activeMetrics.length > 1 && (() => {
|
||||||
<select
|
const allSelected = activeMetrics.length > 0 && activeMetrics.every(m => selectedMetrics.includes(m.metric_id));
|
||||||
value={noteMetric}
|
return (
|
||||||
onChange={e => setNoteMetric(e.target.value)}
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
style={{
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||||
width: '100%', marginBottom: '0.5rem',
|
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569' }}>
|
||||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
|
Metrics
|
||||||
borderRadius: '0.25rem', color: '#CBD5E1',
|
</span>
|
||||||
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
<button
|
||||||
}}>
|
onClick={() => {
|
||||||
{activeMetrics.map(m => (
|
if (allSelected) {
|
||||||
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} — {m.category}</option>
|
setSelectedMetrics([activeMetrics[0].metric_id]);
|
||||||
))}
|
} else {
|
||||||
</select>
|
setSelectedMetrics(activeMetrics.map(m => m.metric_id));
|
||||||
)}
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: '0.68rem', fontFamily: 'monospace',
|
||||||
|
color: TEAL, padding: 0,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
>
|
||||||
|
{allSelected ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||||
|
{activeMetrics.map(m => {
|
||||||
|
const isSelected = selectedMetrics.includes(m.metric_id);
|
||||||
|
const color = categoryColor(m.category);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.metric_id}
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
if (selectedMetrics.length > 1) {
|
||||||
|
setSelectedMetrics(selectedMetrics.filter(id => id !== m.metric_id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedMetrics([...selectedMetrics, m.metric_id]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: isSelected ? `${color}25` : `${color}08`,
|
||||||
|
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color: isSelected ? color : `${color}90`,
|
||||||
|
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||||
|
cursor: (isSelected && selectedMetrics.length === 1) ? 'default' : 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
opacity: (isSelected && selectedMetrics.length === 1) ? 0.85 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.metric_id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<textarea
|
<textarea
|
||||||
value={noteText}
|
value={noteText}
|
||||||
@@ -244,13 +320,13 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
|
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
|
||||||
/>
|
/>
|
||||||
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
|
<button onClick={handleAddNote} disabled={!noteText.trim() || selectedMetrics.length === 0 || submitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem 0.625rem', flexShrink: 0,
|
padding: '0.5rem 0.625rem', flexShrink: 0,
|
||||||
background: noteText.trim() ? `${TEAL}20` : 'transparent',
|
background: (noteText.trim() && selectedMetrics.length > 0) ? `${TEAL}20` : 'transparent',
|
||||||
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
|
border: `1px solid ${(noteText.trim() && selectedMetrics.length > 0) ? TEAL : 'rgba(20,184,166,0.2)'}`,
|
||||||
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
|
borderRadius: '0.375rem', color: (noteText.trim() && selectedMetrics.length > 0) ? TEAL : '#334155',
|
||||||
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
|
cursor: (noteText.trim() && selectedMetrics.length > 0) ? 'pointer' : 'default', transition: 'all 0.15s',
|
||||||
}}>
|
}}>
|
||||||
{submitting
|
{submitting
|
||||||
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
|
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user