diff --git a/frontend/src/components/AdminScopeToggle.js b/frontend/src/components/AdminScopeToggle.js index 03feb8a..d098bb3 100644 --- a/frontend/src/components/AdminScopeToggle.js +++ b/frontend/src/components/AdminScopeToggle.js @@ -1,59 +1,164 @@ // 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. +// Multi-select BU scope picker for Admin users. +// Allows selecting any combination of teams to filter Reporting/Compliance/Exports. +// "My Teams" preset selects the admin's assigned bu_teams. +// "All BUs" preset selects everything. +// Custom selections are persisted in localStorage. -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { useAuth } from '../contexts/AuthContext'; function AdminScopeToggle() { - const { isAdmin, adminScope, toggleAdminScope, hasTeams } = useAuth(); + const { isAdmin, user, adminScope, setAdminScopeTeams, KNOWN_TEAMS, hasTeams } = useAuth(); + const [open, setOpen] = useState(false); + const ref = useRef(null); - // 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; + // Close dropdown on outside click + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); - const isAllMode = adminScope === 'all'; + // Only render for Admin users + if (!isAdmin()) return null; + + const selectedTeams = adminScope || []; + const myTeams = user?.teams || []; + const allSelected = selectedTeams.length === KNOWN_TEAMS.length; + const isMyTeams = myTeams.length > 0 && + selectedTeams.length === myTeams.length && + myTeams.every(t => selectedTeams.includes(t)); + + const toggleTeam = (team) => { + const next = selectedTeams.includes(team) + ? selectedTeams.filter(t => t !== team) + : [...selectedTeams, team]; + setAdminScopeTeams(next); + }; + + const selectAll = () => setAdminScopeTeams([...KNOWN_TEAMS]); + const selectMyTeams = () => setAdminScopeTeams([...myTeams]); + + // Label for the button + let label; + if (allSelected || selectedTeams.length === 0) { + label = 'All BUs'; + } else if (isMyTeams) { + label = 'My Teams'; + } else { + label = selectedTeams.join(', '); + } return ( -
- Scope: +
+ + {open && ( +
+ {/* Presets */} +
+ + {myTeams.length > 0 && ( + + )} +
+ + {/* Individual team checkboxes */} +
+ {KNOWN_TEAMS.map(team => { + const checked = selectedTeams.includes(team); + return ( + + ); + })} +
+
+ )}
); } diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.js index e4e3ffe..b752168 100644 --- a/frontend/src/contexts/AuthContext.js +++ b/frontend/src/contexts/AuthContext.js @@ -7,15 +7,33 @@ const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV']; const AuthContext = createContext(null); +// Load admin scope from localStorage — returns array of selected teams +function loadAdminScope() { + try { + const saved = localStorage.getItem('admin_bu_scope'); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) return parsed; + } + } catch { /* ignore */ } + // Default: null means "not yet initialized" — will be set to user's teams on first load + return null; +} + +function saveAdminScope(teams) { + try { + localStorage.setItem('admin_bu_scope', JSON.stringify(teams)); + } catch { /* ignore */ } +} + export function AuthProvider({ children }) { const [user, setUser] = useState(null); 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' - ); + // Admin scope — array of currently selected teams for filtering + // null = not initialized yet (will default to user's teams after login) + const [adminScope, setAdminScope] = useState(loadAdminScope); // Check if user is authenticated on mount const checkAuth = useCallback(async () => { @@ -27,6 +45,12 @@ export function AuthProvider({ children }) { if (response.ok) { const data = await response.json(); setUser(data.user); + // Initialize admin scope to user's teams if not yet set + if (adminScope === null && data.user?.teams?.length > 0) { + const initial = data.user.teams; + setAdminScope(initial); + saveAdminScope(initial); + } } else { setUser(null); } @@ -36,7 +60,7 @@ export function AuthProvider({ children }) { } finally { setLoading(false); } - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { checkAuth(); @@ -60,6 +84,12 @@ export function AuthProvider({ children }) { } setUser(data.user); + // Initialize scope to user's teams on login + if (data.user?.teams?.length > 0 && adminScope === null) { + const initial = data.user.teams; + setAdminScope(initial); + saveAdminScope(initial); + } return { success: true }; } catch (err) { setError(err.message); @@ -87,7 +117,6 @@ export function AuthProvider({ children }) { const canWrite = () => isInGroup('Admin', 'Standard_User'); // Check if user can delete a resource - // Admin: always true; Standard_User: only if they own the resource; others: false const canDelete = (resource) => { if (!user) return false; if (isInGroup('Admin')) return true; @@ -108,36 +137,51 @@ export function AuthProvider({ children }) { // 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) + // Whether the user is a member of a specific team const isTeamMember = (team) => { if (!user) return false; - if (isInGroup('Admin') && adminScope === 'all') return true; + if (isInGroup('Admin')) { + // Admin: check against current scope selection + const scope = adminScope || []; + return scope.length === 0 || scope.includes(team); + } 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; - }); + // Set the admin scope to a specific set of teams + const setAdminScopeTeams = (teams) => { + setAdminScope(teams); + saveAdminScope(teams); }; // 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 ''; + + if (isInGroup('Admin')) { + const scope = adminScope || []; + // If all teams selected or empty, no filter + if (scope.length === 0 || scope.length === KNOWN_TEAMS.length) return ''; + return scope.join(','); + } + + // Non-admin: always use their assigned teams 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; + + if (isInGroup('Admin')) { + const scope = adminScope || []; + // If all selected or empty, show all known teams + if (scope.length === 0 || scope.length === KNOWN_TEAMS.length) return KNOWN_TEAMS; + return scope; + } + return user.teams || []; }; @@ -158,7 +202,7 @@ export function AuthProvider({ children }) { hasTeams, isTeamMember, adminScope, - toggleAdminScope, + setAdminScopeTeams, getActiveTeamsParam, getAvailableTeams, KNOWN_TEAMS,