Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13075-added-1762776269124.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

IAM Parent/Child: permissions switch account ([#13075](https://github.com/linode/manager/pull/13075))
8 changes: 6 additions & 2 deletions packages/manager/src/features/Account/AccountLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useTabs } from 'src/hooks/useTabs';
import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics';

import { PlatformMaintenanceBanner } from '../../components/PlatformMaintenanceBanner/PlatformMaintenanceBanner';
import { useIsIAMDelegationEnabled } from '../IAM/hooks/useIsIAMEnabled';
import { usePermissions } from '../IAM/hooks/usePermissions';
import { SwitchAccountButton } from './SwitchAccountButton';
import { SwitchAccountDrawer } from './SwitchAccountDrawer';
Expand Down Expand Up @@ -60,6 +61,8 @@ export const AccountLanding = () => {
globalGrantType: 'child_account_access',
});

const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();

const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser });

const { tabs, handleTabChange, tabIndex, getTabIndex } = useTabs([
Expand Down Expand Up @@ -124,8 +127,9 @@ export const AccountLanding = () => {
};

const isBillingTabSelected = getTabIndex('/account/billing') === tabIndex;
const canSwitchBetweenParentOrProxyAccount =
(!isChildAccountAccessRestricted && isParentUser) || isProxyUser;
const canSwitchBetweenParentOrProxyAccount = isIAMDelegationEnabled
? isParentUser
: (!isChildAccountAccessRestricted && isParentUser) || isProxyUser;

const landingHeaderProps: LandingHeaderProps = {
breadcrumbProps: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton';
import { renderWithTheme } from 'src/utilities/testHelpers';

const queryMocks = vi.hoisted(() => ({
userPermissions: vi.fn(() => ({
data: {
create_child_account_token: true,
},
})),
useFlags: vi.fn().mockReturnValue({}),
}));
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
usePermissions: queryMocks.userPermissions,
}));

vi.mock('src/hooks/useFlags', () => {
const actual = vi.importActual('src/hooks/useFlags');
return {
...actual,
useFlags: queryMocks.useFlags,
};
});

describe('SwitchAccountButton', () => {
test('renders Switch Account button with SwapIcon', () => {
renderWithTheme(<SwitchAccountButton />);
Expand All @@ -22,4 +42,59 @@ describe('SwitchAccountButton', () => {

expect(onClickMock).toHaveBeenCalledTimes(1);
});

test('enables the button when user has create_child_account_token permission', () => {
queryMocks.useFlags.mockReturnValue({
iamDelegation: { enabled: true },
});

renderWithTheme(<SwitchAccountButton />);

const button = screen.getByRole('button', { name: /switch account/i });
expect(button).toBeEnabled();
});

test('disables the button when user does not have create_child_account_token permission', async () => {
queryMocks.userPermissions.mockReturnValue({
data: {
create_child_account_token: false,
},
});

queryMocks.useFlags.mockReturnValue({
iamDelegation: { enabled: true },
});

renderWithTheme(<SwitchAccountButton />);

const button = screen.getByRole('button', { name: /switch account/i });
expect(button).toBeDisabled();

// Check that the tooltip is properly configured
expect(button).toHaveAttribute('aria-describedby', 'button-tooltip');

// Hover over the button to show the tooltip
await userEvent.hover(button);

// Wait for tooltip to appear and check its content
await waitFor(() => {
screen.getByRole('tooltip');
});

expect(
screen.getByText('You do not have permission to switch accounts.')
).toBeVisible();
});

test('enables the button when iamDelegation flag is off', async () => {
queryMocks.useFlags.mockReturnValue({
iamDelegation: { enabled: false },
});

renderWithTheme(<SwitchAccountButton />);

const button = screen.getByRole('button', { name: /switch account/i });
expect(button).toBeEnabled();
expect(button).not.toHaveAttribute('aria-describedby', 'button-tooltip');
});
});
17 changes: 17 additions & 0 deletions packages/manager/src/features/Account/SwitchAccountButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,23 @@ import * as React from 'react';

import SwapIcon from 'src/assets/icons/swapSmall.svg';

import { useIsIAMDelegationEnabled } from '../IAM/hooks/useIsIAMEnabled';
import { usePermissions } from '../IAM/hooks/usePermissions';

import type { ButtonProps } from '@linode/ui';

export const SwitchAccountButton = (props: ButtonProps) => {
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();

const { data: permissions } = usePermissions('account', [
'create_child_account_token',
]);

return (
<Button
disabled={
isIAMDelegationEnabled ? !permissions.create_child_account_token : false
}
startIcon={<SwapIcon data-testid="swap-icon" />}
sx={(theme) => ({
'& .MuiButton-startIcon svg path': {
Expand All @@ -16,6 +28,11 @@ export const SwitchAccountButton = (props: ButtonProps) => {
font: theme.tokens.alias.Typography.Label.Semibold.S,
marginTop: theme.tokens.spacing.S4,
})}
tooltipText={
isIAMDelegationEnabled && !permissions.create_child_account_token
? 'You do not have permission to switch accounts.'
: undefined
}
{...props}
>
Switch Account
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MaintenanceBannerV2 } from 'src/components/MaintenanceBanner/Maintenanc
import { switchAccountSessionContext } from 'src/context/switchAccountSessionContext';
import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired';
import { getRestrictedResourceText } from 'src/features/Account/utils';
import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
import { useFlags } from 'src/hooks/useFlags';
import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics';
Expand Down Expand Up @@ -45,6 +46,8 @@ export const BillingLanding = () => {
globalGrantType: 'child_account_access',
});

const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();

const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser });

const isReadOnly = !permissions.make_billing_payment || isChildUser;
Expand All @@ -56,8 +59,9 @@ export const BillingLanding = () => {
return <Navigate replace to="/account/billing" />;
}

const canSwitchBetweenParentOrProxyAccount =
(!isChildAccountAccessRestricted && isParentUser) || isProxyUser;
const canSwitchBetweenParentOrProxyAccount = isIAMDelegationEnabled
? isParentUser
: (!isChildAccountAccessRestricted && isParentUser) || isProxyUser;

const handleAccountSwitch = () => {
if (isParentTokenExpired) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { Link } from 'src/components/Link';
import { switchAccountSessionContext } from 'src/context/switchAccountSessionContext';
import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton';
import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired';
import { useIsIAMEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
import {
useIsIAMDelegationEnabled,
useIsIAMEnabled,
} from 'src/features/IAM/hooks/useIsIAMEnabled';
import { useFlags } from 'src/hooks/useFlags';
import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics';
Expand Down Expand Up @@ -46,11 +49,14 @@ export const UserMenuPopover = (props: UserMenuPopoverProps) => {
globalGrantType: 'child_account_access',
});

const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();

const isProxyUser = profile?.user_type === 'proxy';

const canSwitchBetweenParentOrProxyAccount =
(profile?.user_type === 'parent' && !isChildAccountAccessRestricted) ||
profile?.user_type === 'proxy';
const canSwitchBetweenParentOrProxyAccount = isIAMDelegationEnabled
? profile?.user_type === 'parent'
: (profile?.user_type === 'parent' && !isChildAccountAccessRestricted) ||
profile?.user_type === 'proxy';
Comment on lines +56 to +59
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be the right logic but my feeling is that we need to introduce the delegate user here. This will be confirmed once we start using the API, but once a parent switches over to act on an third party child-account, it becomes user_type='delegate' during that session.


const open = Boolean(anchorEl);
const id = open ? 'user-menu-popover' : undefined;
Expand Down