From df304309563f4f26b5f28cd78728e2cafcfbf153 Mon Sep 17 00:00:00 2001 From: jramos Date: Mon, 13 Apr 2026 12:27:56 -0600 Subject: [PATCH] feat: add FP submission editing with lifecycle tracking, clickable workflow badges, and edit modal - Add migration for lifecycle_status, batch UUID, updated_at columns and submission history table - Add backend endpoints: GET/PUT/POST/PATCH for viewing, editing, adding findings/attachments, and status changes - Add pure helpers: validateLifecycleTransition, mergeFindings, buildSubmissionHistoryEntry - Add FpEditModal with tabbed UI (Details, Findings, Attachments, History) - Make workflow badges clickable for Reworked/Rejected/Expired states with pencil icon - Add submissions list section to QueuePanel with lifecycle status badges - Wire state and data flow in ReportingPage for submissions fetch and edit callbacks --- .../specs/fp-submission-editing/.config.kiro | 1 + .kiro/specs/fp-submission-editing/design.md | 428 ++++++++++++ .../fp-submission-editing/requirements.md | 122 ++++ .kiro/specs/fp-submission-editing/tasks.md | 182 +++++ .../migrations/add_fp_submission_editing.js | 94 +++ backend/routes/ivantiFpWorkflow.js | 627 ++++++++++++++++- .../src/components/pages/ReportingPage.js | 645 +++++++++++++++++- 7 files changed, 2092 insertions(+), 7 deletions(-) create mode 100644 .kiro/specs/fp-submission-editing/.config.kiro create mode 100644 .kiro/specs/fp-submission-editing/design.md create mode 100644 .kiro/specs/fp-submission-editing/requirements.md create mode 100644 .kiro/specs/fp-submission-editing/tasks.md create mode 100644 backend/migrations/add_fp_submission_editing.js diff --git a/.kiro/specs/fp-submission-editing/.config.kiro b/.kiro/specs/fp-submission-editing/.config.kiro new file mode 100644 index 0000000..8f9a84c --- /dev/null +++ b/.kiro/specs/fp-submission-editing/.config.kiro @@ -0,0 +1 @@ +{"specId": "a7e2c1f8-9b34-4d6a-b5e0-8f1c3a2d7e90", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/fp-submission-editing/design.md b/.kiro/specs/fp-submission-editing/design.md new file mode 100644 index 0000000..06a6dd0 --- /dev/null +++ b/.kiro/specs/fp-submission-editing/design.md @@ -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` diff --git a/.kiro/specs/fp-submission-editing/requirements.md b/.kiro/specs/fp-submission-editing/requirements.md new file mode 100644 index 0000000..9488207 --- /dev/null +++ b/.kiro/specs/fp-submission-editing/requirements.md @@ -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 diff --git a/.kiro/specs/fp-submission-editing/tasks.md b/.kiro/specs/fp-submission-editing/tasks.md new file mode 100644 index 0000000..6452bb5 --- /dev/null +++ b/.kiro/specs/fp-submission-editing/tasks.md @@ -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` diff --git a/backend/migrations/add_fp_submission_editing.js b/backend/migrations/add_fp_submission_editing.js new file mode 100644 index 0000000..d696046 --- /dev/null +++ b/backend/migrations/add_fp_submission_editing.js @@ -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 + db.run( + `ALTER TABLE ivanti_fp_submissions ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP`, + (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!'); +}); diff --git a/backend/routes/ivantiFpWorkflow.js b/backend/routes/ivantiFpWorkflow.js index 3d50db3..9d25a8c 100644 --- a/backend/routes/ivantiFpWorkflow.js +++ b/backend/routes/ivantiFpWorkflow.js @@ -3,7 +3,7 @@ const express = require('express'); const multer = require('multer'); const path = require('path'); const { requireGroup } = require('../middleware/auth'); -const { ivantiFormPost } = require('../helpers/ivantiApi'); +const { ivantiFormPost, ivantiPost, ivantiMultipartPost } = require('../helpers/ivantiApi'); const logAudit = require('../helpers/auditLog'); // --------------------------------------------------------------------------- @@ -112,6 +112,48 @@ function buildIvantiFormFields(formData, findingIds) { ]; } +const LIFECYCLE_STATUSES = new Set([ + 'submitted', 'approved', 'rejected', 'rework', 'resubmitted' +]); + +/** + * Validates whether a lifecycle status transition is allowed. + * Returns { valid: true } or { valid: false, error: string }. + */ +function validateLifecycleTransition(currentStatus, newStatus) { + if (currentStatus === 'approved') { + return { valid: false, error: 'This submission is finalized and cannot be edited.' }; + } + if (!LIFECYCLE_STATUSES.has(newStatus)) { + return { valid: false, error: 'Invalid lifecycle status.' }; + } + return { valid: true }; +} + +/** + * Merges existing finding IDs (JSON string) with new IDs (array), deduplicates, + * and returns the merged array as a JSON string. + */ +function mergeFindings(existingJson, newIds) { + const existing = JSON.parse(existingJson || '[]'); + const merged = [...new Set([...existing, ...newIds])]; + return JSON.stringify(merged); +} + +/** + * Builds a submission history entry object. + * The caller is responsible for setting submission_id on the returned object. + */ +function buildSubmissionHistoryEntry(changeType, details, userId, username) { + return { + user_id: userId, + username: username, + change_type: changeType, + change_details_json: JSON.stringify(details), + created_at: new Date().toISOString() + }; +} + // --------------------------------------------------------------------------- // Multer configuration // --------------------------------------------------------------------------- @@ -385,6 +427,586 @@ function createIvantiFpWorkflowRouter(db, requireAuth) { }); }); + // ----------------------------------------------------------------------- + // GET /api/ivanti/fp-submissions + // Returns the authenticated user's FP submission records with history. + // ----------------------------------------------------------------------- + router.get('/submissions', requireAuth(db), (req, res) => { + (async () => { + try { + const submissions = await new Promise((resolve, reject) => { + db.all( + `SELECT * FROM ivanti_fp_submissions WHERE user_id = ? ORDER BY created_at DESC`, + [req.user.id], + (err, rows) => { if (err) reject(err); else resolve(rows || []); } + ); + }); + + // Fetch history for all submissions in one query if there are any + if (submissions.length > 0) { + const submissionIds = submissions.map(s => s.id); + const placeholders = submissionIds.map(() => '?').join(','); + const historyRows = await new Promise((resolve, reject) => { + db.all( + `SELECT * FROM ivanti_fp_submission_history WHERE submission_id IN (${placeholders}) ORDER BY created_at ASC`, + submissionIds, + (err, rows) => { if (err) reject(err); else resolve(rows || []); } + ); + }); + + // Group history by submission_id + const historyMap = {}; + for (const row of historyRows) { + if (!historyMap[row.submission_id]) historyMap[row.submission_id] = []; + historyMap[row.submission_id].push(row); + } + + for (const sub of submissions) { + sub.history = historyMap[sub.id] || []; + } + } else { + // No submissions, nothing to do + } + + res.json(submissions); + } catch (err) { + console.error('Error fetching FP submissions:', err); + res.status(500).json({ error: 'Internal server error.' }); + } + })().catch((unexpectedErr) => { + console.error('Unexpected error in GET /submissions:', unexpectedErr); + res.status(500).json({ error: 'Internal server error.' }); + }); + }); + + // ----------------------------------------------------------------------- + // PUT /api/ivanti/fp-submissions/:id + // Updates form fields and proxies to Ivanti update endpoint. + // ----------------------------------------------------------------------- + router.put('/submissions/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + (async () => { + const submissionId = req.params.id; + + // 1. Fetch submission and verify ownership + const submission = await new Promise((resolve, reject) => { + db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { + if (err) reject(err); else resolve(row); + }); + }); + + if (!submission) { + return res.status(404).json({ error: 'Submission not found.' }); + } + if (submission.user_id !== req.user.id) { + return res.status(403).json({ error: 'You can only edit your own submissions.' }); + } + + // 2. Lifecycle guard + if (submission.lifecycle_status === 'approved') { + return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' }); + } + + // 3. Validate body + const validationErrors = validateFpWorkflowForm(req.body); + if (Object.keys(validationErrors).length > 0) { + return res.status(400).json({ success: false, errors: validationErrors }); + } + + // 4. Proxy to Ivanti + const apiKey = process.env.IVANTI_API_KEY; + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + + if (!apiKey) { + return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' }); + } + + const scopeMap = { 'Authorized': 'AUTHORIZED', 'None': 'NONE', 'Automated': 'AUTOMATED' }; + const updateUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/update`; + const updateBody = { + id: submission.ivanti_workflow_batch_id, + name: req.body.name, + reason: req.body.reason, + description: req.body.description || '', + expirationDate: req.body.expirationDate, + overrideControl: scopeMap[req.body.scopeOverride] || 'AUTHORIZED' + }; + + let ivantiResult; + try { + ivantiResult = await ivantiPost(updateUrl, updateBody, apiKey, skipTls); + } catch (networkErr) { + logAudit(db, { + userId: req.user.id, username: req.user.username, + action: 'ivanti_fp_submission_edit_failed', entityType: 'ivanti_workflow', + details: { error: networkErr.message, submissionId }, + ipAddress: req.ip + }); + return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', details: networkErr.message }); + } + + if (ivantiResult.status !== 200 && ivantiResult.status !== 201 && ivantiResult.status !== 202) { + const errorMap = { + 401: 'Ivanti API key is invalid or missing. Contact your administrator.', + 419: 'API key lacks permissions for this operation.', + 429: 'Ivanti API rate limit reached. Please try again in a few minutes.' + }; + const errorMsg = ivantiResult.status >= 500 + ? 'Ivanti API is temporarily unavailable. Please try again later.' + : (errorMap[ivantiResult.status] || `Operation failed: ${ivantiResult.status}`); + return res.status(ivantiResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg }); + } + + // 5. Determine new lifecycle_status + let newLifecycleStatus = submission.lifecycle_status; + if (submission.lifecycle_status === 'rejected' || submission.lifecycle_status === 'rework') { + newLifecycleStatus = 'resubmitted'; + } + + // 6. Update local record + const now = new Date().toISOString(); + try { + await new Promise((resolve, reject) => { + db.run( + `UPDATE ivanti_fp_submissions + SET workflow_name = ?, reason = ?, description = ?, expiration_date = ?, scope_override = ?, lifecycle_status = ?, updated_at = ? + WHERE id = ?`, + [ + req.body.name, + req.body.reason, + req.body.description || null, + req.body.expirationDate, + req.body.scopeOverride || 'Authorized', + newLifecycleStatus, + now, + submissionId + ], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + } catch (dbErr) { + console.error('Failed to update submission record:', dbErr); + return res.status(500).json({ success: false, error: 'Failed to update local record.' }); + } + + // 7. Insert history row + const historyEntry = buildSubmissionHistoryEntry('fields_updated', { + changed: { + name: { from: submission.workflow_name, to: req.body.name }, + reason: { from: submission.reason, to: req.body.reason }, + description: { from: submission.description, to: req.body.description || '' }, + expirationDate: { from: submission.expiration_date, to: req.body.expirationDate }, + scopeOverride: { from: submission.scope_override, to: req.body.scopeOverride || 'Authorized' } + } + }, req.user.id, req.user.username); + + try { + await new Promise((resolve, reject) => { + db.run( + `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + } catch (histErr) { + console.error('Failed to insert history row:', histErr); + } + + // 8. Audit log + logAudit(db, { + userId: req.user.id, username: req.user.username, + action: 'ivanti_fp_submission_edited', entityType: 'ivanti_workflow', + entityId: String(submission.ivanti_workflow_batch_id), + details: { submissionId, workflowName: req.body.name }, + ipAddress: req.ip + }); + + // 9. Return updated record + const updatedRecord = await new Promise((resolve, reject) => { + db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { + if (err) reject(err); else resolve(row); + }); + }); + + res.json({ success: true, submission: updatedRecord }); + })().catch((unexpectedErr) => { + console.error('Unexpected error in PUT /submissions/:id:', unexpectedErr); + res.status(500).json({ success: false, error: 'Internal server error.' }); + }); + }); + + // ----------------------------------------------------------------------- + // POST /api/ivanti/fp-submissions/:id/findings + // Maps additional findings to the existing workflow batch. + // ----------------------------------------------------------------------- + router.post('/submissions/:id/findings', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + (async () => { + const submissionId = req.params.id; + const { findingIds, queueItemIds } = req.body; + + // Validate arrays + if (!Array.isArray(findingIds) || findingIds.length === 0) { + return res.status(400).json({ error: 'At least one finding ID is required.' }); + } + if (!Array.isArray(queueItemIds) || queueItemIds.length === 0) { + return res.status(400).json({ error: 'At least one queue item ID is required.' }); + } + + // 1. Fetch submission and verify ownership + const submission = await new Promise((resolve, reject) => { + db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { + if (err) reject(err); else resolve(row); + }); + }); + + if (!submission) { + return res.status(404).json({ error: 'Submission not found.' }); + } + if (submission.user_id !== req.user.id) { + return res.status(403).json({ error: 'You can only edit your own submissions.' }); + } + + // 2. Lifecycle guard + if (submission.lifecycle_status === 'approved') { + return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' }); + } + + // 3. Verify queue items belong to user, are FP type, and pending + const placeholders = queueItemIds.map(() => '?').join(','); + const queueRows = await new Promise((resolve, reject) => { + db.all( + `SELECT id, workflow_type, status, user_id FROM ivanti_todo_queue WHERE id IN (${placeholders})`, + queueItemIds, + (err, rows) => { if (err) reject(err); else resolve(rows || []); } + ); + }); + + if (queueRows.length !== queueItemIds.length) { + return res.status(400).json({ error: 'One or more queue items not found.' }); + } + for (const row of queueRows) { + if (row.user_id !== req.user.id) { + return res.status(403).json({ error: 'You can only submit your own queue items.' }); + } + if (row.workflow_type !== 'FP') { + return res.status(400).json({ error: `Queue item ${row.id} is not an FP workflow type.` }); + } + if (row.status !== 'pending') { + return res.status(400).json({ error: `Queue item ${row.id} is not in pending status.` }); + } + } + + // 4. Proxy to Ivanti map endpoint + const apiKey = process.env.IVANTI_API_KEY; + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + + if (!apiKey) { + return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' }); + } + + const mapUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(submission.ivanti_workflow_batch_uuid)}/map`; + const formFields = [{ name: 'subjectFilterRequest', value: buildSubjectFilterRequest(findingIds) }]; + + let mapResult; + try { + mapResult = await ivantiFormPost(mapUrl, formFields, [], apiKey, skipTls); + } catch (networkErr) { + logAudit(db, { + userId: req.user.id, username: req.user.username, + action: 'ivanti_fp_findings_add_failed', entityType: 'ivanti_workflow', + details: { error: networkErr.message, submissionId, findingIds }, + ipAddress: req.ip + }); + return res.status(502).json({ success: false, error: 'Failed to connect to Ivanti API.', details: networkErr.message }); + } + + if (mapResult.status !== 200 && mapResult.status !== 201 && mapResult.status !== 202) { + const errorMap = { + 401: 'Ivanti API key is invalid or missing. Contact your administrator.', + 419: 'API key lacks permissions for this operation.', + 429: 'Ivanti API rate limit reached. Please try again in a few minutes.' + }; + const errorMsg = mapResult.status >= 500 + ? 'Ivanti API is temporarily unavailable. Please try again later.' + : (errorMap[mapResult.status] || `Operation failed: ${mapResult.status}`); + return res.status(mapResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg }); + } + + // 5. Merge finding IDs + const mergedJson = mergeFindings(submission.finding_ids_json, findingIds); + const now = new Date().toISOString(); + + try { + await new Promise((resolve, reject) => { + db.run( + `UPDATE ivanti_fp_submissions SET finding_ids_json = ?, updated_at = ? WHERE id = ?`, + [mergedJson, now, submissionId], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + } catch (dbErr) { + console.error('Failed to update finding_ids_json:', dbErr); + } + + // 6. Mark queue items complete + let queueItemsUpdated = 0; + try { + const queuePlaceholders = queueItemIds.map(() => '?').join(','); + queueItemsUpdated = await new Promise((resolve, reject) => { + db.run( + `UPDATE ivanti_todo_queue SET status='complete', updated_at=CURRENT_TIMESTAMP WHERE id IN (${queuePlaceholders}) AND user_id=?`, + [...queueItemIds, req.user.id], + function (err) { if (err) reject(err); else resolve(this.changes); } + ); + }); + } catch (queueErr) { + console.error('Failed to update queue items:', queueErr); + } + + // 7. Insert history row + const historyEntry = buildSubmissionHistoryEntry('findings_added', { + addedFindingIds: findingIds, + queueItemIds: queueItemIds + }, req.user.id, req.user.username); + + try { + await new Promise((resolve, reject) => { + db.run( + `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + } catch (histErr) { + console.error('Failed to insert history row:', histErr); + } + + // 8. Audit log + logAudit(db, { + userId: req.user.id, username: req.user.username, + action: 'ivanti_fp_findings_added', entityType: 'ivanti_workflow', + entityId: String(submission.ivanti_workflow_batch_id), + details: { submissionId, addedFindingIds: findingIds, queueItemsUpdated }, + ipAddress: req.ip + }); + + res.json({ success: true, addedFindings: findingIds, queueItemsUpdated }); + })().catch((unexpectedErr) => { + console.error('Unexpected error in POST /submissions/:id/findings:', unexpectedErr); + res.status(500).json({ success: false, error: 'Internal server error.' }); + }); + }); + + // ----------------------------------------------------------------------- + // POST /api/ivanti/fp-submissions/:id/attachments + // Uploads additional files to the existing workflow batch. + // ----------------------------------------------------------------------- + router.post('/submissions/:id/attachments', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + fpUpload(req, res, (multerErr) => { + if (multerErr) { + if (multerErr.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ error: 'File exceeds the 10 MB size limit.' }); + } + return res.status(400).json({ error: multerErr.message }); + } + + const files = req.files || []; + if (files.length === 0) { + return res.status(400).json({ error: 'At least one file is required.' }); + } + + // Validate extensions (belt-and-suspenders) + for (const file of files) { + if (!isAllowedFileExtension(file.originalname)) { + return res.status(400).json({ + error: `File type not allowed: ${file.originalname}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` + }); + } + } + + (async () => { + const submissionId = req.params.id; + + // 1. Fetch submission and verify ownership + const submission = await new Promise((resolve, reject) => { + db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { + if (err) reject(err); else resolve(row); + }); + }); + + if (!submission) { + return res.status(404).json({ error: 'Submission not found.' }); + } + if (submission.user_id !== req.user.id) { + return res.status(403).json({ error: 'You can only edit your own submissions.' }); + } + + // 2. Lifecycle guard + if (submission.lifecycle_status === 'approved') { + return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' }); + } + + // 3. Upload each file to Ivanti + const apiKey = process.env.IVANTI_API_KEY; + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + + if (!apiKey) { + return res.status(500).json({ success: false, error: 'Ivanti API key is not configured.' }); + } + + const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(submission.ivanti_workflow_batch_uuid)}/attach`; + const attachmentResults = []; + + for (const f of files) { + try { + const result = await ivantiMultipartPost(attachUrl, f.buffer, f.originalname, apiKey, skipTls); + const success = result.status === 200 || result.status === 201 || result.status === 202; + attachmentResults.push({ filename: f.originalname, success, ...(success ? {} : { error: `Upload failed: ${result.status}` }) }); + } catch (uploadErr) { + attachmentResults.push({ filename: f.originalname, success: false, error: uploadErr.message }); + } + } + + // 4. Update attachment_count and attachment_results_json + const existingResults = JSON.parse(submission.attachment_results_json || '[]'); + const allResults = [...existingResults, ...attachmentResults]; + const successCount = attachmentResults.filter(r => r.success).length; + const newAttachmentCount = (submission.attachment_count || 0) + successCount; + const now = new Date().toISOString(); + + try { + await new Promise((resolve, reject) => { + db.run( + `UPDATE ivanti_fp_submissions SET attachment_count = ?, attachment_results_json = ?, updated_at = ? WHERE id = ?`, + [newAttachmentCount, JSON.stringify(allResults), now, submissionId], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + } catch (dbErr) { + console.error('Failed to update attachment records:', dbErr); + } + + // 5. Insert history row + const historyEntry = buildSubmissionHistoryEntry('attachments_added', { + files: attachmentResults + }, req.user.id, req.user.username); + + try { + await new Promise((resolve, reject) => { + db.run( + `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + } catch (histErr) { + console.error('Failed to insert history row:', histErr); + } + + // 6. Audit log + logAudit(db, { + userId: req.user.id, username: req.user.username, + action: 'ivanti_fp_attachments_added', entityType: 'ivanti_workflow', + entityId: String(submission.ivanti_workflow_batch_id), + details: { submissionId, attachmentResults }, + ipAddress: req.ip + }); + + const allSucceeded = attachmentResults.every(r => r.success); + res.json({ success: true, attachmentResults, status: allSucceeded ? 'success' : 'partial' }); + })().catch((unexpectedErr) => { + console.error('Unexpected error in POST /submissions/:id/attachments:', unexpectedErr); + res.status(500).json({ success: false, error: 'Internal server error.' }); + }); + }); + }); + + // ----------------------------------------------------------------------- + // PATCH /api/ivanti/fp-submissions/:id/status + // Updates the lifecycle status of a submission. + // ----------------------------------------------------------------------- + router.patch('/submissions/:id/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { + (async () => { + const submissionId = req.params.id; + const newStatus = req.body.lifecycle_status; + + // 1. Fetch submission and verify ownership + const submission = await new Promise((resolve, reject) => { + db.get(`SELECT * FROM ivanti_fp_submissions WHERE id = ?`, [submissionId], (err, row) => { + if (err) reject(err); else resolve(row); + }); + }); + + if (!submission) { + return res.status(404).json({ error: 'Submission not found.' }); + } + if (submission.user_id !== req.user.id) { + return res.status(403).json({ error: 'You can only edit your own submissions.' }); + } + + // 2. Validate transition + const transition = validateLifecycleTransition(submission.lifecycle_status, newStatus); + if (!transition.valid) { + return res.status(400).json({ error: transition.error }); + } + + // 3. Update lifecycle_status and updated_at + const now = new Date().toISOString(); + const previousStatus = submission.lifecycle_status; + + try { + await new Promise((resolve, reject) => { + db.run( + `UPDATE ivanti_fp_submissions SET lifecycle_status = ?, updated_at = ? WHERE id = ?`, + [newStatus, now, submissionId], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + } catch (dbErr) { + console.error('Failed to update lifecycle status:', dbErr); + return res.status(500).json({ success: false, error: 'Failed to update status.' }); + } + + // 4. Insert history row + const historyEntry = buildSubmissionHistoryEntry('status_changed', { + from: previousStatus, + to: newStatus + }, req.user.id, req.user.username); + + try { + await new Promise((resolve, reject) => { + db.run( + `INSERT INTO ivanti_fp_submission_history (submission_id, user_id, username, change_type, change_details_json, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [submissionId, historyEntry.user_id, historyEntry.username, historyEntry.change_type, historyEntry.change_details_json, historyEntry.created_at], + (err) => { if (err) reject(err); else resolve(); } + ); + }); + } catch (histErr) { + console.error('Failed to insert history row:', histErr); + } + + // 5. Audit log + logAudit(db, { + userId: req.user.id, username: req.user.username, + action: 'ivanti_fp_status_changed', entityType: 'ivanti_workflow', + entityId: String(submission.ivanti_workflow_batch_id), + details: { submissionId, from: previousStatus, to: newStatus }, + ipAddress: req.ip + }); + + res.json({ success: true, previousStatus, newStatus }); + })().catch((unexpectedErr) => { + console.error('Unexpected error in PATCH /submissions/:id/status:', unexpectedErr); + res.status(500).json({ success: false, error: 'Internal server error.' }); + }); + }); + return router; } @@ -393,3 +1015,6 @@ module.exports.validateFpWorkflowForm = validateFpWorkflowForm; module.exports.buildIvantiFormFields = buildIvantiFormFields; module.exports.buildSubjectFilterRequest = buildSubjectFilterRequest; module.exports.isAllowedFileExtension = isAllowedFileExtension; +module.exports.validateLifecycleTransition = validateLifecycleTransition; +module.exports.mergeFindings = mergeFindings; +module.exports.buildSubmissionHistoryEntry = buildSubmissionHistoryEntry; diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 0f5e2f8..3acf5cc 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import ReactDOM from 'react-dom'; -import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight } from 'lucide-react'; +import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3 } from 'lucide-react'; import * as XLSX from 'xlsx'; import { useAuth } from '../../contexts/AuthContext'; import IvantiCountsChart from './IvantiCountsChart'; @@ -178,6 +178,22 @@ function workflowStyle(state) { } } +function lifecycleStatusBadge(status) { + switch ((status || '').toLowerCase()) { + case 'submitted': + case 'resubmitted': + return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.4)', text: '#0EA5E9' }; + case 'approved': + return { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.4)', text: '#10B981' }; + case 'rejected': + return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; + case 'rework': + return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; + default: + return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; + } +} + // --------------------------------------------------------------------------- // SVG Donut Chart — Open vs Closed findings // --------------------------------------------------------------------------- @@ -922,7 +938,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan // --------------------------------------------------------------------------- // Render a single table cell by column key // --------------------------------------------------------------------------- -function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave }) { +function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission }) { switch (colKey) { case 'findingId': return ( @@ -1045,22 +1061,42 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave const wf = finding.workflow; if (!wf || !wf.id) return —; const ws = workflowStyle(wf.state); + const isEditable = ['reworked', 'rejected', 'expired'].includes((wf.state || '').toLowerCase()); + + const handleBadgeClick = isEditable ? () => { + const numericId = parseInt(String(wf.id).replace(/\D/g, ''), 10); + const sub = fpSubmissions.find(s => s.ivanti_workflow_batch_id === numericId); + if (sub) onEditSubmission(sub); + } : undefined; + return ( { + e.currentTarget.style.borderColor = ws.text; + e.currentTarget.style.background = ws.bg.replace('0.12', '0.2'); + } : undefined} + onMouseLeave={isEditable ? (e) => { + e.currentTarget.style.borderColor = ws.border; + e.currentTarget.style.background = ws.bg; + } : undefined} > {wf.id} {wf.state} + {isEditable && } ); @@ -1249,7 +1285,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd // --------------------------------------------------------------------------- // QueuePanel — fixed slide-out panel showing the user's Ivanti queue // --------------------------------------------------------------------------- -function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite }) { +function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission }) { const pendingCount = items.filter((i) => i.status === 'pending').length; const completedCount = items.filter((i) => i.status === 'complete').length; @@ -1548,6 +1584,88 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on ))} + {/* Submissions section */} + {fpSubmissions && fpSubmissions.length > 0 && ( +
+
+ + Submissions + + + {fpSubmissions.length} + +
+ {fpSubmissions.map((sub) => { + const lsBadge = lifecycleStatusBadge(sub.lifecycle_status); + const findingCount = (() => { + try { return JSON.parse(sub.finding_ids_json || '[]').length; } catch { return 0; } + })(); + const clickable = canWrite && onEditSubmission; + return ( +
onEditSubmission(sub) : undefined} + style={{ + display: 'flex', alignItems: 'center', gap: '0.5rem', + padding: '0.45rem 0.625rem', + marginBottom: '0.25rem', + borderRadius: '0.375rem', + background: 'rgba(245,158,11,0.04)', + border: '1px solid rgba(245,158,11,0.1)', + cursor: clickable ? 'pointer' : 'default', + transition: 'all 0.15s', + }} + onMouseEnter={clickable ? (e) => { + e.currentTarget.style.borderColor = 'rgba(245,158,11,0.3)'; + e.currentTarget.style.background = 'rgba(245,158,11,0.08)'; + } : undefined} + onMouseLeave={clickable ? (e) => { + e.currentTarget.style.borderColor = 'rgba(245,158,11,0.1)'; + e.currentTarget.style.background = 'rgba(245,158,11,0.04)'; + } : undefined} + > +
+
+ {sub.workflow_name || `Batch ${sub.ivanti_workflow_batch_id}`} +
+
+ + #{sub.ivanti_workflow_batch_id} + + + {findingCount} finding{findingCount !== 1 ? 's' : ''} + + + {sub.created_at ? new Date(sub.created_at).toLocaleDateString() : ''} + +
+
+ + {sub.lifecycle_status || 'submitted'} + +
+ ); + })} +
+ )} + {/* Footer */}
{ + if (submission) { + setName(submission.workflow_name || ''); + setReason(submission.reason || ''); + setDescription(submission.description || ''); + setExpirationDate(submission.expiration_date || ''); + setScopeOverride(submission.scope_override || ''); + setStatusValue(submission.lifecycle_status || 'submitted'); + setActiveTab('details'); + setErrors({}); + setResult(null); + setFiles([]); + setAdditionalFindingIds(new Set()); + } + }, [submission]); + + if (!open || !submission) return null; + + const isApproved = (submission.lifecycle_status || '').toLowerCase() === 'approved'; + const currentFindings = (() => { + try { return JSON.parse(submission.finding_ids_json || '[]'); } catch { return []; } + })(); + const existingAttachments = (() => { + try { return JSON.parse(submission.attachment_results_json || '[]'); } catch { return []; } + })(); + const history = submission.history || []; + + const pendingFpQueue = (queueItems || []).filter(i => + i.workflow_type === 'FP' && i.status === 'pending' && !currentFindings.includes(String(i.finding_id)) + ); + + const handleSaveDetails = async () => { + setSaving(true); setErrors({}); setResult(null); + try { + const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}`, { + method: 'PUT', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, reason, description, expirationDate, scopeOverride }), + }); + const data = await res.json(); + if (res.ok) { + setResult({ type: 'success', message: 'Details saved successfully.' }); + if (onSuccess) onSuccess(); + } else { + setResult({ type: 'error', message: data.error || 'Failed to save details.' }); + } + } catch (e) { + setResult({ type: 'error', message: 'Network error saving details.' }); + } finally { setSaving(false); } + }; + + const handleAddFindings = async () => { + if (additionalFindingIds.size === 0) return; + setSaving(true); setResult(null); + const selectedItems = pendingFpQueue.filter(i => additionalFindingIds.has(i.id)); + const findingIds = selectedItems.map(i => String(i.finding_id)); + const queueItemIds = selectedItems.map(i => i.id); + try { + const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/findings`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ findingIds, queueItemIds }), + }); + const data = await res.json(); + if (res.ok) { + setResult({ type: 'success', message: `Added ${findingIds.length} finding(s).` }); + setAdditionalFindingIds(new Set()); + if (onSuccess) onSuccess(); + } else { + setResult({ type: 'error', message: data.error || 'Failed to add findings.' }); + } + } catch (e) { + setResult({ type: 'error', message: 'Network error adding findings.' }); + } finally { setSaving(false); } + }; + + const handleUploadAttachments = async () => { + if (files.length === 0) return; + setSaving(true); setResult(null); + const formData = new FormData(); + files.forEach(f => formData.append('files', f)); + try { + const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/attachments`, { + method: 'POST', credentials: 'include', body: formData, + }); + const data = await res.json(); + if (res.ok) { + const successCount = (data.attachmentResults || []).filter(r => r.success).length; + setResult({ type: 'success', message: `Uploaded ${successCount} file(s).` }); + setFiles([]); + if (onSuccess) onSuccess(); + } else { + setResult({ type: 'error', message: data.error || 'Failed to upload attachments.' }); + } + } catch (e) { + setResult({ type: 'error', message: 'Network error uploading attachments.' }); + } finally { setSaving(false); } + }; + + const handleStatusChange = async (newStatus) => { + setSaving(true); setResult(null); + try { + const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/status`, { + method: 'PATCH', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lifecycle_status: newStatus }), + }); + const data = await res.json(); + if (res.ok) { + setResult({ type: 'success', message: `Status changed to ${newStatus}.` }); + setStatusValue(newStatus); + if (onSuccess) onSuccess(); + } else { + setResult({ type: 'error', message: data.error || 'Failed to change status.' }); + } + } catch (e) { + setResult({ type: 'error', message: 'Network error changing status.' }); + } finally { setSaving(false); } + }; + + const lsBadge = lifecycleStatusBadge(statusValue); + const tabs = ['details', 'findings', 'attachments', 'history']; + + const inputStyle = { + width: '100%', boxSizing: 'border-box', + background: isApproved ? 'rgba(100,116,139,0.06)' : 'rgba(14,165,233,0.05)', + border: `1px solid ${isApproved ? 'rgba(100,116,139,0.15)' : 'rgba(14,165,233,0.2)'}`, + borderRadius: '0.25rem', padding: '0.4rem 0.5rem', + color: isApproved ? '#64748B' : '#CBD5E1', + fontSize: '0.78rem', fontFamily: 'monospace', outline: 'none', + }; + const labelStyle = { display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#94A3B8', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.06em' }; + + return ReactDOM.createPortal( +
+
e.stopPropagation()} style={{ + width: '640px', maxHeight: '85vh', display: 'flex', flexDirection: 'column', + background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)', + border: '1px solid rgba(14,165,233,0.2)', + borderRadius: '0.5rem', + boxShadow: '0 20px 60px rgba(0,0,0,0.8)', + }}> + {/* Header */} +
+
+
+ + + Edit FP Workflow + + + {statusValue} + +
+ +
+
+ {submission.workflow_name || `Batch #${submission.ivanti_workflow_batch_id}`} +
+ {isApproved && ( +
+ This submission is finalized and cannot be edited. +
+ )} +
+ + {/* Tab bar */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Status change row */} + {!isApproved && ( +
+ Status: + +
+ )} + + {/* Result banner */} + {result && ( +
+ {result.type === 'success' ? : } + {result.message} +
+ )} + + {/* Tab content */} +
+ {/* Details tab */} + {activeTab === 'details' && ( +
+
+ + setName(e.target.value)} disabled={isApproved} style={inputStyle} /> +
+
+ + +
+
+ +