From 569c5ee2cf2c69e90cf4da20257cb7b7a4389786 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 27 Feb 2025 09:48:38 -0500 Subject: [PATCH] chore: [M3-9440] - Improve Banner Spacing (#11724) * improve-banner-spacing * update test * add changeset --------- Co-authored-by: Banks Nussman --- ...r-11762-upcoming-features-1740743199251.md | 5 + packages/api-v4/src/iam/types.ts | 1 + ...r-11762-upcoming-features-1740739892540.md | 5 + .../manager/src/factories/accountResources.ts | 32 ++ .../manager/src/factories/userPermissions.ts | 41 +++ .../AssignedPermissionsPanel.tsx | 22 +- .../AssignedRolesTable.test.tsx | 2 +- .../AssignedRolesTable/AssignedRolesTable.tsx | 300 +++++++----------- .../features/IAM/Shared/Entities/Entities.tsx | 9 +- .../Shared/Permissions/Permissions.style.ts | 31 +- .../IAM/Shared/Permissions/Permissions.tsx | 76 ++--- .../src/features/IAM/Shared/utilities.test.ts | 79 ++++- .../src/features/IAM/Shared/utilities.ts | 179 +++++++++++ .../AssignedEntitiesTable.test.tsx | 2 +- .../UserEntities}/AssignedEntitiesTable.tsx | 33 +- .../IAM/Users/UserEntities/UserEntities.tsx | 2 +- .../Users/UserRoles/AssignedEntities.test.tsx | 52 +++ .../IAM/Users/UserRoles/AssignedEntities.tsx | 110 +++++++ .../IAM/Users/UserRoles/UserRoles.test.tsx | 2 +- .../IAM/Users/UserRoles/UserRoles.tsx | 2 +- 20 files changed, 703 insertions(+), 282 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-11762-upcoming-features-1740743199251.md create mode 100644 packages/manager/.changeset/pr-11762-upcoming-features-1740739892540.md rename packages/manager/src/features/IAM/{Shared/AssignedEntitiesTable => Users/UserEntities}/AssignedEntitiesTable.test.tsx (97%) rename packages/manager/src/features/IAM/{Shared/AssignedEntitiesTable => Users/UserEntities}/AssignedEntitiesTable.tsx (87%) create mode 100644 packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.test.tsx create mode 100644 packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx diff --git a/packages/api-v4/.changeset/pr-11762-upcoming-features-1740743199251.md b/packages/api-v4/.changeset/pr-11762-upcoming-features-1740743199251.md new file mode 100644 index 00000000000..10d4c303ec1 --- /dev/null +++ b/packages/api-v4/.changeset/pr-11762-upcoming-features-1740743199251.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +update types for iam ([#11762](https://github.com/linode/manager/pull/11762)) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 0c1ed28bbfd..9f8d7353483 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -16,6 +16,7 @@ export type AccountAccessType = | 'linode_creator' | 'linode_contributor' | 'account_admin' + | 'account_viewer' | 'firewall_creator'; export type RoleType = diff --git a/packages/manager/.changeset/pr-11762-upcoming-features-1740739892540.md b/packages/manager/.changeset/pr-11762-upcoming-features-1740739892540.md new file mode 100644 index 00000000000..df005abe018 --- /dev/null +++ b/packages/manager/.changeset/pr-11762-upcoming-features-1740739892540.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +update assigned roles and entities table, update styles for permission component ([#11762](https://github.com/linode/manager/pull/11762)) diff --git a/packages/manager/src/factories/accountResources.ts b/packages/manager/src/factories/accountResources.ts index e9bb03940e5..77c696db9ef 100644 --- a/packages/manager/src/factories/accountResources.ts +++ b/packages/manager/src/factories/accountResources.ts @@ -16,6 +16,38 @@ export const accountResourcesFactory = Factory.Sync.makeFactory< id: 23456789, name: 'linode-uk-123', }, + { + id: 1, + name: 'debian-us-1', + }, + { + id: 2, + name: 'linode-uk-1', + }, + { + id: 3, + name: 'debian-us-2', + }, + { + id: 4, + name: 'linode-uk-2', + }, + { + id: 5, + name: 'debian-us-3', + }, + { + id: 6, + name: 'linode-uk-3', + }, + { + id: 7, + name: 'debian-us-4', + }, + { + id: 8, + name: 'linode-uk-4', + }, ], }, { diff --git a/packages/manager/src/factories/userPermissions.ts b/packages/manager/src/factories/userPermissions.ts index 057373386c4..c5439efbee7 100644 --- a/packages/manager/src/factories/userPermissions.ts +++ b/packages/manager/src/factories/userPermissions.ts @@ -9,6 +9,7 @@ export const userPermissionsFactory = Factory.Sync.makeFactory { const [showFullDescription, setShowFullDescription] = React.useState(false); + const theme = useTheme(); + const description = role.description.length < 110 || showFullDescription ? role.description @@ -31,23 +34,32 @@ export const AssignedPermissionsPanel = ({ role }: Props) => { return ( ({ + sx={{ backgroundColor: theme.name === 'light' ? theme.tokens.color.Neutrals[5] : theme.tokens.color.Neutrals[90], marginTop: theme.spacing(1.25), - padding: `${theme.spacing(1)} ${theme.spacing(1.25)}`, - })} + padding: `${theme.tokens.spacing[50]} ${theme.tokens.spacing[40]}`, + }} > {description}{' '} {description.length > 110 && ( setShowFullDescription((show) => !show)} - sx={{ fontSize: '0.875rem' }} > {showFullDescription ? 'Hide' : 'Expand'} diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx index 63bd6055247..61ff830378a 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.test.tsx @@ -60,7 +60,7 @@ describe('AssignedRolesTable', () => { ); expect(getByText('account_linode_admin')).toBeInTheDocument(); - expect(getAllByText('All linodes')[0]).toBeInTheDocument(); + expect(getAllByText('All Linodes')[0]).toBeInTheDocument(); const actionMenuButton = getAllByLabelText('action menu')[0]; expect(actionMenuButton).toBeInTheDocument(); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index 6b1fe52d489..dd04409c203 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -1,7 +1,12 @@ -import { Autocomplete, Chip, CircleProgress, Typography } from '@linode/ui'; -import { Grid, styled } from '@mui/material'; +import { + Autocomplete, + CircleProgress, + StyledLinkButton, + Typography, +} from '@linode/ui'; +import { Grid, useTheme } from '@mui/material'; import React from 'react'; -import { useParams } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { CollapsibleTable } from 'src/components/CollapsibleTable/CollapsibleTable'; @@ -9,39 +14,36 @@ import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextFiel import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; +import { useOrder } from 'src/hooks/useOrder'; import { useAccountPermissions, useAccountUserPermissions, } from 'src/queries/iam/iam'; import { useAccountResources } from 'src/queries/resources/resources'; +import { truncate } from 'src/utilities/truncate'; -import { getFilteredRoles, mapEntityTypes } from '../utilities'; +import { Permissions } from '../Permissions/Permissions'; +import { + addResourceNamesToRoles, + combineRoles, + getFilteredRoles, + mapEntityTypes, + mapRolesToPermissions, +} from '../utilities'; +import { AssignedEntities } from '../../Users/UserRoles/AssignedEntities'; import type { EntitiesType, ExtendedRoleMap, RoleMap } from '../utilities'; -import type { - AccountAccessType, - IamAccess, - IamAccountPermissions, - IamAccountResource, - IamUserPermissions, - RoleType, - Roles, -} from '@linode/api-v4'; +import type { AccountAccessType, RoleType } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; import type { TableItem } from 'src/components/CollapsibleTable/CollapsibleTable'; - -interface AllResources { - resource: IamAccess; - type: 'account' | 'resource'; -} - -interface CombinedRoles { - id: null | number[]; - name: AccountAccessType | RoleType; -} +import { capitalize } from '@linode/utilities'; export const AssignedRolesTable = () => { const { username } = useParams<{ username: string }>(); + const history = useHistory(); + const { handleOrderChange, order, orderBy } = useOrder(); + const theme = useTheme(); const { data: accountPermissions, @@ -63,6 +65,7 @@ export const AssignedRolesTable = () => { const userRoles = combineRoles(assignedRoles); let roles = mapRolesToPermissions(accountPermissions, userRoles); + const resourceTypes = getResourceTypes(roles); if (resources) { @@ -76,6 +79,16 @@ export const AssignedRolesTable = () => { const [entityType, setEntityType] = React.useState(null); + const [showFullDescription, setShowFullDescription] = React.useState(false); + + const handleClick = (roleName: AccountAccessType | RoleType) => { + const selectedRole = roleName; + history.push({ + pathname: `/iam/users/${username}/entities`, + state: { selectedRole }, + }); + }; + const memoizedTableItems: TableItem[] = React.useMemo(() => { const filteredRoles = getFilteredRoles({ entityType: entityType?.rawValue, @@ -85,10 +98,6 @@ export const AssignedRolesTable = () => { }); return filteredRoles.map((role: ExtendedRoleMap) => { - const resources = role.resource_names?.map((name: string) => ( - - )); - const accountMenu: Action[] = [ { onClick: () => { @@ -106,9 +115,7 @@ export const AssignedRolesTable = () => { const entitiesMenu: Action[] = [ { - onClick: () => { - // mock - }, + onClick: () => handleClick(role.name), title: 'View Entities', }, { @@ -136,34 +143,64 @@ export const AssignedRolesTable = () => { const OuterTableCells = ( <> {role.access === 'account' ? ( - + {role.resource_type === 'account' - ? 'All entities' - : `All ${role.resource_type}s`} + ? 'All Entities' + : `All ${capitalize(role.resource_type)}s`} ) : ( - {resources} + + + )} - + ); + const description = + role.description.length < 150 || showFullDescription + ? role.description + : truncate(role.description, 150); + const InnerTable = ( ({ - background: theme.color.grey5, - paddingBottom: 1.5, - paddingLeft: 4.5, - paddingRight: 4.5, - paddingTop: 1.5, - })} + sx={{ + padding: `${theme.tokens.spacing[0]} ${theme.tokens.spacing[60]}`, + }} > - Description: - {role.description} + + Description + + + {' '} + {description}{' '} + {description.length > 150 && ( + setShowFullDescription((show) => !show)} + > + {showFullDescription ? 'Hide' : 'Expand'} + + )} + + ); @@ -174,12 +211,37 @@ export const AssignedRolesTable = () => { label: role.name, }; }); - }, [roles, query, entityType]); + }, [roles, query, entityType, showFullDescription]); if (accountPermissionsLoading || resourcesLoading || assignedRolesLoading) { return ; } + const RoleTableRowHead = ( + + + Role + + + Entities + + + + ); + return ( { direction="row" > { ); }; -const RoleTableRowHead = ( - - Role - Entities - - -); - -/** - * Group account_access and resource_access roles of the user - * - */ -const combineRoles = (data: IamUserPermissions): CombinedRoles[] => { - const combinedRoles: CombinedRoles[] = []; - const roleMap: Map = new Map(); - - // Add account access roles with resource_id set to null - data.account_access.forEach((role: AccountAccessType) => { - if (!roleMap.has(role)) { - roleMap.set(role, null); - } - }); - - // Add resource access roles with their respective resource_id - data.resource_access.forEach( - (resource: { resource_id: number; roles: RoleType[] }) => { - resource.roles?.forEach((role: RoleType) => { - if (roleMap.has(role)) { - const existingResourceIds = roleMap.get(role); - if (existingResourceIds && existingResourceIds !== null) { - roleMap.set(role, [...existingResourceIds, resource.resource_id]); - } - } else { - roleMap.set(role, [resource.resource_id]); - } - }); - } - ); - - // Convert the Map into the final combinedRoles array - roleMap.forEach((id, name) => { - combinedRoles.push({ id, name }); - }); - - return combinedRoles; -}; - -/** - * Add descriptions, permissions, type to roles - */ -const mapRolesToPermissions = ( - accountPermissions: IamAccountPermissions, - userRoles: { - id: null | number[]; - name: AccountAccessType | RoleType; - }[] -): RoleMap[] => { - const roleMap = new Map(); - - // Flatten resources and map roles for quick lookup - const allResources11: AllResources[] = [ - ...accountPermissions.account_access.map((resource) => ({ - resource, - type: 'account' as const, - })), - ...accountPermissions.resource_access.map((resource) => ({ - resource, - type: 'resource' as const, - })), - ]; - - const roleLookup = new Map(); - allResources11.forEach(({ resource, type }) => { - resource.roles.forEach((role: Roles) => { - roleLookup.set(role.name, { resource, type }); - }); - }); - - // Map userRoles to permissions - userRoles.forEach(({ id, name }) => { - const match = roleLookup.get(name); - if (match) { - const { resource, type } = match; - const role = resource.roles.find((role: Roles) => role.name === name)!; - roleMap.set(name, { - access: type, - description: role.description, - id: name, - name, - permissions: role.permissions, - resource_ids: id, - resource_type: resource.resource_type, - }); - } - }); - - return Array.from(roleMap.values()); -}; - -const addResourceNamesToRoles = ( - roles: ExtendedRoleMap[], - resources: IamAccountResource -): ExtendedRoleMap[] => { - const resourcesArray: IamAccountResource[] = Object.values(resources); - - return roles.map((role) => { - // Find the resource group by resource_type - const resourceGroup = resourcesArray.find( - (res) => res.resource_type === role.resource_type - ); - - if (resourceGroup && role.resource_ids) { - // Map resource_ids to their names - const resourceNames = role.resource_ids - .map( - (id) => - resourceGroup.resources.find((resource) => resource.id === id)?.name - ) - .filter((name): name is string => name !== undefined); // Remove undefined values - - return { ...role, resource_names: resourceNames }; - } - - // If no matching resource_type, return the role unchanged - return { ...role, resource_names: [] }; - }); -}; - const getResourceTypes = (data: RoleMap[]): EntitiesType[] => mapEntityTypes(data, ' Roles'); -export const StyledTypography = styled(Typography, { - label: 'StyledTypography', -})(({ theme }) => ({ - fontFamily: theme.font.bold, - marginBottom: 0, -})); - const getSearchableFields = (role: ExtendedRoleMap): string[] => { const resourceNames = role.resource_names || []; return [ diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx index c5fe31d84a5..613223c79ef 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx @@ -1,4 +1,5 @@ import { Autocomplete, Typography } from '@linode/ui'; +import { useTheme } from '@mui/material'; import React from 'react'; import { FormLabel } from 'src/components/FormLabel'; @@ -26,6 +27,7 @@ interface EntitiesOption { export const Entities = ({ access, type }: Props) => { const { data: resources } = useAccountResources(); + const theme = useTheme(); const [selectedEntities, setSelectedEntities] = React.useState< EntitiesOption[] @@ -43,7 +45,11 @@ export const Entities = ({ access, type }: Props) => { return ( <> - + Entities @@ -68,6 +74,7 @@ export const Entities = ({ access, type }: Props) => { onChange={(_, value) => setSelectedEntities(value)} options={memoizedEntities} placeholder={selectedEntities.length ? ' ' : getPlaceholder(type)} + sx={{ marginTop: theme.tokens.spacing[50] }} value={selectedEntities} /> ); diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts index 2f46f0ed7f1..e94480362cc 100644 --- a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.style.ts @@ -7,13 +7,6 @@ export const sxTooltipIcon = { padding: 0, }; -export const StyledTypography = styled(Typography, { - label: 'StyledTypography', -})(({ theme }) => ({ - fontFamily: theme.font.bold, - marginBottom: 0, -})); - export const StyledGrid = styled(Grid, { label: 'StyledGrid' })(() => ({ alignItems: 'center', marginBottom: 2, @@ -37,6 +30,9 @@ export const StyledContainer = styled('div', { export const StyledClampedContent = styled('div', { label: 'StyledClampedContent', })<{ showAll?: boolean }>(({ showAll }) => ({ + '& p:last-child': { + borderRight: 0, + }, WebkitBoxOrient: 'vertical', WebkitLineClamp: showAll ? 'unset' : 2, display: '-webkit-box', @@ -46,23 +42,6 @@ export const StyledClampedContent = styled('div', { export const StyledBox = styled(Box, { label: 'StyledBox', })(({ theme }) => ({ - backgroundColor: - theme.name === 'light' - ? theme.tokens.color.Neutrals[5] - : theme.tokens.color.Neutrals[90], - bottom: 1, - display: 'flex', - justifyContent: 'space-between', - position: 'absolute', - right: 0, + font: theme.tokens.typography.Label.Semibold.Xs, + paddingLeft: theme.tokens.spacing[30], })); - -export const StyledSpan = styled(Typography, { label: 'StyledSpan' })( - ({ theme }) => ({ - borderRight: `1px solid ${theme.tokens.border.Normal}`, - bottom: 0, - marginRight: theme.spacing(0.5), - paddingLeft: theme.spacing(0.5), - paddingRight: theme.spacing(0.5), - }) -); diff --git a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx index 2735c294727..0b718bd2b63 100644 --- a/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx +++ b/packages/manager/src/features/IAM/Shared/Permissions/Permissions.tsx @@ -1,16 +1,15 @@ -import { StyledLinkButton, TooltipIcon } from '@linode/ui'; +import { StyledLinkButton, TooltipIcon, Typography } from '@linode/ui'; import { debounce } from '@mui/material'; import Grid from '@mui/material/Grid'; import * as React from 'react'; +import { useCalculateHiddenItems } from '../utilities'; import { StyledBox, StyledClampedContent, StyledContainer, StyledGrid, StyledPermissionItem, - StyledSpan, - StyledTypography, sxTooltipIcon, } from './Permissions.style'; @@ -22,38 +21,13 @@ type Props = { export const Permissions = ({ permissions }: Props) => { const [showAll, setShowAll] = React.useState(false); - const [numHiddenItems, setNumHiddenItems] = React.useState(0); - const containerRef = React.useRef(null); - - const itemRefs = React.useRef<(HTMLSpanElement | null)[]>([]); - - const calculateHiddenItems = React.useCallback(() => { - if (showAll || !containerRef.current) { - setNumHiddenItems(0); - return; - } - - if (!itemRefs.current) { - return; - } - - const containerBottom = containerRef.current.getBoundingClientRect().bottom; - - const itemsArray = Array.from(itemRefs.current); - - const firstHiddenIndex = itemsArray.findIndex( - (item: HTMLParagraphElement) => { - const rect = item.getBoundingClientRect(); - return rect.top >= containerBottom; - } - ); - - const numHiddenItems = - firstHiddenIndex !== -1 ? itemsArray.length - firstHiddenIndex : 0; - - setNumHiddenItems(numHiddenItems); - }, [showAll, permissions]); + const { + calculateHiddenItems, + containerRef, + itemRefs, + numHiddenItems, + } = useCalculateHiddenItems(permissions, showAll); const handleResize = React.useMemo( () => debounce(() => calculateHiddenItems(), 100), @@ -73,16 +47,17 @@ export const Permissions = ({ permissions }: Props) => { }, [calculateHiddenItems, handleResize]); // TODO: update the link for TooltipIcon when it's ready - UIE-8534 - return ( - + - Permissions + ({ + font: theme.tokens.typography.Label.Bold.S, + })} + > + Permissions + + { - {(numHiddenItems > 0 || showAll) && ( - - {!showAll && +{numHiddenItems} } - setShowAll(!showAll)}> - {showAll ? 'Hide' : ` Expand`} - - - )} - {permissions.map((permission: PermissionType, index: number) => ( @@ -113,6 +79,14 @@ export const Permissions = ({ permissions }: Props) => { ))} + + {(numHiddenItems > 0 || showAll) && ( + + setShowAll(!showAll)}> + {showAll ? 'Hide' : ` Expand (+${numHiddenItems})`} + + + )} ); diff --git a/packages/manager/src/features/IAM/Shared/utilities.test.ts b/packages/manager/src/features/IAM/Shared/utilities.test.ts index d023fc90c78..aa41617d2ce 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.test.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.test.ts @@ -1,6 +1,12 @@ -import { getAllRoles, getRoleByName } from './utilities'; +import { + combineRoles, + getAllRoles, + getRoleByName, + mapRolesToPermissions, +} from './utilities'; -import type { IamAccountPermissions } from '@linode/api-v4'; +import type { CombinedRoles } from './utilities'; +import type { IamAccountPermissions, IamUserPermissions } from '@linode/api-v4'; const accountPermissions: IamAccountPermissions = { account_access: [ @@ -41,6 +47,17 @@ const accountPermissions: IamAccountPermissions = { ], }; +const userPermissions: IamUserPermissions = { + account_access: ['account_linode_admin', 'linode_creator'], + resource_access: [ + { + resource_id: 12345678, + resource_type: 'linode', + roles: ['linode_contributor'], + }, + ], +}; + describe('getAllRoles', () => { it('should return a list of roles for each access type', () => { const expectedRoles = [ @@ -81,3 +98,61 @@ describe('getRoleByName', () => { expect(getRoleByName(accountPermissions, roleName)).toEqual(expectedRole); }); }); + +describe('combineRoles', () => { + it('should return an object of users roles', () => { + const expectedRoles = [ + { id: null, name: 'account_linode_admin' }, + { id: null, name: 'linode_creator' }, + { id: [12345678], name: 'linode_contributor' }, + ]; + + expect(combineRoles(userPermissions)).toEqual(expectedRoles); + }); +}); + +describe('mapRolesToPermissions', () => { + it('should return an object of users roles', () => { + const userRoles: CombinedRoles[] = [ + { id: null, name: 'account_admin' }, + { id: null, name: 'account_linode_admin' }, + { id: [12345678], name: 'linode_contributor' }, + ]; + + const expectedRoles = [ + { + access: 'account', + description: + 'Access to perform any supported action on all resources in the account', + id: 'account_admin', + name: 'account_admin', + permissions: ['create_linode', 'update_linode', 'update_firewall'], + resource_ids: null, + resource_type: 'account', + }, + { + access: 'account', + description: + 'Access to perform any supported action on all linode instances in the account', + id: 'account_linode_admin', + name: 'account_linode_admin', + permissions: ['create_linode', 'update_linode', 'delete_linode'], + resource_ids: null, + resource_type: 'linode', + }, + { + access: 'resource', + description: 'Access to update a linode instance', + id: 'linode_contributor', + name: 'linode_contributor', + permissions: ['update_linode', 'view_linode'], + resource_ids: [12345678], + resource_type: 'linode', + }, + ]; + + expect(mapRolesToPermissions(accountPermissions, userRoles)).toEqual( + expectedRoles + ); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index 85b69380632..0d76ab32154 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -1,5 +1,7 @@ import { capitalize } from '@linode/utilities'; +import React from 'react'; + import { useFlags } from 'src/hooks/useFlags'; import type { @@ -7,6 +9,8 @@ import type { IamAccess, IamAccessType, IamAccountPermissions, + IamAccountResource, + IamUserPermissions, PermissionType, ResourceType, ResourceTypePermissions, @@ -197,3 +201,178 @@ export const mapEntityTypes = ( value: capitalize(resource) + suffix, })); }; + +export interface CombinedRoles { + id: null | number[]; + name: AccountAccessType | RoleType; +} + +/** + * Group account_access and resource_access roles of the user + * + */ +export const combineRoles = (data: IamUserPermissions): CombinedRoles[] => { + const combinedRoles: CombinedRoles[] = []; + const roleMap: Map = new Map(); + + // Add account access roles with resource_id set to null + data.account_access.forEach((role: AccountAccessType) => { + if (!roleMap.has(role)) { + roleMap.set(role, null); + } + }); + + // Add resource access roles with their respective resource_id + data.resource_access.forEach( + (resource: { resource_id: number; roles: RoleType[] }) => { + resource.roles?.forEach((role: RoleType) => { + if (roleMap.has(role)) { + const existingResourceIds = roleMap.get(role); + if (existingResourceIds && existingResourceIds !== null) { + roleMap.set(role, [...existingResourceIds, resource.resource_id]); + } + } else { + roleMap.set(role, [resource.resource_id]); + } + }); + } + ); + + // Convert the Map into the final combinedRoles array + roleMap.forEach((id, name) => { + combinedRoles.push({ id, name }); + }); + + return combinedRoles; +}; + +interface AllResources { + resource: IamAccess; + type: 'account' | 'resource'; +} + +/** + * Add descriptions, permissions, type to roles + */ +export const mapRolesToPermissions = ( + accountPermissions: IamAccountPermissions, + userRoles: CombinedRoles[] +): RoleMap[] => { + const roleMap = new Map(); + + // Flatten resources and map roles for quick lookup + const allResources: AllResources[] = [ + ...accountPermissions.account_access.map((resource) => ({ + resource, + type: 'account' as const, + })), + ...accountPermissions.resource_access.map((resource) => ({ + resource, + type: 'resource' as const, + })), + ]; + + const roleLookup = new Map(); + allResources.forEach(({ resource, type }) => { + resource.roles.forEach((role: Roles) => { + roleLookup.set(role.name, { resource, type }); + }); + }); + + // Map userRoles to permissions + userRoles.forEach(({ id, name }) => { + const match = roleLookup.get(name); + if (match) { + const { resource, type } = match; + const role = resource.roles.find((role: Roles) => role.name === name)!; + roleMap.set(name, { + access: type, + description: role.description, + id: name, + name, + permissions: role.permissions, + resource_ids: id, + resource_type: resource.resource_type, + }); + } + }); + + return Array.from(roleMap.values()); +}; + +/** + * Add assigned entities to role + */ +export const addResourceNamesToRoles = ( + roles: ExtendedRoleMap[], + resources: IamAccountResource +): ExtendedRoleMap[] => { + const resourcesArray: IamAccountResource[] = Object.values(resources); + + return roles.map((role) => { + // Find the resource group by resource_type + const resourceGroup = resourcesArray.find( + (res) => res.resource_type === role.resource_type + ); + + if (resourceGroup && role.resource_ids) { + // Map resource_ids to their names + const resourceNames = role.resource_ids + .map( + (id) => + resourceGroup.resources.find((resource) => resource.id === id)?.name + ) + .filter((name): name is string => name !== undefined); // Remove undefined values + + return { ...role, resource_names: resourceNames }; + } + + // If no matching resource_type, return the role unchanged + return { ...role, resource_names: [] }; + }); +}; + +/** + * Custom hook to calculate hidden items + */ +export const useCalculateHiddenItems = ( + items: PermissionType[] | string[], + showAll?: boolean +) => { + const [numHiddenItems, setNumHiddenItems] = React.useState(0); + + const containerRef = React.useRef(null); + + const itemRefs = React.useRef<(HTMLDivElement | HTMLSpanElement | null)[]>( + [] + ); + + const calculateHiddenItems = React.useCallback(() => { + if (showAll || !containerRef.current) { + setNumHiddenItems(0); + return; + } + + if (!itemRefs.current) { + return; + } + + const containerBottom = containerRef.current.getBoundingClientRect().bottom; + + const itemsArray = Array.from(itemRefs.current); + + const firstHiddenIndex = itemsArray.findIndex( + (item: HTMLParagraphElement) => { + const rect = item.getBoundingClientRect(); + return rect.top >= containerBottom; + } + ); + + const numHiddenItems = + firstHiddenIndex !== -1 ? itemsArray.length - firstHiddenIndex : 0; + + setNumHiddenItems(numHiddenItems); + }, [items, showAll]); + + return { calculateHiddenItems, containerRef, itemRefs, numHiddenItems }; +}; diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx similarity index 97% rename from packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx rename to packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx index 9b31759a4aa..8086b0b1c5a 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx @@ -5,7 +5,7 @@ import { accountResourcesFactory } from 'src/factories/accountResources'; import { userPermissionsFactory } from 'src/factories/userPermissions'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { AssignedEntitiesTable } from './AssignedEntitiesTable'; +import { AssignedEntitiesTable } from '../../Users/UserEntities/AssignedEntitiesTable'; const queryMocks = vi.hoisted(() => ({ useAccountResources: vi.fn().mockReturnValue({}), diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx similarity index 87% rename from packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx rename to packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx index 1b6f8dcd371..21801ed1c6b 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx @@ -2,7 +2,7 @@ import { Autocomplete, Typography } from '@linode/ui'; import { capitalize } from '@linode/utilities'; import { Grid } from '@mui/material'; import React from 'react'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; @@ -19,9 +19,9 @@ import { useOrder } from 'src/hooks/useOrder'; import { useAccountUserPermissions } from 'src/queries/iam/iam'; import { useAccountResources } from 'src/queries/resources/resources'; -import { getFilteredRoles, mapEntityTypes } from '../utilities'; +import { getFilteredRoles, mapEntityTypes } from '../../Shared/utilities'; -import type { EntitiesRole, EntitiesType } from '../utilities'; +import type { EntitiesRole, EntitiesType } from '../../Shared/utilities'; import type { IamAccountResource, IamUserPermissions, @@ -31,12 +31,19 @@ import type { } from '@linode/api-v4'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; +interface LocationState { + selectedRole?: string; +} + export const AssignedEntitiesTable = () => { const { username } = useParams<{ username: string }>(); + const location = useLocation(); + + const locationState = location.state as LocationState; const { handleOrderChange, order, orderBy } = useOrder(); - const [query, setQuery] = React.useState(''); + const [query, setQuery] = React.useState(locationState?.selectedRole ?? ''); const [entityType, setEntityType] = React.useState(null); @@ -112,10 +119,10 @@ export const AssignedEntitiesTable = () => { {el.resource_name} - + {capitalize(el.resource_type)} - + {el.role_name} @@ -142,12 +149,19 @@ export const AssignedEntitiesTable = () => { direction="row" > { direction={order} handleClick={handleOrderChange} label="entity" - sx={{ width: '32%' }} > Entity @@ -179,7 +192,7 @@ export const AssignedEntitiesTable = () => { direction={order} handleClick={handleOrderChange} label="entityType" - sx={{ width: '32%' }} + sx={{ display: { sm: 'table-cell', xs: 'none' } }} > Entity type @@ -188,7 +201,7 @@ export const AssignedEntitiesTable = () => { direction={order} handleClick={handleOrderChange} label="role" - sx={{ width: '25%' }} + sx={{ display: { sm: 'table-cell', xs: 'none' } }} > Assigned Role diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx index 2510ba8520e..7561fe24de4 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx @@ -6,9 +6,9 @@ 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 { AssignedEntitiesTable } from './AssignedEntitiesTable'; export const UserEntities = () => { const { username } = useParams<{ username: string }>(); diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.test.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.test.tsx new file mode 100644 index 00000000000..ea94e970e24 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AssignedEntities } from './AssignedEntities'; + +const mockEntities: string[] = ['linode-uk-123']; + +const mockEntitiesLong: string[] = [ + 'debian-us-123', + 'linode-uk-123', + 'debian-us-1', + 'linode-uk-1', + 'debian-us-2', + 'linode-uk-2', + 'debian-us-3', + 'linode-uk-3', + 'debian-us-4', + 'linode-uk-4', +]; + +const handleClick = vi.fn(); + +describe('AssignedEntities', () => { + it('renders the correct number of entity chips', () => { + const { getAllByTestId, getByText } = renderWithTheme( + + ); + + const chips = getAllByTestId('entities'); + expect(chips).toHaveLength(1); + + expect(getByText('linode-uk-123')).toBeInTheDocument(); + }); + + it('renders the correct number of entity chips', () => { + const { getAllByTestId } = renderWithTheme( + + ); + + const chips = getAllByTestId('entities'); + expect(chips).toHaveLength(mockEntitiesLong.length); + }); +}); diff --git a/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx new file mode 100644 index 00000000000..3cd9e8c0683 --- /dev/null +++ b/packages/manager/src/features/IAM/Users/UserRoles/AssignedEntities.tsx @@ -0,0 +1,110 @@ +import { Box, Button, Chip, Tooltip } from '@linode/ui'; +import CloseIcon from '@mui/icons-material/Close'; +import { debounce, useTheme } from '@mui/material'; +import * as React from 'react'; + +import { useCalculateHiddenItems } from '../../Shared/utilities'; + +import type { AccountAccessType, RoleType } from '@linode/api-v4'; + +type Props = { + entities: string[]; + onButtonClick: (roleName: AccountAccessType | RoleType) => void; + roleName: AccountAccessType | RoleType; +}; + +export const AssignedEntities = ({ + entities, + onButtonClick, + roleName, +}: Props) => { + const theme = useTheme(); + + const handleDelete = () => {}; + + const { + calculateHiddenItems, + containerRef, + itemRefs, + numHiddenItems, + } = useCalculateHiddenItems(entities); + + const handleResize = React.useMemo( + () => debounce(() => calculateHiddenItems(), 100), + [calculateHiddenItems] + ); + + React.useEffect(() => { + // Ensure calculateHiddenItems runs after layout stabilization on initial render + const rafId = requestAnimationFrame(() => calculateHiddenItems()); + + window.addEventListener('resize', handleResize); + + return () => { + cancelAnimationFrame(rafId); + window.removeEventListener('resize', handleResize); + }; + }, [calculateHiddenItems, handleResize]); + + const items = entities?.map((name: string, index: number) => ( +
(itemRefs.current[index] = el)} + style={{ display: 'inline-block', marginRight: 8 }} + > + } + key={name} + label={name} + onDelete={handleDelete} + /> +
+ )); + + return ( + +
+ {items} +
+ {numHiddenItems > 0 && ( + + + + + + )} +
+ ); +}; diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx index 377498b9b51..e6cbd236534 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.test.tsx @@ -63,7 +63,7 @@ describe('UserRoles', () => { ); expect(getByText('account_linode_admin')).toBeInTheDocument(); - expect(getAllByText('All linodes')[0]).toBeInTheDocument(); + expect(getAllByText('All Linodes')[0]).toBeInTheDocument(); const actionMenuButton = getAllByLabelText('action menu')[0]; expect(actionMenuButton).toBeInTheDocument(); diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx index 7b92baba42d..e421e226e65 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx @@ -6,9 +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 { AssignedRolesTable } from '../../Shared/AssignedRolesTable/AssignedRolesTable'; import { AssignNewRoleDrawer } from './AssignNewRoleDrawer'; export const UserRoles = () => {