diff --git a/src/pages/users/Read.tsx b/src/pages/users/Read.tsx index f69553a..f047eec 100644 --- a/src/pages/users/Read.tsx +++ b/src/pages/users/Read.tsx @@ -30,14 +30,14 @@ import Typography from '@mui/material/Typography'; import {useCurrentUser} from '../../authentication'; import ChangeTitle from '../../tab-title'; import {useGetUserById, usePutGroupMembersById} from '../../api/apiComponents'; -import {OktaUser, OktaUserGroupMember, PolymorphicGroup, RoleGroup} from '../../api/apiSchemas'; +import {AppGroup, OktaUser, OktaUserGroupMember, PolymorphicGroup, RoleGroup} from '../../api/apiSchemas'; import UserAvatar from './UserAvatar'; import NotFound from '../NotFound'; import Ending from '../../components/Ending'; import Loading from '../../components/Loading'; import RemoveGroupsDialog, {RemoveGroupsDialogParameters} from '../roles/RemoveGroups'; import RemoveOwnDirectAccessDialog, {RemoveOwnDirectAccessDialogParameters} from '../groups/RemoveOwnDirectAccess'; -import {groupBy, displayUserName, displayGroupType} from '../../helpers'; +import {groupBy, displayUserName} from '../../helpers'; import {canManageGroup, isGroupOwner} from '../../authorization'; import {EmptyListEntry} from '../../components/EmptyListEntry'; import MembershipChip from '../../components/MembershipChip'; @@ -51,6 +51,58 @@ function sortUserGroups( return aName.localeCompare(bName); } +interface PartitionedGroups { + roles: Record; + appGroups: Record; + standardGroups: Record; +} + +function partitionByType(members: OktaUserGroupMember[] | undefined): PartitionedGroups { + const roles: OktaUserGroupMember[] = []; + const appGroups: OktaUserGroupMember[] = []; + const standardGroups: OktaUserGroupMember[] = []; + + for (const m of members ?? []) { + const type = m.active_group?.type; + if (type === 'role_group') { + roles.push(m); + } else if (type === 'app_group') { + appGroups.push(m); + } else { + standardGroups.push(m); + } + } + + return { + roles: groupBy(roles, (m) => m.active_group?.id), + appGroups: groupBy(appGroups, (m) => m.active_group?.id), + standardGroups: groupBy(standardGroups, (m) => m.active_group?.id), + }; +} + +interface AppSubGroup { + appId: string; + appName: string; + groups: Record; +} + +function groupByApp(appGroupsById: Record): AppSubGroup[] { + const byApp: Record = {}; + + for (const [groupId, members] of Object.entries(appGroupsById)) { + const appGroup = members[0].active_group as AppGroup; + const appId = appGroup?.app?.id ?? ''; + const appName = appGroup?.app?.name ?? ''; + + if (!byApp[appId]) { + byApp[appId] = {appId, appName, groups: {}}; + } + byApp[appId].groups[groupId] = members; + } + + return Object.values(byApp).sort((a, b) => a.appName.localeCompare(b.appName)); +} + interface ProfileToCardProps { user: OktaUser; } @@ -120,15 +172,23 @@ function ReportingToCard({user}: ReportingToCardProps) { ); } -interface OwnerTableProps { +interface GroupTableProps { + title: string; + groups: Record; user: OktaUser; - ownerships: Record; + owner: boolean; onClickRemoveGroupFromRole: (removeGroup: PolymorphicGroup, fromRole: RoleGroup, owner: boolean) => void; onClickRemoveDirectAccess: (id: string, fromGroup: PolymorphicGroup, owner: boolean) => void; } -function OwnerTable({user, ownerships, onClickRemoveGroupFromRole, onClickRemoveDirectAccess}: OwnerTableProps) { - const currentUser = useCurrentUser(); +function GroupTable({ + title, + groups, + user, + owner, + onClickRemoveGroupFromRole, + onClickRemoveDirectAccess, +}: GroupTableProps) { const navigate = useNavigate(); const putGroupUsers = usePutGroupMembersById({ @@ -138,7 +198,9 @@ function OwnerTable({user, ownerships, onClickRemoveGroupFromRole, onClickRemove const removeUserFromGroup = React.useCallback( (groupId: string) => { putGroupUsers.mutate({ - body: {owners_to_remove: [user.id], members_to_add: [], members_to_remove: [], owners_to_add: []}, + body: owner + ? {owners_to_remove: [user.id], members_to_add: [], members_to_remove: [], owners_to_add: []} + : {members_to_remove: [user.id], members_to_add: [], owners_to_remove: [], owners_to_add: []}, pathParams: {groupId}, }); }, @@ -147,56 +209,43 @@ function OwnerTable({user, ownerships, onClickRemoveGroupFromRole, onClickRemove return ( - +
- Owner of Group or Roles + {title} - - - - Total Groups: {Object.keys(ownerships).length} - - Name - Type Ending Direct or via Roles - {Object.keys(ownerships).length > 0 ? ( - Object.entries(ownerships) + {Object.keys(groups).length > 0 ? ( + Object.entries(groups) .sort(sortUserGroups) - .map(([groupId, groups]: [string, Array]) => ( + .map(([groupId, groupMembers]: [string, Array]) => ( theme.palette.primary.main, }, }} component={RouterLink}> - {groups[0].active_group?.name} + {groupMembers[0].active_group?.name} - {displayGroupType(groups[0].active_group)} - + - {groups.map((group) => + {groupMembers.map((group) => group.active_group ? ( { - onClickRemoveGroupFromRole(group.active_group!, roleGroup, true); + onClickRemoveGroupFromRole(group.active_group!, roleGroup, owner); }} removeDirectAccessAsUser={() => { - onClickRemoveDirectAccess(user.id, group.active_group!, true); + onClickRemoveDirectAccess(user.id, group.active_group!, owner); }} removeDirectAccessAsGroupManager={() => { removeUserFromGroup(group.active_group!.id ?? ''); @@ -229,7 +278,7 @@ function OwnerTable({user, ownerships, onClickRemoveGroupFromRole, onClickRemove )) ) : ( - + )} @@ -240,129 +289,44 @@ function OwnerTable({user, ownerships, onClickRemoveGroupFromRole, onClickRemove ); } -interface MemberTableProps { - user: OktaUser; +interface SideBySideTablesProps { + ownerships: Record; memberships: Record; + user: OktaUser; onClickRemoveGroupFromRole: (removeGroup: PolymorphicGroup, fromRole: RoleGroup, owner: boolean) => void; onClickRemoveDirectAccess: (id: string, fromGroup: PolymorphicGroup, owner: boolean) => void; } -function MemberTable({user, memberships, onClickRemoveGroupFromRole, onClickRemoveDirectAccess}: MemberTableProps) { - const currentUser = useCurrentUser(); - const navigate = useNavigate(); - - const putGroupUsers = usePutGroupMembersById({ - onSuccess: () => navigate(0), - }); - - const removeUserFromGroup = React.useCallback( - (groupId: string) => { - putGroupUsers.mutate({ - body: {members_to_remove: [user.id], members_to_add: [], owners_to_remove: [], owners_to_add: []}, - pathParams: {groupId}, - }); - }, - [putGroupUsers], - ); - +function SideBySideTables({ + ownerships, + memberships, + user, + onClickRemoveGroupFromRole, + onClickRemoveDirectAccess, +}: SideBySideTablesProps) { return ( - -
- - - - - Member of Groups or Roles - - - - - - Total Groups: {Object.keys(memberships).length} - - - - - Name - Type - Ending - Direct or via Roles - - - - {Object.keys(memberships).length > 0 ? ( - Object.entries(memberships) - .sort(sortUserGroups) - .map(([groupId, groups]: [string, Array]) => ( - - - theme.palette.primary.main, - }, - }} - component={RouterLink}> - {groups[0].active_group?.name} - - - {displayGroupType(groups[0].active_group)} - - - - - - {groups.map((group) => - group.active_group ? ( - { - onClickRemoveGroupFromRole(group.active_group!, roleGroup, false); - }} - removeDirectAccessAsUser={() => { - onClickRemoveDirectAccess(user.id, group.active_group!, false); - }} - removeDirectAccessAsGroupManager={() => { - removeUserFromGroup(group.active_group!.id ?? ''); - }} - /> - ) : null, - )} - - - - )) - ) : ( - - - - None - - - - )} - - - - -
-
+ + + + + + + + ); } @@ -393,8 +357,37 @@ export default function ReadUser() { const user = data ?? ({} as OktaUser); - const ownerships = groupBy(user.active_group_ownerships, (m) => m.active_group?.id); - const memberships = groupBy(user.active_group_memberships, (m) => m.active_group?.id); + const ownerPartitions = partitionByType(user.active_group_ownerships); + const memberPartitions = partitionByType(user.active_group_memberships); + const ownerAppsByApp = groupByApp(ownerPartitions.appGroups); + const memberAppsByApp = groupByApp(memberPartitions.appGroups); + + const appMap = new Map< + string, + { + appId: string; + appName: string; + ownerships: Record; + memberships: Record; + } + >(); + for (const entry of ownerAppsByApp) { + appMap.set(entry.appId, {appId: entry.appId, appName: entry.appName, ownerships: entry.groups, memberships: {}}); + } + for (const entry of memberAppsByApp) { + const existing = appMap.get(entry.appId); + if (existing) { + existing.memberships = entry.groups; + } else { + appMap.set(entry.appId, {appId: entry.appId, appName: entry.appName, ownerships: {}, memberships: entry.groups}); + } + } + const allAppEntries = Array.from(appMap.values()).sort((a, b) => a.appName.localeCompare(b.appName)); + + const hasRoles = Object.keys(ownerPartitions.roles).length > 0 || Object.keys(memberPartitions.roles).length > 0; + const hasAppGroups = allAppEntries.length > 0; + const hasStandardGroups = + Object.keys(ownerPartitions.standardGroups).length > 0 || Object.keys(memberPartitions.standardGroups).length > 0; const showRemoveGroupFromRoleDialog = (removeGroup: PolymorphicGroup, fromRole: RoleGroup, owner: boolean) => { setRemoveGroupsFromRoleDialogParameters({ @@ -473,22 +466,66 @@ export default function ReadUser() { - - - - - - + {hasRoles && ( + + + Roles + + + + )} + {hasAppGroups && ( + + + Apps + + {allAppEntries.map((appEntry) => ( + + + theme.palette.primary.main, + }, + }} + component={RouterLink}> + {appEntry.appName} + + + + + ))} + + )} + {hasStandardGroups && ( + + + Groups + + + + )}