Files
cve-dashboard/frontend/src/components/RemediationModal.js

363 lines
11 KiB
JavaScript
Raw Normal View History

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>
);
}