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.
This commit is contained in:
346
frontend/src/components/CardActionModal.js
Normal file
346
frontend/src/components/CardActionModal.js
Normal file
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user