diff --git a/packages/api-v4/.changeset/pr-12053-upcoming-features-1744891132478.md b/packages/api-v4/.changeset/pr-12053-upcoming-features-1744891132478.md new file mode 100644 index 00000000000..c793509f17f --- /dev/null +++ b/packages/api-v4/.changeset/pr-12053-upcoming-features-1744891132478.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +added new types for iam ([#12053](https://github.com/linode/manager/pull/12053)) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index fdee06778b6..59772b6ac64 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -18,7 +18,8 @@ export type AccountAccessRole = | 'account_volume_admin' | 'firewall_creator' | 'linode_contributor' - | 'linode_creator'; + | 'linode_creator' + | 'stackscript_creator'; export type EntityAccessRole = | 'database_admin' @@ -27,6 +28,8 @@ export type EntityAccessRole = | 'linode_contributor' | 'linode_creator' | 'linode_viewer' + | 'stackscript_admin' + | 'stackscript_viewer' | 'update_firewall'; export interface IamUserPermissions { diff --git a/packages/manager/.changeset/pr-12053-upcoming-features-1744891161989.md b/packages/manager/.changeset/pr-12053-upcoming-features-1744891161989.md new file mode 100644 index 00000000000..7e52fdb3065 --- /dev/null +++ b/packages/manager/.changeset/pr-12053-upcoming-features-1744891161989.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +implement a logic for getting a description for the facade roles ([#12053](https://github.com/linode/manager/pull/12053)) diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index 7bb1d6059e2..8286a0af0b0 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -1,8 +1,9 @@ -import { Button, Select } from '@linode/ui'; +import { Button, Select, Typography } from '@linode/ui'; import { capitalizeAllWords } from '@linode/utilities'; import Grid from '@mui/material/Grid2'; import Paper from '@mui/material/Paper'; import { + sortRows, Table, TableBody, TableCell, @@ -10,15 +11,17 @@ import { TableHeaderCell, TableRow, TableRowExpanded, - sortRows, } from 'akamai-cds-react-components/Table'; import React, { useState } from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { StyledTextTooltip } from 'src/features/components/PlansPanel/PlansAvailabilityNotice.styles'; +import { Link } from 'src/components/Link'; import { RolesTableActionMenu } from 'src/features/IAM/Roles/RolesTable/RolesTableActionMenu'; import { RolesTableExpandedRow } from 'src/features/IAM/Roles/RolesTable/RolesTableExpandedRow'; -import { mapEntityTypesForSelect } from 'src/features/IAM/Shared/utilities'; +import { + getFacadeRoleDescription, + mapEntityTypesForSelect, +} from 'src/features/IAM/Shared/utilities'; import type { SelectOption } from '@linode/ui'; import type { Order } from 'akamai-cds-react-components/Table'; @@ -45,16 +48,20 @@ export const RolesTable = ({ roles }: Props) => { }, [roles]); const [filterableEntityType, setFilterableEntityType] = - useState(ALL_ROLES_OPTION); + useState(ALL_ROLES_OPTION); const [sort, setSort] = useState< - { column: string; order: Order } | undefined + undefined | { column: string; order: Order } >(undefined); const [selectedRows, setSelectedRows] = useState([]); const areAllSelected = React.useMemo(() => { - return !!rows?.length && !!selectedRows?.length && rows?.length === selectedRows?.length; + return ( + !!rows?.length && + !!selectedRows?.length && + rows?.length === selectedRows?.length + ); }, [rows, selectedRows]); const handleSort = (event: CustomEvent, column: string) => { @@ -107,20 +114,18 @@ export const RolesTable = ({ roles }: Props) => { return ( ({ marginTop: theme.spacing(2) })}> ({ - justifyContent: 'space-between', - })} container direction="row" spacing={2} + sx={{ justifyContent: 'space-between' }} > ({ - alignItems: 'center', - justifyContent: 'flex-start', - })} container direction="row" + sx={{ + alignItems: 'center', + justifyContent: 'flex-start', + }} > { /> @@ -176,7 +181,7 @@ export const RolesTable = ({ roles }: Props) => { sort={(event) => handleSort(event, 'access')} sortable sorted={sort?.column === 'access' ? sort.order : undefined} - style={{ minWidth: '18%' }} + style={{ minWidth: '14%' }} > Role Type @@ -184,11 +189,11 @@ export const RolesTable = ({ roles }: Props) => { sort={(event) => handleSort(event, 'description')} sortable sorted={sort?.column === 'description' ? sort.order : undefined} - style={{ minWidth: '30%' }} + style={{ minWidth: '38%' }} > Description - + @@ -210,20 +215,21 @@ export const RolesTable = ({ roles }: Props) => { {roleRow.name} - + {capitalizeAllWords(roleRow.access, '_')} - - {roleRow.description.length <= 80 ? ( - <>{roleRow.description} + + {roleRow.permissions.length ? ( + roleRow.description ) : ( - - {roleRow.description.substring(0, 80)}{'... '} - - + // TODO: update the link for the description when it's ready - UIE-8534 + + {getFacadeRoleDescription(roleRow)}{' '} + Learn more. + )} - + { - const [showFullDescription, setShowFullDescription] = React.useState(false); - const theme = useTheme(); - const description = - role.description.length < 110 || showFullDescription - ? role.description - : truncate(role.description, 110); - + // TODO: update the link for the description when it's ready - UIE-8534 return ( - {description}{' '} - {description.length > 110 && ( - setShowFullDescription((show) => !show)} - sx={{ - font: theme.tokens.alias.Typography.Label.Semibold.Xs, - width: 'max-content', - }} - type="button" - > - {showFullDescription ? 'Hide' : 'Expand'} - + {role.permissions.length ? ( + role.description + ) : ( + <> + {getFacadeRoleDescription(role)} Learn more. + )} diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index 5c96e4548a9..d6c68f55487 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -1,16 +1,12 @@ -import { - Autocomplete, - CircleProgress, - StyledLinkButton, - Typography, -} from '@linode/ui'; -import { capitalize, truncate } from '@linode/utilities'; -import { Grid, useTheme } from '@mui/material'; +import { Autocomplete, CircleProgress, Typography } from '@linode/ui'; +import { useTheme } from '@mui/material'; +import Grid from '@mui/material/Grid2'; import React from 'react'; import { useHistory, useParams } from 'react-router-dom'; import { CollapsibleTable } from 'src/components/CollapsibleTable/CollapsibleTable'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; @@ -28,7 +24,9 @@ import { RemoveAssignmentConfirmationDialog } from '../RemoveAssignmentConfirmat import { addEntitiesNamesToRoles, combineRoles, + getFacadeRoleDescription, getFilteredRoles, + getFormattedEntityType, mapEntityTypes, mapRolesToPermissions, transformedAccountEntities, @@ -126,8 +124,6 @@ export const AssignedRolesTable = () => { const [entityType, setEntityType] = React.useState(null); - const [showFullDescription, setShowFullDescription] = React.useState(false); - const handleViewEntities = ( roleName: AccountAccessRole | EntityAccessRole ) => { @@ -154,7 +150,7 @@ export const AssignedRolesTable = () => { {role.entity_type === 'account' ? 'All Entities' - : `All ${capitalize(role.entity_type)}s`} + : `All ${getFormattedEntityType(role.entity_type)}s`} ) : ( @@ -177,12 +173,7 @@ export const AssignedRolesTable = () => { ); - - const description = - role.description.length < 150 || showFullDescription - ? role.description - : truncate(role.description, 150); - + // TODO: update the link for 'Learn more' in the description when it's ready - UIE-8534 const InnerTable = ( { Description - {' '} - {description}{' '} - {description.length > 150 && ( - setShowFullDescription((show) => !show)} - sx={{ - font: theme.tokens.alias.Typography.Label.Semibold.Xs, - width: 'max-content', - }} - > - {showFullDescription ? 'Hide' : 'Expand'} - + {role.permissions.length ? ( + role.description + ) : ( + <> + {getFacadeRoleDescription(role)} Learn more. + )} @@ -224,7 +211,7 @@ export const AssignedRolesTable = () => { label: role.name, }; }); - }, [roles, query, entityType, showFullDescription]); + }, [roles, query, entityType]); if (accountPermissionsLoading || entitiesLoading || assignedRolesLoading) { return ; diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx index 4681da1f8ec..25384f41619 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.test.tsx @@ -73,7 +73,7 @@ describe('Entities', () => { const autocomplete = screen.queryAllByRole('combobox'); expect(screen.getByText('Entities')).toBeVisible(); - expect(screen.getByText('All firewalls')).toBeVisible(); + expect(screen.getByText('All Firewalls')).toBeVisible(); // check that the autocomplete doesn't exist expect(autocomplete.length).toBe(0); diff --git a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx index 92d29cd8389..eb4bb5d4b41 100644 --- a/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx +++ b/packages/manager/src/features/IAM/Shared/Entities/Entities.tsx @@ -1,5 +1,4 @@ import { Autocomplete, Notice, Typography } from '@linode/ui'; -import { capitalizeAllWords } from '@linode/utilities'; import { useTheme } from '@mui/material'; import React from 'react'; @@ -9,6 +8,7 @@ import { useAccountEntities } from 'src/queries/entities/entities'; import { getCreateLinkForEntityType, + getFormattedEntityType, placeholderMap, transformedAccountEntities, } from '../utilities'; @@ -62,7 +62,9 @@ export const Entities = ({ - {type === 'account' ? 'All entities' : `All ${type}s`} + {type === 'account' + ? 'All entities' + : `All ${getFormattedEntityType(type)}s`} ); @@ -81,7 +83,11 @@ export const Entities = ({ onChange(newValue || []); }} options={memoizedEntities} - placeholder={getPlaceholder(type, value.length, memoizedEntities.length)} + placeholder={getPlaceholder( + type, + value.length, + memoizedEntities.length + )} readOnly={getReadonlyState(mode, memoizedEntities.length)} sx={{ marginTop: theme.tokens.spacing.S12 }} value={value || []} @@ -90,7 +96,7 @@ export const Entities = ({ - Create a {capitalizeAllWords(type)} Entity + Create a {getFormattedEntityType(type)} Entity {' '} first or choose a different role to continue assignment. diff --git a/packages/manager/src/features/IAM/Shared/constants.ts b/packages/manager/src/features/IAM/Shared/constants.ts index afe203c1250..88d696adcfa 100644 --- a/packages/manager/src/features/IAM/Shared/constants.ts +++ b/packages/manager/src/features/IAM/Shared/constants.ts @@ -11,3 +11,11 @@ export const NO_ASSIGNED_ENTITIES_TEXT = `The user doesn't have any entities ass // TODO: update the link when it's ready - UIE-8534 export const IAM_DOCS_LINK = 'https://www.linode.com/docs/platform/identity-access-management/'; + +export const PAID_ENTITY_TYPES = [ + 'database', + 'linode', + 'nodebalancer', + 'volume', + 'image', +]; diff --git a/packages/manager/src/features/IAM/Shared/utilities.test.ts b/packages/manager/src/features/IAM/Shared/utilities.test.ts index dc9a28bf9d6..bf93b3be372 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.test.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.test.ts @@ -6,13 +6,15 @@ import { deleteUserEntity, deleteUserRole, getAllRoles, + getFacadeRoleDescription, + getFormattedEntityType, getRoleByName, mapRolesToPermissions, toEntityAccess, updateUserRoles, } from './utilities'; -import type { CombinedRoles } from './utilities'; +import type { CombinedRoles, ExtendedRoleMap } from './utilities'; import type { EntityAccess, IamAccountPermissions, @@ -613,3 +615,95 @@ describe('deleteUserEntity', () => { ).toEqual(expectedRoles); }); }); + +describe('getFormattedEntityType', () => { + it('returns overridden capitalization for "vpc"', () => { + expect(getFormattedEntityType('vpc')).toBe('VPC'); + }); + + it('returns overridden capitalization for "stackscript"', () => { + expect(getFormattedEntityType('stackscript')).toBe('StackScript'); + }); + + it('returns overridden capitalization for "nodebalancer"', () => { + expect(getFormattedEntityType('nodebalancer')).toBe('NodeBalancer'); + }); + + it('returns default capitalization for other entity types', () => { + expect(getFormattedEntityType('linode')).toBe('Linode'); + expect(getFormattedEntityType('database')).toBe('Database'); + expect(getFormattedEntityType('volume')).toBe('Volume'); + }); +}); + +describe('getFacadeRoleDescription', () => { + it('returns description for account_access with non-paid entity types', () => { + const role: ExtendedRoleMap = { + access: 'account_access', + description: 'stackscript creator', + entity_ids: null, + entity_type: 'stackscript', + id: 'stackscript_creator', + name: 'stackscript_creator', + permissions: [], + }; + + const result = getFacadeRoleDescription(role); + expect(result).toBe( + `This role grants the same access as the legacy "Can add StackScripts to this account" global permissions.` + ); + }); + + it('returns description for account_access with paid entity types', () => { + const role: ExtendedRoleMap = { + access: 'account_access', + description: 'linode creator', + entity_ids: null, + entity_type: 'linode', + id: 'linode_creator', + name: 'linode_creator', + permissions: [], + }; + + const result = getFacadeRoleDescription(role); + expect(result).toBe( + `This role grants the same access as the legacy "Can add Linodes to this account ($)" global permissions.` + ); + }); + + it('returns description for entity_access with admin role', () => { + const role: ExtendedRoleMap = { + access: 'entity_access', + description: 'stackscript admin', + entity_ids: [1], + entity_names: ['test'], + entity_type: 'stackscript', + id: 'stackscript_admin', + name: 'stackscript_admin', + permissions: [], + }; + + const result = getFacadeRoleDescription(role); + expect(result).toBe( + `This role grants the same access as the legacy Read-Write special permission for the StackScripts attached to this role.` + ); + }); + + it('returns description for entity_access with viewer role', () => { + const role: ExtendedRoleMap = { + access: 'entity_access', + description: 'stackscript viewer', + entity_ids: [1], + entity_names: ['test'], + entity_type: 'stackscript', + id: 'stackscript_viewer', + name: 'stackscript_viewer', + permissions: [], + }; + + const result = getFacadeRoleDescription(role); + expect(result).toBe( + `This role grants the same access as the legacy Read-Only special permission for the StackScripts attached to this role.` + ); + }); +}); diff --git a/packages/manager/src/features/IAM/Shared/utilities.ts b/packages/manager/src/features/IAM/Shared/utilities.ts index 60d0f418a25..eef53165459 100644 --- a/packages/manager/src/features/IAM/Shared/utilities.ts +++ b/packages/manager/src/features/IAM/Shared/utilities.ts @@ -1,4 +1,6 @@ -import { capitalizeAllWords } from '@linode/utilities'; +import { capitalize, capitalizeAllWords } from '@linode/utilities'; + +import { PAID_ENTITY_TYPES } from './constants'; import type { AccountAccessRole, @@ -187,7 +189,7 @@ export const mapEntityTypes = ( const entityTypes = Array.from(new Set(data.map((el) => el.entity_type))); return entityTypes.map((entity) => ({ - label: capitalizeAllWords(entity, '_') + suffix, + label: capitalizeAllWords(getFormattedEntityType(entity), '_') + suffix, rawValue: entity, value: capitalizeAllWords(entity, '_') + suffix, })); @@ -201,7 +203,7 @@ export const mapEntityTypesForSelect = ( return entityTypes .map((entity) => ({ - label: capitalizeAllWords(entity, '_') + suffix, + label: capitalizeAllWords(getFormattedEntityType(entity), '_') + suffix, value: entity, })) .sort((a, b) => (a?.value ?? '').localeCompare(b?.value ?? '')); @@ -595,3 +597,34 @@ export const getCreateLinkForEntityType = ( // TODO - find the exceptions to this rule - most use the route of /{entityType}s/create (note the "s") return `/${entityType}s/create`; }; + +export const getFacadeRoleDescription = ( + role: ExtendedRole | ExtendedRoleMap +): string => { + if (role.access === 'account_access') { + const dollarSign = PAID_ENTITY_TYPES.includes(role.entity_type) + ? ' ($)' + : ''; + + return `This role grants the same access as the legacy "Can add ${getFormattedEntityType(role.entity_type)}s to this account${dollarSign}" global permissions.`; + } + + if (role.access === 'entity_access') { + const access = role.name.includes('admin') ? 'Read-Write' : 'Read-Only'; + + return `This role grants the same access as the legacy ${access} special permission for the ${getFormattedEntityType(role.entity_type)}s attached to this role.`; + } + + return role.description; +}; + +export const getFormattedEntityType = (entityType: string): string => { + const overrideCapitalization: Record = { + vpc: 'VPC', + stackscript: 'StackScript', + nodebalancer: 'NodeBalancer', + }; + + // Return the overridden capitalization if it exists, otherwise capitalize normally + return overrideCapitalization[entityType] || capitalize(entityType); +}; diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx index 4cdb83bfe3f..1fe906a429a 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx @@ -1,6 +1,5 @@ import { Autocomplete, Typography } from '@linode/ui'; -import { capitalize } from '@linode/utilities'; -import { Grid } from '@mui/material'; +import Grid from '@mui/material/Grid2'; import React from 'react'; import { useLocation, useParams } from 'react-router-dom'; @@ -22,6 +21,7 @@ import { useAccountUserPermissions } from 'src/queries/iam/iam'; import { RemoveAssignmentConfirmationDialog } from '../../Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog'; import { getFilteredRoles, + getFormattedEntityType, mapEntityTypes, transformedAccountEntities, } from '../../Shared/utilities'; @@ -154,7 +154,9 @@ export const AssignedEntitiesTable = () => { {el.entity_name} - {capitalize(el.entity_type)} + + {getFormattedEntityType(el.entity_type)} + {el.role_name}