diff --git a/backend/helpers/cardApi.js b/backend/helpers/cardApi.js index 35bf6b2..813d87e 100644 --- a/backend/helpers/cardApi.js +++ b/backend/helpers/cardApi.js @@ -252,8 +252,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) { /** * GET /api/v1/owner/{assetId} — get owner record including update_token. */ -async function getOwner(assetId) { - const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`); +async function getOwner(assetId, options) { + const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options); return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; } @@ -298,35 +298,54 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) { /** * Resolve a bare IP address to a full CARD asset ID by trying known suffixes. * Returns the first asset ID that returns a valid owner record, or null if none found. + * + * @param {string} ip - IP address or existing asset ID + * @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use) */ -async function resolveAssetId(ip) { - const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP']; +async function resolveAssetId(ip, options) { + const quick = options && options.quick; + const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP']; + const timeout = quick ? 15000 : undefined; // 15s timeout for quick mode const trimmedIp = (ip || '').trim(); if (!trimmedIp) return null; // If it already has a suffix (contains a dash followed by letters), use as-is if (/\d+-[A-Z]+$/i.test(trimmedIp)) { - const result = await getOwner(trimmedIp); - if (result.ok) return trimmedIp; + try { + const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined); + if (result.ok) return trimmedIp; + } catch (err) { + // Timeout — throw so caller can distinguish from "not found" + if (quick && err.message && err.message.includes('timed out')) { + throw new Error('CARD_TIMEOUT'); + } + return null; + } } // Try each suffix for (const suffix of SUFFIXES) { const candidate = `${trimmedIp}-${suffix}`; try { - const result = await getOwner(candidate); + const result = await getOwner(candidate, timeout ? { timeout } : undefined); if (result.ok) return candidate; - } catch (_) { + } catch (err) { + // Timeout — throw so caller can distinguish from "not found" + if (quick && err.message && err.message.includes('timed out')) { + throw new Error('CARD_TIMEOUT'); + } // Continue to next suffix } } - // Try bare IP as last resort - try { - const result = await getOwner(trimmedIp); - if (result.ok) return trimmedIp; - } catch (_) { - // Not found + // Try bare IP as last resort (skip in quick mode to avoid extra delay) + if (!quick) { + try { + const result = await getOwner(trimmedIp); + if (result.ok) return trimmedIp; + } catch (_) { + // Not found + } } return null; diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index c8d6afa..71a9fbe 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.js @@ -508,8 +508,11 @@ function createCardApiRouter() { * confirm/decline/redirect operations. * * @param {string} ip - IP address (path parameter) + * @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups. * @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token } + * @response 400 - { error: string } — missing IP * @response 404 - { error: string } — IP not found in CARD + * @response 504 - { error: string, timeout: true } — CARD lookup timed out * @response 503 - { error: string } — CARD not configured */ router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { @@ -522,8 +525,19 @@ function createCardApiRouter() { return res.status(400).json({ error: 'IP address is required.' }); } + // Use quick mode (CTEC only, 15s timeout) for tooltip lookups + const quick = req.query.quick === '1'; + // Resolve to full asset ID - const assetId = await resolveAssetId(ip.trim()); + let assetId; + try { + assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined); + } catch (err) { + if (err.message === 'CARD_TIMEOUT') { + return res.status(504).json({ error: 'CARD lookup timed out', timeout: true }); + } + return handleCardError(err, res); + } if (!assetId) { return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` }); } @@ -552,6 +566,208 @@ function createCardApiRouter() { } }); + /** + * POST /owner/:assetId/confirm + * + * Directly confirm ownership of a CARD asset (no queue item required). + * Fetches the owner record for the update_token, then calls CARD confirm. + * + * @param {string} assetId - CARD asset identifier or IP address (path parameter) + * @body {string} teamName - Team to confirm ownership for (required) + * @body {string} [comment] - Optional comment + * @response 200 - { success: true, cardResponse: object } + * @response 400 - { error: string } — missing fields + * @response 404 - { error: string } — asset not found + * @response 502 - { error: string } — CARD API failure + * @response 503 - { error: string, missingVars: string[] } — CARD not configured + */ + router.post('/owner/:assetId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + if (!isConfigured) { + return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); + } + + let assetId = req.params.assetId; + const { teamName, comment } = req.body || {}; + + if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { + return res.status(400).json({ error: 'teamName is required.' }); + } + + // Resolve bare IP to full CARD asset ID + if (!/\d+-[A-Z]+$/i.test(assetId)) { + const resolved = await resolveAssetId(assetId); + if (!resolved) { + return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` }); + } + assetId = resolved; + } + + try { + const ownerResult = await getOwner(assetId); + if (!ownerResult.ok) { + return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` }); + } + + let ownerData; + try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } + const updateToken = ownerData.owner && ownerData.owner.update_token; + + if (!updateToken) { + return res.status(502).json({ error: 'update_token not found in owner record.' }); + } + + const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || ''); + + if (confirmResult.ok) { + let cardResponse; + try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; } + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip }); + return res.json({ success: true, cardResponse }); + } + + let errorBody; + try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; } + return res.status(confirmResult.status).json(errorBody); + } catch (err) { + return handleCardError(err, res); + } + }); + + /** + * POST /owner/:assetId/decline + * + * Directly decline ownership of a CARD asset (no queue item required). + * Fetches the owner record for the update_token, then calls CARD decline. + * + * @param {string} assetId - CARD asset identifier or IP address (path parameter) + * @body {string} teamName - Team to decline ownership for (required) + * @body {string} [comment] - Optional comment + * @response 200 - { success: true, cardResponse: object } + * @response 400 - { error: string } — missing fields + * @response 404 - { error: string } — asset not found + * @response 502 - { error: string } — CARD API failure + * @response 503 - { error: string, missingVars: string[] } — CARD not configured + */ + router.post('/owner/:assetId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + if (!isConfigured) { + return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); + } + + let assetId = req.params.assetId; + const { teamName, comment } = req.body || {}; + + if (!teamName || typeof teamName !== 'string' || !teamName.trim()) { + return res.status(400).json({ error: 'teamName is required.' }); + } + + if (!/\d+-[A-Z]+$/i.test(assetId)) { + const resolved = await resolveAssetId(assetId); + if (!resolved) { + return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` }); + } + assetId = resolved; + } + + try { + const ownerResult = await getOwner(assetId); + if (!ownerResult.ok) { + return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` }); + } + + let ownerData; + try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } + const updateToken = ownerData.owner && ownerData.owner.update_token; + + if (!updateToken) { + return res.status(502).json({ error: 'update_token not found in owner record.' }); + } + + const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || ''); + + if (declineResult.ok) { + let cardResponse; + try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; } + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip }); + return res.json({ success: true, cardResponse }); + } + + let errorBody; + try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; } + return res.status(declineResult.status).json(errorBody); + } catch (err) { + return handleCardError(err, res); + } + }); + + /** + * POST /owner/:assetId/redirect + * + * Directly redirect a CARD asset between teams (no queue item required). + * Fetches the owner record for the update_token, then calls CARD redirect. + * + * @param {string} assetId - CARD asset identifier or IP address (path parameter) + * @body {string} fromTeam - Current owning team (required) + * @body {string} toTeam - Target team (required) + * @response 200 - { success: true, cardResponse: object } + * @response 400 - { error: string } — missing fields + * @response 404 - { error: string } — asset not found + * @response 502 - { error: string } — CARD API failure + * @response 503 - { error: string, missingVars: string[] } — CARD not configured + */ + router.post('/owner/:assetId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { + if (!isConfigured) { + return res.status(503).json({ error: 'CARD API is not configured.', missingVars }); + } + + let assetId = req.params.assetId; + const { fromTeam, toTeam } = req.body || {}; + + if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) { + return res.status(400).json({ error: 'fromTeam is required.' }); + } + if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) { + return res.status(400).json({ error: 'toTeam is required.' }); + } + + if (!/\d+-[A-Z]+$/i.test(assetId)) { + const resolved = await resolveAssetId(assetId); + if (!resolved) { + return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` }); + } + assetId = resolved; + } + + try { + const ownerResult = await getOwner(assetId); + if (!ownerResult.ok) { + return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` }); + } + + let ownerData; + try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; } + const updateToken = ownerData.owner && ownerData.owner.update_token; + + if (!updateToken) { + return res.status(502).json({ error: 'update_token not found in owner record.' }); + } + + const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken); + + if (redirectResult.ok) { + let cardResponse; + try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; } + logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect_direct', entityType: 'card_asset', entityId: assetId, details: { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }, ipAddress: req.ip }); + return res.json({ success: true, cardResponse }); + } + + let errorBody; + try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; } + return res.status(redirectResult.status).json(errorBody); + } catch (err) { + return handleCardError(err, res); + } + }); + /** * POST /enrich-batch * diff --git a/frontend/src/components/CardDetailModal.js b/frontend/src/components/CardDetailModal.js new file mode 100644 index 0000000..fdb8700 --- /dev/null +++ b/frontend/src/components/CardDetailModal.js @@ -0,0 +1,376 @@ +/** + * CardDetailModal — Full CARD ownership detail view + * + * Opens from the CARD tooltip "Actions" button on the reporting page. + * Shows the full ownership record and allows confirm/decline/redirect + * directly against the CARD API (no queue item required). + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { X, Loader, AlertCircle, CheckCircle, XCircle, ArrowRightLeft } from 'lucide-react'; + +// ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api +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: '580px', 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 CardDetailModal({ isOpen, onClose, ip, ownerData: initialOwnerData, cardTeams }) { + const [ownerData, setOwnerData] = useState(initialOwnerData || null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [action, setAction] = useState('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); + const [execSuccess, setExecSuccess] = useState(null); + + // Fetch owner data if not provided or refresh on open + useEffect(() => { + if (!isOpen || !ip) return; + + // If we already have data from the tooltip cache, use it + if (initialOwnerData && !initialOwnerData.notFound && !initialOwnerData.error) { + setOwnerData(initialOwnerData); + // Pre-fill team fields + if (initialOwnerData.confirmed) { + setTeamName(initialOwnerData.confirmed.name || ''); + setFromTeam(initialOwnerData.confirmed.name || ''); + } else if (initialOwnerData.unconfirmed) { + setTeamName(initialOwnerData.unconfirmed.name || ''); + setFromTeam(initialOwnerData.unconfirmed.name || ''); + } + return; + } + + // Fetch fresh + setLoading(true); + setError(null); + fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}`, { 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); + 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, ip, initialOwnerData]); + + // Reset state on close + useEffect(() => { + if (!isOpen) { + setExecError(null); + setExecSuccess(null); + setComment(''); + } + }, [isOpen]); + + const handleExecute = useCallback(async () => { + if (!ownerData?.asset_id) return; + setExecuting(true); + setExecError(null); + setExecSuccess(null); + + try { + let url, body; + const assetId = ownerData.asset_id; + + if (action === 'confirm') { + url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/confirm`; + body = { teamName: teamName.trim(), comment: comment.trim() }; + } else if (action === 'decline') { + url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/decline`; + body = { teamName: teamName.trim(), comment: comment.trim() }; + } else if (action === 'redirect') { + url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/redirect`; + body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }; + } + + 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.`); + } else { + setExecSuccess(`${action.charAt(0).toUpperCase() + action.slice(1)} successful.`); + } + } catch (err) { + setExecError(err.message || 'Network error.'); + } finally { + setExecuting(false); + } + }, [ownerData, action, teamName, fromTeam, toTeam, comment]); + + 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 ( +