Files
cve-dashboard/frontend/src/components/CardActionModal.js
Jordan Ramos 8224183679 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.
2026-05-28 14:58:27 -06:00

347 lines
17 KiB
JavaScript

/**
* 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 (
<div style={OVERLAY} onClick={onClose}>
<div style={MODAL} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div>
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '0.95rem' }}>CARD Asset Action</h3>
{ownerData && (
<div style={{ fontSize: '0.7rem', color: '#7C3AED', fontFamily: 'monospace', marginTop: '0.2rem' }}>
{ownerData.asset_id}
</div>
)}
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Loading */}
{loading && (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<Loader style={{ width: '20px', height: '20px', color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading CARD data...</div>
</div>
)}
{/* Error loading */}
{error && (
<div style={{ ...SECTION, borderColor: 'rgba(239, 68, 68, 0.4)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444' }} />
<span style={{ fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
</div>
</div>
)}
{/* Owner data display */}
{ownerData && (
<>
{/* Ownership section */}
<div style={SECTION}>
<div style={LABEL}>Ownership</div>
<div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.3rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Confirmed:</span>
{ownerData.confirmed ? (
<span style={TEAM_BADGE('#10B981')}>{ownerData.confirmed.name}</span>
) : (
<span style={{ fontSize: '0.7rem', color: '#475569' }}></span>
)}
{ownerData.confirmed && (
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
(score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource})
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Unconfirmed:</span>
{ownerData.unconfirmed ? (
<span style={TEAM_BADGE('#F59E0B')}>{ownerData.unconfirmed.name}</span>
) : (
<span style={{ fontSize: '0.7rem', color: '#475569' }}></span>
)}
</div>
{ownerData.candidate && ownerData.candidate.length > 0 && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Candidates:</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
{ownerData.candidate.map((c, i) => (
<span key={i} style={TEAM_BADGE('#94A3B8')}>{c.name} ({c.score})</span>
))}
</div>
</div>
)}
{ownerData.declined && ownerData.declined.length > 0 && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Declined:</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
{ownerData.declined.map((d, i) => (
<span key={i} style={TEAM_BADGE('#EF4444')}>{d.name}</span>
))}
</div>
</div>
)}
</div>
</div>
{/* Queue item info */}
<div style={SECTION}>
<div style={LABEL}>Queue Item</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem', marginTop: '0.3rem' }}>
<div><span style={{ fontSize: '0.6rem', color: '#64748B' }}>Hostname: </span><span style={VALUE}>{item.hostname || ''}</span></div>
<div><span style={{ fontSize: '0.6rem', color: '#64748B' }}>IP: </span><span style={VALUE}>{item.ip_address || ''}</span></div>
<div><span style={{ fontSize: '0.6rem', color: '#64748B' }}>Finding: </span><span style={VALUE}>{item.finding_id || ''}</span></div>
<div><span style={{ fontSize: '0.6rem', color: '#64748B' }}>CVE: </span><span style={{ ...VALUE, fontSize: '0.65rem' }}>{item.cves_json ? JSON.parse(item.cves_json).slice(0, 2).join(', ') : ''}</span></div>
</div>
</div>
{/* Action section */}
<div style={{ ...SECTION, borderColor: 'rgba(124, 58, 237, 0.3)' }}>
<div style={LABEL}>Action</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', marginBottom: '0.75rem' }}>
{['confirm', 'decline', 'redirect'].map(a => (
<button
key={a}
onClick={() => setAction(a)}
style={{
...BTN,
padding: '0.35rem 0.75rem',
background: action === a ? (a === 'confirm' ? 'rgba(16,185,129,0.15)' : a === 'decline' ? 'rgba(239,68,68,0.15)' : 'rgba(14,165,233,0.15)') : 'transparent',
border: `1px solid ${action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#334155'}`,
color: action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#64748B',
}}
>
{a === 'confirm' && <CheckCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
{a === 'decline' && <XCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
{a === 'redirect' && <ArrowRightLeft style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
{a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
{/* Action-specific fields */}
{(action === 'confirm' || action === 'decline') && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div>
<label style={{ ...LABEL, display: 'block' }}>Team</label>
<select style={INPUT} value={teamName} onChange={e => setTeamName(e.target.value)}>
<option value="">Select team...</option>
{/* Pre-populate with owner teams first */}
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
<option key={c.name} value={c.name}>{c.name} (candidate, score: {c.score})</option>
))}
<option disabled></option>
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label style={{ ...LABEL, display: 'block' }}>Comment (optional)</label>
<input style={INPUT} value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional comment..." />
</div>
</div>
)}
{action === 'redirect' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div>
<label style={{ ...LABEL, display: 'block' }}>From Team</label>
<select style={INPUT} value={fromTeam} onChange={e => setFromTeam(e.target.value)}>
<option value="">Select from team...</option>
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
{cardTeams && cardTeams.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label style={{ ...LABEL, display: 'block' }}>To Team</label>
<select style={INPUT} value={toTeam} onChange={e => setToTeam(e.target.value)}>
<option value="">Select to team...</option>
{cardTeams && cardTeams.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Execution error */}
{execError && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>{execError}</span>
</div>
)}
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
<button onClick={onClose} style={{ ...BTN, background: '#334155', color: '#E2E8F0' }}>Cancel</button>
<button
onClick={handleExecute}
disabled={!canExecute() || executing}
style={{
...BTN,
background: canExecute() && !executing ? '#7C3AED' : '#1E293B',
color: canExecute() && !executing ? '#fff' : '#475569',
cursor: canExecute() && !executing ? 'pointer' : 'not-allowed',
}}
>
{executing ? 'Executing...' : `Execute ${action.charAt(0).toUpperCase() + action.slice(1)}`}
</button>
</div>
</>
)}
</div>
</div>
);
}