Skip to content

Commit 8b2e89d

Browse files
fix: [UIE-9987] - IAM Parent/Child - Various fixes to Parent Account Flow (#13278)
* Enhanced SwitchAccount drawer * tests * e2e failur * Added changeset: IAM Parent/Child - Various fixes to Parent Account Flow
1 parent a1cc683 commit 8b2e89d

8 files changed

Lines changed: 268 additions & 190 deletions

File tree

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 Parent/Child - Various fixes to Parent Account Flow ([#13278](https://github.com/linode/manager/pull/13278))

packages/manager/src/features/Account/SwitchAccountButton.test.tsx

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
1-
import { screen, waitFor } from '@testing-library/react';
1+
import { screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import React from 'react';
44

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

88
const queryMocks = vi.hoisted(() => ({
9-
userPermissions: vi.fn(() => ({
10-
data: {
11-
create_child_account_token: true,
12-
},
13-
})),
149
useFlags: vi.fn().mockReturnValue({}),
1510
}));
16-
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
17-
usePermissions: queryMocks.userPermissions,
18-
}));
1911

2012
vi.mock('src/hooks/useFlags', () => {
2113
const actual = vi.importActual('src/hooks/useFlags');
@@ -54,38 +46,6 @@ describe('SwitchAccountButton', () => {
5446
expect(button).toBeEnabled();
5547
});
5648

57-
test('disables the button when user does not have create_child_account_token permission', async () => {
58-
queryMocks.userPermissions.mockReturnValue({
59-
data: {
60-
create_child_account_token: false,
61-
},
62-
});
63-
64-
queryMocks.useFlags.mockReturnValue({
65-
iamDelegation: { enabled: true },
66-
});
67-
68-
renderWithTheme(<SwitchAccountButton />);
69-
70-
const button = screen.getByRole('button', { name: /switch account/i });
71-
expect(button).toBeDisabled();
72-
73-
// Check that the tooltip is properly configured
74-
expect(button).toHaveAttribute('aria-describedby', 'button-tooltip');
75-
76-
// Hover over the button to show the tooltip
77-
await userEvent.hover(button);
78-
79-
// Wait for tooltip to appear and check its content
80-
await waitFor(() => {
81-
screen.getByRole('tooltip');
82-
});
83-
84-
expect(
85-
screen.getByText('You do not have permission to switch accounts.')
86-
).toBeVisible();
87-
});
88-
8949
test('enables the button when iamDelegation flag is off', async () => {
9050
queryMocks.useFlags.mockReturnValue({
9151
iamDelegation: { enabled: false },

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

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,11 @@ import * as React from 'react';
33

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

6-
import { useIsIAMDelegationEnabled } from '../IAM/hooks/useIsIAMEnabled';
7-
import { usePermissions } from '../IAM/hooks/usePermissions';
8-
96
import type { ButtonProps } from '@linode/ui';
107

118
export const SwitchAccountButton = (props: ButtonProps) => {
12-
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
13-
14-
const { data: permissions } = usePermissions('account', [
15-
'create_child_account_token',
16-
]);
17-
189
return (
1910
<Button
20-
disabled={
21-
isIAMDelegationEnabled ? !permissions.create_child_account_token : false
22-
}
2311
startIcon={<SwapIcon data-testid="swap-icon" />}
2412
sx={(theme) => ({
2513
'& .MuiButton-startIcon svg path': {
@@ -28,11 +16,6 @@ export const SwitchAccountButton = (props: ButtonProps) => {
2816
font: theme.tokens.alias.Typography.Label.Semibold.S,
2917
marginTop: theme.tokens.spacing.S4,
3018
})}
31-
tooltipText={
32-
isIAMDelegationEnabled && !permissions.create_child_account_token
33-
? 'You do not have permission to switch accounts.'
34-
: undefined
35-
}
3619
{...props}
3720
>
3821
Switch Account

packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,45 @@ import { profileFactory } from '@linode/utilities';
22
import { fireEvent, waitFor } from '@testing-library/react';
33
import * as React from 'react';
44

5-
import { http, HttpResponse, server } from 'src/mocks/testServer';
5+
import { accountFactory } from 'src/factories';
66
import { renderWithTheme } from 'src/utilities/testHelpers';
77

88
import { SwitchAccountDrawer } from './SwitchAccountDrawer';
99

10+
const queryMocks = vi.hoisted(() => ({
11+
useProfile: vi.fn().mockReturnValue({}),
12+
useAllListMyDelegatedChildAccountsQuery: vi.fn().mockReturnValue({}),
13+
}));
14+
15+
vi.mock('@linode/queries', async () => {
16+
const actual = await vi.importActual('@linode/queries');
17+
return {
18+
...actual,
19+
useProfile: queryMocks.useProfile,
20+
useAllListMyDelegatedChildAccountsQuery:
21+
queryMocks.useAllListMyDelegatedChildAccountsQuery,
22+
};
23+
});
24+
1025
const props = {
1126
onClose: vi.fn(),
1227
open: true,
1328
userType: undefined,
1429
};
1530

1631
describe('SwitchAccountDrawer', () => {
32+
beforeEach(() => {
33+
queryMocks.useProfile.mockReturnValue({});
34+
queryMocks.useAllListMyDelegatedChildAccountsQuery.mockReturnValue({
35+
data: accountFactory.buildList(5, {
36+
company: 'Test Account 1',
37+
euuid: '123',
38+
}),
39+
isLoading: false,
40+
isRefetching: false,
41+
});
42+
});
43+
1744
it('should have a title', () => {
1845
const { getByText } = renderWithTheme(<SwitchAccountDrawer {...props} />);
1946
expect(getByText('Switch Account')).toBeInTheDocument();
@@ -36,11 +63,9 @@ describe('SwitchAccountDrawer', () => {
3663
});
3764

3865
it('should include a link to switch back to the parent account if the active user is a proxy user', async () => {
39-
server.use(
40-
http.get('*/profile', () => {
41-
return HttpResponse.json(profileFactory.build({ user_type: 'proxy' }));
42-
})
43-
);
66+
queryMocks.useProfile.mockReturnValue({
67+
data: profileFactory.build({ user_type: 'proxy' }),
68+
});
4469

4570
const { findByLabelText, getByText } = renderWithTheme(
4671
<SwitchAccountDrawer {...props} userType="proxy" />

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

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1+
import {
2+
useAllListMyDelegatedChildAccountsQuery,
3+
useChildAccountsInfiniteQuery,
4+
} from '@linode/queries';
15
import { Drawer, LinkButton, Notice, Typography } from '@linode/ui';
2-
import React from 'react';
6+
import React, { useMemo, useState } from 'react';
37

48
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
59
import { PARENT_USER_SESSION_EXPIRED } from 'src/features/Account/constants';
610
import { useParentChildAuthentication } from 'src/features/Account/SwitchAccounts/useParentChildAuthentication';
711
import { setTokenInLocalStorage } from 'src/features/Account/SwitchAccounts/utils';
12+
import { useIsIAMDelegationEnabled } from 'src/features/IAM/hooks/useIsIAMEnabled';
813
import { sendSwitchToParentAccountEvent } from 'src/utilities/analytics/customEventAnalytics';
914
import { getStorage, setStorage, storage } from 'src/utilities/storage';
1015

1116
import { ChildAccountList } from './SwitchAccounts/ChildAccountList';
1217
import { updateParentTokenInLocalStorage } from './SwitchAccounts/utils';
1318

14-
import type { APIError, UserType } from '@linode/api-v4';
19+
import type { APIError, Filter, UserType } from '@linode/api-v4';
1520

1621
interface Props {
1722
onClose: () => void;
@@ -33,8 +38,8 @@ export const SwitchAccountDrawer = (props: Props) => {
3338
const [isParentTokenError, setIsParentTokenError] = React.useState<
3439
APIError[]
3540
>([]);
36-
const [query, setQuery] = React.useState<string>('');
37-
41+
const [searchQuery, setSearchQuery] = React.useState<string>('');
42+
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
3843
const isProxyUser = userType === 'proxy';
3944
const currentParentTokenWithBearer =
4045
getStorage('authentication/parent_token/token') ?? '';
@@ -50,6 +55,48 @@ export const SwitchAccountDrawer = (props: Props) => {
5055

5156
const createTokenErrorReason = createTokenError?.[0]?.reason;
5257

58+
const filter: Filter = {
59+
['+order']: 'asc',
60+
['+order_by']: 'company',
61+
...(searchQuery && { company: { '+contains': searchQuery } }),
62+
};
63+
64+
const {
65+
data,
66+
fetchNextPage,
67+
hasNextPage,
68+
isError: childAccountInfiniteError,
69+
isFetchingNextPage,
70+
isInitialLoading,
71+
isRefetching,
72+
refetch: refetchChildAccounts,
73+
} = useChildAccountsInfiniteQuery(
74+
{
75+
filter,
76+
headers:
77+
userType === 'proxy'
78+
? {
79+
Authorization: currentTokenWithBearer,
80+
}
81+
: undefined,
82+
},
83+
isIAMDelegationEnabled === false
84+
);
85+
const {
86+
data: allChildAccounts,
87+
error: allChildAccountsError,
88+
isLoading: allChildAccountsLoading,
89+
isRefetching: allChildAccountsIsRefetching,
90+
refetch: refetchAllChildAccounts,
91+
} = useAllListMyDelegatedChildAccountsQuery({
92+
params: {},
93+
enabled: isIAMDelegationEnabled,
94+
});
95+
96+
const refetchFn = isIAMDelegationEnabled
97+
? refetchAllChildAccounts
98+
: refetchChildAccounts;
99+
53100
const handleSwitchToChildAccount = React.useCallback(
54101
async ({
55102
currentTokenWithBearer,
@@ -120,8 +167,30 @@ export const SwitchAccountDrawer = (props: Props) => {
120167
location.reload();
121168
}, [onClose, revokeToken, validateParentToken, updateCurrentToken]);
122169

170+
const [isSwitchingChildAccounts, setIsSwitchingChildAccounts] =
171+
useState<boolean>(false);
172+
173+
const handleClose = () => {
174+
setIsSwitchingChildAccounts(false);
175+
onClose();
176+
};
177+
178+
const childAccounts = useMemo(() => {
179+
if (isIAMDelegationEnabled) {
180+
if (searchQuery && allChildAccounts) {
181+
// Client-side filter: match company field with searchQuery (case-insensitive, contains)
182+
const normalizedQuery = searchQuery.toLowerCase();
183+
return allChildAccounts.filter((account) =>
184+
account.company?.toLowerCase().includes(normalizedQuery)
185+
);
186+
}
187+
return allChildAccounts;
188+
}
189+
return data?.pages.flatMap((page) => page.data);
190+
}, [isIAMDelegationEnabled, searchQuery, allChildAccounts, data]);
191+
123192
return (
124-
<Drawer onClose={onClose} open={open} title="Switch Account">
193+
<Drawer onClose={handleClose} open={open} title="Switch Account">
125194
{createTokenErrorReason && (
126195
<Notice text={createTokenErrorReason} variant="error" />
127196
)}
@@ -130,7 +199,7 @@ export const SwitchAccountDrawer = (props: Props) => {
130199
)}
131200
<Typography
132201
sx={(theme) => ({
133-
margin: `${theme.spacing(3)} 0`,
202+
margin: `${theme.spacingFunction(24)} 0`,
134203
})}
135204
>
136205
Select an account to view and manage its settings and configurations
@@ -151,24 +220,65 @@ export const SwitchAccountDrawer = (props: Props) => {
151220
)}
152221
.
153222
</Typography>
154-
<DebouncedSearchTextField
155-
clearable
156-
debounceTime={250}
157-
hideLabel
158-
label="Search"
159-
onSearch={setQuery}
160-
placeholder="Search"
161-
sx={{ marginBottom: 3 }}
162-
value={query}
163-
/>
223+
{isIAMDelegationEnabled &&
224+
allChildAccounts &&
225+
allChildAccounts.length !== 0 && (
226+
<>
227+
<DebouncedSearchTextField
228+
clearable
229+
debounceTime={250}
230+
hideLabel
231+
label="Search"
232+
onSearch={setSearchQuery}
233+
placeholder="Search"
234+
sx={{ marginBottom: 3 }}
235+
value={searchQuery}
236+
/>
237+
{searchQuery && childAccounts && childAccounts.length === 0 && (
238+
<Typography sx={{ fontStyle: 'italic' }}>
239+
No search results
240+
</Typography>
241+
)}
242+
</>
243+
)}
244+
{!isIAMDelegationEnabled && (
245+
<DebouncedSearchTextField
246+
clearable
247+
debounceTime={250}
248+
hideLabel
249+
label="Search"
250+
onSearch={setSearchQuery}
251+
placeholder="Search"
252+
sx={{ marginBottom: 3 }}
253+
value={searchQuery}
254+
/>
255+
)}
164256
<ChildAccountList
257+
childAccounts={childAccounts}
165258
currentTokenWithBearer={
166259
isProxyUser ? currentParentTokenWithBearer : currentTokenWithBearer
167260
}
168-
isLoading={isSubmitting}
261+
errors={{
262+
childAccountInfiniteError,
263+
allChildAccountsError,
264+
}}
265+
fetchNextPage={fetchNextPage}
266+
filter={filter}
267+
hasNextPage={hasNextPage}
268+
isFetchingNextPage={isFetchingNextPage}
269+
isLoading={
270+
isInitialLoading ||
271+
isSubmitting ||
272+
isSwitchingChildAccounts ||
273+
isRefetching ||
274+
allChildAccountsLoading ||
275+
allChildAccountsIsRefetching
276+
}
277+
isSwitchingChildAccounts={isSwitchingChildAccounts}
169278
onClose={onClose}
170279
onSwitchAccount={handleSwitchToChildAccount}
171-
searchQuery={query}
280+
refetchFn={refetchFn}
281+
setIsSwitchingChildAccounts={setIsSwitchingChildAccounts}
172282
userType={userType}
173283
/>
174284
</Drawer>

0 commit comments

Comments
 (0)