feat: replace binary scope toggle with multi-select BU picker
- Add IVANTI_BU_FILTER to .env with all four BUs (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV) - Rework AdminScopeToggle from binary (My Teams/All) to multi-select dropdown - Admin can now pick any combination of BUs to view - Presets: 'All BUs' and 'My Teams' for quick selection - Individual team checkboxes for custom combinations - Selection persisted in localStorage as JSON array - AuthContext updated: adminScope is now an array of selected teams - getActiveTeamsParam() returns comma-joined selected teams (empty = no filter) - getAvailableTeams() returns selected teams for compliance selector
This commit is contained in:
@@ -1,59 +1,164 @@
|
|||||||
// AdminScopeToggle.js
|
// AdminScopeToggle.js
|
||||||
// Two-state toggle for Admin users: "My Teams" vs "All BUs"
|
// Multi-select BU scope picker for Admin users.
|
||||||
// Controls whether data on Reporting, Compliance, and Exports pages
|
// Allows selecting any combination of teams to filter Reporting/Compliance/Exports.
|
||||||
// is scoped to the admin's assigned teams or shows everything.
|
// "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';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
function AdminScopeToggle() {
|
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
|
// Close dropdown on outside click
|
||||||
// (if no teams assigned, both modes are identical — no toggle needed)
|
useEffect(() => {
|
||||||
if (!isAdmin() || !hasTeams()) return null;
|
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 (
|
return (
|
||||||
<div
|
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-label="Select BU scope"
|
||||||
|
aria-expanded={open}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.4rem',
|
gap: '0.35rem',
|
||||||
padding: '0.25rem 0.5rem',
|
padding: '0.3rem 0.6rem',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
background: 'rgba(14, 165, 233, 0.05)',
|
background: 'rgba(14, 165, 233, 0.05)',
|
||||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.68rem',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0EA5E9',
|
||||||
|
cursor: 'pointer',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
maxWidth: '200px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: '#64748B', fontWeight: '500' }}>Scope:</span>
|
<span style={{ color: '#64748B', fontWeight: '500' }}>Scope:</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: '#475569' }}>▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 4px)',
|
||||||
|
right: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
minWidth: '180px',
|
||||||
|
background: 'linear-gradient(135deg, #1E293B 0%, #0F172A 100%)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
}}>
|
||||||
|
{/* Presets */}
|
||||||
|
<div style={{ padding: '0.25rem 0.75rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={toggleAdminScope}
|
onClick={selectAll}
|
||||||
aria-label={`Switch to ${isAllMode ? 'My Teams' : 'All BUs'} view`}
|
|
||||||
aria-pressed={isAllMode}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'block', width: '100%', textAlign: 'left',
|
||||||
alignItems: 'center',
|
background: allSelected ? 'rgba(139,92,246,0.12)' : 'none',
|
||||||
gap: '0.25rem',
|
border: 'none', padding: '0.3rem 0.4rem', borderRadius: '0.25rem',
|
||||||
padding: '0.2rem 0.5rem',
|
color: allSelected ? '#A78BFA' : '#94A3B8',
|
||||||
borderRadius: '0.25rem',
|
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||||
border: 'none',
|
cursor: 'pointer', marginBottom: '0.2rem',
|
||||||
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'}
|
⊕ All BUs
|
||||||
</button>
|
</button>
|
||||||
|
{myTeams.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={selectMyTeams}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', textAlign: 'left',
|
||||||
|
background: isMyTeams ? 'rgba(14,165,233,0.12)' : 'none',
|
||||||
|
border: 'none', padding: '0.3rem 0.4rem', borderRadius: '0.25rem',
|
||||||
|
color: isMyTeams ? '#0EA5E9' : '#94A3B8',
|
||||||
|
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⊙ My Teams ({myTeams.join(', ')})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Individual team checkboxes */}
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem' }}>
|
||||||
|
{KNOWN_TEAMS.map(team => {
|
||||||
|
const checked = selectedTeams.includes(team);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={team}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
padding: '0.3rem 0.25rem', cursor: 'pointer',
|
||||||
|
borderRadius: '0.2rem',
|
||||||
|
fontSize: '0.72rem', fontFamily: 'monospace',
|
||||||
|
color: checked ? '#E2E8F0' : '#64748B',
|
||||||
|
fontWeight: checked ? '600' : '400',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleTeam(team)}
|
||||||
|
style={{ accentColor: '#0EA5E9', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
{team}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,33 @@ const KNOWN_TEAMS = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
|||||||
|
|
||||||
const AuthContext = createContext(null);
|
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 }) {
|
export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Admin scope toggle — persisted in localStorage
|
// Admin scope — array of currently selected teams for filtering
|
||||||
const [adminScope, setAdminScope] = useState(
|
// null = not initialized yet (will default to user's teams after login)
|
||||||
() => localStorage.getItem('admin_bu_scope') || 'my-teams'
|
const [adminScope, setAdminScope] = useState(loadAdminScope);
|
||||||
);
|
|
||||||
|
|
||||||
// Check if user is authenticated on mount
|
// Check if user is authenticated on mount
|
||||||
const checkAuth = useCallback(async () => {
|
const checkAuth = useCallback(async () => {
|
||||||
@@ -27,6 +45,12 @@ export function AuthProvider({ children }) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setUser(data.user);
|
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 {
|
} else {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
@@ -36,7 +60,7 @@ export function AuthProvider({ children }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
@@ -60,6 +84,12 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUser(data.user);
|
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 };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -87,7 +117,6 @@ export function AuthProvider({ children }) {
|
|||||||
const canWrite = () => isInGroup('Admin', 'Standard_User');
|
const canWrite = () => isInGroup('Admin', 'Standard_User');
|
||||||
|
|
||||||
// Check if user can delete a resource
|
// Check if user can delete a resource
|
||||||
// Admin: always true; Standard_User: only if they own the resource; others: false
|
|
||||||
const canDelete = (resource) => {
|
const canDelete = (resource) => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (isInGroup('Admin')) return true;
|
if (isInGroup('Admin')) return true;
|
||||||
@@ -108,36 +137,51 @@ export function AuthProvider({ children }) {
|
|||||||
// Whether the user has any BU teams assigned
|
// Whether the user has any BU teams assigned
|
||||||
const hasTeams = () => (user?.teams?.length ?? 0) > 0;
|
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) => {
|
const isTeamMember = (team) => {
|
||||||
if (!user) return false;
|
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);
|
return (user.teams || []).includes(team);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle admin scope between 'my-teams' and 'all'
|
// Set the admin scope to a specific set of teams
|
||||||
const toggleAdminScope = () => {
|
const setAdminScopeTeams = (teams) => {
|
||||||
setAdminScope(prev => {
|
setAdminScope(teams);
|
||||||
const next = prev === 'my-teams' ? 'all' : 'my-teams';
|
saveAdminScope(teams);
|
||||||
localStorage.setItem('admin_bu_scope', next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Returns the comma-joined teams string for API query params.
|
// Returns the comma-joined teams string for API query params.
|
||||||
// Empty string means "no filter" (show all).
|
// Empty string means "no filter" (show all).
|
||||||
const getActiveTeamsParam = () => {
|
const getActiveTeamsParam = () => {
|
||||||
if (!user) return '';
|
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 || [];
|
const teams = user.teams || [];
|
||||||
return teams.join(',');
|
return teams.join(',');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Returns the list of teams available for UI selectors (compliance team picker, etc.)
|
// 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 = () => {
|
const getAvailableTeams = () => {
|
||||||
if (!user) return [];
|
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 || [];
|
return user.teams || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,7 +202,7 @@ export function AuthProvider({ children }) {
|
|||||||
hasTeams,
|
hasTeams,
|
||||||
isTeamMember,
|
isTeamMember,
|
||||||
adminScope,
|
adminScope,
|
||||||
toggleAdminScope,
|
setAdminScopeTeams,
|
||||||
getActiveTeamsParam,
|
getActiveTeamsParam,
|
||||||
getAvailableTeams,
|
getAvailableTeams,
|
||||||
KNOWN_TEAMS,
|
KNOWN_TEAMS,
|
||||||
|
|||||||
Reference in New Issue
Block a user