From 8224183679445cc1aeece771b46c4db423f19615 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Thu, 28 May 2026 14:58:27 -0600 Subject: [PATCH] Add CARD Action Modal with full owner context Replace inline CARD action form with a centered modal that: - Fetches and displays the full CARD owner record (confirmed, unconfirmed, candidates, declined teams with scores/sources) - Shows queue item info (hostname, IP, finding, CVEs) - Lets user switch between Confirm/Decline/Redirect actions - Pre-fills team dropdowns from the actual owner data - Shows CARD API errors inline with full detail Add GET /api/card/owner-lookup/:ip endpoint that resolves a bare IP to a CARD asset ID and returns the structured owner record. --- backend/routes/cardApi.js | 61 ++- frontend/src/components/CardActionModal.js | 346 ++++++++++++++++++ .../src/components/pages/ReportingPage.js | 31 +- 3 files changed, 427 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/CardActionModal.js diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index acd8d1e..bc71039 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.js @@ -500,16 +500,69 @@ function createCardApiRouter() { } }); + /** + * GET /owner-lookup/:ip + * + * Resolve an IP to a CARD asset ID and return the full owner record. + * Used by the CARD Action Modal to display ownership state before + * confirm/decline/redirect operations. + * + * @param {string} ip - IP address (path parameter) + * @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token } + * @response 404 - { error: string } — IP not found in CARD + * @response 503 - { error: string } — CARD not configured + */ + router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + if (!isConfigured) { + return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); + } + + const ip = req.params.ip; + if (!ip || !ip.trim()) { + return res.status(400).json({ error: 'IP address is required.' }); + } + + // Resolve to full asset ID + const assetId = await resolveAssetId(ip.trim()); + if (!assetId) { + return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` }); + } + + // Fetch full owner record + try { + const ownerResult = await getOwner(assetId); + if (!ownerResult.ok) { + return res.status(ownerResult.status).json({ error: `Failed to fetch owner: HTTP ${ownerResult.status}` }); + } + + const data = JSON.parse(ownerResult.body); + const owner = data.owner || {}; + + res.json({ + asset_id: assetId, + ip: ip.trim(), + confirmed: owner.confirmed || null, + unconfirmed: owner.unconfirmed || null, + declined: owner.declined || [], + candidate: owner.candidate || [], + update_token: owner.update_token || null, + }); + } catch (err) { + return handleCardError(err, res); + } + }); + /** * POST /enrich-batch * - * Batch lookup IPs in CARD to extract Granite loader fields. Tries each IP - * with known asset ID suffixes (CTEC, NATL, CHTR, etc.) and falls back to - * bare IP lookup. Returns enrichment results for each IP. + * Batch lookup IPs in CARD to extract Granite loader fields. Fetches team + * assets (paginated, across confirmed and unconfirmed dispositions) and + * matches against the provided IPs. Returns enrichment results for each IP. * * @body {string[]} ips - Non-empty array of IP address strings (max 200) + * @body {string} [team="NTS-AEO-STEAM"] - Team name to search assets under * @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number } - * Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: null, equip_status?: string|null, error?: string } + * Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: string|null, equip_status?: string|null, serial_number?: string|null, error?: string } * @response 400 - { error: string } — invalid or empty ips array, or exceeds 200 * @response 503 - { error: string, missingVars: string[] } — CARD not configured */ diff --git a/frontend/src/components/CardActionModal.js b/frontend/src/components/CardActionModal.js new file mode 100644 index 0000000..106b5c9 --- /dev/null +++ b/frontend/src/components/CardActionModal.js @@ -0,0 +1,346 @@ +/** + * CardActionModal — CARD Asset Action Modal + * + * Shows the full CARD owner record for an asset and allows + * confirm/decline/redirect operations with full context. + */ + +import React, { useState, useEffect } from 'react'; +import { X, Loader, AlertCircle, CheckCircle, ArrowRightLeft, XCircle } from 'lucide-react'; // ⚠️ CONVENTION: Removed unused `Shield` import to satisfy no-unused-vars lint rule + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const OVERLAY = { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)', + zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center', +}; +const MODAL = { + background: 'linear-gradient(135deg, #1E293B, #0F172A)', + borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)', + width: '90vw', maxWidth: '600px', maxHeight: '85vh', overflow: 'auto', + padding: '1.5rem', position: 'relative', +}; +const SECTION = { + background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)', + borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem', +}; +const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' }; +const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" }; +const TEAM_BADGE = (color) => ({ + display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', + fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace', + background: `${color}15`, border: `1px solid ${color}40`, color, +}); +const INPUT = { + width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)', + border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem', + color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem', + fontFamily: "'JetBrains Mono', monospace", outline: 'none', +}; +const BTN = { + padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none', + fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s', +}; + +export default function CardActionModal({ isOpen, onClose, item, initialAction, cardTeams, onSuccess }) { + const [ownerData, setOwnerData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [action, setAction] = useState(initialAction || 'confirm'); + const [teamName, setTeamName] = useState(''); + const [fromTeam, setFromTeam] = useState(''); + const [toTeam, setToTeam] = useState(''); + const [comment, setComment] = useState(''); + const [executing, setExecuting] = useState(false); + const [execError, setExecError] = useState(null); + + // Fetch owner data when modal opens + useEffect(() => { + if (!isOpen || !item?.ip_address) return; + + setLoading(true); + setError(null); + setOwnerData(null); + setExecError(null); + + fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(item.ip_address)}`, { credentials: 'include' }) + .then(r => { + if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); }); + return r.json(); + }) + .then(data => { + setOwnerData(data); + // Pre-fill team fields based on owner data + if (data.confirmed) { + setTeamName(data.confirmed.name || ''); + setFromTeam(data.confirmed.name || ''); + } else if (data.unconfirmed) { + setTeamName(data.unconfirmed.name || ''); + setFromTeam(data.unconfirmed.name || ''); + } + setLoading(false); + }) + .catch(err => { + setError(err.message); + setLoading(false); + }); + }, [isOpen, item]); + + // Reset action when initialAction changes + useEffect(() => { + if (initialAction) setAction(initialAction); + }, [initialAction]); + + const handleExecute = async () => { + setExecuting(true); + setExecError(null); + + try { + let url, body; + + if (action === 'confirm') { + url = `${API_BASE}/card/queue/${item.id}/confirm`; + body = { teamName: teamName.trim(), assetId: item.ip_address, comment: comment.trim() }; + } else if (action === 'decline') { + url = `${API_BASE}/card/queue/${item.id}/decline`; + body = { teamName: teamName.trim(), assetId: item.ip_address, comment: comment.trim() }; + } else if (action === 'redirect') { + url = `${API_BASE}/card/queue/${item.id}/redirect`; + body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), assetId: item.ip_address }; + } + + const res = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await res.json(); + if (!res.ok) { + setExecError(data.error || data.message || `${action} failed.`); + setExecuting(false); + return; + } + + setExecuting(false); + if (onSuccess) onSuccess(item.id, action); + onClose(); + } catch (err) { + setExecError(err.message || 'Network error.'); + setExecuting(false); + } + }; + + if (!isOpen) return null; + + const canExecute = () => { + if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0; + if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0; + return false; + }; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+

CARD Asset Action

+ {ownerData && ( +
+ {ownerData.asset_id} +
+ )} +
+ +
+ + {/* Loading */} + {loading && ( +
+ +
Loading CARD data...
+
+ )} + + {/* Error loading */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Owner data display */} + {ownerData && ( + <> + {/* Ownership section */} +
+
Ownership
+
+
+ Confirmed: + {ownerData.confirmed ? ( + {ownerData.confirmed.name} + ) : ( + + )} + {ownerData.confirmed && ( + + (score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource}) + + )} +
+
+ Unconfirmed: + {ownerData.unconfirmed ? ( + {ownerData.unconfirmed.name} + ) : ( + + )} +
+ {ownerData.candidate && ownerData.candidate.length > 0 && ( +
+ Candidates: +
+ {ownerData.candidate.map((c, i) => ( + {c.name} ({c.score}) + ))} +
+
+ )} + {ownerData.declined && ownerData.declined.length > 0 && ( +
+ Declined: +
+ {ownerData.declined.map((d, i) => ( + {d.name} + ))} +
+
+ )} +
+
+ + {/* Queue item info */} +
+
Queue Item
+
+
Hostname: {item.hostname || '—'}
+
IP: {item.ip_address || '—'}
+
Finding: {item.finding_id || '—'}
+
CVE: {item.cves_json ? JSON.parse(item.cves_json).slice(0, 2).join(', ') : '—'}
+
+
+ + {/* Action section */} +
+
Action
+
+ {['confirm', 'decline', 'redirect'].map(a => ( + + ))} +
+ + {/* Action-specific fields */} + {(action === 'confirm' || action === 'decline') && ( +
+
+ + +
+
+ + setComment(e.target.value)} placeholder="Optional comment..." /> +
+
+ )} + + {action === 'redirect' && ( +
+
+ + +
+
+ + +
+
+ )} +
+ + {/* Execution error */} + {execError && ( +
+ + {execError} +
+ )} + + {/* Footer */} +
+ + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index 1952dcb..dfa1d10 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -9,6 +9,7 @@ import CveTooltip from '../CveTooltip'; import RedirectModal from '../RedirectModal'; import AtlasBadge from '../AtlasBadge'; import LoaderModal from '../LoaderModal'; +import CardActionModal from '../CardActionModal'; import ConsolidationModal from '../ConsolidationModal'; import AtlasSlideOutPanel from '../AtlasSlideOutPanel'; import AtlasIcon from '../AtlasIcon'; @@ -1541,6 +1542,10 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on // Granite Loader Sheet modal state const [showLoaderModal, setShowLoaderModal] = useState(false); + // CARD Action Modal state + const [cardModalItem, setCardModalItem] = useState(null); + const [cardModalAction, setCardModalAction] = useState('confirm'); + // Create Jira modal state const [createJiraOpen, setCreateJiraOpen] = useState(false); const [createJiraForm, setCreateJiraForm] = useState({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' }); @@ -1628,14 +1633,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on setTimeout(() => setRedirectSuccess(null), 3000); }; - // CARD action handlers + // CARD action handlers — open the CardActionModal instead of inline form const openCardAction = (itemId, type) => { - setCardAction({ itemId, type }); - setCardFormTeam(''); - setCardFormComment(''); - setCardFormFromTeam(''); - setCardFormToTeam(''); - setCardActionError(null); + const targetItem = items.find(i => i.id === itemId); + if (targetItem) { + setCardModalItem(targetItem); + setCardModalAction(type); + } }; const closeCardAction = () => { @@ -3169,6 +3173,19 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on return items.filter(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })); })() : null} /> + + {/* CARD Action Modal */} + setCardModalItem(null)} + item={cardModalItem} + initialAction={cardModalAction} + cardTeams={cardTeams} + onSuccess={(itemId, _action) => { + onUpdate(itemId, { status: 'complete' }); + setCardModalItem(null); + }} + /> ); }