feat: add multi-BU tenancy with per-user team scoping (Option B)
- Add bu_teams column to users table (migration + fresh schema) - Create shared KNOWN_TEAMS constant and validateTeams helper - Expose user teams in auth middleware, login, and /me responses - Add bu_teams CRUD to user management routes with audit logging - Make Ivanti FINDINGS_FILTERS configurable via IVANTI_BU_FILTER env var - Add query-time team filtering to GET /findings and /findings/counts - Update AuthContext with teams helpers and admin scope toggle - Create AdminScopeToggle component (My Teams / All BUs) - Scope ReportingPage findings fetch by user teams - Scope CompliancePage team selector by user teams - Scope ExportsPage findings exports by user teams - Add BU teams multi-select to UserManagement create/edit forms - Display team badges in user list table
This commit is contained in:
61
frontend/src/components/AdminScopeToggle.js
Normal file
61
frontend/src/components/AdminScopeToggle.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// AdminScopeToggle.js
|
||||
// Two-state toggle for Admin users: "My Teams" vs "All BUs"
|
||||
// Controls whether data on Reporting, Compliance, and Exports pages
|
||||
// is scoped to the admin's assigned teams or shows everything.
|
||||
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
function AdminScopeToggle() {
|
||||
const { isAdmin, adminScope, toggleAdminScope, hasTeams } = useAuth();
|
||||
|
||||
// Only render for Admin users who have teams assigned
|
||||
// (if no teams assigned, both modes are identical — no toggle needed)
|
||||
if (!isAdmin() || !hasTeams()) return null;
|
||||
|
||||
const isAllMode = adminScope === 'all';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(14, 165, 233, 0.05)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: 'monospace',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#64748B', fontWeight: '500' }}>Scope:</span>
|
||||
<button
|
||||
onClick={toggleAdminScope}
|
||||
aria-label={`Switch to ${isAllMode ? 'My Teams' : 'All BUs'} view`}
|
||||
aria-pressed={isAllMode}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '0.02em',
|
||||
transition: 'all 0.15s ease',
|
||||
background: isAllMode ? 'rgba(139, 92, 246, 0.15)' : 'rgba(14, 165, 233, 0.15)',
|
||||
color: isAllMode ? '#8B5CF6' : '#0EA5E9',
|
||||
}}
|
||||
>
|
||||
{isAllMode ? '⊕ All BUs' : '⊙ My Teams'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminScopeToggle;
|
||||
@@ -510,6 +510,36 @@ export default function AtlasSlideOutPanel({ hostId, hostName, findingId, qualys
|
||||
}
|
||||
const data = await res.json();
|
||||
const remotePlans = parseAtlasPlans(data);
|
||||
|
||||
// If Atlas returns no plans, check local cache for optimistic bulk-create stubs
|
||||
if (remotePlans.length === 0) {
|
||||
try {
|
||||
const cacheRes = await fetch(`${API_BASE}/atlas/status`, { credentials: 'include' });
|
||||
if (cacheRes.ok) {
|
||||
const cacheData = await cacheRes.json();
|
||||
const hostCache = cacheData.find(r => r.host_id === hostId);
|
||||
if (hostCache && hostCache.has_action_plan === 1 && hostCache.plans_json) {
|
||||
let cachedPlans = [];
|
||||
try { cachedPlans = typeof hostCache.plans_json === 'string' ? JSON.parse(hostCache.plans_json) : hostCache.plans_json; } catch (_) {}
|
||||
const stubs = cachedPlans
|
||||
.filter(p => p.source === 'bulk-create')
|
||||
.map((p, i) => ({
|
||||
action_plan_id: 'pending-' + hostId + '-' + i,
|
||||
plan_type: p.plan_type || 'unknown',
|
||||
commit_date: p.commit_date || '',
|
||||
status: 'pending',
|
||||
_localPending: true,
|
||||
created_at: p.created_at || '',
|
||||
}));
|
||||
if (stubs.length > 0) {
|
||||
setPlans(stubs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore cache fallback errors */ }
|
||||
}
|
||||
|
||||
// Merge: keep local pending plans that aren't yet confirmed by Atlas
|
||||
setPlans(prev => {
|
||||
const localPending = prev.filter(p => p._localPending);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import AdminScopeToggle from './AdminScopeToggle';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
@@ -63,6 +64,12 @@ export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate })
|
||||
|
||||
{/* Nav items */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
|
||||
{/* Admin scope toggle — between header and nav items */}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<AdminScopeToggle />
|
||||
</div>
|
||||
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
|
||||
@@ -180,7 +180,8 @@ export default function UserManagement({ onClose }) {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
group: 'Read_Only'
|
||||
group: 'Read_Only',
|
||||
bu_teams: ''
|
||||
});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formSuccess, setFormSuccess] = useState('');
|
||||
@@ -240,7 +241,7 @@ export default function UserManagement({ onClose }) {
|
||||
setTimeout(() => {
|
||||
setShowAddUser(false);
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' });
|
||||
setFormSuccess('');
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
@@ -278,7 +279,8 @@ export default function UserManagement({ onClose }) {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
group: user.group
|
||||
group: user.group,
|
||||
bu_teams: user.bu_teams || ''
|
||||
});
|
||||
setShowAddUser(true);
|
||||
setFormError('');
|
||||
@@ -361,7 +363,7 @@ export default function UserManagement({ onClose }) {
|
||||
onClick={() => {
|
||||
setShowAddUser(true);
|
||||
setEditingUser(null);
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only' });
|
||||
setFormData({ username: '', email: '', password: '', group: 'Read_Only', bu_teams: '' });
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
}}
|
||||
@@ -482,6 +484,50 @@ export default function UserManagement({ onClose }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BU Teams assignment */}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<label style={styles.label}>BU Teams</label>
|
||||
<div style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
background: 'rgba(30,41,59,0.6)',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.5rem',
|
||||
}}>
|
||||
{['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'].map(team => {
|
||||
const currentTeams = formData.bu_teams ? formData.bu_teams.split(',').filter(Boolean) : [];
|
||||
const isChecked = currentTeams.includes(team);
|
||||
return (
|
||||
<label key={team} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||
cursor: 'pointer', fontSize: '0.8rem', fontFamily: 'monospace',
|
||||
color: isChecked ? '#38BDF8' : '#94A3B8',
|
||||
padding: '0.25rem 0.5rem', borderRadius: '0.25rem',
|
||||
background: isChecked ? 'rgba(14,165,233,0.1)' : 'transparent',
|
||||
border: isChecked ? '1px solid rgba(14,165,233,0.3)' : '1px solid transparent',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => {
|
||||
const updated = isChecked
|
||||
? currentTeams.filter(t => t !== team)
|
||||
: [...currentTeams, team];
|
||||
setFormData({ ...formData, bu_teams: updated.join(',') });
|
||||
}}
|
||||
style={{ accentColor: '#0EA5E9' }}
|
||||
/>
|
||||
{team}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.65rem', color: '#64748B', marginTop: '0.375rem' }}>
|
||||
Determines which BU data the user sees on Reporting and Compliance pages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', paddingTop: '1rem' }}>
|
||||
<button type="submit" style={styles.primaryBtn}
|
||||
onMouseEnter={e => {
|
||||
@@ -523,6 +569,7 @@ export default function UserManagement({ onClose }) {
|
||||
<tr>
|
||||
<th style={styles.th}>User</th>
|
||||
<th style={styles.th}>Group</th>
|
||||
<th style={styles.th}>Teams</th>
|
||||
<th style={styles.th}>Status</th>
|
||||
<th style={styles.th}>Last Login</th>
|
||||
<th style={styles.thRight}>Actions</th>
|
||||
@@ -547,6 +594,25 @@ export default function UserManagement({ onClose }) {
|
||||
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
{(user.teams && user.teams.length > 0) ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{user.teams.map(t => (
|
||||
<span key={t} style={{
|
||||
fontSize: '0.65rem', fontFamily: 'monospace',
|
||||
padding: '0.1rem 0.35rem', borderRadius: '0.2rem',
|
||||
background: 'rgba(14,165,233,0.1)',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
color: '#7DD3FC',
|
||||
}}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.7rem', color: '#F59E0B', fontStyle: 'italic' }}>
|
||||
⚠ No teams
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<button
|
||||
onClick={() => handleToggleActive(user)}
|
||||
|
||||
@@ -9,7 +9,6 @@ import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const TEAMS = ['STEAM', 'ACCESS-ENG'];
|
||||
|
||||
// Build definitions lookup map once at module level
|
||||
const METRIC_DEFINITIONS = {};
|
||||
@@ -246,9 +245,10 @@ function SeenBadge({ count }) {
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CompliancePage({ onNavigate }) {
|
||||
const { canWrite, isAdmin } = useAuth();
|
||||
const { canWrite, isAdmin, getAvailableTeams, adminScope } = useAuth();
|
||||
|
||||
const [activeTeam, setActiveTeam] = useState('STEAM');
|
||||
const availableTeams = getAvailableTeams();
|
||||
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [metricFilter, setMetricFilter] = useState(null);
|
||||
const [hostSearch, setHostSearch] = useState('');
|
||||
@@ -298,6 +298,14 @@ export default function CompliancePage({ onNavigate }) {
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
}, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// When admin scope changes, reset to first available team
|
||||
useEffect(() => {
|
||||
const teams = getAvailableTeams();
|
||||
if (teams.length > 0 && !teams.includes(activeTeam)) {
|
||||
setActiveTeam(teams[0]);
|
||||
}
|
||||
}, [adminScope]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setMetricFilter(null);
|
||||
fetchDevices(activeTeam, activeTab);
|
||||
@@ -419,8 +427,19 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
|
||||
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||||
{availableTeams.length === 0 && !isAdmin() ? (
|
||||
<div style={{
|
||||
padding: '1.5rem', marginBottom: '1.5rem',
|
||||
borderRadius: '0.5rem', border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
background: 'rgba(245, 158, 11, 0.05)',
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', color: '#F59E0B',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
No BU teams assigned to your account. Contact an admin to configure your team access.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
|
||||
{TEAMS.map(team => {
|
||||
{availableTeams.map(team => {
|
||||
const isActive = activeTeam === team;
|
||||
return (
|
||||
<button key={team} onClick={() => setActiveTeam(team)}
|
||||
@@ -441,6 +460,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||
{families.length > 0 ? (
|
||||
|
||||
@@ -97,8 +97,11 @@ function findingRow(f) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// API fetchers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchFindings() {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
async function fetchFindings(teamsParam) {
|
||||
const url = teamsParam
|
||||
? `${API_BASE}/ivanti/findings?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/ivanti/findings`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Ivanti findings returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.findings || [];
|
||||
@@ -129,8 +132,8 @@ async function fetchAtlasStatus() {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchAtlasAndFindings() {
|
||||
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings()]);
|
||||
async function fetchAtlasAndFindings(teamsParam) {
|
||||
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]);
|
||||
// Build a lookup from hostId → finding details (hostname, IP, BU, etc.)
|
||||
const hostMap = {};
|
||||
findings.forEach(f => {
|
||||
@@ -244,7 +247,8 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ExportsPage() {
|
||||
const { canExport } = useAuth();
|
||||
const { canExport, getActiveTeamsParam } = useAuth();
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const [loading, setLoading] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [cveStatus, setCveStatus] = useState('');
|
||||
@@ -266,32 +270,35 @@ export default function ExportsPage() {
|
||||
// ---- Card 1: Ivanti Findings ----
|
||||
|
||||
const exportFullFindings = () => run('ivanti-full', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const scopeLabel = teamsParam || 'ALL';
|
||||
toXLSX(
|
||||
[FINDING_HEADERS, ...findings.map(findingRow)],
|
||||
'All Findings',
|
||||
`findings-full-${dateStr()}.xlsx`,
|
||||
`findings-full-${scopeLabel}-${dateStr()}.xlsx`,
|
||||
);
|
||||
});
|
||||
|
||||
const exportPending = () => run('ivanti-pending', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const scopeLabel = teamsParam || 'ALL';
|
||||
const rows = findings.filter(f => classifyFinding(f) === 'pending').map(findingRow);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${dateStr()}.xlsx`);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Pending Action', `findings-pending-${scopeLabel}-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportOverdue = () => run('ivanti-overdue', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const scopeLabel = teamsParam || 'ALL';
|
||||
const today = dateStr();
|
||||
const rows = findings.filter(f => {
|
||||
if (!f.dueDate && !(f.slaStatus || '').toLowerCase().includes('overdue')) return false;
|
||||
return f.dueDate < today || (f.slaStatus || '').toUpperCase() === 'OVERDUE';
|
||||
}).map(findingRow);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${dateStr()}.xlsx`);
|
||||
toXLSX([FINDING_HEADERS, ...rows], 'Overdue', `findings-overdue-${scopeLabel}-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportByBU = () => run('ivanti-bu', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const groups = {};
|
||||
findings.forEach(f => {
|
||||
const bu = f.buOwnership || 'Unknown';
|
||||
@@ -308,7 +315,7 @@ export default function ExportsPage() {
|
||||
// ---- Card 2: FP Workflow Summary ----
|
||||
|
||||
const exportFPSummary = () => run('fp-summary', async () => {
|
||||
const findings = await fetchFindings();
|
||||
const findings = await fetchFindings(teamsParam);
|
||||
const fpMap = {};
|
||||
findings.forEach(f => {
|
||||
if (!f.workflow?.id) return;
|
||||
@@ -383,20 +390,20 @@ export default function ExportsPage() {
|
||||
}
|
||||
|
||||
const exportAtlasStatus = () => run('atlas-status', async () => {
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
|
||||
const rows = atlasRows.flatMap(a => atlasRow(a, hostMap[a.host_id]));
|
||||
toXLSX([ATLAS_HEADERS, ...rows], 'Atlas Status', `atlas-action-plans-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportAtlasGaps = () => run('atlas-gaps', async () => {
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
|
||||
const gaps = atlasRows.filter(a => !a.has_action_plan);
|
||||
const rows = gaps.flatMap(a => atlasRow(a, hostMap[a.host_id]));
|
||||
toXLSX([ATLAS_HEADERS, ...rows], 'Coverage Gaps', `atlas-coverage-gaps-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportAtlasFull = () => run('atlas-full', async () => {
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings();
|
||||
const { atlasRows, hostMap } = await fetchAtlasAndFindings(teamsParam);
|
||||
const withPlans = atlasRows.filter(a => a.has_action_plan);
|
||||
const withoutPlans = atlasRows.filter(a => !a.has_action_plan);
|
||||
const sheets = [
|
||||
|
||||
@@ -4492,7 +4492,11 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
const seen = new Map();
|
||||
for (const f of selectedFindings) {
|
||||
if (f.hostId && !seen.has(f.hostId)) {
|
||||
seen.set(f.hostId, { hostId: f.hostId, hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId) });
|
||||
seen.set(f.hostId, {
|
||||
hostId: f.hostId,
|
||||
hostName: f.overrides?.hostName || f.hostName || f.ipAddress || String(f.hostId),
|
||||
findingId: f.id ? Number(f.id) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
@@ -4575,7 +4579,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
if (!commitDate) { setError('Commit date is required'); return; }
|
||||
if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; }
|
||||
const needsQualys = NEEDS_QUALYS.has(planType);
|
||||
if (needsQualys && selectedQualys.size === 0) {
|
||||
if (needsQualys && selectedQualys.size === 0 && availableQualys.length > 0) {
|
||||
setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`);
|
||||
return;
|
||||
}
|
||||
@@ -4583,12 +4587,19 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const qualysIds = needsQualys ? [...selectedQualys] : [null];
|
||||
// If qualys IDs are selected, iterate per-qualys; otherwise send one request without qualys_id
|
||||
const qualysIds = (needsQualys && selectedQualys.size > 0) ? [...selectedQualys] : [null];
|
||||
const results = [];
|
||||
|
||||
for (const qid of qualysIds) {
|
||||
const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate };
|
||||
if (qid) body.qualys_id = qid;
|
||||
// When no qualys_id is available, include the first finding ID per host
|
||||
// so Atlas can associate the plan with a specific vulnerability
|
||||
if (!qid && needsQualys) {
|
||||
const firstWithFinding = hostEntries.find(h => h.findingId);
|
||||
if (firstWithFinding) body.active_host_findings_id = firstWithFinding.findingId;
|
||||
}
|
||||
if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim();
|
||||
if (archerExc.trim()) body.archer_exc = archerExc.trim();
|
||||
|
||||
@@ -4796,7 +4807,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
|
||||
{!vulnsLoading && !vulnsError && availableQualys.length === 0 && (
|
||||
<div style={{ color: '#475569', fontSize: '0.72rem', fontStyle: 'italic', padding: '0.5rem 0' }}>
|
||||
No vulnerabilities found in Atlas for these hosts
|
||||
No vulnerabilities found in Atlas for these hosts — Qualys ID will be omitted
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4908,7 +4919,7 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
// Main ReportingPage
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const { canWrite } = useAuth();
|
||||
const { canWrite, getActiveTeamsParam, hasTeams, isAdmin, adminScope } = useAuth();
|
||||
const [findings, setFindings] = useState([]);
|
||||
const [total, setTotal] = useState(null);
|
||||
const [syncedAt, setSyncedAt] = useState(null);
|
||||
@@ -5041,7 +5052,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const fetchCounts = async () => {
|
||||
setCountsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' });
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const url = teamsParam
|
||||
? `${API_BASE}/ivanti/findings/counts?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/ivanti/findings/counts`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
|
||||
} catch (e) {
|
||||
@@ -5127,7 +5142,11 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const fetchFindings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
const teamsParam = getActiveTeamsParam();
|
||||
const url = teamsParam
|
||||
? `${API_BASE}/ivanti/findings?teams=${encodeURIComponent(teamsParam)}`
|
||||
: `${API_BASE}/ivanti/findings`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
applyState(data);
|
||||
@@ -5169,6 +5188,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
fetchCardStatus();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Re-fetch findings and counts when admin scope toggle changes
|
||||
useEffect(() => {
|
||||
fetchFindings();
|
||||
fetchCounts();
|
||||
}, [adminScope]); // eslint-disable-line
|
||||
|
||||
// Set/clear a single column filter
|
||||
const setColFilter = useCallback((colKey, vals) => {
|
||||
setColumnFilters((prev) => {
|
||||
|
||||
@@ -2,6 +2,9 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// Known BU teams — must match backend helpers/teams.js
|
||||
const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
@@ -9,6 +12,11 @@ export function AuthProvider({ children }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Admin scope toggle — persisted in localStorage
|
||||
const [adminScope, setAdminScope] = useState(
|
||||
() => localStorage.getItem('admin_bu_scope') || 'my-teams'
|
||||
);
|
||||
|
||||
// Check if user is authenticated on mount
|
||||
const checkAuth = useCallback(async () => {
|
||||
try {
|
||||
@@ -93,6 +101,46 @@ export function AuthProvider({ children }) {
|
||||
// Check if user is admin
|
||||
const isAdmin = () => isInGroup('Admin');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Multi-BU tenancy helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Whether the user has any BU teams assigned
|
||||
const hasTeams = () => (user?.teams?.length ?? 0) > 0;
|
||||
|
||||
// Whether the user is a member of a specific team (or is Admin in "All BUs" mode)
|
||||
const isTeamMember = (team) => {
|
||||
if (!user) return false;
|
||||
if (isInGroup('Admin') && adminScope === 'all') return true;
|
||||
return (user.teams || []).includes(team);
|
||||
};
|
||||
|
||||
// Toggle admin scope between 'my-teams' and 'all'
|
||||
const toggleAdminScope = () => {
|
||||
setAdminScope(prev => {
|
||||
const next = prev === 'my-teams' ? 'all' : 'my-teams';
|
||||
localStorage.setItem('admin_bu_scope', next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Returns the comma-joined teams string for API query params.
|
||||
// Empty string means "no filter" (show all).
|
||||
const getActiveTeamsParam = () => {
|
||||
if (!user) return '';
|
||||
if (isInGroup('Admin') && adminScope === 'all') return '';
|
||||
const teams = user.teams || [];
|
||||
return teams.join(',');
|
||||
};
|
||||
|
||||
// Returns the list of teams available for UI selectors (compliance team picker, etc.)
|
||||
// Admin in "All BUs" mode sees all known teams; otherwise scoped to user's teams.
|
||||
const getAvailableTeams = () => {
|
||||
if (!user) return [];
|
||||
if (isInGroup('Admin') && adminScope === 'all') return KNOWN_TEAMS;
|
||||
return user.teams || [];
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
@@ -105,7 +153,15 @@ export function AuthProvider({ children }) {
|
||||
canDelete,
|
||||
canExport,
|
||||
isAdmin,
|
||||
isAuthenticated: !!user
|
||||
isAuthenticated: !!user,
|
||||
// Multi-BU tenancy
|
||||
hasTeams,
|
||||
isTeamMember,
|
||||
adminScope,
|
||||
toggleAdminScope,
|
||||
getActiveTeamsParam,
|
||||
getAvailableTeams,
|
||||
KNOWN_TEAMS,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user