diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 1b262dc3ca5..677cb9cafb5 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -358,3 +358,5 @@ export interface Roles { } export type IamAccessType = keyof IamAccountRoles; + +export type PickPermissions = T; diff --git a/packages/manager/.changeset/pr-12714-changed-1755514005125.md b/packages/manager/.changeset/pr-12714-changed-1755514005125.md new file mode 100644 index 00000000000..cd52ed18b37 --- /dev/null +++ b/packages/manager/.changeset/pr-12714-changed-1755514005125.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Update RBAC IAM Users Permissions ([#12714](https://github.com/linode/manager/pull/12714)) diff --git a/packages/manager/src/features/IAM/Roles/Roles.test.tsx b/packages/manager/src/features/IAM/Roles/Roles.test.tsx index f5a08f19996..0fbfccaf172 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.test.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.test.tsx @@ -8,10 +8,11 @@ import { RolesLanding } from './Roles'; const queryMocks = vi.hoisted(() => ({ useAccountRoles: vi.fn().mockReturnValue({}), + usePermissions: vi.fn().mockReturnValue({}), })); vi.mock('@linode/queries', async () => { - const actual = await vi.importActual('@linode/queries'); + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAccountRoles: queryMocks.useAccountRoles, @@ -19,15 +20,21 @@ vi.mock('@linode/queries', async () => { }); vi.mock('src/features/IAM/Shared/utilities', async () => { - const actual = await vi.importActual( - 'src/features/IAM/Shared/utilities' - ); + const actual = await vi.importActual('src/features/IAM/Shared/utilities'); return { ...actual, mapAccountPermissionsToRoles: vi.fn(), }; }); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual('src/features/IAM/hooks/usePermissions'); + return { + ...actual, + usePermissions: queryMocks.usePermissions, + }; +}); + beforeEach(() => { vi.clearAllMocks(); }); @@ -46,6 +53,11 @@ describe('RolesLanding', () => { it('renders roles table when permissions are loaded', async () => { const mockPermissions = accountRolesFactory.build(); + queryMocks.usePermissions.mockReturnValue({ + data: { + is_account_admin: true, + }, + }); queryMocks.useAccountRoles.mockReturnValue({ data: mockPermissions, isLoading: false, @@ -55,4 +67,17 @@ describe('RolesLanding', () => { // RolesTable has a textbox at the top expect(screen.getByRole('textbox')).toBeInTheDocument(); }); + + it('should show an error message if user does not have permissions', () => { + queryMocks.usePermissions.mockReturnValue({ + data: { + is_account_admin: false, + }, + }); + + renderWithTheme(); + expect( + screen.getByText('You do not have permission to view roles.') + ).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 9b3c0ef197f..9324c61113d 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -1,12 +1,17 @@ import { useAccountRoles } from '@linode/queries'; -import { CircleProgress, Paper, Typography } from '@linode/ui'; +import { CircleProgress, Notice, Paper, Typography } from '@linode/ui'; import React from 'react'; import { RolesTable } from 'src/features/IAM/Roles/RolesTable/RolesTable'; import { mapAccountPermissionsToRoles } from 'src/features/IAM/Shared/utilities'; +import { usePermissions } from '../hooks/usePermissions'; + export const RolesLanding = () => { - const { data: accountRoles, isLoading } = useAccountRoles(); + const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { data: accountRoles, isLoading } = useAccountRoles( + permissions?.is_account_admin + ); const { roles } = React.useMemo(() => { if (!accountRoles) { @@ -20,6 +25,12 @@ export const RolesLanding = () => { return ; } + if (!permissions?.is_account_admin) { + return ( + You do not have permission to view roles. + ); + } + return ( ({ marginTop: theme.tokens.spacing.S16 })}> Roles diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx index e2ae276e7a7..5c469650dfb 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.test.tsx @@ -7,6 +7,10 @@ import { RolesTable } from './RolesTable'; import type { RoleView } from '../../Shared/types'; +const queryMocks = { + usePermissions: vi.fn(), +}; + vi.mock('src/features/IAM/Shared/utilities', async () => { const actual = await vi.importActual( 'src/features/IAM/Shared/utilities' @@ -17,6 +21,16 @@ vi.mock('src/features/IAM/Shared/utilities', async () => { }; }); +vi.mock('src/features/IAM/hooks/usePermissions', async () => { + const actual = await vi.importActual( + 'src/features/IAM/hooks/usePermissions' + ); + return { + ...actual, + usePermissions: vi.fn().mockReturnValue({}), + }; +}); + const mockRoles: RoleView[] = [ { access: 'account_access', @@ -40,6 +54,11 @@ const mockRoles: RoleView[] = [ beforeEach(() => { vi.clearAllMocks(); + queryMocks.usePermissions.mockReturnValue({ + data: { + is_account_admin: true, + }, + }); }); describe('RolesTable', () => { diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index 59903a32489..f06fdaaa32d 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -26,6 +26,7 @@ import { } from 'src/features/IAM/Shared/utilities'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { usePermissions } from '../../hooks/usePermissions'; import { ROLES_LEARN_MORE_LINK, ROLES_TABLE_PREFERENCE_KEY, @@ -43,6 +44,7 @@ interface Props { roles?: RoleView[]; } const DEFAULT_PAGE_SIZE = 10; + export const RolesTable = ({ roles = [] }: Props) => { // Filter string for the search bar const [filterString, setFilterString] = React.useState(''); @@ -54,6 +56,9 @@ export const RolesTable = ({ roles = [] }: Props) => { const [selectedRows, setSelectedRows] = useState([]); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { data: permissions } = usePermissions('account', ['is_account_admin']); + const isAccountAdmin = permissions?.is_account_admin; + const pagination = usePaginationV2({ currentRoute: '/iam/roles', initialPage: 1, @@ -194,12 +199,14 @@ export const RolesTable = ({ roles = [] }: Props) => { diff --git a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx index f8a85c53368..f02ca9cfa18 100644 --- a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx +++ b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx @@ -3,6 +3,7 @@ import React from 'react'; import EmptyState from 'src/assets/icons/empty-state-cloud.svg'; +import { usePermissions } from '../../hooks/usePermissions'; import { AssignNewRoleDrawer } from '../../Users/UserRoles/AssignNewRoleDrawer'; interface Props { @@ -13,6 +14,7 @@ interface Props { export const NoAssignedRoles = (props: Props) => { const { text, hasAssignNewRoleDrawer } = props; const theme = useTheme(); + const { data: permissions } = usePermissions('account', ['is_account_admin']); const [isAssignNewRoleDrawerOpen, setIsAssignNewRoleDrawerOpen] = React.useState(false); @@ -42,7 +44,13 @@ export const NoAssignedRoles = (props: Props) => { {hasAssignNewRoleDrawer && ( diff --git a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.test.tsx index 5126852229f..cc1874b1778 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.test.tsx @@ -26,7 +26,9 @@ describe('DeleteUserPanel', () => { username: 'current_user', }); - const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme( + + ); const deleteButton = getByTestId('button'); expect(deleteButton).toBeDisabled(); @@ -42,7 +44,9 @@ describe('DeleteUserPanel', () => { username: 'current_user', }); - const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme( + + ); const deleteButton = getByTestId('button'); expect(deleteButton).toBeDisabled(); @@ -58,7 +62,9 @@ describe('DeleteUserPanel', () => { username: 'user', }); - const { getByTestId } = renderWithTheme(); + const { getByTestId } = renderWithTheme( + + ); const deleteButton = getByTestId('button'); expect(deleteButton).toBeEnabled(); @@ -75,7 +81,7 @@ describe('DeleteUserPanel', () => { }); const { getByTestId, getByText } = renderWithTheme( - + ); const deleteButton = getByTestId('button'); @@ -85,4 +91,17 @@ describe('DeleteUserPanel', () => { getByText('The user will be deleted permanently.') ).toBeInTheDocument(); }); + + it('disables the delete button when the user does not have delete_user permission', async () => { + const user = accountUserFactory.build({ + username: 'my-linode-username', + }); + + const { getByTestId } = renderWithTheme( + + ); + + const deleteButton = getByTestId('button'); + expect(deleteButton).toBeDisabled(); + }); }); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx index 3f0adbee15d..cb5513ee45f 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/DeleteUserPanel.tsx @@ -10,10 +10,11 @@ import { UserDeleteConfirmation } from './UserDeleteConfirmation'; import type { User } from '@linode/api-v4'; interface Props { + canDeleteUser: boolean; user: User; } -export const DeleteUserPanel = ({ user }: Props) => { +export const DeleteUserPanel = ({ canDeleteUser, user }: Props) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const navigate = useNavigate(); @@ -35,9 +36,17 @@ export const DeleteUserPanel = ({ user }: Props) => { diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx index 18250bc1c34..6b7ed5453f9 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx @@ -25,7 +25,9 @@ describe('UserEmailPanel', () => { it("initializes the form with the user's email", async () => { const user = accountUserFactory.build(); - const { getByLabelText } = renderWithTheme(); + const { getByLabelText } = renderWithTheme( + + ); const emailTextField = getByLabelText('Email'); @@ -43,7 +45,7 @@ describe('UserEmailPanel', () => { ); const { findByLabelText, getByLabelText, getByText } = renderWithTheme( - + ); const warning = await findByLabelText( @@ -68,7 +70,7 @@ describe('UserEmailPanel', () => { }); const { getByLabelText, getByText } = renderWithTheme( - + ); const warning = getByLabelText('This field can’t be modified.'); @@ -92,7 +94,7 @@ describe('UserEmailPanel', () => { username: 'user-1', }); - renderWithTheme(); + renderWithTheme(); const emailInput = screen.getByLabelText('Email'); @@ -105,4 +107,19 @@ describe('UserEmailPanel', () => { const errorText = screen.getByText(/invalid email address/i); expect(errorText).toBeInTheDocument(); }); + + it('disables the save button when the user does not have update_user permission', async () => { + const user = accountUserFactory.build({ + email: 'my-linode-email', + }); + + const { getByRole, findByDisplayValue } = renderWithTheme( + + ); + + await findByDisplayValue(user.email); + + const saveButton = getByRole('button', { name: 'Save' }); + expect(saveButton).toBeDisabled(); + }); }); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx index 860bb8a5e7c..fe6c003ee84 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx @@ -9,10 +9,11 @@ import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; import type { User } from '@linode/api-v4'; interface Props { + canUpdateUser: boolean; user: User; } -export const UserEmailPanel = ({ user }: Props) => { +export const UserEmailPanel = ({ canUpdateUser, user }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { data: profile } = useProfile(); @@ -73,9 +74,14 @@ export const UserEmailPanel = ({ user }: Props) => { />