diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js index 72e25f8..e8feeb4 100644 --- a/backend/routes/ivantiFindings.js +++ b/backend/routes/ivantiFindings.js @@ -1766,6 +1766,84 @@ function createIvantiFindingsRouter(db, requireAuth) { } }); + /** + * GET /api/ivanti/findings/org-hierarchy + * + * Return the full SVP list and BU list from the Ivanti suggest API. + * Used by admin tooling to display the organizational hierarchy for + * BU filter configuration and team assignment verification. + * Requires Admin group. + * + * @returns {Object} 200 - { svps: string[], bus: string[] } + * @returns {Object} 503 - { error: string } when Ivanti API key is not configured + * @returns {Object} 500 - { error: string } on API failure + */ + router.get('/org-hierarchy', requireGroup('Admin'), async (req, res) => { + try { + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const apiKey = process.env.IVANTI_API_KEY; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + if (!apiKey) return res.status(503).json({ error: 'Ivanti API key not configured' }); + + // Fetch SVP values + const svpResult = await ivantiPost(`/client/${clientId}/hostFinding/suggest`, { + filter: { field: 'assetCustomAttributes.1550_host_10.value', operator: 'WILDCARD', value: '*', exclusive: false } + }, apiKey, skipTls); + + // Fetch all BU values + const buResult = await ivantiPost(`/client/${clientId}/hostFinding/suggest`, { + filter: { field: 'assetCustomAttributes.1550_host_1.value', operator: 'WILDCARD', value: '*', exclusive: false } + }, apiKey, skipTls); + + const svps = svpResult.status === 200 ? JSON.parse(svpResult.body) : []; + const bus = buResult.status === 200 ? JSON.parse(buResult.body) : []; + + res.json({ svps, bus }); + } catch (err) { + console.error('[Ivanti] Org hierarchy error:', err.message); + res.status(500).json({ error: 'Failed to fetch org hierarchy' }); + } + }); + + /** + * GET /api/ivanti/findings/bus-by-svp + * + * Return BU names that belong to a specific SVP, queried from the Ivanti + * suggest API with the SVP as a filter. Used for cascading dropdowns in + * admin configuration UIs. + * Requires Admin group. + * + * @query {string} svp - The SVP name to filter BUs by (required) + * + * @returns {Object} 200 - { svp: string, bus: string[] } + * @returns {Object} 400 - { error: string } when svp query parameter is missing + * @returns {Object} 503 - { error: string } when Ivanti API key is not configured + * @returns {Object} 500 - { error: string } on API failure + */ + router.get('/bus-by-svp', requireGroup('Admin'), async (req, res) => { + const svp = (req.query.svp || '').trim(); + if (!svp) return res.status(400).json({ error: 'svp query parameter is required' }); + + try { + const clientId = process.env.IVANTI_CLIENT_ID || '1550'; + const apiKey = process.env.IVANTI_API_KEY; + const skipTls = process.env.IVANTI_SKIP_TLS === 'true'; + if (!apiKey) return res.status(503).json({ error: 'Ivanti API key not configured' }); + + // Get BUs filtered by the selected SVP + const result = await ivantiPost(`/client/${clientId}/hostFinding/suggest`, { + filter: { field: 'assetCustomAttributes.1550_host_1.value', operator: 'WILDCARD', value: '*', exclusive: false }, + filters: [{ field: 'assetCustomAttributes.1550_host_10.value', operator: 'EXACT', value: svp, exclusive: false }] + }, apiKey, skipTls); + + const bus = result.status === 200 ? JSON.parse(result.body) : []; + res.json({ svp, bus }); + } catch (err) { + console.error('[Ivanti] BUs by SVP error:', err.message); + res.status(500).json({ error: 'Failed to fetch BUs for SVP' }); + } + }); + return router; } diff --git a/frontend/src/components/pages/AdminPage.js b/frontend/src/components/pages/AdminPage.js index 0ffc5df..da85787 100644 --- a/frontend/src/components/pages/AdminPage.js +++ b/frontend/src/components/pages/AdminPage.js @@ -996,6 +996,53 @@ function BULookupPanel() { const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [orgData, setOrgData] = useState(null); + const [orgLoading, setOrgLoading] = useState(false); + const [selectedSvp, setSelectedSvp] = useState(''); + const [svpBus, setSvpBus] = useState(null); + const [svpBusLoading, setSvpBusLoading] = useState(false); + + const CONFIGURED_BUS = ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG', 'NTS-AEO-ACCESS-OPS', 'NTS-AEO-INTELDEV']; + + const handleLoadOrgHierarchy = async () => { + setOrgLoading(true); + setOrgData(null); + setSelectedSvp(''); + setSvpBus(null); + try { + const res = await fetch(`${API_BASE}/ivanti/findings/org-hierarchy`, { credentials: 'include' }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || `HTTP ${res.status}`); + } + const data = await res.json(); + setOrgData(data); + } catch (err) { + setError(err.message); + } finally { + setOrgLoading(false); + } + }; + + const handleSvpSelect = async (svp) => { + setSelectedSvp(svp); + setSvpBus(null); + if (!svp) return; + setSvpBusLoading(true); + try { + const res = await fetch(`${API_BASE}/ivanti/findings/bus-by-svp?svp=${encodeURIComponent(svp)}`, { credentials: 'include' }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || `HTTP ${res.status}`); + } + const data = await res.json(); + setSvpBus(data); + } catch (err) { + setError(err.message); + } finally { + setSvpBusLoading(false); + } + }; const handleSearch = async () => { const q = query.trim(); @@ -1106,6 +1153,112 @@ function BULookupPanel() { )} )} + + {/* ----------------------------------------------------------------- */} + {/* Org Discovery — SVP-based BU discovery */} + {/* ----------------------------------------------------------------- */} +
+ Load SVP and BU data from the Ivanti suggest API to discover which teams exist under each SVP. Configured BUs are highlighted in green. +
+ +| BU Name | +Finding Count | +Status | +
|---|---|---|
| {bu.key} | +{(bu.count || 0).toLocaleString()} | ++ + {CONFIGURED_BUS.includes(bu.key) ? 'Configured' : 'Not Configured'} + + | +