Skip to content

Commit c771de5

Browse files
aaleksee-akamaimpolotsk-akamaicorya-akamai
authored
feat: [UIE-8845, UIE-8832, UIE-8834, UIE-8833] - IAM RBAC: add a permission check in Profile and Account (#12561)
* feat: [UIE-8845] - IAM RBAC: add a permission check in Profile and IAM * feat: [UIE-8834] - IAM RBAC: add a permission check in Accunt/billing * Added changeset: IAM RBAC: add a permission check in Profile and Account/Billing * unit tests fix * e2e tests fix --------- Co-authored-by: mpolotsk <[email protected]> Co-authored-by: Conal Ryan <[email protected]>
1 parent 5075e69 commit c771de5

File tree

16 files changed

+356
-72
lines changed

16 files changed

+356
-72
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
IAM RBAC: add a permission check in Profile and Account/Billing ([#12561](https://github.com/linode/manager/pull/12561))

packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.test.tsx

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
1111

1212
import { PaymentMethodRow } from './PaymentMethodRow';
1313

14+
const queryMocks = vi.hoisted(() => ({
15+
userPermissions: vi.fn(() => ({
16+
permissions: {
17+
make_billing_payment: false,
18+
update_account: false,
19+
},
20+
})),
21+
}));
22+
23+
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
24+
usePermissions: queryMocks.userPermissions,
25+
}));
26+
1427
vi.mock('@linode/api-v4/lib/account', async () => {
1528
const actual = await vi.importActual('@linode/api-v4/lib/account');
1629
return {
@@ -132,7 +145,12 @@ describe('Payment Method Row', () => {
132145

133146
it('Calls `onDelete` callback when "Delete" action is clicked', async () => {
134147
const mockFunction = vi.fn();
135-
148+
queryMocks.userPermissions.mockReturnValue({
149+
permissions: {
150+
make_billing_payment: false,
151+
update_account: true,
152+
},
153+
});
136154
const { getByLabelText, getByText } = renderWithTheme(
137155
<PayPalScriptProvider options={{ clientId: PAYPAL_CLIENT_ID }}>
138156
<PaymentMethodRow
@@ -153,6 +171,12 @@ describe('Payment Method Row', () => {
153171
});
154172

155173
it('Makes payment method default when "Make Default" action is clicked', async () => {
174+
queryMocks.userPermissions.mockReturnValue({
175+
permissions: {
176+
make_billing_payment: true,
177+
update_account: true,
178+
},
179+
});
156180
const paymentMethod = paymentMethodFactory.build({
157181
data: {
158182
card_type: 'Visa',
@@ -177,6 +201,58 @@ describe('Payment Method Row', () => {
177201
expect(makeDefaultPaymentMethod).toBeCalledTimes(1);
178202
});
179203

204+
it('should disable "Make a Payment" button if the user does not have make_billing_payment permissions', async () => {
205+
queryMocks.userPermissions.mockReturnValue({
206+
permissions: {
207+
make_billing_payment: false,
208+
update_account: false,
209+
},
210+
});
211+
const { getByLabelText, getByText } = renderWithTheme(
212+
<PayPalScriptProvider options={{ clientId: PAYPAL_CLIENT_ID }}>
213+
<PaymentMethodRow
214+
onDelete={vi.fn()}
215+
paymentMethod={paymentMethodFactory.build({ is_default: true })}
216+
/>
217+
</PayPalScriptProvider>
218+
);
219+
220+
const actionMenu = getByLabelText('Action menu for card ending in 1881');
221+
await userEvent.click(actionMenu);
222+
223+
const makePaymentButton = getByText('Make a Payment');
224+
expect(makePaymentButton).toBeVisible();
225+
expect(
226+
makePaymentButton.closest('li')?.getAttribute('aria-disabled')
227+
).toEqual('true');
228+
});
229+
230+
it('should enable "Make a Payment" button if the user has make_billing_payment permissions', async () => {
231+
queryMocks.userPermissions.mockReturnValue({
232+
permissions: {
233+
make_billing_payment: true,
234+
update_account: false,
235+
},
236+
});
237+
const { getByLabelText, getByText } = renderWithTheme(
238+
<PayPalScriptProvider options={{ clientId: PAYPAL_CLIENT_ID }}>
239+
<PaymentMethodRow
240+
onDelete={vi.fn()}
241+
paymentMethod={paymentMethodFactory.build({ is_default: true })}
242+
/>
243+
</PayPalScriptProvider>
244+
);
245+
246+
const actionMenu = getByLabelText('Action menu for card ending in 1881');
247+
await userEvent.click(actionMenu);
248+
249+
const makePaymentButton = getByText('Make a Payment');
250+
expect(makePaymentButton).toBeVisible();
251+
expect(
252+
makePaymentButton.closest('li')?.getAttribute('aria-disabled')
253+
).not.toEqual('true');
254+
});
255+
180256
it('Opens "Make a Payment" drawer with the payment method preselected if "Make a Payment" action is clicked', async () => {
181257
const paymentMethods = [
182258
paymentMethodFactory.build({

packages/manager/src/components/PaymentMethodRow/PaymentMethodRow.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as React from 'react';
77

88
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
99
import CreditCard from 'src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/CreditCard';
10+
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
1011

1112
import { ThirdPartyPayment } from './ThirdPartyPayment';
1213

@@ -18,10 +19,6 @@ interface Props {
1819
* Whether the user is a child user.
1920
*/
2021
isChildUser?: boolean | undefined;
21-
/**
22-
* Whether the user is a restricted user.
23-
*/
24-
isRestrictedUser?: boolean | undefined;
2522
/**
2623
* Function called when the delete button in the Action Menu is pressed.
2724
*/
@@ -38,14 +35,19 @@ interface Props {
3835
*/
3936
export const PaymentMethodRow = (props: Props) => {
4037
const theme = useTheme();
41-
const { isRestrictedUser, onDelete, paymentMethod } = props;
38+
const { onDelete, paymentMethod, isChildUser } = props;
4239
const { is_default, type } = paymentMethod;
4340
const { enqueueSnackbar } = useSnackbar();
4441
const navigate = useNavigate();
4542

4643
const { mutateAsync: makePaymentMethodDefault } =
4744
useMakeDefaultPaymentMethodMutation(props.paymentMethod.id);
4845

46+
const { permissions } = usePermissions('account', [
47+
'make_billing_payment',
48+
'update_account',
49+
]);
50+
4951
const makeDefault = () => {
5052
makePaymentMethodDefault().catch((errors) =>
5153
enqueueSnackbar(
@@ -57,7 +59,7 @@ export const PaymentMethodRow = (props: Props) => {
5759

5860
const actions: Action[] = [
5961
{
60-
disabled: isRestrictedUser,
62+
disabled: isChildUser || !permissions.make_billing_payment,
6163
onClick: () => {
6264
navigate({
6365
to: '/account/billing',
@@ -71,15 +73,17 @@ export const PaymentMethodRow = (props: Props) => {
7173
title: 'Make a Payment',
7274
},
7375
{
74-
disabled: isRestrictedUser || paymentMethod.is_default,
76+
disabled:
77+
isChildUser || !permissions.update_account || paymentMethod.is_default,
7578
onClick: makeDefault,
7679
title: 'Make Default',
7780
tooltip: paymentMethod.is_default
7881
? 'This is already your default payment method.'
7982
: undefined,
8083
},
8184
{
82-
disabled: isRestrictedUser || paymentMethod.is_default,
85+
disabled:
86+
isChildUser || !permissions.update_account || paymentMethod.is_default,
8387
onClick: onDelete,
8488
title: 'Delete',
8589
tooltip: paymentMethod.is_default
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as React from 'react';
2+
3+
import { renderWithTheme } from 'src/utilities/testHelpers';
4+
5+
import { AccountLanding } from './AccountLanding';
6+
7+
const queryMocks = vi.hoisted(() => ({
8+
userPermissions: vi.fn(() => ({
9+
permissions: { make_billing_payment: false },
10+
})),
11+
}));
12+
13+
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
14+
usePermissions: queryMocks.userPermissions,
15+
}));
16+
17+
describe('AccountLanding', () => {
18+
it('should disable "Make a Payment" button if the user does not have make_billing_payment permission', async () => {
19+
const { getByRole } = renderWithTheme(<AccountLanding />);
20+
21+
const addTagBtn = getByRole('button', {
22+
name: 'Make a Payment',
23+
});
24+
expect(addTagBtn).toHaveAttribute('aria-disabled', 'true');
25+
});
26+
27+
it('should enable "Make a Payment" button if the user has make_billing_payment permission', async () => {
28+
queryMocks.userPermissions.mockReturnValue({
29+
permissions: { make_billing_payment: true },
30+
});
31+
32+
const { getByRole } = renderWithTheme(<AccountLanding />);
33+
34+
const addTagBtn = getByRole('button', {
35+
name: 'Make a Payment',
36+
});
37+
expect(addTagBtn).not.toHaveAttribute('aria-disabled', 'true');
38+
});
39+
});

packages/manager/src/features/Account/AccountLanding.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useTabs } from 'src/hooks/useTabs';
2323
import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics';
2424

2525
import { PlatformMaintenanceBanner } from '../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner';
26+
import { usePermissions } from '../IAM/hooks/usePermissions';
2627
import { SwitchAccountButton } from './SwitchAccountButton';
2728
import { SwitchAccountDrawer } from './SwitchAccountDrawer';
2829

@@ -38,6 +39,8 @@ export const AccountLanding = () => {
3839
const { data: profile } = useProfile();
3940
const { limitsEvolution } = useFlags();
4041

42+
const { permissions } = usePermissions('account', ['make_billing_payment']);
43+
4144
const [isDrawerOpen, setIsDrawerOpen] = React.useState<boolean>(false);
4245
const sessionContext = React.useContext(switchAccountSessionContext);
4346

@@ -55,11 +58,7 @@ export const AccountLanding = () => {
5558

5659
const showQuotasTab = limitsEvolution?.enabled ?? false;
5760

58-
const isReadOnly =
59-
useRestrictedGlobalGrantCheck({
60-
globalGrantType: 'account_access',
61-
permittedGrantLevel: 'read_write',
62-
}) || isChildUser;
61+
const isReadOnly = !permissions.make_billing_payment || isChildUser;
6362

6463
const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({
6564
globalGrantType: 'child_account_access',

packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ import BillingSummary from './BillingSummary';
1212
const accountBalanceText = 'account-balance-text';
1313
const accountBalanceValue = 'account-balance-value';
1414

15+
const queryMocks = vi.hoisted(() => ({
16+
userPermissions: vi.fn(() => ({
17+
permissions: {
18+
create_promo_code: false,
19+
},
20+
})),
21+
}));
22+
23+
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
24+
usePermissions: queryMocks.userPermissions,
25+
}));
26+
1527
vi.mock('@linode/api-v4/lib/account', async () => {
1628
const actual = await vi.importActual('@linode/api-v4/lib/account');
1729
return {
@@ -165,4 +177,38 @@ describe('BillingSummary', () => {
165177
expect(getByTestId('drawer')).toBeVisible();
166178
expect(getByTestId('drawer-title').textContent).toEqual('Make a Payment');
167179
});
180+
181+
it('does not display the "Add a promo code" button if user does not have create_promo_code permission', async () => {
182+
const { queryByText } = renderWithTheme(
183+
<PayPalScriptProvider options={{ clientId: PAYPAL_CLIENT_ID }}>
184+
<BillingSummary balance={5} balanceUninvoiced={5} paymentMethods={[]} />
185+
</PayPalScriptProvider>,
186+
{
187+
initialRoute: '/account/billing',
188+
}
189+
);
190+
expect(queryByText('Add a promo code')).not.toBeInTheDocument();
191+
});
192+
193+
it('displays the "Add a promo code" button if user has create_promo_code permission', async () => {
194+
queryMocks.userPermissions.mockReturnValue({
195+
permissions: {
196+
create_promo_code: true,
197+
},
198+
});
199+
const { queryByText } = renderWithTheme(
200+
<PayPalScriptProvider options={{ clientId: PAYPAL_CLIENT_ID }}>
201+
<BillingSummary
202+
balance={-10}
203+
balanceUninvoiced={5}
204+
paymentMethods={[]}
205+
promotions={[]}
206+
/>
207+
</PayPalScriptProvider>,
208+
{
209+
initialRoute: '/account/billing',
210+
}
211+
);
212+
expect(queryByText('Add a promo code')).toBeInTheDocument();
213+
});
168214
});

packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
import {
2-
useAccount,
3-
useGrants,
4-
useNotificationsQuery,
5-
useProfile,
6-
} from '@linode/queries';
1+
import { useAccount, useGrants, useNotificationsQuery } from '@linode/queries';
72
import { Box, Button, Divider, TooltipIcon, Typography } from '@linode/ui';
83
import Grid from '@mui/material/Grid';
94
import { useTheme } from '@mui/material/styles';
105
import { useNavigate, useSearch } from '@tanstack/react-router';
116
import * as React from 'react';
127

138
import { Currency } from 'src/components/Currency';
9+
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
1410
import { isWithinDays } from 'src/utilities/date';
1511

1612
import { BillingPaper } from '../../BillingDetail';
@@ -33,9 +29,8 @@ export const BillingSummary = (props: BillingSummaryProps) => {
3329

3430
const { data: notifications } = useNotificationsQuery();
3531
const { data: account } = useAccount();
36-
const { data: profile } = useProfile();
3732

38-
const isRestrictedUser = profile?.restricted;
33+
const { permissions } = usePermissions('account', ['create_promo_code']);
3934

4035
const [isPromoDialogOpen, setIsPromoDialogOpen] =
4136
React.useState<boolean>(false);
@@ -154,7 +149,7 @@ export const BillingSummary = (props: BillingSummaryProps) => {
154149

155150
const showAddPromoLink =
156151
balance <= 0 &&
157-
!isRestrictedUser &&
152+
permissions.create_promo_code &&
158153
isWithinDays(90, account?.active_since) &&
159154
promotions?.length === 0;
160155

0 commit comments

Comments
 (0)