diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index b1f355d..6e2541a 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -4,6 +4,7 @@ const express = require('express'); const https = require('https'); +const { requireRole } = require('../middleware/auth'); const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1'; const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -151,9 +152,25 @@ function initTables(db) { VALUES (1, 0, 0) `, (err) => { if (err) return reject(err); }); + db.run(` + CREATE TABLE IF NOT EXISTS ivanti_finding_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + finding_id TEXT NOT NULL, + field TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(finding_id, field) + ) + `, (err) => { if (err) return reject(err); }); + db.run(` CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id ON ivanti_finding_notes(finding_id) + `, (err) => { if (err) return reject(err); }); + + db.run(` + CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id + ON ivanti_finding_overrides(finding_id) `, (err) => { if (err) reject(err); else resolve(); @@ -395,9 +412,28 @@ function readCounts(db) { }); } +// Returns { findingId: { hostName: 'override', dns: 'override' }, ... } +function readOverrides(db) { + return new Promise((resolve, reject) => { + db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => { + if (err) return reject(err); + const map = {}; + (rows || []).forEach((r) => { + if (!map[r.finding_id]) map[r.finding_id] = {}; + map[r.finding_id][r.field] = r.value; + }); + resolve(map); + }); + }); +} + async function readStateWithNotes(db) { - const [state, notes] = await Promise.all([readState(db), readNotes(db)]); - state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' })); + const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]); + state.findings = state.findings.map((f) => ({ + ...f, + note: notes[f.id] || '', + overrides: overrides[f.id] || {}, + })); return state; } @@ -441,6 +477,42 @@ function createIvantiFindingsRouter(db, requireAuth) { } }); + // PUT /:findingId/override — save or clear a field override (editor/admin only) + const OVERRIDE_ALLOWED = ['hostName', 'dns']; + router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => { + const { findingId } = req.params; + const { field, value } = req.body; + + if (!OVERRIDE_ALLOWED.includes(field)) { + return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` }); + } + + const val = String(value ?? '').trim(); + + if (val === '') { + // Empty value = clear the override (revert to Ivanti) + db.run( + 'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?', + [findingId, field], + (err) => { + if (err) return res.status(500).json({ error: 'Failed to clear override' }); + res.json({ finding_id: findingId, field, value: null }); + } + ); + } else { + db.run( + `INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`, + [findingId, field, val], + (err) => { + if (err) return res.status(500).json({ error: 'Failed to save override' }); + res.json({ finding_id: findingId, field, value: val }); + } + ); + } + }); + // PUT /:findingId/note — save or update a note (max 255 chars enforced here) router.put('/:findingId/note', (req, res) => { const { findingId } = req.params; diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index e41e966..0f3d282 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -1,7 +1,8 @@ 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 } from 'lucide-react'; +import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw } from 'lucide-react'; import * as XLSX from 'xlsx'; +import { useAuth } from '../../contexts/AuthContext'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const STORAGE_KEY = 'steam_findings_columns_v2'; @@ -391,6 +392,119 @@ function SortIcon({ colKey, sort }) { : ; } +// --------------------------------------------------------------------------- +// OverrideCell — inline editable hostname/dns with amber dot when overridden +// --------------------------------------------------------------------------- +function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) { + const effective = initialOverride ?? originalValue ?? ''; + const [value, setValue] = useState(effective); + const [isOverridden, setOverridden] = useState(!!initialOverride); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const lastSaved = useRef(effective); + const inputRef = useRef(null); + + // Sync when the finding updates (e.g. after a full sync) + useEffect(() => { + const eff = initialOverride ?? originalValue ?? ''; + setValue(eff); + setOverridden(!!initialOverride); + lastSaved.current = eff; + }, [initialOverride, originalValue]); + + useEffect(() => { + if (editing && inputRef.current) inputRef.current.focus(); + }, [editing]); + + const persist = useCallback(async (newVal) => { + const trimmed = newVal.trim(); + if (trimmed === lastSaved.current) return; + setSaving(true); + try { + const res = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ field, value: trimmed }), + }); + if (res.ok) { + const data = await res.json(); + const cleared = data.value === null; + const displayed = cleared ? (originalValue ?? '') : trimmed; + setValue(displayed); + setOverridden(!cleared); + lastSaved.current = displayed; + } else { + setValue(lastSaved.current); // revert on error + } + } catch { + setValue(lastSaved.current); + } finally { + setSaving(false); + } + }, [findingId, field, originalValue]); + + const handleBlur = () => { setEditing(false); persist(value); }; + const handleKeyDown = (e) => { + if (e.key === 'Enter') { e.target.blur(); } + if (e.key === 'Escape') { setValue(lastSaved.current); setEditing(false); } + }; + const handleRevert = (e) => { e.stopPropagation(); setValue(''); persist(''); }; + + if (editing) { + return ( + + setValue(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + style={{ + width: '100%', minWidth: '120px', + background: 'rgba(14,165,233,0.08)', + border: '1px solid rgba(14,165,233,0.4)', + borderRadius: '0.25rem', + padding: '0.2rem 0.4rem', + color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.72rem', + outline: 'none', + }} + /> + + ); + } + + return ( + + setEditing(true) : undefined} + title={isOverridden ? `Ivanti value: ${originalValue || '—'}\nClick to edit` : canWrite ? 'Click to edit' : undefined} + style={{ + display: 'inline-flex', alignItems: 'center', gap: '0.3rem', + color: isOverridden ? '#E2E8F0' : '#94A3B8', + fontFamily: 'monospace', fontSize: '0.72rem', + cursor: canWrite ? 'text' : 'default', + }} + > + {isOverridden && ( + + )} + {value || '—'} + {saving && } + {isOverridden && canWrite && !saving && ( + + )} + + + ); +} + // --------------------------------------------------------------------------- // NoteCell — inline editable, saves on blur // --------------------------------------------------------------------------- @@ -713,7 +827,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan // --------------------------------------------------------------------------- // Render a single table cell by column key // --------------------------------------------------------------------------- -function TableCell({ colKey, finding }) { +function TableCell({ colKey, finding, canWrite }) { switch (colKey) { case 'findingId': return ( @@ -763,19 +877,30 @@ function TableCell({ colKey, finding }) { ); } case 'hostName': + return ( + + ); case 'ipAddress': return ( - {finding[colKey] || '—'} + {finding.ipAddress || '—'} ); case 'dns': return ( - - - {finding.dns || '—'} - - + ); case 'dueDate': { const color = dueDateColor(finding.dueDate); @@ -861,6 +986,7 @@ function TableCell({ colKey, finding }) { // Main ReportingPage // --------------------------------------------------------------------------- export default function ReportingPage({ filterDate, filterEXC }) { + const { canWrite } = useAuth(); const [findings, setFindings] = useState([]); const [total, setTotal] = useState(null); const [syncedAt, setSyncedAt] = useState(null); @@ -1384,7 +1510,7 @@ export default function ReportingPage({ filterDate, filterEXC }) { onMouseLeave={(e) => e.currentTarget.style.background = rowBg} > {visibleCols.map((col) => ( - + ))} );