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:
jramos
2026-04-15 15:27:21 -06:00
parent ed48522932
commit e1b0236874
6 changed files with 1224 additions and 119 deletions

View File

@@ -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, 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 { useAuth } from '../../contexts/AuthContext';
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 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 }) {
const [name, setName] = useState('');
const [reason, setReason] = useState('');
@@ -1815,12 +2230,11 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
const [expirationDate, setExpirationDate] = useState('');
const [scopeOverride, setScopeOverride] = useState('Authorized');
const [files, setFiles] = useState([]);
const [libraryDocs, setLibraryDocs] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
const [errors, setErrors] = useState({});
const [result, setResult] = useState(null);
const fileInputRef = useRef(null);
const dropRef = useRef(null);
// Reset form when modal opens
useEffect(() => {
@@ -1831,6 +2245,7 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
setExpirationDate('');
setScopeOverride('Authorized');
setFiles([]);
setLibraryDocs([]);
setSubmitting(false);
setProgress({ step: '', current: 0, total: 0 });
setErrors({});
@@ -1846,44 +2261,6 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
return () => document.removeEventListener('keydown', handler);
}, [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 errs = {};
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('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
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) {
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: files.length });
const totalAttachments = files.length + libraryDocs.length;
if (totalAttachments > 0) {
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: totalAttachments });
}
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
@@ -1966,12 +2347,6 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
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 ----
const overlayStyle = {
position: 'fixed', inset: 0, zIndex: 10000,
@@ -2048,6 +2423,19 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
{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' }}>
{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>
</div>
))}
@@ -2073,6 +2461,19 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
{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' }}>
{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>
</div>
))}
@@ -2239,59 +2640,16 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
</div>
</div>
{/* File upload */}
{/* Attachments */}
<div style={sectionStyle}>
<div style={labelStyle}>Attachments</div>
<div
ref={dropRef}
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => fileInputRef.current?.click()}
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(',')}
<AttachmentSourcePicker
files={files}
onFilesChange={setFiles}
libraryDocs={libraryDocs}
onLibraryDocsChange={setLibraryDocs}
disabled={submitting}
/>
{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}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.15rem' }}
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
@@ -2354,6 +2712,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
const [errors, setErrors] = useState({});
const [result, setResult] = useState(null);
const [files, setFiles] = useState([]);
const [libraryDocs, setLibraryDocs] = useState([]);
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
const [statusValue, setStatusValue] = useState('');
@@ -2370,6 +2729,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
setErrors({});
setResult(null);
setFiles([]);
setLibraryDocs([]);
setAdditionalFindingIds(new Set());
}
}, [submission]);
@@ -2435,10 +2795,13 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
};
const handleUploadAttachments = async () => {
if (files.length === 0) return;
if (files.length === 0 && libraryDocs.length === 0) return;
setSaving(true); setResult(null);
const formData = new FormData();
files.forEach(f => formData.append('attachments', f));
if (libraryDocs.length > 0) {
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
}
try {
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/attachments`, {
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;
setResult({ type: 'success', message: `Uploaded ${successCount} file(s).` });
setFiles([]);
setLibraryDocs([]);
if (onSuccess) onSuccess();
} else {
setResult({ type: 'error', message: data.error || 'Failed to upload attachments.' });
@@ -2731,13 +3095,39 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
</div>
)}
</div>
<div style={{
padding: '0.625rem 0.75rem', borderRadius: '0.375rem',
background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.15)',
fontFamily: 'monospace', fontSize: '0.72rem', color: '#F59E0B',
}}>
To add additional attachments, upload them directly in the Ivanti platform on the workflow detail page.
</div>
{!isApproved && (
<div style={{ marginTop: '0.75rem' }}>
<AttachmentSourcePicker
files={files}
onFilesChange={setFiles}
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>
)}