- Add 'Remediate' as a valid workflow type (vendor-required, like FP/Archer) - Create queue_remediation_notes table with FK cascade and 5000 char limit - Add POST/GET /api/ivanti/todo-queue/:id/notes endpoints - Include remediation_notes_count in queue item GET response - Add RemediationModal component for viewing/adding notes - Add notes count badge on Remediate queue items (purple #A855F7 theme) - Add delete confirmation warning when removing items with notes - Append remediation notes to Jira ticket descriptions - Add property-based tests for all correctness properties
363 lines
11 KiB
JavaScript
363 lines
11 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { X, Loader, AlertCircle, Send, RefreshCw } from 'lucide-react';
|
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles — matches dark theme tactical intelligence aesthetic
|
|
// ---------------------------------------------------------------------------
|
|
const STYLES = {
|
|
overlay: {
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex: 100,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
backdrop: {
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0,0,0,0.7)',
|
|
backdropFilter: 'blur(4px)',
|
|
},
|
|
content: {
|
|
position: 'relative',
|
|
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
|
border: '1px solid rgba(14, 165, 233, 0.25)',
|
|
borderRadius: '16px',
|
|
padding: '2rem',
|
|
width: '90%',
|
|
maxWidth: '560px',
|
|
maxHeight: '85vh',
|
|
overflowY: 'auto',
|
|
zIndex: 101,
|
|
},
|
|
header: {
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
justifyContent: 'space-between',
|
|
marginBottom: '1.25rem',
|
|
},
|
|
title: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.8rem',
|
|
fontWeight: 700,
|
|
color: '#A855F7',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.1em',
|
|
},
|
|
subtitle: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.7rem',
|
|
color: '#94A3B8',
|
|
marginTop: '0.35rem',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
maxWidth: '400px',
|
|
},
|
|
closeBtn: {
|
|
background: 'none',
|
|
border: 'none',
|
|
color: '#64748B',
|
|
cursor: 'pointer',
|
|
padding: '0.25rem',
|
|
borderRadius: '4px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
textarea: {
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
minHeight: '100px',
|
|
background: 'rgba(15, 23, 42, 0.8)',
|
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
|
borderRadius: '8px',
|
|
padding: '0.75rem',
|
|
color: '#F8FAFC',
|
|
fontSize: '0.85rem',
|
|
fontFamily: 'monospace',
|
|
resize: 'vertical',
|
|
outline: 'none',
|
|
},
|
|
charCounter: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.6rem',
|
|
color: '#64748B',
|
|
textAlign: 'right',
|
|
marginTop: '0.25rem',
|
|
},
|
|
submitBtn: {
|
|
padding: '0.5rem 1rem',
|
|
borderRadius: '8px',
|
|
border: '1px solid rgba(168, 85, 247, 0.4)',
|
|
background: 'rgba(168, 85, 247, 0.15)',
|
|
color: '#C084FC',
|
|
cursor: 'pointer',
|
|
fontSize: '0.8rem',
|
|
fontWeight: 600,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '0.4rem',
|
|
transition: 'all 0.2s',
|
|
},
|
|
submitBtnDisabled: {
|
|
opacity: 0.4,
|
|
cursor: 'not-allowed',
|
|
},
|
|
noteItem: {
|
|
padding: '0.75rem',
|
|
marginBottom: '0.5rem',
|
|
background: 'rgba(14, 165, 233, 0.04)',
|
|
border: '1px solid rgba(14, 165, 233, 0.1)',
|
|
borderRadius: '8px',
|
|
},
|
|
noteMeta: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.6rem',
|
|
color: '#64748B',
|
|
marginBottom: '0.35rem',
|
|
display: 'flex',
|
|
gap: '0.5rem',
|
|
},
|
|
noteText: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.75rem',
|
|
color: '#CBD5E1',
|
|
lineHeight: 1.5,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
},
|
|
error: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
padding: '0.5rem 0.75rem',
|
|
background: 'rgba(239, 68, 68, 0.08)',
|
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
|
borderRadius: '0.5rem',
|
|
marginBottom: '0.75rem',
|
|
},
|
|
errorText: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.7rem',
|
|
color: '#FCA5A5',
|
|
},
|
|
emptyState: {
|
|
textAlign: 'center',
|
|
padding: '1.5rem 0',
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.7rem',
|
|
color: '#475569',
|
|
},
|
|
divider: {
|
|
height: '1px',
|
|
background: 'rgba(14, 165, 233, 0.1)',
|
|
margin: '1rem 0',
|
|
},
|
|
retryBtn: {
|
|
padding: '0.4rem 0.75rem',
|
|
borderRadius: '6px',
|
|
border: '1px solid rgba(14, 165, 233, 0.3)',
|
|
background: 'rgba(14, 165, 233, 0.1)',
|
|
color: '#7DD3FC',
|
|
cursor: 'pointer',
|
|
fontSize: '0.7rem',
|
|
fontWeight: 600,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '0.3rem',
|
|
marginTop: '0.5rem',
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RemediationModal — add and view remediation notes for a queue item
|
|
// ---------------------------------------------------------------------------
|
|
export default function RemediationModal({ item, onClose, onNoteAdded }) {
|
|
const [notes, setNotes] = useState([]);
|
|
const [newNoteText, setNewNoteText] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const [fetchError, setFetchError] = useState(null);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fetch existing notes on mount
|
|
// ---------------------------------------------------------------------------
|
|
const fetchNotes = useCallback(async () => {
|
|
setLoading(true);
|
|
setFetchError(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.error || `HTTP ${res.status}`);
|
|
}
|
|
const data = await res.json();
|
|
setNotes(data);
|
|
} catch (e) {
|
|
setFetchError(e.message || 'Failed to load notes.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [item.id]);
|
|
|
|
useEffect(() => {
|
|
fetchNotes();
|
|
}, [fetchNotes]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Submit new note
|
|
// ---------------------------------------------------------------------------
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!newNoteText.trim() || saving) return;
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ note_text: newNoteText }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(data.error || `HTTP ${res.status}`);
|
|
}
|
|
// Prepend new note to list (most recent first)
|
|
setNotes((prev) => [data, ...prev]);
|
|
setNewNoteText('');
|
|
if (onNoteAdded) onNoteAdded();
|
|
} catch (e) {
|
|
setError(e.message || 'Failed to save note.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [newNoteText, saving, item.id, onNoteAdded]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Format date as YYYY-MM-DD
|
|
// ---------------------------------------------------------------------------
|
|
const formatDate = (dateStr) => {
|
|
try {
|
|
const d = new Date(dateStr);
|
|
return d.toISOString().slice(0, 10);
|
|
} catch {
|
|
return '—';
|
|
}
|
|
};
|
|
|
|
const canSubmit = newNoteText.trim().length > 0 && !saving;
|
|
const remaining = 5000 - newNoteText.length;
|
|
|
|
return (
|
|
<div style={STYLES.overlay}>
|
|
<div style={STYLES.backdrop} onClick={onClose} />
|
|
<div style={STYLES.content}>
|
|
{/* Header */}
|
|
<div style={STYLES.header}>
|
|
<div>
|
|
<div style={STYLES.title}>Remediation Notes</div>
|
|
<div style={STYLES.subtitle} title={item.finding_title || item.finding_id}>
|
|
{item.finding_title || item.finding_id}
|
|
</div>
|
|
<div style={{ ...STYLES.subtitle, fontSize: '0.6rem', color: '#64748B' }}>
|
|
ID: {item.finding_id}
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} style={STYLES.closeBtn} aria-label="Close modal">
|
|
<X style={{ width: '18px', height: '18px' }} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* New note input */}
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<textarea
|
|
value={newNoteText}
|
|
onChange={(e) => setNewNoteText(e.target.value)}
|
|
maxLength={5000}
|
|
placeholder="Describe what remediation steps were taken…"
|
|
style={STYLES.textarea}
|
|
disabled={saving}
|
|
/>
|
|
<div style={STYLES.charCounter}>
|
|
{remaining} characters remaining
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error message */}
|
|
{error && (
|
|
<div style={STYLES.error}>
|
|
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
|
<span style={STYLES.errorText}>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit button */}
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!canSubmit}
|
|
style={{
|
|
...STYLES.submitBtn,
|
|
...(canSubmit ? {} : STYLES.submitBtnDisabled),
|
|
}}
|
|
>
|
|
<Send style={{ width: '14px', height: '14px' }} />
|
|
{saving ? 'Saving...' : 'Add Note'}
|
|
</button>
|
|
</div>
|
|
|
|
<div style={STYLES.divider} />
|
|
|
|
{/* Notes list */}
|
|
{loading && (
|
|
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
|
|
<Loader style={{ width: '20px', height: '20px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
|
|
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569', marginTop: '0.5rem' }}>
|
|
Loading notes...
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{fetchError && !loading && (
|
|
<div style={STYLES.error}>
|
|
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
|
<span style={STYLES.errorText}>{fetchError}</span>
|
|
<button onClick={fetchNotes} style={STYLES.retryBtn}>
|
|
<RefreshCw style={{ width: '12px', height: '12px' }} />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !fetchError && notes.length === 0 && (
|
|
<div style={STYLES.emptyState}>
|
|
No remediation notes yet.
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !fetchError && notes.length > 0 && (
|
|
<div>
|
|
{notes.map((note) => (
|
|
<div key={note.id} style={STYLES.noteItem}>
|
|
<div style={STYLES.noteMeta}>
|
|
<span style={{ color: '#A855F7', fontWeight: 600 }}>{note.username}</span>
|
|
<span>{formatDate(note.created_at)}</span>
|
|
</div>
|
|
<div style={STYLES.noteText}>{note.note_text}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|