From 93de7bbca761a98093ea0f1049409b716e33b984 Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Fri, 26 Jun 2026 09:33:45 -0600 Subject: [PATCH] Add SVP org hierarchy discovery to BU Lookup panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - GET /api/ivanti/findings/org-hierarchy — returns all SVP names and all BU names from Ivanti suggest API (Admin only) - GET /api/ivanti/findings/bus-by-svp?svp= — returns BUs under a specific SVP with finding counts (Admin only) Frontend (Admin > BU Lookup tab): - 'Load Org Hierarchy' button fetches all SVPs and BUs - SVP dropdown (sorted by finding count) to filter BUs by org leader - Results table shows BU name, finding count, and configured status (green badge for BUs already in the dashboard, grey for unconfigured) - Enables admin to discover which BUs roll up under an SVP for correct team assignment when onboarding new users --- backend/routes/ivantiFindings.js | 78 +++++++++++ frontend/src/components/pages/AdminPage.js | 153 +++++++++++++++++++++ 2 files changed, 231 insertions(+) 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 */} + {/* ----------------------------------------------------------------- */} +
+

+ Org 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. +

+ +
+ + {orgData && ( + + {orgData.svps.length} SVPs, {orgData.bus.length} BUs loaded + + )} +
+ + {orgData && orgData.svps.length > 0 && ( +
+ +
+ )} + + {svpBusLoading && ( +
Loading BUs for selected SVP...
+ )} + + {svpBus && !svpBusLoading && ( +
+
+ {svpBus.bus.length} BU(s) under {svpBus.svp}: +
+ {svpBus.bus.length === 0 ? ( +
+ No BUs found for this SVP. +
+ ) : ( + + + + + + + + + + {svpBus.bus + .sort((a, b) => (b.count || 0) - (a.count || 0)) + .map((bu, i) => ( + + + + + + )) + } + +
BU NameFinding CountStatus
{bu.key}{(bu.count || 0).toLocaleString()} + + {CONFIGURED_BUS.includes(bu.key) ? 'Configured' : 'Not Configured'} + +
+ )} +
+ )} +
); }