feat: add FP attachment library — attach existing CVE documents to FP submissions
- Add GET /api/ivanti/fp-workflow/documents/search endpoint for querying the document library - Update POST /api/ivanti/fp-workflow to accept libraryDocIds for attaching library documents on create - Update POST .../submissions/:id/attachments to accept libraryDocIds on edit - Add AttachmentSourcePicker component with local upload and library search modes - Integrate picker into FpWorkflowModal (create) and FpEditModal (edit) - Track attachment source (local/library) in attachment_results_json for traceability
This commit is contained in:
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
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { requireGroup } = require('../middleware/auth');
|
const { requireGroup } = require('../middleware/auth');
|
||||||
const { ivantiFormPost, ivantiPost } = require('../helpers/ivantiApi');
|
const { ivantiFormPost, ivantiPost } = require('../helpers/ivantiApi');
|
||||||
const logAudit = require('../helpers/auditLog');
|
const logAudit = require('../helpers/auditLog');
|
||||||
@@ -246,6 +247,45 @@ const fpUpload = multer({
|
|||||||
function createIvantiFpWorkflowRouter(db, requireAuth) {
|
function createIvantiFpWorkflowRouter(db, requireAuth) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ivanti/fp-workflow/documents/search
|
||||||
|
*
|
||||||
|
* Searches the CVE document library for existing documents that can be
|
||||||
|
* attached to FP workflow submissions.
|
||||||
|
*
|
||||||
|
* @param {string} [req.query.q] - Optional search term matched against name, cve_id, vendor
|
||||||
|
* @returns {Array<object>} 200 - Array of matching document records
|
||||||
|
* @returns {object} 500 - Database error
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
console.error('Error searching documents:', err);
|
||||||
|
return res.status(500).json({ error: 'Database error.' });
|
||||||
|
}
|
||||||
|
res.json(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/ivanti/fp-workflow
|
* POST /api/ivanti/fp-workflow
|
||||||
*
|
*
|
||||||
@@ -306,6 +346,23 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
return res.status(400).json({ error: 'At least one queue item ID is required.' });
|
return res.status(400).json({ error: 'At least one queue item ID is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Parse and validate libraryDocIds ---
|
||||||
|
let libraryDocIds;
|
||||||
|
try {
|
||||||
|
libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(libraryDocIds)) {
|
||||||
|
return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
|
||||||
|
}
|
||||||
|
// Validate each ID is a positive integer
|
||||||
|
for (const id of libraryDocIds) {
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
return res.status(400).json({ error: `Invalid library document ID: ${id}. Each ID must be a positive integer.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Validate form fields ---
|
// --- Validate form fields ---
|
||||||
const validationErrors = validateFpWorkflowForm(req.body);
|
const validationErrors = validateFpWorkflowForm(req.body);
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
@@ -365,7 +422,44 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
|
|
||||||
// 1. Build form fields and call Ivanti API (multipart/form-data)
|
// 1. Build form fields and call Ivanti API (multipart/form-data)
|
||||||
const formFields = buildIvantiFormFields(req.body, findingIds);
|
const formFields = buildIvantiFormFields(req.body, findingIds);
|
||||||
const formFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname }));
|
|
||||||
|
// --- Look up library documents and read from disk ---
|
||||||
|
let libraryDocs = [];
|
||||||
|
const libraryAttachmentResults = [];
|
||||||
|
if (libraryDocIds.length > 0) {
|
||||||
|
const docPlaceholders = libraryDocIds.map(() => '?').join(',');
|
||||||
|
libraryDocs = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id IN (${docPlaceholders})`,
|
||||||
|
libraryDocIds,
|
||||||
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate all IDs were found
|
||||||
|
const foundIds = new Set(libraryDocs.map(d => d.id));
|
||||||
|
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build library file buffers (read from disk)
|
||||||
|
const libraryFormFiles = [];
|
||||||
|
for (const doc of libraryDocs) {
|
||||||
|
try {
|
||||||
|
const buffer = fs.readFileSync(doc.file_path);
|
||||||
|
libraryFormFiles.push({ name: 'files', buffer, filename: doc.name });
|
||||||
|
libraryAttachmentResults.push({ filename: doc.name, success: true, source: 'library', documentId: doc.id });
|
||||||
|
} catch (readErr) {
|
||||||
|
console.warn(`Library file not found on disk: ${doc.file_path} (document ID: ${doc.id})`);
|
||||||
|
libraryAttachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine local file buffers and library file buffers
|
||||||
|
const localFormFiles = files.map(f => ({ name: 'files', buffer: f.buffer, filename: f.originalname }));
|
||||||
|
const formFiles = [...localFormFiles, ...libraryFormFiles];
|
||||||
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
|
const createUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/request`;
|
||||||
|
|
||||||
let createResult;
|
let createResult;
|
||||||
@@ -431,6 +525,9 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Insert submission record
|
// 4. Insert submission record
|
||||||
|
const localAttachmentResults = files.map(f => ({ filename: f.originalname, success: true, source: 'local' }));
|
||||||
|
const allAttachmentResults = [...localAttachmentResults, ...libraryAttachmentResults];
|
||||||
|
const totalAttachmentCount = files.length + libraryDocIds.length;
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
@@ -449,8 +546,8 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
req.body.scopeOverride || 'Authorized',
|
req.body.scopeOverride || 'Authorized',
|
||||||
JSON.stringify(findingIds),
|
JSON.stringify(findingIds),
|
||||||
JSON.stringify(queueItemIds),
|
JSON.stringify(queueItemIds),
|
||||||
files.length,
|
totalAttachmentCount,
|
||||||
JSON.stringify(files.map(f => ({ filename: f.originalname, success: true }))),
|
JSON.stringify(allAttachmentResults),
|
||||||
status,
|
status,
|
||||||
null
|
null
|
||||||
],
|
],
|
||||||
@@ -467,7 +564,7 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
userId: req.user.id, username: req.user.username,
|
userId: req.user.id, username: req.user.username,
|
||||||
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
|
action: 'ivanti_fp_workflow_created', entityType: 'ivanti_workflow',
|
||||||
entityId: String(workflowBatchId),
|
entityId: String(workflowBatchId),
|
||||||
details: { workflowName: req.body.name, findingIds, attachmentCount: files.length, status },
|
details: { workflowName: req.body.name, findingIds, attachmentCount: totalAttachmentCount, libraryDocCount: libraryDocIds.length, status },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -997,20 +1094,25 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
* POST /api/ivanti/fp-submissions/:id/attachments
|
* POST /api/ivanti/fp-submissions/:id/attachments
|
||||||
*
|
*
|
||||||
* Uploads additional file attachments to an existing FP workflow batch
|
* Uploads additional file attachments to an existing FP workflow batch
|
||||||
* via the Ivanti attach endpoint. Updates the local submission record's
|
* via the Ivanti attach endpoint. Supports both local file uploads and
|
||||||
|
* library document references. Updates the local submission record's
|
||||||
* attachment_count and attachment_results_json, and records the change
|
* attachment_count and attachment_results_json, and records the change
|
||||||
* in submission history and audit log.
|
* in submission history and audit log.
|
||||||
*
|
*
|
||||||
* Content-Type: multipart/form-data
|
* Content-Type: multipart/form-data
|
||||||
*
|
*
|
||||||
* @param {string} req.params.id - Local FP submission ID
|
* @param {string} req.params.id - Local FP submission ID
|
||||||
* @param {File[]} req.files - One or more file attachments (field name "attachments",
|
* @param {File[]} req.files - Zero or more file attachments (field name "attachments",
|
||||||
* max 10 files, max 10 MB each); allowed extensions:
|
* max 10 files, max 10 MB each); allowed extensions:
|
||||||
* .pdf .png .jpg .jpeg .gif .doc .docx .xlsx .csv .txt .zip
|
* .pdf .png .jpg .jpeg .gif .doc .docx .xlsx .csv .txt .zip
|
||||||
|
* @param {string} [req.body.libraryDocIds] - JSON-encoded array of document IDs from the
|
||||||
|
* documents table to attach from the library (optional)
|
||||||
|
*
|
||||||
|
* At least one local file or library document ID is required.
|
||||||
*
|
*
|
||||||
* @returns {object} 200 - Success (or partial success)
|
* @returns {object} 200 - Success (or partial success)
|
||||||
* { success: true,
|
* { success: true,
|
||||||
* attachmentResults: Array<{ filename: string, success: boolean, error?: string }>,
|
* attachmentResults: Array<{ filename: string, success: boolean, source: string, documentId?: number, error?: string }>,
|
||||||
* status: 'success' | 'partial' }
|
* status: 'success' | 'partial' }
|
||||||
* @returns {object} 400 - Validation error, lifecycle guard, file constraint, or UUID resolution failure
|
* @returns {object} 400 - Validation error, lifecycle guard, file constraint, or UUID resolution failure
|
||||||
* { error: string } or { success: false, error: string }
|
* { error: string } or { success: false, error: string }
|
||||||
@@ -1031,8 +1133,27 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = req.files || [];
|
const files = req.files || [];
|
||||||
if (files.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'At least one file is required.' });
|
// --- Parse and validate libraryDocIds ---
|
||||||
|
let libraryDocIds;
|
||||||
|
try {
|
||||||
|
libraryDocIds = JSON.parse(req.body.libraryDocIds || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(libraryDocIds)) {
|
||||||
|
return res.status(400).json({ error: 'libraryDocIds must be a valid JSON array.' });
|
||||||
|
}
|
||||||
|
// Validate each ID is a positive integer
|
||||||
|
for (const id of libraryDocIds) {
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
return res.status(400).json({ error: `Invalid library document ID: ${id}. Each ID must be a positive integer.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at least one file (local or library)
|
||||||
|
if (files.length === 0 && libraryDocIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'At least one file or library document is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate extensions (belt-and-suspenders)
|
// Validate extensions (belt-and-suspenders)
|
||||||
@@ -1066,6 +1187,27 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
|
return res.status(400).json({ error: 'This submission is finalized and cannot be edited.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.5. Look up library documents and read from disk
|
||||||
|
let libraryDocs = [];
|
||||||
|
const libraryAttachmentResults = [];
|
||||||
|
if (libraryDocIds.length > 0) {
|
||||||
|
const docPlaceholders = libraryDocIds.map(() => '?').join(',');
|
||||||
|
libraryDocs = await new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT id, name, file_path, file_size, mime_type FROM documents WHERE id IN (${docPlaceholders})`,
|
||||||
|
libraryDocIds,
|
||||||
|
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate all IDs were found
|
||||||
|
const foundIds = new Set(libraryDocs.map(d => d.id));
|
||||||
|
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Upload each file to Ivanti
|
// 3. Upload each file to Ivanti
|
||||||
const apiKey = process.env.IVANTI_API_KEY;
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
@@ -1083,14 +1225,35 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(attachUuid)}/attach`;
|
const attachUrl = `/client/${encodeURIComponent(clientId)}/workflowBatch/falsePositive/${encodeURIComponent(attachUuid)}/attach`;
|
||||||
const attachmentResults = [];
|
const attachmentResults = [];
|
||||||
|
|
||||||
|
// Upload local files
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
try {
|
try {
|
||||||
const formFiles = [{ name: 'file', buffer: f.buffer, filename: f.originalname, contentType: f.mimetype || 'application/octet-stream' }];
|
const formFiles = [{ name: 'file', buffer: f.buffer, filename: f.originalname, contentType: f.mimetype || 'application/octet-stream' }];
|
||||||
const result = await ivantiFormPost(attachUrl, [], formFiles, apiKey, skipTls);
|
const result = await ivantiFormPost(attachUrl, [], formFiles, apiKey, skipTls);
|
||||||
const success = result.status === 200 || result.status === 201 || result.status === 202;
|
const success = result.status === 200 || result.status === 201 || result.status === 202;
|
||||||
attachmentResults.push({ filename: f.originalname, success, ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
|
attachmentResults.push({ filename: f.originalname, success, source: 'local', ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
|
||||||
} catch (uploadErr) {
|
} catch (uploadErr) {
|
||||||
attachmentResults.push({ filename: f.originalname, success: false, error: uploadErr.message });
|
attachmentResults.push({ filename: f.originalname, success: false, source: 'local', error: uploadErr.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload library files
|
||||||
|
for (const doc of libraryDocs) {
|
||||||
|
let buffer;
|
||||||
|
try {
|
||||||
|
buffer = fs.readFileSync(doc.file_path);
|
||||||
|
} catch (readErr) {
|
||||||
|
console.warn(`Library file not found on disk: ${doc.file_path} (document ID: ${doc.id})`);
|
||||||
|
attachmentResults.push({ success: false, error: 'File not found on disk', source: 'library', documentId: doc.id, filename: doc.name });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const formFiles = [{ name: 'file', buffer, filename: doc.name, contentType: doc.mime_type || 'application/octet-stream' }];
|
||||||
|
const result = await ivantiFormPost(attachUrl, [], formFiles, apiKey, skipTls);
|
||||||
|
const success = result.status === 200 || result.status === 201 || result.status === 202;
|
||||||
|
attachmentResults.push({ filename: doc.name, success, source: 'library', documentId: doc.id, ...(success ? {} : { error: `Upload failed: ${result.status}` }) });
|
||||||
|
} catch (uploadErr) {
|
||||||
|
attachmentResults.push({ filename: doc.name, success: false, source: 'library', documentId: doc.id, error: uploadErr.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,7 +1299,7 @@ function createIvantiFpWorkflowRouter(db, requireAuth) {
|
|||||||
userId: req.user.id, username: req.user.username,
|
userId: req.user.id, username: req.user.username,
|
||||||
action: 'ivanti_fp_attachments_added', entityType: 'ivanti_workflow',
|
action: 'ivanti_fp_attachments_added', entityType: 'ivanti_workflow',
|
||||||
entityId: String(submission.ivanti_workflow_batch_id),
|
entityId: String(submission.ivanti_workflow_batch_id),
|
||||||
details: { submissionId, attachmentResults },
|
details: { submissionId, attachmentResults, libraryDocCount: libraryDocIds.length },
|
||||||
ipAddress: req.ip
|
ipAddress: req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
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, Edit3, Square, CheckSquare, MinusSquare } 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, Square, CheckSquare, MinusSquare, Search, Database } from 'lucide-react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import IvantiCountsChart from './IvantiCountsChart';
|
import IvantiCountsChart from './IvantiCountsChart';
|
||||||
@@ -1808,6 +1808,421 @@ function filterFpItems(items) {
|
|||||||
const ALLOWED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'];
|
const ALLOWED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'];
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AttachmentSourcePicker — shared component for local + library attachments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function AttachmentSourcePicker({ files, onFilesChange, libraryDocs, onLibraryDocsChange, disabled }) {
|
||||||
|
const [mode, setMode] = useState('local');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState(null);
|
||||||
|
const [fileErrors, setFileErrors] = useState(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const dropRef = useRef(null);
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
|
||||||
|
// Format file size helper
|
||||||
|
const formatSize = (bytes) => {
|
||||||
|
const n = Number(bytes);
|
||||||
|
if (isNaN(n) || n < 0) return '0 B';
|
||||||
|
if (n < 1024) return n + ' B';
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||||
|
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
// File validation
|
||||||
|
const isAllowedExtension = (filename) => {
|
||||||
|
const ext = '.' + filename.split('.').pop().toLowerCase();
|
||||||
|
return ALLOWED_EXTENSIONS.includes(ext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFiles = (newFiles) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const errors = [];
|
||||||
|
const valid = [];
|
||||||
|
Array.from(newFiles).forEach(f => {
|
||||||
|
if (!isAllowedExtension(f.name)) {
|
||||||
|
errors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||||||
|
} else if (f.size > MAX_FILE_SIZE) {
|
||||||
|
errors.push(`"${f.name}" — exceeds 10 MB limit`);
|
||||||
|
} else {
|
||||||
|
valid.push(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (errors.length) {
|
||||||
|
setFileErrors(errors.join('; '));
|
||||||
|
} else {
|
||||||
|
setFileErrors(null);
|
||||||
|
}
|
||||||
|
if (valid.length) onFilesChange([...files, ...valid]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (idx) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onFilesChange(files.filter((_, i) => i !== idx));
|
||||||
|
setFileErrors(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLibraryDoc = (docId) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onLibraryDocsChange(libraryDocs.filter(d => d.id !== docId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (disabled) return;
|
||||||
|
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
|
||||||
|
|
||||||
|
// Library search with debounce
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'library') return;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
try {
|
||||||
|
const url = searchQuery.trim()
|
||||||
|
? `${API_BASE}/ivanti/fp-workflow/documents/search?q=${encodeURIComponent(searchQuery.trim())}`
|
||||||
|
: `${API_BASE}/ivanti/fp-workflow/documents/search`;
|
||||||
|
const res = await fetch(url, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`Search failed (${res.status})`);
|
||||||
|
const data = await res.json();
|
||||||
|
setSearchResults(data);
|
||||||
|
} catch (err) {
|
||||||
|
setSearchError(err.message || 'Failed to search documents');
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
|
}, [searchQuery, mode]);
|
||||||
|
|
||||||
|
const selectLibraryDoc = (doc) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (libraryDocs.some(d => d.id === doc.id)) return;
|
||||||
|
onLibraryDocsChange([...libraryDocs, {
|
||||||
|
id: doc.id,
|
||||||
|
cve_id: doc.cve_id,
|
||||||
|
vendor: doc.vendor,
|
||||||
|
name: doc.name,
|
||||||
|
file_size: doc.file_size,
|
||||||
|
mime_type: doc.mime_type,
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedIds = new Set(libraryDocs.map(d => d.id));
|
||||||
|
|
||||||
|
// ---- Styles ----
|
||||||
|
const tabBtnStyle = (active) => ({
|
||||||
|
flex: 1,
|
||||||
|
padding: '0.45rem 0.5rem',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: active ? '2px solid #0EA5E9' : '2px solid transparent',
|
||||||
|
color: active ? '#0EA5E9' : '#475569',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropZoneStyle = {
|
||||||
|
border: '1px dashed rgba(14,165,233,0.25)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
background: 'rgba(14,165,233,0.03)',
|
||||||
|
transition: 'border-color 0.15s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchInputStyle = {
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
background: 'rgba(14,165,233,0.05)',
|
||||||
|
border: '1px solid rgba(14,165,233,0.2)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
padding: '0.45rem 0.6rem 0.45rem 2rem',
|
||||||
|
color: '#CBD5E1',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
outline: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultItemStyle = (isSelected) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.4rem 0.5rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||||||
|
cursor: disabled || isSelected ? 'default' : 'pointer',
|
||||||
|
opacity: isSelected ? 0.5 : 1,
|
||||||
|
background: isSelected ? 'rgba(14,165,233,0.04)' : 'transparent',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
});
|
||||||
|
|
||||||
|
const badgeStyle = (type) => ({
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.1rem 0.3rem',
|
||||||
|
borderRadius: '0.15rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.58rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
...(type === 'local'
|
||||||
|
? { background: 'rgba(14,165,233,0.15)', color: '#0EA5E9', border: '1px solid rgba(14,165,233,0.3)' }
|
||||||
|
: { background: 'rgba(245,158,11,0.15)', color: '#F59E0B', border: '1px solid rgba(245,158,11,0.3)' }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAttachments = files.length + libraryDocs.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Mode toggle tabs */}
|
||||||
|
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.06)', marginBottom: '0.625rem' }}>
|
||||||
|
<button
|
||||||
|
style={tabBtnStyle(mode === 'local')}
|
||||||
|
onClick={() => !disabled && setMode('local')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Local Upload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={tabBtnStyle(mode === 'library')}
|
||||||
|
onClick={() => !disabled && setMode('library')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Library
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local Upload mode */}
|
||||||
|
{mode === 'local' && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
ref={dropRef}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||||
|
style={dropZoneStyle}
|
||||||
|
>
|
||||||
|
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||||
|
Drop files here or click to browse
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
|
||||||
|
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
|
||||||
|
accept={ALLOWED_EXTENSIONS.join(',')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{fileErrors && (
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#EF4444', marginTop: '0.3rem' }}>{fileErrors}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Library mode */}
|
||||||
|
{mode === 'library' && (
|
||||||
|
<div>
|
||||||
|
{/* Search input */}
|
||||||
|
<div style={{ position: 'relative', marginBottom: '0.5rem' }}>
|
||||||
|
<Search size={14} style={{ position: 'absolute', left: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: '#475569' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search documents by name, CVE, or vendor..."
|
||||||
|
disabled={disabled}
|
||||||
|
style={searchInputStyle}
|
||||||
|
/>
|
||||||
|
{searching && (
|
||||||
|
<Loader size={14} style={{ position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search results */}
|
||||||
|
<div style={{
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid rgba(14,165,233,0.1)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
background: 'rgba(15,23,42,0.5)',
|
||||||
|
}}>
|
||||||
|
{searchError && (
|
||||||
|
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#EF4444', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem' }}>
|
||||||
|
<AlertCircle size={13} />
|
||||||
|
{searchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!searchError && !searching && searchResults.length === 0 && (
|
||||||
|
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#475569' }}>
|
||||||
|
No documents found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!searchError && searching && searchResults.length === 0 && (
|
||||||
|
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#64748B', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem' }}>
|
||||||
|
<Loader size={13} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!searchError && searchResults.map(doc => {
|
||||||
|
const isSelected = selectedIds.has(doc.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
style={resultItemStyle(isSelected)}
|
||||||
|
onClick={() => !isSelected && selectLibraryDoc(doc)}
|
||||||
|
>
|
||||||
|
{isSelected ? (
|
||||||
|
<Check size={13} style={{ color: '#10B981', flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<Database size={13} style={{ color: '#F59E0B', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
|
||||||
|
{doc.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', display: 'flex', gap: '0.5rem', marginTop: '0.1rem' }}>
|
||||||
|
{doc.cve_id && <span style={{ color: '#0EA5E9' }}>{doc.cve_id}</span>}
|
||||||
|
{doc.vendor && <span>{doc.vendor}</span>}
|
||||||
|
<span>{formatSize(doc.file_size)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unified attachment list */}
|
||||||
|
{totalAttachments > 0 && (
|
||||||
|
<div style={{ marginTop: '0.625rem' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#64748B',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
marginBottom: '0.35rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
Attachments ({totalAttachments})
|
||||||
|
</div>
|
||||||
|
{/* Local files */}
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<div key={`local-${i}`} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.3rem 0.25rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||||||
|
}}>
|
||||||
|
<span style={badgeStyle('local')}>LOCAL</span>
|
||||||
|
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
|
||||||
|
<span style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
color: '#CBD5E1',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>
|
||||||
|
{formatSize(f.size)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#64748B',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
padding: '0.15rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Library docs */}
|
||||||
|
{libraryDocs.map(doc => (
|
||||||
|
<div key={`lib-${doc.id}`} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.3rem 0.25rem',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||||||
|
}}>
|
||||||
|
<span style={badgeStyle('library')}>LIBRARY</span>
|
||||||
|
<Database size={13} style={{ color: '#F59E0B', flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
color: '#CBD5E1',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{doc.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#64748B', fontFamily: 'monospace', display: 'flex', gap: '0.5rem' }}>
|
||||||
|
{doc.cve_id && <span style={{ color: '#0EA5E9' }}>{doc.cve_id}</span>}
|
||||||
|
{doc.vendor && <span>{doc.vendor}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.62rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>
|
||||||
|
{formatSize(doc.file_size)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeLibraryDoc(doc.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#64748B',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
padding: '0.15rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
@@ -1815,12 +2230,11 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
|||||||
const [expirationDate, setExpirationDate] = useState('');
|
const [expirationDate, setExpirationDate] = useState('');
|
||||||
const [scopeOverride, setScopeOverride] = useState('Authorized');
|
const [scopeOverride, setScopeOverride] = useState('Authorized');
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
|
const [libraryDocs, setLibraryDocs] = useState([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
|
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
const dropRef = useRef(null);
|
|
||||||
|
|
||||||
// Reset form when modal opens
|
// Reset form when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1831,6 +2245,7 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
|||||||
setExpirationDate('');
|
setExpirationDate('');
|
||||||
setScopeOverride('Authorized');
|
setScopeOverride('Authorized');
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
|
setLibraryDocs([]);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setProgress({ step: '', current: 0, total: 0 });
|
setProgress({ step: '', current: 0, total: 0 });
|
||||||
setErrors({});
|
setErrors({});
|
||||||
@@ -1846,44 +2261,6 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
|||||||
return () => document.removeEventListener('keydown', handler);
|
return () => document.removeEventListener('keydown', handler);
|
||||||
}, [open, submitting, onClose]);
|
}, [open, submitting, onClose]);
|
||||||
|
|
||||||
const isAllowedExtension = (filename) => {
|
|
||||||
const ext = '.' + filename.split('.').pop().toLowerCase();
|
|
||||||
return ALLOWED_EXTENSIONS.includes(ext);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addFiles = (newFiles) => {
|
|
||||||
const fileErrors = [];
|
|
||||||
const valid = [];
|
|
||||||
Array.from(newFiles).forEach(f => {
|
|
||||||
if (!isAllowedExtension(f.name)) {
|
|
||||||
fileErrors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
|
||||||
} else if (f.size > MAX_FILE_SIZE) {
|
|
||||||
fileErrors.push(`"${f.name}" — exceeds 10 MB limit`);
|
|
||||||
} else {
|
|
||||||
valid.push(f);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (fileErrors.length) {
|
|
||||||
setErrors(prev => ({ ...prev, files: fileErrors.join('; ') }));
|
|
||||||
} else {
|
|
||||||
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
|
||||||
}
|
|
||||||
if (valid.length) setFiles(prev => [...prev, ...valid]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFile = (idx) => {
|
|
||||||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
|
||||||
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
|
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const errs = {};
|
const errs = {};
|
||||||
if (!name.trim()) errs.name = 'Workflow name is required';
|
if (!name.trim()) errs.name = 'Workflow name is required';
|
||||||
@@ -1917,9 +2294,13 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
|||||||
formData.append('findingIds', JSON.stringify(selectedItems.map(i => i.finding_id)));
|
formData.append('findingIds', JSON.stringify(selectedItems.map(i => i.finding_id)));
|
||||||
formData.append('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
|
formData.append('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
|
||||||
files.forEach(f => formData.append('attachments', f));
|
files.forEach(f => formData.append('attachments', f));
|
||||||
|
if (libraryDocs.length > 0) {
|
||||||
|
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
|
||||||
|
}
|
||||||
|
|
||||||
if (files.length > 0) {
|
const totalAttachments = files.length + libraryDocs.length;
|
||||||
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: files.length });
|
if (totalAttachments > 0) {
|
||||||
|
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: totalAttachments });
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
|
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
|
||||||
@@ -1966,12 +2347,6 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
|||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const formatSize = (bytes) => {
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Styles ----
|
// ---- Styles ----
|
||||||
const overlayStyle = {
|
const overlayStyle = {
|
||||||
position: 'fixed', inset: 0, zIndex: 10000,
|
position: 'fixed', inset: 0, zIndex: 10000,
|
||||||
@@ -2048,6 +2423,19 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
|||||||
{result.attachmentResults.map((a, i) => (
|
{result.attachmentResults.map((a, i) => (
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
||||||
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
padding: '0.1rem 0.3rem',
|
||||||
|
borderRadius: '0.2rem',
|
||||||
|
background: (a.source || 'local') === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)',
|
||||||
|
color: (a.source || 'local') === 'library' ? '#A855F7' : '#0EA5E9',
|
||||||
|
border: `1px solid ${(a.source || 'local') === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)'}`,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>{(a.source || 'local') === 'library' ? 'Library' : 'Local'}</span>
|
||||||
<span>{a.filename}</span>
|
<span>{a.filename}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -2073,6 +2461,19 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
|||||||
{result.attachmentResults.map((a, i) => (
|
{result.attachmentResults.map((a, i) => (
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
||||||
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
padding: '0.1rem 0.3rem',
|
||||||
|
borderRadius: '0.2rem',
|
||||||
|
background: (a.source || 'local') === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)',
|
||||||
|
color: (a.source || 'local') === 'library' ? '#A855F7' : '#0EA5E9',
|
||||||
|
border: `1px solid ${(a.source || 'local') === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)'}`,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>{(a.source || 'local') === 'library' ? 'Library' : 'Local'}</span>
|
||||||
<span>{a.filename}</span>
|
<span>{a.filename}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -2239,59 +2640,16 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File upload */}
|
{/* Attachments */}
|
||||||
<div style={sectionStyle}>
|
<div style={sectionStyle}>
|
||||||
<div style={labelStyle}>Attachments</div>
|
<div style={labelStyle}>Attachments</div>
|
||||||
<div
|
<AttachmentSourcePicker
|
||||||
ref={dropRef}
|
files={files}
|
||||||
onDrop={handleDrop}
|
onFilesChange={setFiles}
|
||||||
onDragOver={handleDragOver}
|
libraryDocs={libraryDocs}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onLibraryDocsChange={setLibraryDocs}
|
||||||
style={{
|
|
||||||
border: '1px dashed rgba(14,165,233,0.25)',
|
|
||||||
borderRadius: '0.375rem',
|
|
||||||
padding: '1rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
|
||||||
background: 'rgba(14,165,233,0.03)',
|
|
||||||
transition: 'border-color 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
|
|
||||||
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
|
||||||
Drop files here or click to browse
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
|
|
||||||
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
|
|
||||||
accept={ALLOWED_EXTENSIONS.join(',')}
|
|
||||||
/>
|
|
||||||
{errors.files && <div style={errorTextStyle}>{errors.files}</div>}
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
|
||||||
{files.map((f, i) => (
|
|
||||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
|
||||||
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
|
|
||||||
<span style={{ flex: 1, fontSize: '0.75rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
|
||||||
<span style={{ fontSize: '0.68rem', color: '#475569', flexShrink: 0 }}>{formatSize(f.size)}</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
|
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.15rem' }}
|
/>
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -2354,6 +2712,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
|
const [libraryDocs, setLibraryDocs] = useState([]);
|
||||||
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
|
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
|
||||||
const [statusValue, setStatusValue] = useState('');
|
const [statusValue, setStatusValue] = useState('');
|
||||||
|
|
||||||
@@ -2370,6 +2729,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
setErrors({});
|
setErrors({});
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
|
setLibraryDocs([]);
|
||||||
setAdditionalFindingIds(new Set());
|
setAdditionalFindingIds(new Set());
|
||||||
}
|
}
|
||||||
}, [submission]);
|
}, [submission]);
|
||||||
@@ -2435,10 +2795,13 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadAttachments = async () => {
|
const handleUploadAttachments = async () => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0 && libraryDocs.length === 0) return;
|
||||||
setSaving(true); setResult(null);
|
setSaving(true); setResult(null);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach(f => formData.append('attachments', f));
|
files.forEach(f => formData.append('attachments', f));
|
||||||
|
if (libraryDocs.length > 0) {
|
||||||
|
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/attachments`, {
|
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/attachments`, {
|
||||||
method: 'POST', credentials: 'include', body: formData,
|
method: 'POST', credentials: 'include', body: formData,
|
||||||
@@ -2448,6 +2811,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
const successCount = (data.attachmentResults || []).filter(r => r.success).length;
|
const successCount = (data.attachmentResults || []).filter(r => r.success).length;
|
||||||
setResult({ type: 'success', message: `Uploaded ${successCount} file(s).` });
|
setResult({ type: 'success', message: `Uploaded ${successCount} file(s).` });
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
|
setLibraryDocs([]);
|
||||||
if (onSuccess) onSuccess();
|
if (onSuccess) onSuccess();
|
||||||
} else {
|
} else {
|
||||||
setResult({ type: 'error', message: data.error || 'Failed to upload attachments.' });
|
setResult({ type: 'error', message: data.error || 'Failed to upload attachments.' });
|
||||||
@@ -2731,13 +3095,39 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
{!isApproved && (
|
||||||
padding: '0.625rem 0.75rem', borderRadius: '0.375rem',
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.15)',
|
<AttachmentSourcePicker
|
||||||
fontFamily: 'monospace', fontSize: '0.72rem', color: '#F59E0B',
|
files={files}
|
||||||
}}>
|
onFilesChange={setFiles}
|
||||||
To add additional attachments, upload them directly in the Ivanti platform on the workflow detail page.
|
libraryDocs={libraryDocs}
|
||||||
|
onLibraryDocsChange={setLibraryDocs}
|
||||||
|
disabled={isApproved}
|
||||||
|
/>
|
||||||
|
{(files.length > 0 || libraryDocs.length > 0) && (
|
||||||
|
<button
|
||||||
|
onClick={handleUploadAttachments}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
padding: '0.4rem 1rem',
|
||||||
|
background: saving ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
|
||||||
|
border: `1px solid ${saving ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: saving ? '#92700C' : '#F59E0B',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
cursor: saving ? 'not-allowed' : 'pointer',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Uploading…' : `Upload ${files.length + libraryDocs.length} Attachment(s)`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user