From d22d230ecd9a190ee5aa1da38930db10549c6064 Mon Sep 17 00:00:00 2001 From: Anastasiia Alekseenko Date: Tue, 17 Feb 2026 17:33:44 +0100 Subject: [PATCH 1/3] feat: [UIE-10184] - IAM: add permissions to UI --- packages/api-v4/src/iam/types.ts | 1 + .../Delegations/AccountDelegations.test.tsx | 32 +++++++++++++++++ .../IAM/Delegations/AccountDelegations.tsx | 19 ++++++++-- .../Defaults/DefaultEntityAccess.test.tsx | 34 ++++++++++++++++++ .../Roles/Defaults/DefaultEntityAccess.tsx | 21 +++++++++-- .../IAM/Roles/Defaults/DefaultRoles.test.tsx | 35 +++++++++++++++++++ .../IAM/Roles/Defaults/DefaultRoles.tsx | 28 +++++++++++++-- .../manager/src/features/IAM/Roles/Roles.tsx | 7 ++-- .../UserDelegations/UserDelegations.test.tsx | 35 +++++++++++++++++++ .../Users/UserDelegations/UserDelegations.tsx | 19 ++++++++-- .../features/IAM/Users/UsersTable/Users.tsx | 1 + .../adapters/accountGrantsToPermissions.ts | 1 + .../features/IAM/hooks/useDelegationRole.ts | 6 ++-- .../features/TopMenu/UserMenu/UserMenu.tsx | 3 +- packages/manager/src/routes/IAM/index.ts | 4 ++- 15 files changed, 229 insertions(+), 17 deletions(-) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 1da75c56215..fa36b3e8bf5 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -125,6 +125,7 @@ export type AccountAdmin = | 'view_account_login' | 'view_account_settings' | 'view_child_account' + | 'view_default_delegate_access' | 'view_enrolled_beta_program' | 'view_lock' | 'view_network_usage' diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx index 6827a0013b3..b98a0b6c970 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx @@ -13,8 +13,17 @@ const mocks = vi.hoisted(() => ({ mockUseGetChildAccountsQuery: vi.fn(), useParams: vi.fn().mockReturnValue({}), useSearch: vi.fn().mockReturnValue({}), + usePermissions: vi.fn().mockReturnValue({}), })); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: mocks.usePermissions, + }; +}); + vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { @@ -53,6 +62,10 @@ describe('AccountDelegations', () => { data: { data: mockDelegations, results: mockDelegations.length }, isLoading: false, }); + mocks.usePermissions.mockReturnValue({ + data: { list_all_child_accounts: true }, + isLoading: false, + }); }); it('should render the delegations table with data', async () => { @@ -93,4 +106,23 @@ describe('AccountDelegations', () => { expect(emptyElement).toBeInTheDocument(); }); }); + + it('should not render if user does not have permissions', () => { + mocks.usePermissions.mockReturnValue({ + data: { + list_all_child_accounts: false, + }, + isLoading: false, + }); + + renderWithTheme(, { + flags: { iamDelegation: { enabled: true }, iam: { enabled: true } }, + initialRoute: '/iam', + }); + expect( + screen.queryByText( + 'You do not have permission to view account delegations.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx index 2393e8649f3..ca54e98572f 100644 --- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx +++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx @@ -1,5 +1,5 @@ import { useGetChildAccountsQuery } from '@linode/queries'; -import { CircleProgress, Paper, Stack } from '@linode/ui'; +import { CircleProgress, Notice, Paper, Stack } from '@linode/ui'; import { useMediaQuery, useTheme } from '@mui/material'; import { useNavigate, useSearch } from '@tanstack/react-router'; import React from 'react'; @@ -10,6 +10,7 @@ import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { useIsIAMDelegationEnabled } from '../hooks/useIsIAMEnabled'; +import { usePermissions } from '../hooks/usePermissions'; import { AccountDelegationsTable } from './AccountDelegationsTable'; const DELEGATIONS_ROUTE = '/iam/delegations'; @@ -17,6 +18,10 @@ const DELEGATIONS_ROUTE = '/iam/delegations'; export const AccountDelegations = () => { const navigate = useNavigate(); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( + 'account', + ['list_all_child_accounts'] + ); const { company } = useSearch({ from: '/iam', @@ -78,12 +83,22 @@ export const AccountDelegations = () => { }); }; - if (isLoading) { + if (isLoading || isPermissionsLoading) { return ; } + + if (!permissions?.list_all_child_accounts) { + return ( + + You do not have permission to view account delegations. + + ); + } + if (!isIAMDelegationEnabled) { return null; } + return ( ({ marginTop: theme.tokens.spacing.S16 })}> ({ useIsDefaultDelegationRolesForChildAccount: vi .fn() .mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }), + usePermissions: vi.fn().mockReturnValue({}), })); vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ @@ -41,6 +42,14 @@ vi.mock('src/queries/entities/entities', async () => { }; }); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { @@ -51,6 +60,14 @@ vi.mock('@tanstack/react-router', async () => { }); describe('DefaultEntityAccess', () => { + beforeEach(() => { + vi.clearAllMocks(); + + queryMocks.usePermissions.mockReturnValue({ + data: { view_default_delegate_access: true }, + isLoading: false, + }); + }); it('should render', async () => { queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ data: { @@ -92,4 +109,21 @@ describe('DefaultEntityAccess', () => { renderWithTheme(); expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible(); }); + + it('should not render if user does not have permissions', () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + view_default_delegate_access: false, + }, + isLoading: false, + }); + + renderWithTheme(); + + expect( + screen.queryByText( + 'You do not have permission to view default entity access for delegate users.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx index 633c1b3d914..61552a9bf45 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx @@ -2,12 +2,14 @@ import { useGetDefaultDelegationAccessQuery } from '@linode/queries'; import { CircleProgress, ErrorState, + Notice, Paper, Stack, Typography, } from '@linode/ui'; import * as React from 'react'; +import { usePermissions } from '../../hooks/usePermissions'; import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable'; import { ERROR_STATE_TEXT, @@ -16,20 +18,35 @@ import { import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const DefaultEntityAccess = () => { + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( + 'account', + ['view_default_delegate_access'] + ); const { data: defaultAccess, isLoading: defaultAccessLoading, error, - } = useGetDefaultDelegationAccessQuery({ enabled: true }); + } = useGetDefaultDelegationAccessQuery({ + enabled: permissions?.view_default_delegate_access, + }); const hasAssignedEntities = defaultAccess ? defaultAccess.entity_access.length > 0 : false; - if (defaultAccessLoading) { + if (defaultAccessLoading || isPermissionsLoading) { return ; } + if (!permissions?.view_default_delegate_access) { + return ( + + You do not have permission to view default entity access for delegate + users. + + ); + } + if (error) { return ; } diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx index b27283a6393..9c560567186 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx @@ -17,6 +17,7 @@ const queryMocks = vi.hoisted(() => ({ useIsDefaultDelegationRolesForChildAccount: vi .fn() .mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }), + usePermissions: vi.fn().mockReturnValue({}), })); vi.mock('@tanstack/react-router', async () => { @@ -35,11 +36,28 @@ vi.mock('@linode/queries', async () => { queryMocks.useGetDefaultDelegationAccessQuery, }; }); + +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({ useIsDefaultDelegationRolesForChildAccount: queryMocks.useIsDefaultDelegationRolesForChildAccount, })); describe('DefaultRoles', () => { + beforeEach(() => { + vi.clearAllMocks(); + + queryMocks.usePermissions.mockReturnValue({ + data: { view_default_delegate_access: true }, + isLoading: false, + }); + }); it('should render', async () => { queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({ data: { @@ -84,4 +102,21 @@ describe('DefaultRoles', () => { renderWithTheme(); expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible(); }); + + it('should not render if user does not have permissions', () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + view_default_delegate_access: false, + }, + isLoading: false, + }); + + renderWithTheme(); + + expect( + screen.queryByText( + 'You do not have permission to view default roles for delegate users.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx index 7c979c02d71..11e60aba199 100644 --- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx +++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx @@ -1,7 +1,14 @@ import { useGetDefaultDelegationAccessQuery } from '@linode/queries'; -import { CircleProgress, ErrorState, Paper, Typography } from '@linode/ui'; +import { + CircleProgress, + ErrorState, + Notice, + Paper, + Typography, +} from '@linode/ui'; import * as React from 'react'; +import { usePermissions } from '../../hooks/usePermissions'; import { AssignedRolesTable } from '../../Shared/AssignedRolesTable/AssignedRolesTable'; import { ERROR_STATE_TEXT, @@ -10,20 +17,35 @@ import { import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const DefaultRoles = () => { + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( + 'account', + ['view_default_delegate_access'] + ); const { data: defaultRolesData, isLoading: defaultRolesLoading, error, - } = useGetDefaultDelegationAccessQuery({ enabled: true }); + } = useGetDefaultDelegationAccessQuery({ + enabled: permissions?.view_default_delegate_access, + }); + const hasAssignedRoles = defaultRolesData ? defaultRolesData.account_access.length > 0 || defaultRolesData.entity_access.length > 0 : false; - if (defaultRolesLoading) { + if (defaultRolesLoading || isPermissionsLoading) { return ; } + if (!permissions?.view_default_delegate_access) { + return ( + + You do not have permission to view default roles for delegate users. + + ); + } + if (error) { return ; } diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 45aa59d204e..d14073ebb3a 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -19,7 +19,8 @@ export const RolesLanding = () => { permissions?.list_role_permissions ); const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled(); - const { isChildUserType, isProfileLoading } = useDelegationRole(); + const { isChildUserType, isProfileLoading, isDelegateUserType } = + useDelegationRole(); const { roles } = React.useMemo(() => { if (!accountRoles) { @@ -41,7 +42,9 @@ export const RolesLanding = () => { return ( <> - {isChildUserType && isIAMDelegationEnabled && } + {(isChildUserType || isDelegateUserType) && isIAMDelegationEnabled && ( + + )} ({ marginTop: theme.tokens.spacing.S16 })}> Roles diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx index 8b729db6fcd..9a34910cc71 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx @@ -14,6 +14,7 @@ const queryMocks = vi.hoisted(() => ({ useNavigate: vi.fn().mockReturnValue(vi.fn()), useGetDelegatedChildAccountsForUserQuery: vi.fn().mockReturnValue({}), useAccountRoles: vi.fn().mockReturnValue({}), + usePermissions: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { @@ -26,6 +27,14 @@ vi.mock('@linode/queries', async () => { }; }); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + vi.mock('@tanstack/react-router', async () => { const actual = await vi.importActual('@tanstack/react-router'); return { @@ -48,6 +57,10 @@ describe('UserDelegations', () => { data: accountRolesFactory.build(), isLoading: false, }); + queryMocks.usePermissions.mockReturnValue({ + data: { list_user_delegate_accounts: true }, + isLoading: false, + }); }); it('should display no roles text if no roles are assigned to user', async () => { @@ -86,4 +99,26 @@ describe('UserDelegations', () => { expect(screen.getByText('Account Delegations')).toBeVisible(); }); + + it('should not render if user does not have permissions', () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + list_user_delegate_accounts: false, + }, + }); + + renderWithTheme(, { + flags: { + iam: { enabled: true }, + iamDelegation: { + enabled: true, + }, + }, + }); + expect( + screen.queryByText( + `You do not have permission to view this user's account delegations.` + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx index bca7ac4d982..cc0cf34ba74 100644 --- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx +++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx @@ -1,10 +1,11 @@ import { useGetDelegatedChildAccountsForUserQuery } from '@linode/queries'; -import { CircleProgress, ErrorState } from '@linode/ui'; +import { CircleProgress, ErrorState, Notice } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { usePermissions } from '../../hooks/usePermissions'; import { ERROR_STATE_TEXT, NO_ACCOUNT_DELEGATIONS_TEXT, @@ -15,22 +16,36 @@ import { UserDelegationsTable } from './UserDelegationsTable'; export const UserDelegations = () => { const { username } = useParams({ from: '/iam/users/$username' }); + const { data: permissions, isLoading: isPermissionsLoading } = usePermissions( + 'account', + ['list_user_delegate_accounts'] + ); + const { data: allDelegatedChildAccounts, isLoading, error, } = useGetDelegatedChildAccountsForUserQuery({ username, + enabled: permissions?.list_user_delegate_accounts, }); const hasDelegatedChildAccounts = allDelegatedChildAccounts ? allDelegatedChildAccounts.data.length > 0 : false; - if (isLoading) { + if (isLoading || isPermissionsLoading) { return ; } + if (!permissions?.list_user_delegate_accounts) { + return ( + + You do not have permission to view this user's account delegations. + + ); + } + if (error) { return ; } diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index 0b928d251a1..6e4f452a119 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -202,6 +202,7 @@ export const UsersLanding = () => { /> {isChildOrDelegateWithDelegationEnabled && (