diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx new file mode 100644 index 00000000000..43998897725 --- /dev/null +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.test.tsx @@ -0,0 +1,60 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DefaultEntityAccess } from './DefaultEntityAccess'; + +const queryMocks = vi.hoisted(() => ({ + useAllAccountEntities: vi.fn().mockReturnValue({}), + useParams: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), + useGetDefaultDelegationAccessQuery: vi.fn().mockReturnValue({}), + useIsDefaultDelegationRolesForChildAccount: vi + .fn() + .mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }), +})); + +vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ + useIsDefaultDelegationRolesForChildAccount: + queryMocks.useIsDefaultDelegationRolesForChildAccount, +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useGetDefaultDelegationAccessQuery: + queryMocks.useGetDefaultDelegationAccessQuery, + }; +}); + +vi.mock('src/queries/entities/entities', async () => { + const actual = await vi.importActual('src/queries/entities/entities'); + return { + ...actual, + useAllAccountEntities: queryMocks.useAllAccountEntities, + }; +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + useSearch: queryMocks.useSearch, + }; +}); + +describe('DefaultEntityAccess', () => { + it('should render', async () => { + renderWithTheme(); + + expect( + screen.getByText('Default Entity Access for Delegate Users') + ).toBeVisible(); + expect(screen.getByPlaceholderText('Search')).toBeVisible(); + expect(screen.getByPlaceholderText('All Entities')).toBeVisible(); + expect(screen.getByRole('table')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx index 39366c19e9b..6f5ce3ba3ca 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx @@ -1,10 +1,12 @@ import { Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; +import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; + export const DefaultEntityAccess = () => { return ( - + Default Entity Access for Delegate Users @@ -15,6 +17,7 @@ export const DefaultEntityAccess = () => { the assignment. + ); }; diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx similarity index 97% rename from packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx rename to packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx index d0c06967d7d..46ee9ab8a92 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.test.tsx @@ -6,7 +6,7 @@ import { accountEntityFactory } from 'src/factories/accountEntities'; import { userRolesFactory } from 'src/factories/userRoles'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { AssignedEntitiesTable } from '../../Users/UserEntities/AssignedEntitiesTable'; +import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; const queryMocks = vi.hoisted(() => ({ useAllAccountEntities: vi.fn().mockReturnValue({}), diff --git a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx similarity index 84% rename from packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx rename to packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx index fc922a54522..3f44e4b2ab1 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -1,7 +1,10 @@ -import { useUserRoles } from '@linode/queries'; +import { + useGetDefaultDelegationAccessQuery, + useUserRoles, +} from '@linode/queries'; import { Select, Typography, useTheme } from '@linode/ui'; import Grid from '@mui/material/Grid'; -import { useParams, useSearch } from '@tanstack/react-router'; +import { useSearch } from '@tanstack/react-router'; import React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; @@ -20,19 +23,23 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useAllAccountEntities } from 'src/queries/entities/entities'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { usePermissions } from '../../hooks/usePermissions'; -import { ENTITIES_TABLE_PREFERENCE_KEY } from '../../Shared/constants'; -import { RemoveAssignmentConfirmationDialog } from '../../Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog'; +import { + addEntityNamesToRoles, + getSearchableFields, +} from '../../Users/UserEntities/utils'; +import { ENTITIES_TABLE_PREFERENCE_KEY } from '../constants'; +import { RemoveAssignmentConfirmationDialog } from '../RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog'; import { getFilteredRoles, getFormattedEntityType, groupAccountEntitiesByType, mapEntityTypesForSelect, -} from '../../Shared/utilities'; +} from '../utilities'; import { ChangeRoleForEntityDrawer } from './ChangeRoleForEntityDrawer'; -import { addEntityNamesToRoles, getSearchableFields } from './utils'; -import type { DrawerModes, EntitiesRole } from '../../Shared/types'; +import type { DrawerModes, EntitiesRole } from '../types'; import type { EntityType } from '@linode/api-v4'; import type { SelectOption } from '@linode/ui'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -44,13 +51,17 @@ const ALL_ENTITIES_OPTION: SelectOption = { type OrderByKeys = 'entity_name' | 'entity_type' | 'role_name'; -export const AssignedEntitiesTable = () => { - const { username } = useParams({ - from: '/iam/users/$username', - }); +interface Props { + username?: string; +} + +export const AssignedEntitiesTable = ({ username }: Props) => { const theme = useTheme(); const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + const { selectedRole: selectedRoleSearchParam } = useSearch({ strict: false, }); @@ -59,7 +70,9 @@ export const AssignedEntitiesTable = () => { const [orderBy, setOrderBy] = React.useState('entity_name'); const pagination = usePaginationV2({ - currentRoute: '/iam/users/$username/entities', + currentRoute: isDefaultDelegationRolesForChildAccount + ? '/iam/roles/defaults/entity-access' + : `/iam/users/$username/entities`, initialPage: 1, preferenceKey: ENTITIES_TABLE_PREFERENCE_KEY, }); @@ -93,10 +106,30 @@ export const AssignedEntitiesTable = () => { } = useAllAccountEntities({}); const { - data: assignedRoles, - error: assignedRolesError, - isLoading: assignedRolesLoading, - } = useUserRoles(username ?? ''); + data: assignedUserRoles, + error: assignedUserRolesError, + isLoading: assignedUserRolesLoading, + } = useUserRoles(username ?? '', !isDefaultDelegationRolesForChildAccount); + + const { + data: delegateDefaultRoles, + error: delegateDefaultRolesError, + isLoading: delegateDefaultRolesLoading, + } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; + + const error = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRolesError + : assignedUserRolesError; + + const loading = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRolesLoading + : assignedUserRolesLoading; const { filterableOptions, roles } = React.useMemo(() => { if (!assignedRoles || !entities) { @@ -158,11 +191,11 @@ export const AssignedEntitiesTable = () => { }); const renderTableBody = () => { - if (entitiesLoading || assignedRolesLoading) { + if (entitiesLoading || loading) { return ; } - if (entitiesError || assignedRolesError) { + if (entitiesError || error) { return ( { onClose={() => setIsChangeRoleForEntityDrawerOpen(false)} open={isChangeRoleForEntityDrawerOpen} role={selectedRole} + username={username} /> handleRemoveAssignmentDialogClose()} open={isRemoveAssignmentDialogOpen} role={selectedRole} + username={username} /> {filteredRoles.length > PAGE_SIZES[0] && ( void; open: boolean; role: EntitiesRole | undefined; + username?: string; } export const ChangeRoleForEntityDrawer = ({ @@ -43,18 +48,33 @@ export const ChangeRoleForEntityDrawer = ({ onClose, open, role, + username, }: Props) => { const theme = useTheme(); - const { username } = useParams({ - from: '/iam/users/$username', - }); + + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); const { data: accountRoles, isLoading: accountPermissionsLoading } = useAccountRoles(); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { data: assignedUserRoles } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); + + const { data: delegateDefaultRoles } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; + + const { mutateAsync: updateUserRoles } = useUserRolesMutation(username ?? ''); - const { mutateAsync: updateUserRoles } = useUserRolesMutation(username); + const { mutateAsync: updateDefaultDelegationRoles } = + useUpdateDefaultDelegationAccessQuery(); // filtered roles by entity_type and access const allRoles = React.useMemo(() => { @@ -62,10 +82,30 @@ export const ChangeRoleForEntityDrawer = ({ return []; } - return getAllRoles(accountRoles).filter( - (el) => el.entity_type === role?.entity_type && el.access === role?.access - ); - }, [accountRoles, role]); + return getAllRoles(accountRoles).filter((el) => { + const matchesRoleContext = + el.entity_type === role?.entity_type && + el.access === role?.access && + el.value !== role?.role_name; + + // Exclude account roles already assigned to the user + if (isAccountRole(el)) { + return ( + !assignedRoles?.account_access.includes(el.value) && + matchesRoleContext + ); + } + // Exclude entity roles already assigned to the user + if (isEntityRole(el)) { + return ( + !assignedRoles?.entity_access.some((entity) => + entity.roles.includes(el.value) + ) && matchesRoleContext + ); + } + return true; + }); + }, [accountRoles, role, assignedRoles]); const { control, @@ -93,6 +133,10 @@ export const ChangeRoleForEntityDrawer = ({ return getRoleByName(accountRoles, selectedOptions.value); }, [selectedOptions, accountRoles]); + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultDelegationRoles + : updateUserRoles; + const onSubmit = async (data: { roleName: ExtendedEntityRole }) => { if (role?.role_name === data.roleName.label) { handleClose(); @@ -112,7 +156,7 @@ export const ChangeRoleForEntityDrawer = ({ newRole ); - await updateUserRoles({ + await mutationFn({ ...assignedRoles!, entity_access: updatedEntityRoles, }); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index cb20cbaac04..519e17e0f06 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -424,6 +424,7 @@ export const AssignedRolesTable = () => { onClose={() => setIsRemoveAssignmentDialogOpen(false)} open={isRemoveAssignmentDialogOpen} role={selectedRoleDetails} + username={username} /> {filteredAndSortedRolesCount > PAGE_SIZES[0] && ( ({ - useParams: vi.fn().mockReturnValue({}), useAccountRoles: vi.fn().mockReturnValue({}), useUserRoles: vi.fn().mockReturnValue({}), + useIsDefaultDelegationRolesForChildAccount: vi + .fn() + .mockReturnValue({ isDefaultDelegationRolesForChildAccount: false }), +})); + +vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ + useIsDefaultDelegationRolesForChildAccount: + queryMocks.useIsDefaultDelegationRolesForChildAccount, })); vi.mock('@linode/queries', async () => { @@ -40,14 +47,6 @@ vi.mock('@linode/queries', async () => { }; }); -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useParams: queryMocks.useParams, - }; -}); - const mockDeleteUserRole = vi.fn(); vi.mock('@linode/api-v4', async () => { return { @@ -60,14 +59,10 @@ vi.mock('@linode/api-v4', async () => { }); describe('RemoveAssignmentConfirmationDialog', () => { - beforeEach(() => { - queryMocks.useParams.mockReturnValue({ - username: 'test_user', - }); - }); - it('should render', async () => { - renderWithTheme(); + renderWithTheme( + + ); const headerText = screen.getByText( 'Remove the Test entity from the firewall_admin role assignment?' @@ -131,4 +126,22 @@ describe('RemoveAssignmentConfirmationDialog', () => { }); }); }); + + it('should render when isDefaultDelegationRolesForChildAccount is true', async () => { + queryMocks.useIsDefaultDelegationRolesForChildAccount.mockReturnValue({ + isDefaultDelegationRolesForChildAccount: true, + }); + renderWithTheme(); + + const headerText = screen.getByText( + 'Remove the Test entity from the list?' + ); + expect(headerText).toBeVisible(); + + const paragraph = screen.getByText(/Delegated users won’t get the/i); + + expect(paragraph).toBeVisible(); + expect(paragraph).toHaveTextContent(mockRole.entity_name); + expect(paragraph).toHaveTextContent(mockRole.role_name); + }); }); diff --git a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx index 4531f0be1cf..6eff0ef3bbc 100644 --- a/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx +++ b/packages/manager/src/features/IAM/Shared/RemoveAssignmentConfirmationDialog/RemoveAssignmentConfirmationDialog.tsx @@ -1,11 +1,16 @@ -import { useUserRoles, useUserRolesMutation } from '@linode/queries'; +import { + useGetDefaultDelegationAccessQuery, + useUpdateDefaultDelegationAccessQuery, + useUserRoles, + useUserRolesMutation, +} from '@linode/queries'; import { ActionsPanel, Notice, Typography } from '@linode/ui'; -import { useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; import { deleteUserEntity, getErrorMessage } from '../utilities'; import type { EntitiesRole } from '../types'; @@ -15,11 +20,14 @@ interface Props { onSuccess?: () => void; open: boolean; role: EntitiesRole | undefined; + username?: string; } export const RemoveAssignmentConfirmationDialog = (props: Props) => { - const { onClose: _onClose, onSuccess, open, role } = props; - const { username } = useParams({ from: '/iam/users/$username' }); + const { onClose: _onClose, onSuccess, open, role, username } = props; + + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); const { enqueueSnackbar } = useSnackbar(); @@ -28,15 +36,33 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { isPending, mutateAsync: updateUserRoles, reset, - } = useUserRolesMutation(username); + } = useUserRolesMutation(username ?? ''); + + const { mutateAsync: updateDefaultDelegationRoles } = + useUpdateDefaultDelegationAccessQuery(); + + const { data: assignedUserRoles } = useUserRoles( + username ?? '', + !isDefaultDelegationRolesForChildAccount + ); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { data: delegateDefaultRoles } = useGetDefaultDelegationAccessQuery({ + enabled: isDefaultDelegationRolesForChildAccount, + }); const onClose = () => { reset(); // resets the error state of the useMutation _onClose(); }; + const mutationFn = isDefaultDelegationRolesForChildAccount + ? updateDefaultDelegationRoles + : updateUserRoles; + + const assignedRoles = isDefaultDelegationRolesForChildAccount + ? delegateDefaultRoles + : assignedUserRoles; + const onDelete = async () => { if (!role || !assignedRoles) return; @@ -49,7 +75,7 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { entity_type ); - await updateUserRoles({ + await mutationFn({ ...assignedRoles, entity_access: updatedUserEntityRoles, }); @@ -81,14 +107,26 @@ export const RemoveAssignmentConfirmationDialog = (props: Props) => { error={getErrorMessage(error)} onClose={onClose} open={open} - title={`Remove the ${role?.entity_name} entity from the ${role?.role_name} role assignment?`} + title={ + isDefaultDelegationRolesForChildAccount + ? `Remove the ${role?.entity_name} entity from the list?` + : `Remove the ${role?.entity_name} entity from the ${role?.role_name} role assignment?` + } > - - You’re about to remove the {role?.entity_name} entity - from the {role?.role_name} role for{' '} - {username}. This change will be applied immediately. - + {isDefaultDelegationRolesForChildAccount ? ( + + Delegated users won’t get the {role?.role_name} access on the{' '} + {role?.entity_name} entity by default. + + ) : ( + + You’re about to remove the {role?.entity_name}{' '} + entity from the {role?.role_name} role for{' '} + {username}. This change will be applied + immediately. + + )} ); diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx index b203ace51b3..0b469b44a89 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx @@ -13,12 +13,12 @@ import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { usePermissions } from '../../hooks/usePermissions'; +import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; import { ERROR_STATE_TEXT, NO_ASSIGNED_ENTITIES_TEXT, } from '../../Shared/constants'; import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; -import { AssignedEntitiesTable } from './AssignedEntitiesTable'; export const UserEntities = () => { const theme = useTheme(); @@ -71,7 +71,7 @@ export const UserEntities = () => { View and manage entities attached to user's entity access roles. - + ) : ( { profileUserName: profile?.username, }; }; + +/** + * isDefaultDelegationRolesForChildAccount is true if: + * - IAM Delegation is enabled for the account + * - The current user is a child account + * - The current route includes '/iam/roles/defaults' + * + * This flag is used to determine if the component should show or fetch/update delegated default roles + * instead of regular user roles, and to adjust UI/logic for the delegate context. + */ +export const useIsDefaultDelegationRolesForChildAccount = () => { + const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { isChildAccount } = useDelegationRole(); + const location = useLocation(); + + return { + isDefaultDelegationRolesForChildAccount: + (isIAMDelegationEnabled && + isChildAccount && + location.pathname.includes('/iam/roles/defaults')) ?? + false, + }; +}; diff --git a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts index 5782db4237f..b7415ba38cb 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/delegation.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/delegation.ts @@ -293,12 +293,12 @@ export const defaultDelegationAccess = () => [ ], entity_access: [ { - id: 12345678, + id: 1, type: 'linode' as const, roles: ['linode_contributor'], }, { - id: 45678901, + id: 1, type: 'firewall' as const, roles: ['firewall_admin'], }, diff --git a/packages/queries/src/iam/delegation.ts b/packages/queries/src/iam/delegation.ts index b3905e70221..5fabd3900cf 100644 --- a/packages/queries/src/iam/delegation.ts +++ b/packages/queries/src/iam/delegation.ts @@ -331,11 +331,11 @@ export const useGenerateChildAccountTokenQuery = (): UseMutationResult< * - Audience: Child account administrators reviewing default delegate access. * - Data: IamUserRoles with `account_access` and `entity_access` for `GET /iam/delegation/default-role-permissions`. */ -export const useGetDefaultDelegationAccessQuery = (): UseQueryResult< - IamUserRoles, - APIError[] -> => { +export const useGetDefaultDelegationAccessQuery = ({ + enabled = true, +}): UseQueryResult => { return useQuery({ + enabled, ...delegationQueries.defaultAccess, }); };