diff --git a/src/App.tsx b/src/App.tsx index d95a92fe..177bd38e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import {lightGreen, red, yellow} from '@mui/material/colors'; import AuditGroup from './pages/groups/Audit'; import AuditRole from './pages/roles/Audit'; import AuditUser from './pages/users/Audit'; +import DiffUsers from './pages/users/DiffUsers'; import ExpiringGroups from './pages/groups/Expiring'; import ExpiringRoles from './pages/roles/Expiring'; import Home from './pages/Home'; @@ -53,6 +54,7 @@ import ReadUser from './pages/users/Read'; import {useCurrentUser} from './authentication'; import ReadRequest from './pages/requests/Read'; import ReadRoleRequest from './pages/role_requests/Read'; + import * as Sentry from '@sentry/react'; const drawerWidth: number = 240; @@ -243,6 +245,7 @@ function Dashboard({setThemeMode}: {setThemeMode: (theme: PaletteMode) => void}) } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/api/apiComponents.ts b/src/api/apiComponents.ts index ef118ca4..f944edab 100644 --- a/src/api/apiComponents.ts +++ b/src/api/apiComponents.ts @@ -1183,6 +1183,37 @@ export const useGetUsers = ( }); }; +export type GetAllUsersError = Fetcher.ErrorWrapper<{ + status: ClientErrorStatus | ServerErrorStatus; + payload: Schemas.UserPagination; +}>; + +export type GetAllUsersVariables = ApiContext['fetcherOptions']; + +export const fetchGetAllUsers = (variables: GetAllUsersVariables, signal?: AbortSignal) => + apiFetch({ + url: '/api/users', + method: 'get', + ...variables, + signal, + }); + +export const useGetAllUsers = ( + variables: GetAllUsersVariables, + options?: Omit< + reactQuery.UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + >, +) => { + const {fetcherOptions, queryOptions, queryKeyFn} = useApiContext(options); + return reactQuery.useQuery({ + queryKey: queryKeyFn({path: '/api/users', operationId: 'getAllUsers', variables}), + queryFn: ({signal}) => fetchGetAllUsers({...fetcherOptions, ...variables}, signal), + ...options, + ...queryOptions, + }); +}; + export type GetUserByIdPathParams = { userId: string; }; @@ -1310,4 +1341,9 @@ export type QueryOperation = path: '/api/users/{userId}'; operationId: 'getUserById'; variables: GetUserByIdVariables; + } + | { + path: '/api/users'; + operationId: 'getAllUsers'; + variables: GetAllUsersVariables; }; diff --git a/src/components/NavItems.tsx b/src/components/NavItems.tsx index 8163d38a..2f0f9843 100644 --- a/src/components/NavItems.tsx +++ b/src/components/NavItems.tsx @@ -164,6 +164,9 @@ export default function NavItems(props: NavItemsProps) { } sx={{pl: 4}} /> + + + } /> ); } diff --git a/src/pages/users/DiffUsers.tsx b/src/pages/users/DiffUsers.tsx new file mode 100644 index 00000000..14c5804c --- /dev/null +++ b/src/pages/users/DiffUsers.tsx @@ -0,0 +1,559 @@ +import React from 'react'; +import { + Box, + Card, + CardContent, + Typography, + Link, + List, + ListItem, + ListItemText, + Chip, + CircularProgress, + Alert, + Divider, + Autocomplete, + TextField, + Button, + Grid, +} from '@mui/material'; +import {useGetAllUsers, useGetUserById} from '../../api/apiComponents'; +import {OktaUser, OktaUserGroupMember} from '../../api/apiSchemas'; +import {Link as RouterLink} from 'react-router-dom'; + +const groupTypeLabels = { + role_group: 'Role Group', + app_group: 'App Group', + okta_group: 'Okta Group', +}; + +const findCommonMemberships = (user1: OktaUser, user2: OktaUser): OktaUserGroupMember[] => { + if (user1.id === user2.id) { + return user1?.active_group_memberships || []; + } + + if (!user1?.active_group_memberships?.length || !user2?.active_group_memberships?.length) { + console.log('DEBUG - One user has no memberships, returning empty array'); + return []; + } + + const commonMemberships: OktaUserGroupMember[] = []; + + for (const membership1 of user1.active_group_memberships) { + const groupId1 = membership1.group?.id || membership1.active_group?.id; + + if (!groupId1) { + continue; + } + // Check if user2 has a membership with the same group ID + const hasCommonGroup = user2.active_group_memberships.some((membership2) => { + const groupId2 = membership2.group?.id || membership2.active_group?.id; + return groupId1 === groupId2; + }); + + if (hasCommonGroup) { + commonMemberships.push(membership1); + } + } + return commonMemberships; +}; + +// Function to find groups where both users have ownerships +const findCommonOwnerships = (user1: OktaUser, user2: OktaUser): OktaUserGroupMember[] => { + if (user1.id === user2.id) { + return user1?.active_group_ownerships || []; + } + + if (!user1?.active_group_ownerships?.length || !user2?.active_group_ownerships?.length) { + return []; + } + + const commonOwnerships: OktaUserGroupMember[] = []; + + for (const ownership1 of user1.active_group_ownerships) { + const groupId1 = ownership1.group?.id || ownership1.active_group?.id; + + if (!groupId1) { + continue; + } + + const hasCommonGroup = user2.active_group_ownerships.some((ownership2) => { + const groupId2 = ownership2.group?.id || ownership2.active_group?.id; + return groupId1 === groupId2; + }); + + if (hasCommonGroup) { + commonOwnerships.push(ownership1); + } + } + + return commonOwnerships; +}; + +// Function to find memberships, left join on primary +const findUniqueMemberships = (primary: OktaUser, compareUser: OktaUser): OktaUserGroupMember[] => { + if (!primary?.active_group_memberships?.length) { + return []; + } + + // If compareUser has no memberships, all of primary's memberships are unique + if (!compareUser?.active_group_memberships?.length) { + return primary.active_group_memberships; + } + + // Collect all group IDs from compareUser for comparison + const compareUserGroupIds = new Set(); + + compareUser.active_group_memberships.forEach((membership) => { + const groupId = membership.group?.id || membership.active_group?.id; + if (groupId) { + compareUserGroupIds.add(groupId); + } + }); + + // Return primary's memberships that are not in compareUser's groups + return primary.active_group_memberships.filter((membership) => { + const groupId = membership.group?.id || membership.active_group?.id; + return groupId && !compareUserGroupIds.has(groupId); + }); +}; + +// Function to find unique ownerships, left join on primary +const findUniqueOwnerships = (primary: OktaUser, compareUser: OktaUser): OktaUserGroupMember[] => { + if (!primary?.active_group_ownerships?.length) { + return []; + } + + // If compareUser has no ownerships, all of primary's ownerships are unique + if (!compareUser?.active_group_ownerships?.length) { + return primary.active_group_ownerships; + } + + // Collect all group IDs from compareUser for comparison + const compareUserGroupIds = new Set(); + + compareUser.active_group_ownerships.forEach((ownership) => { + const groupId = ownership.group?.id || ownership.active_group?.id; + if (groupId) { + compareUserGroupIds.add(groupId); + } + }); + + // Return primary's ownerships that are not in compareUser's groups + return primary.active_group_ownerships.filter((ownership) => { + const groupId = ownership.group?.id || ownership.active_group?.id; + return groupId && !compareUserGroupIds.has(groupId); + }); +}; + +interface MembershipListProps { + memberships: OktaUserGroupMember[]; + emptyMessage: string; +} + +const MembershipList = ({memberships, emptyMessage}: MembershipListProps) => { + if (memberships.length === 0) { + return ( + + {emptyMessage} + + ); + } + + return ( + + {memberships.map((membership, index) => { + const groupId = membership.group?.id || membership.active_group?.id; + const groupName = membership.group?.name || membership.active_group?.name; + const groupType = membership.group?.type || membership.active_group?.type; + + return ( + + + + + + {groupName ?? 'Unnamed Group'} + + + + + } + secondary={membership.group?.description || membership.active_group?.description || ''} + /> + + {index < memberships.length - 1 && } + + ); + })} + + ); +}; + +export default function DiffUsers() { + // Store the basic user selections from the dropdown + const [selectedUser1, setSelectedUser1] = React.useState(null); + const [selectedUser2, setSelectedUser2] = React.useState(null); + const [isSameUser, setIsSameUser] = React.useState(false); + + // Fetch all users for the dropdown options + const {data: allUsers, isLoading: isLoadingAllUsers, error: errorAllUsers} = useGetAllUsers({}); + + // Fetch detailed user data after selection + const { + data: user1Details, + isLoading: isLoadingUser1, + error: errorUser1, + } = useGetUserById( + { + pathParams: {userId: selectedUser1?.id || ''}, + }, + { + enabled: !!selectedUser1?.id && !!selectedUser2?.id, // Only fetch when both users are selected + }, + ); + + const { + data: user2Details, + isLoading: isLoadingUser2, + error: errorUser2, + } = useGetUserById( + { + pathParams: {userId: selectedUser2?.id || ''}, + }, + { + enabled: !!selectedUser1?.id && !!selectedUser2?.id, // Only fetch when both users are selected + }, + ); + + const error = errorAllUsers || errorUser1 || errorUser2; + + // Reset the comparison view + const resetComparison = () => { + setSelectedUser1(null); + setSelectedUser2(null); + setIsSameUser(false); + }; + + // Update isSameUser flag whenever selections change + React.useEffect(() => { + if (selectedUser1 && selectedUser2) { + setIsSameUser(selectedUser1.id === selectedUser2.id); + } else { + setIsSameUser(false); + } + }, [selectedUser1, selectedUser2]); + + if (error) { + return ( + Error loading users: {error instanceof Error ? error.message : 'Unknown error'} + ); + } + + if (isLoadingAllUsers) { + return ( + + + + ); + } + + // Show user selection screen if no users selected or if detailed data is not loaded + if (!selectedUser1 || !selectedUser2 || !user1Details || !user2Details) { + return ( + + + Compare Users + + + Select two users to compare their memberships and access. + + + + + + + First User + + `${option.first_name} ${option.last_name} (${option.email})`} + renderInput={(params) => ( + + )} + onChange={(event, value) => { + if (value) { + setSelectedUser1(value); + } + }} + renderOption={(props, option) => ( + + + + {option.first_name} {option.last_name} + + + {option.email} + + + + )} + /> + + + + + + + Second User + + `${option.first_name} ${option.last_name} (${option.email})`} + renderInput={(params) => ( + + )} + onChange={(event, value) => { + if (value) { + setSelectedUser2(value); + } + }} + renderOption={(props, option) => ( + + + + {option.first_name} {option.last_name} + + + {option.email} + + + + )} + /> + + + + + ); + } + + if (isLoadingUser1 || isLoadingUser2) { + return ( + + + + ); + } + + // Calculate common and unique memberships/ownerships + const commonMemberships = findCommonMemberships(user1Details, user2Details); + const commonOwnerships = findCommonOwnerships(user1Details, user2Details); + + const uniqueMembershipsUser1 = findUniqueMemberships(user1Details, user2Details); + const uniqueMembershipsUser2 = findUniqueMemberships(user2Details, user1Details); + + const uniqueOwnershipsUser1 = findUniqueOwnerships(user1Details, user2Details); + const uniqueOwnershipsUser2 = findUniqueOwnerships(user2Details, user1Details); + + return ( + + + User Access Comparison + + + + + {isSameUser && ( + + Same user selected in both dropdowns. Showing all access. + + )} + + + + + + + {user1Details.first_name} {user1Details.last_name} + + + {user1Details.email} + + + + Group memberships: {user1Details.active_group_memberships?.length || 0} + + + Group ownerships: {user1Details.active_group_ownerships?.length || 0} + + + + + + + + + {user2Details.first_name} {user2Details.last_name} + + + {user2Details.email} + + + + Group memberships: {user2Details.active_group_memberships?.length || 0} + + + Group ownerships: {user2Details.active_group_ownerships?.length || 0} + + + + + + + {/* Common Groups Section */} + + Common Access + + + {/* Common Memberships */} + + + + + {isSameUser ? 'All Memberships' : 'Common Memberships'} ({commonMemberships.length}) + + + + + + + {/* Common Ownerships */} + + + + + {isSameUser ? 'All Ownerships' : 'Common Ownerships'} ({commonOwnerships.length}) + + + + + + + + {/* Unique Memberships Section */} + + Unique Memberships + + + {/* User 1's Unique Memberships */} + + + + + {user1Details.first_name}'s Unique Memberships ({uniqueMembershipsUser1.length}) + + + + + + + {/* User 2's Unique Memberships */} + + + + + {user2Details.first_name}'s Unique Memberships ({uniqueMembershipsUser2.length}) + + + + + + + + {/* Unique Ownerships Section */} + + Unique Ownerships + + + {/* User 1's Unique Ownerships */} + + + + + {user1Details.first_name}'s Unique Ownerships ({uniqueOwnershipsUser1.length}) + + + + + + + {/* User 2's Unique Ownerships */} + + + + + {user2Details.first_name}'s Unique Ownerships ({uniqueOwnershipsUser2.length}) + + + + + + + + ); +}