diff --git a/packages/api-v4/.changeset/pr-11588-upcoming-features-1738244661751.md b/packages/api-v4/.changeset/pr-11588-upcoming-features-1738244661751.md new file mode 100644 index 00000000000..ce5c3085a46 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11588-upcoming-features-1738244661751.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +updated types for iam ([#11588](https://github.com/linode/manager/pull/11588)) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index ea8fb3ec9d6..0c1ed28bbfd 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -23,6 +23,7 @@ export type RoleType = | 'linode_viewer' | 'firewall_admin' | 'linode_creator' + | 'update_firewall' | 'firewall_creator'; export interface IamUserPermissions { diff --git a/packages/manager/.changeset/pr-11588-upcoming-features-1738244711758.md b/packages/manager/.changeset/pr-11588-upcoming-features-1738244711758.md new file mode 100644 index 00000000000..16dcc84a655 --- /dev/null +++ b/packages/manager/.changeset/pr-11588-upcoming-features-1738244711758.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +add new table component for the assigned entities in the iam ([#11588](https://github.com/linode/manager/pull/11588)) diff --git a/packages/manager/src/factories/userPermissions.ts b/packages/manager/src/factories/userPermissions.ts index ec66e13f35e..057373386c4 100644 --- a/packages/manager/src/factories/userPermissions.ts +++ b/packages/manager/src/factories/userPermissions.ts @@ -21,6 +21,11 @@ export const userPermissionsFactory = Factory.Sync.makeFactory ({ + useAccountResources: vi.fn().mockReturnValue({}), + useAccountUserPermissions: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/iam/iam', async () => { + const actual = await vi.importActual('src/queries/iam/iam'); + return { + ...actual, + useAccountUserPermissions: queryMocks.useAccountUserPermissions, + }; +}); + +vi.mock('src/queries/resources/resources', async () => { + const actual = await vi.importActual('src/queries/resources/resources'); + return { + ...actual, + useAccountResources: queryMocks.useAccountResources, + }; +}); + +describe('AssignedEntitiesTable', () => { + it('should display no roles text if there are no roles assigned to user', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: {}, + }); + + const { getByText } = renderWithTheme(); + + getByText('No Entities are assigned.'); + }); + + it('should display roles and menu when data is available', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: userPermissionsFactory.build(), + }); + + queryMocks.useAccountResources.mockReturnValue({ + data: accountResourcesFactory.build(), + }); + + const { getAllByLabelText, getByText } = renderWithTheme( + + ); + + expect(getByText('firewall-us-123')).toBeInTheDocument(); + expect(getByText('Firewall')).toBeInTheDocument(); + expect(getByText('update_firewall')).toBeInTheDocument(); + + const actionMenuButton = getAllByLabelText('action menu')[0]; + expect(actionMenuButton).toBeInTheDocument(); + + fireEvent.click(actionMenuButton); + expect(getByText('Change Role')).toBeInTheDocument(); + expect(getByText('Remove Assignment')).toBeInTheDocument(); + }); + + it('should display empty state when no roles match filters', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: userPermissionsFactory.build(), + }); + + queryMocks.useAccountResources.mockReturnValue({ + data: accountResourcesFactory.build(), + }); + + const { getByPlaceholderText, getByText } = renderWithTheme( + + ); + + const searchInput = getByPlaceholderText('Search'); + fireEvent.change(searchInput, { target: { value: 'NonExistentRole' } }); + + await waitFor(() => { + expect(getByText('No Entities are assigned.')).toBeInTheDocument(); + }); + }); + + it('should filter roles based on search query', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: userPermissionsFactory.build(), + }); + + queryMocks.useAccountResources.mockReturnValue({ + data: accountResourcesFactory.build(), + }); + + const { getByPlaceholderText, queryByText } = renderWithTheme( + + ); + + const searchInput = getByPlaceholderText('Search'); + fireEvent.change(searchInput, { + target: { value: 'firewall-us-123' }, + }); + + await waitFor(() => { + expect(queryByText('firewall-us-123')).toBeInTheDocument(); + }); + }); + + it('should filter roles based on selected resource type', async () => { + queryMocks.useAccountUserPermissions.mockReturnValue({ + data: userPermissionsFactory.build(), + }); + + queryMocks.useAccountResources.mockReturnValue({ + data: accountResourcesFactory.build(), + }); + + const { getByPlaceholderText, queryByText } = renderWithTheme( + + ); + + const autocomplete = getByPlaceholderText('All Assigned Entities'); + fireEvent.change(autocomplete, { target: { value: 'Firewalls' } }); + + await waitFor(() => { + expect(queryByText('firewall-us-123')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx new file mode 100644 index 00000000000..300c04e6f78 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -0,0 +1,249 @@ +import { Autocomplete, Typography } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { useOrder } from 'src/hooks/useOrder'; +import { useAccountUserPermissions } from 'src/queries/iam/iam'; +import { useAccountResources } from 'src/queries/resources/resources'; +import { capitalize } from 'src/utilities/capitalize'; + +import { getFilteredRoles, mapEntityTypes } from '../utilities'; + +import type { EntitiesRole, EntitiesType } from '../utilities'; +import type { + IamAccountResource, + IamUserPermissions, + Resource, + ResourceAccess, + RoleType, +} from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +export const AssignedEntitiesTable = () => { + const { username } = useParams<{ username: string }>(); + + const { handleOrderChange, order, orderBy } = useOrder(); + + const [query, setQuery] = React.useState(''); + + const [entityType, setEntityType] = React.useState(null); + + const { + data: resources, + error: resourcesError, + isLoading: resourcesLoading, + } = useAccountResources(); + const { + data: assignedRoles, + error: assignedRolesError, + isLoading: assignedRolesLoading, + } = useAccountUserPermissions(username ?? ''); + + const { entityTypes, roles } = React.useMemo(() => { + if (!assignedRoles || !resources) { + return { entityTypes: [], roles: [] }; + } + + const roles = addResourceNamesToRoles(assignedRoles, resources); + const entityTypes = getEntityTypes(roles); + + return { entityTypes, roles }; + }, [assignedRoles, resources]); + + const actions: Action[] = [ + { + onClick: () => { + // mock + }, + title: 'Change Role ', + }, + { + onClick: () => { + // mock + }, + title: 'Remove Assignment', + }, + ]; + + const memoizedTableItems = React.useMemo(() => { + if (resourcesLoading || assignedRolesLoading) { + return ; + } + + if (resourcesError || assignedRolesError) { + return ( + + ); + } + + const filteredRoles = getFilteredRoles({ + entityType: entityType?.rawValue, + getSearchableFields, + query, + roles, + }); + + if (!resources || !assignedRoles || filteredRoles.length === 0) { + return ( + + ); + } + + if (assignedRoles && resources) { + return ( + <> + {filteredRoles.map((el: EntitiesRole) => ( + + + {el.resource_name} + + + {capitalize(el.resource_type)} + + + {el.role_name} + + + + + + ))} + + ); + } + + return null; + }, [roles, query, entityType]); + + return ( + + + + setEntityType(selected ?? null)} + options={entityTypes} + placeholder="All Assigned Entities" + value={entityType} + /> + + + + + + Entity + + + Entity type + + + Assigned Role + + + + + {memoizedTableItems} +
+
+ ); +}; + +const getEntityTypes = (data: EntitiesRole[]): EntitiesType[] => + mapEntityTypes(data, 's'); + +const addResourceNamesToRoles = ( + assignedRoles: IamUserPermissions, + resources: IamAccountResource +): EntitiesRole[] => { + const result: EntitiesRole[] = []; + + const resourcesRoles = assignedRoles.resource_access; + + const resourcesArray: IamAccountResource[] = Object.values(resources); + + resourcesRoles.forEach((resourceRole: ResourceAccess) => { + const resourceByType = resourcesArray.find( + (r: IamAccountResource) => r.resource_type === resourceRole.resource_type + ); + + if (resourceByType) { + const resource = resourceByType.resources.find( + (res: Resource) => res.id === resourceRole.resource_id + ); + + if (resource) { + result.push( + ...resourceRole.roles.map((r: RoleType) => ({ + id: `${r}-${resourceRole.resource_id}`, + resource_id: resourceRole.resource_id, + resource_name: resource.name, + resource_type: resourceRole.resource_type, + role_name: r, + })) + ); + } + } + }); + + return result; +}; + +const getSearchableFields = (role: EntitiesRole): string[] => [ + String(role.resource_id), + role.resource_name, + role.resource_type, + role.role_name, +]; diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index 486320875dc..6b1fe52d489 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -14,30 +14,22 @@ import { useAccountUserPermissions, } from 'src/queries/iam/iam'; import { useAccountResources } from 'src/queries/resources/resources'; -import { capitalize } from 'src/utilities/capitalize'; -import { getFilteredRoles } from '../utilities'; +import { getFilteredRoles, mapEntityTypes } from '../utilities'; -import type { ExtendedRoleMap, RoleMap } from '../utilities'; +import type { EntitiesType, ExtendedRoleMap, RoleMap } from '../utilities'; import type { AccountAccessType, IamAccess, IamAccountPermissions, IamAccountResource, IamUserPermissions, - ResourceType, RoleType, Roles, } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; import type { TableItem } from 'src/components/CollapsibleTable/CollapsibleTable'; -interface ResourcesType { - label: string; - rawValue: ResourceType; - value?: string; -} - interface AllResources { resource: IamAccess; type: 'account' | 'resource'; @@ -82,14 +74,13 @@ export const AssignedRolesTable = () => { const [query, setQuery] = React.useState(''); - const [resourceType, setResourceType] = React.useState( - null - ); + const [entityType, setEntityType] = React.useState(null); const memoizedTableItems: TableItem[] = React.useMemo(() => { const filteredRoles = getFilteredRoles({ + entityType: entityType?.rawValue, + getSearchableFields, query, - resourceType: resourceType?.rawValue, roles, }); @@ -183,7 +174,7 @@ export const AssignedRolesTable = () => { label: role.name, }; }); - }, [roles, query, resourceType]); + }, [roles, query, entityType]); if (accountPermissionsLoading || resourcesLoading || assignedRolesLoading) { return ; @@ -202,7 +193,6 @@ export const AssignedRolesTable = () => { > { hideLabel: true, }} label="Select type" - onChange={(_, selected) => setResourceType(selected ?? null)} + onChange={(_, selected) => setEntityType(selected ?? null)} options={resourceTypes} placeholder="All Assigned Roles" - value={resourceType} + value={entityType} /> { - const resourceTypes = Array.from( - new Set(data.map((el: RoleMap) => el.resource_type)) - ); - - return resourceTypes.map((resource: ResourceType) => ({ - label: capitalize(resource) + ` Roles`, - rawValue: resource, - value: capitalize(resource) + ` Roles`, - })); -}; +const getResourceTypes = (data: RoleMap[]): EntitiesType[] => + mapEntityTypes(data, ' Roles'); export const StyledTypography = styled(Typography, { label: 'StyledTypography', })(({ theme }) => ({ - color: - theme.name === 'light' - ? theme.tokens.color.Neutrals[90] - : theme.tokens.color.Neutrals.Black, fontFamily: theme.font.bold, marginBottom: 0, })); + +const getSearchableFields = (role: ExtendedRoleMap): string[] => { + const resourceNames = role.resource_names || []; + return [ + String(role.id), + role.resource_type, + role.name, + role.access, + role.description, + ...resourceNames, + ...role.permissions, + ]; +}; diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index b687a7d5082..fcb75c3bfd2 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -1,8 +1,10 @@ import { useFlags } from 'src/hooks/useFlags'; +import { capitalize } from 'src/utilities/capitalize'; import type { AccountAccessType, PermissionType, + ResourceType, ResourceTypePermissions, RoleType, } from '@linode/api-v4'; @@ -38,12 +40,6 @@ export const placeholderMap: Record = { vpc: 'Select VPCs', }; -interface FilteredRolesOptions { - query: string; - resourceType?: string; - roles: RoleMap[]; -} - export interface RoleMap { access: 'account' | 'resource'; description: string; @@ -57,23 +53,30 @@ export interface ExtendedRoleMap extends RoleMap { resource_names?: string[]; } +interface FilteredRolesOptions { + entityType?: ResourceType | ResourceTypePermissions; + getSearchableFields: (role: EntitiesRole | ExtendedRoleMap) => string[]; + query: string; + roles: EntitiesRole[] | RoleMap[]; +} + export const getFilteredRoles = (options: FilteredRolesOptions) => { - const { query, resourceType, roles } = options; + const { entityType, getSearchableFields, query, roles } = options; return roles.filter((role: ExtendedRoleMap) => { - if (query && resourceType) { + if (query && entityType) { return ( - getDoesRolesMatchQuery(query, role) && - getDoesRolesMatchType(resourceType, role) + getDoesRolesMatchQuery(query, role, getSearchableFields) && + getDoesRolesMatchType(entityType, role) ); } if (query) { - return getDoesRolesMatchQuery(query, role); + return getDoesRolesMatchQuery(query, role, getSearchableFields); } - if (resourceType) { - return getDoesRolesMatchType(resourceType, role); + if (entityType) { + return getDoesRolesMatchType(entityType, role); } return true; @@ -87,7 +90,10 @@ export const getFilteredRoles = (options: FilteredRolesOptions) => { * @param role The role to compare against * @returns true if the given role has the given type */ -const getDoesRolesMatchType = (resourceType: string, role: ExtendedRoleMap) => { +const getDoesRolesMatchType = ( + resourceType: ResourceType | ResourceTypePermissions, + role: ExtendedRoleMap +) => { return role.resource_type === resourceType; }; @@ -96,27 +102,46 @@ const getDoesRolesMatchType = (resourceType: string, role: ExtendedRoleMap) => { * * @param query the current search query * @param role the Role to compare aginst + * @param getSearchableFields the current searchableFields * @returns true if the Role matches the given query */ -const getDoesRolesMatchQuery = (query: string, role: ExtendedRoleMap) => { - const queryWords = query - .replace(/[,.-]/g, '') - .trim() - .toLocaleLowerCase() - .split(' '); - const resourceNames = role.resource_names || []; - - const searchableFields = [ - String(role.id), - role.resource_type, - role.name, - role.access, - role.description, - ...resourceNames, - ...role.permissions, - ]; +const getDoesRolesMatchQuery = ( + query: string, + role: ExtendedRoleMap, + getSearchableFields: (role: EntitiesRole | ExtendedRoleMap) => string[] +) => { + const queryWords = query.trim().toLocaleLowerCase().split(' '); + + const searchableFields = getSearchableFields(role); return searchableFields.some((field) => queryWords.some((queryWord) => field.toLowerCase().includes(queryWord)) ); }; + +export interface EntitiesRole { + id: string; + resource_id: number; + resource_name: string; + resource_type: ResourceType | ResourceTypePermissions; + role_name: RoleType; +} + +export interface EntitiesType { + label: string; + rawValue: ResourceType | ResourceTypePermissions; + value?: string; +} + +export const mapEntityTypes = ( + data: EntitiesRole[] | RoleMap[], + suffix: string +): EntitiesType[] => { + const resourceTypes = Array.from(new Set(data.map((el) => el.resource_type))); + + return resourceTypes.map((resource) => ({ + label: capitalize(resource) + suffix, + rawValue: resource, + value: capitalize(resource) + suffix, + })); +}; diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx index b5219dabad4..62308a5c2f6 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx @@ -34,7 +34,7 @@ export const UserProfile = () => { return ( <> - + ({ marginTop: theme.spacing(2) })}> diff --git a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx index f902aacb30e..6d2ac2ec134 100644 --- a/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetailsLanding.tsx @@ -11,7 +11,6 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; -import { useAccountUserPermissions } from 'src/queries/iam/iam'; import { IAM_LABEL } from '../Shared/constants'; @@ -38,8 +37,6 @@ export const UserDetailsLanding = () => { const location = useLocation(); const history = useHistory(); - const { data: assignedRoles } = useAccountUserPermissions(username ?? ''); - const tabs = [ { routeName: `/iam/users/${username}/details`, @@ -97,7 +94,7 @@ export const UserDetailsLanding = () => { - + diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx index dfa8ce2a139..2510ba8520e 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx @@ -4,29 +4,26 @@ import React from 'react'; import { useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { useAccountUserPermissions } from 'src/queries/iam/iam'; +import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; import { NO_ASSIGNED_ENTITIES_TEXT } from '../../Shared/constants'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; -import type { IamUserPermissions } from '@linode/api-v4'; - -interface Props { - assignedRoles?: IamUserPermissions; -} - -export const UserEntities = ({ assignedRoles }: Props) => { +export const UserEntities = () => { const { username } = useParams<{ username: string }>(); + const { data: assignedRoles } = useAccountUserPermissions(username ?? ''); const hasAssignedRoles = assignedRoles ? !isEmpty(assignedRoles) : false; return ( <> - + ({ marginTop: theme.spacing(2) })}> Assigned Entities {hasAssignedRoles ? ( -

UIE-8139 - RBAC-5: User Roles - Entities Table

+ ) : ( )} diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx index 2ef26742a0e..63b06dd7cc3 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx @@ -6,34 +6,9 @@ import { useParams } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { useAccountUserPermissions } from 'src/queries/iam/iam'; +import { AssignedRolesTable } from '../../Shared/AssignedRolesTable/AssignedRolesTable'; import { NO_ASSIGNED_ROLES_TEXT } from '../../Shared/constants'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; -import { Permissions } from '../../Shared/Permissions/Permissions'; - -import type { PermissionType } from '@linode/api-v4'; - -// just for demonstaring the Permissions component. -// it will be gone with the AssignedPermissions Component in the next PR -const mockPermissionsLong: PermissionType[] = [ - 'create_nodebalancer', - 'list_nodebalancers', - 'view_nodebalancer', - 'list_nodebalancer_firewalls', - 'view_nodebalancer_statistics', - 'list_nodebalancer_configs', - 'view_nodebalancer_config', - 'list_nodebalancer_config_nodes', - 'view_nodebalancer_config_node', - 'update_nodebalancer', - 'add_nodebalancer_config', - 'update_nodebalancer_config', - 'rebuild_nodebalancer_config', - 'add_nodebalancer_config_node', - 'update_nodebalancer_config_node', - 'delete_nodebalancer', - 'delete_nodebalancer_config', - 'delete_nodebalancer_config_node', -]; export const UserRoles = () => { const { username } = useParams<{ username: string }>(); @@ -49,7 +24,7 @@ export const UserRoles = () => { return ( <> - + ({ marginTop: theme.spacing(2) })}> { {hasAssignedRoles ? ( -
-

UIE-8138 - assigned roles table

- - {/* just for showing the Permissions componnet, it will be gone with the AssignedPermissions component*/} - -
- -
-
+ ) : ( )} diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx index 9015e52053f..562adb7f2d3 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.test.tsx @@ -59,7 +59,7 @@ describe('UsersActionMenu', () => { // Check if only the proxy user action is rendered expect(queryByText('View User Details')).not.toBeInTheDocument(); - expect(queryByText('View User Roles')).not.toBeInTheDocument(); + expect(queryByText('View Assigned Roles')).not.toBeInTheDocument(); expect(queryByText('Delete User')).not.toBeInTheDocument(); // Click "Manage Access" and verify history.push is called with the correct URL @@ -94,11 +94,11 @@ describe('UsersActionMenu', () => { '/iam/users/test_user/details' ); - // Check if "View User Roles" action is present - const viewRolesButton = getByText('View User Roles'); + // Check if "View Assigned Roles" action is present + const viewRolesButton = getByText('View Assigned Roles'); expect(viewRolesButton).toBeInTheDocument(); - // Click "View User Roles" and verify history.push is called with the correct URL + // Click "View Assigned Roles" and verify history.push is called with the correct URL fireEvent.click(viewRolesButton); expect(mockHistory.push).toHaveBeenCalledWith('/iam/users/test_user/roles'); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx index 9e69964d3b0..eccd35af9d6 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx @@ -38,7 +38,13 @@ export const UsersActionMenu = ({ isProxyUser, onDelete, username }: Props) => { onClick: () => { history.push(`/iam/users/${username}/roles`); }, - title: 'View User Roles', + title: 'View Assigned Roles', + }, + { + onClick: () => { + history.push(`/iam/users/${username}/entities`); + }, + title: 'View Assigned Entities', }, { disabled: username === profileUsername, diff --git a/packages/manager/src/queries/iam/iam.ts b/packages/manager/src/queries/iam/iam.ts index f3a16356f97..fb6591c54cf 100644 --- a/packages/manager/src/queries/iam/iam.ts +++ b/packages/manager/src/queries/iam/iam.ts @@ -1,17 +1,21 @@ -import { - APIError, - IamUserPermissions, - IamAccountPermissions, -} from '@linode/api-v4'; -import { iamQueries } from './queries'; import { useQuery } from '@tanstack/react-query'; + import { useProfile } from 'src/queries/profile/profile'; + import { queryPresets } from '../base'; +import { iamQueries } from './queries'; -export const useAccountUserPermissions = (username: string) => { - return useQuery( - iamQueries.user(username)._ctx.permissions - ); +import type { + APIError, + IamAccountPermissions, + IamUserPermissions, +} from '@linode/api-v4'; + +export const useAccountUserPermissions = (username?: string) => { + return useQuery({ + ...iamQueries.user(username ?? '')._ctx.permissions, + enabled: Boolean(username), + }); }; export const useAccountPermissions = () => {