From 1ca7141d1cb1c3343f061eeb7176bd2ba78a4d6a Mon Sep 17 00:00:00 2001 From: Anastasiia Alekseenko Date: Thu, 30 Jan 2025 14:27:51 +0100 Subject: [PATCH] feat: [UIE-8139] - add new assigned entities table component part 1 --- packages/api-v4/src/iam/types.ts | 1 + .../manager/src/factories/userPermissions.ts | 5 + .../AssignedEntitiesTable.test.tsx | 131 +++++++++ .../AssignedEntitiesTable.tsx | 252 ++++++++++++++++++ .../AssignedRolesTable/AssignedRolesTable.tsx | 65 ++--- .../src/features/IAM/Shared/utilities.ts | 91 ++++--- .../IAM/Users/UserDetails/UserProfile.tsx | 2 +- .../features/IAM/Users/UserDetailsLanding.tsx | 5 +- .../IAM/Users/UserEntities/UserEntities.tsx | 15 +- .../IAM/Users/UserRoles/UserRoles.tsx | 45 +--- 10 files changed, 488 insertions(+), 124 deletions(-) create mode 100644 packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx create mode 100644 packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx 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/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..9a9e8a303a7 --- /dev/null +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -0,0 +1,252 @@ +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, + query, + roles, + }, + getSearchableFields + ); + + 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) { + resourceRole.roles.forEach((r: RoleType) => { + result.push({ + 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..0143f54c4db 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,16 +74,17 @@ 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({ - query, - resourceType: resourceType?.rawValue, - roles, - }); + const filteredRoles = getFilteredRoles( + { + entityType: entityType?.rawValue, + query, + roles, + }, + getSearchableFields + ); return filteredRoles.map((role: ExtendedRoleMap) => { const resources = role.resource_names?.map((name: string) => ( @@ -183,7 +176,7 @@ export const AssignedRolesTable = () => { label: role.name, }; }); - }, [roles, query, resourceType]); + }, [roles, query, entityType]); if (accountPermissionsLoading || resourcesLoading || assignedRolesLoading) { return ; @@ -216,10 +209,10 @@ 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..3519203e989 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,32 @@ export interface ExtendedRoleMap extends RoleMap { resource_names?: string[]; } -export const getFilteredRoles = (options: FilteredRolesOptions) => { - const { query, resourceType, roles } = options; +interface FilteredRolesOptions { + entityType?: ResourceType | ResourceTypePermissions; + query: string; + roles: EntitiesRole[] | RoleMap[]; +} + +export const getFilteredRoles = ( + options: FilteredRolesOptions, + getSearchableFields: (role: EntitiesRole | ExtendedRoleMap) => string[] +) => { + const { entityType, 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 +92,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 +104,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*/} - -
- -
-
+ ) : ( )}