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})
+
+
+
+
+
+
+
+ );
+}