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
2 changes: 2 additions & 0 deletions packages/api-v4/src/iam/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@ export const updateChildAccountDelegates = ({

export const getMyDelegatedChildAccounts = ({
params,
filter,
}: GetMyDelegatedChildAccountsParams) =>
Request<Page<Account>>(
setURL(`${BETA_API_ROOT}/iam/delegation/profile/child-accounts`),
setMethod('GET'),
setParams(params),
setXFilter(filter),
);

export const getDelegatedChildAccount = ({ euuid }: { euuid: string }) =>
Expand Down
1 change: 1 addition & 0 deletions packages/api-v4/src/iam/delegation.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface ChildAccountWithDelegates extends ChildAccount {
}

export interface GetMyDelegatedChildAccountsParams {
filter?: Filter;
params?: Params;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

IAM Parent/Child - Enable server side filters on Switch Account drawer ([#13318](https://github.com/linode/manager/pull/13318))
2 changes: 1 addition & 1 deletion packages/manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@tanstack/react-query-devtools": "5.51.24",
"@tanstack/react-router": "^1.111.11",
"@xterm/xterm": "^5.5.0",
"akamai-cds-react-components": "0.0.1-alpha.19",
"akamai-cds-react-components": "0.1.0",
"algoliasearch": "^4.14.3",
"axios": "~1.12.0",
"braintree-web": "^3.92.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ import { SwitchAccountDrawer } from './SwitchAccountDrawer';

const queryMocks = vi.hoisted(() => ({
useProfile: vi.fn().mockReturnValue({}),
useAllListMyDelegatedChildAccountsQuery: vi.fn().mockReturnValue({}),
useGetListMyDelegatedChildAccountsQuery: vi.fn().mockReturnValue({}),
}));

vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useProfile: queryMocks.useProfile,
useAllListMyDelegatedChildAccountsQuery:
queryMocks.useAllListMyDelegatedChildAccountsQuery,
useGetListMyDelegatedChildAccountsQuery:
queryMocks.useGetListMyDelegatedChildAccountsQuery,
};
});

Expand All @@ -31,7 +31,7 @@ const props = {
describe('SwitchAccountDrawer', () => {
beforeEach(() => {
queryMocks.useProfile.mockReturnValue({});
queryMocks.useAllListMyDelegatedChildAccountsQuery.mockReturnValue({
queryMocks.useGetListMyDelegatedChildAccountsQuery.mockReturnValue({
data: accountFactory.buildList(5, {
company: 'Test Account 1',
euuid: '123',
Expand Down
223 changes: 142 additions & 81 deletions packages/manager/src/features/Account/SwitchAccountDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import {
useAllListMyDelegatedChildAccountsQuery,
useChildAccountsInfiniteQuery,
useMyDelegatedChildAccountsQuery,
} from '@linode/queries';
import { Drawer, LinkButton, Notice, Typography } from '@linode/ui';
import {
Button,
Drawer,
LinkButton,
Notice,
Stack,
Typography,
useTheme,
} from '@linode/ui';
import React, { useMemo, useState } from 'react';

import ErrorStateCloud from 'src/assets/icons/error-state-cloud.svg';
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
import { useParentChildAuthentication } from 'src/features/Account/SwitchAccounts/useParentChildAuthentication';
import { useSwitchToParentAccount } from 'src/features/Account/SwitchAccounts/useSwitchToParentAccount';
Expand All @@ -14,6 +23,7 @@ import { sendSwitchToParentAccountEvent } from 'src/utilities/analytics/customEv
import { getStorage, storage } from 'src/utilities/storage';

import { ChildAccountList } from './SwitchAccounts/ChildAccountList';
import { ChildAccountsTable } from './SwitchAccounts/ChildAccountsTable';
import { updateParentTokenInLocalStorage } from './SwitchAccounts/utils';

import type { APIError, Filter, UserType } from '@linode/api-v4';
Expand All @@ -34,10 +44,13 @@ interface HandleSwitchToChildAccountProps {

export const SwitchAccountDrawer = (props: Props) => {
const { onClose, open, userType } = props;
const theme = useTheme();
const [isParentTokenError, setIsParentTokenError] = React.useState<
APIError[]
>([]);
const [searchQuery, setSearchQuery] = React.useState<string>('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
const isParentUserType = userType === 'parent';
const isProxyUserType = userType === 'proxy';
Expand Down Expand Up @@ -94,20 +107,20 @@ export const SwitchAccountDrawer = (props: Props) => {
);

const {
data: allChildAccounts,
error: allChildAccountsError,
isLoading: allChildAccountsLoading,
isRefetching: allChildAccountsIsRefetching,
refetch: refetchAllChildAccounts,
} = useAllListMyDelegatedChildAccountsQuery({
params: {},
data: delegatedChildAccounts,
error: delegatedChildAccountsError,
isLoading: delegatedChildAccountsLoading,
isRefetching: delegatedChildAccountsIsRefetching,
refetch: refetchDelegatedChildAccounts,
} = useMyDelegatedChildAccountsQuery({
params: {
page,
page_size: pageSize,
},
filter,
enabled: isIAMDelegationEnabled && isParentUserType,
});

const refetchFn = isIAMDelegationEnabled
? refetchAllChildAccounts
: refetchChildAccounts;

const handleSwitchToChildAccount = React.useCallback(
async ({
currentTokenWithBearer,
Expand Down Expand Up @@ -147,35 +160,57 @@ export const SwitchAccountDrawer = (props: Props) => {
});
onClose(event);
location.reload();
} catch (error) {
} catch {
// Error is handled by createTokenError.
}
},
[createToken, updateCurrentToken, revokeToken]
[createToken, isProxyUserType, updateCurrentToken, revokeToken]
);

const [isSwitchingChildAccounts, setIsSwitchingChildAccounts] =
useState<boolean>(false);

const isLoading =
isInitialLoading ||
isSubmitting ||
isSwitchingChildAccounts ||
isRefetching ||
delegatedChildAccountsLoading ||
delegatedChildAccountsIsRefetching;

const refetchFn = isIAMDelegationEnabled
? refetchDelegatedChildAccounts
: refetchChildAccounts;
const handleClose = () => {
setIsSwitchingChildAccounts(false);
setSearchQuery('');
onClose();
};

const childAccounts = useMemo(() => {
if (isIAMDelegationEnabled) {
if (searchQuery && allChildAccounts) {
// Client-side filter: match company field with searchQuery (case-insensitive, contains)
const normalizedQuery = searchQuery.toLowerCase();
return allChildAccounts.filter((account) =>
account.company?.toLowerCase().includes(normalizedQuery)
);
}
return allChildAccounts;
return delegatedChildAccounts?.data || [];
}
return data?.pages.flatMap((page) => page.data);
}, [isIAMDelegationEnabled, searchQuery, allChildAccounts, data]);
}, [isIAMDelegationEnabled, delegatedChildAccounts, data]);

const handlePageChange = (newPage: number) => {
setPage(newPage);
};

const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setPage(1); // Reset to first page when page size changes
};

const handleSearchQueryChange = (query: string) => {
setSearchQuery(query);
setPage(1); // Reset to first page when search query changes
};

const hasError = isIAMDelegationEnabled
? delegatedChildAccountsError
: childAccountInfiniteError;
return (
<Drawer onClose={handleClose} open={open} title="Switch Account">
{createTokenErrorReason && (
Expand Down Expand Up @@ -207,69 +242,95 @@ export const SwitchAccountDrawer = (props: Props) => {
)}
.
</Typography>
{isIAMDelegationEnabled &&
allChildAccounts &&
allChildAccounts.length !== 0 && (
<>
<DebouncedSearchTextField
clearable
debounceTime={250}
hideLabel
label="Search"
onSearch={setSearchQuery}
placeholder="Search"
sx={{ marginBottom: 3 }}
value={searchQuery}
/>
{searchQuery && childAccounts && childAccounts.length === 0 && (
<Typography sx={{ fontStyle: 'italic' }}>

{hasError && (
<Stack alignItems="center" gap={1} justifyContent="center">
<ErrorStateCloud />
<Typography>Unable to load data.</Typography>
<Typography>
Try again or contact support if the issue persists.
</Typography>
<Button
buttonType="primary"
onClick={() => refetchFn()}
sx={(theme) => ({
marginTop: theme.spacingFunction(16),
})}
>
Try again
</Button>
</Stack>
)}
{!hasError && (
<>
<DebouncedSearchTextField
clearable
debounceTime={250}
hideLabel
key={`switch-search-${searchQuery}`}
label="Search"
onSearch={handleSearchQueryChange}
placeholder="Search"
sx={{ marginBottom: theme.spacingFunction(12) }}
value={searchQuery}
/>
{searchQuery &&
childAccounts &&
childAccounts.length === 0 &&
!isLoading && (
<Typography
sx={{
fontStyle: 'italic',
marginTop: theme.spacingFunction(6),
}}
>
No search results
</Typography>
)}
</>
)}
</>
)}
{isIAMDelegationEnabled && (
<ChildAccountsTable
childAccounts={childAccounts}
currentTokenWithBearer={
isProxyOrDelegateUserType
? currentParentTokenWithBearer
: currentTokenWithBearer
}
isLoading={isLoading}
isSwitchingChildAccounts={isSwitchingChildAccounts}
onClose={onClose}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSwitchAccount={handleSwitchToChildAccount}
page={page}
pageSize={pageSize}
setIsSwitchingChildAccounts={setIsSwitchingChildAccounts}
totalResults={delegatedChildAccounts?.results || 0}
userType={userType}
/>
)}
{!isIAMDelegationEnabled && (
<DebouncedSearchTextField
clearable
debounceTime={250}
hideLabel
label="Search"
onSearch={setSearchQuery}
placeholder="Search"
sx={{ marginBottom: 3 }}
value={searchQuery}
<ChildAccountList
childAccounts={childAccounts}
currentTokenWithBearer={
isProxyOrDelegateUserType
? currentParentTokenWithBearer
: currentTokenWithBearer
}
fetchNextPage={fetchNextPage}
filter={filter}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
isSwitchingChildAccounts={isSwitchingChildAccounts}
onClose={onClose}
onSwitchAccount={handleSwitchToChildAccount}
refetchFn={refetchFn}
setIsSwitchingChildAccounts={setIsSwitchingChildAccounts}
userType={userType}
/>
)}
<ChildAccountList
childAccounts={childAccounts}
currentTokenWithBearer={
isProxyOrDelegateUserType
? currentParentTokenWithBearer
: currentTokenWithBearer
}
errors={{
childAccountInfiniteError,
allChildAccountsError,
}}
fetchNextPage={fetchNextPage}
filter={filter}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={
isInitialLoading ||
isSubmitting ||
isSwitchingChildAccounts ||
isRefetching ||
allChildAccountsLoading ||
allChildAccountsIsRefetching
}
isSwitchingChildAccounts={isSwitchingChildAccounts}
onClose={onClose}
onSwitchAccount={handleSwitchToChildAccount}
refetchFn={refetchFn}
setIsSwitchingChildAccounts={setIsSwitchingChildAccounts}
userType={userType}
/>
</Drawer>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ const props: ChildAccountListProps = {
onClose: vi.fn(),
onSwitchAccount: vi.fn(),
userType: undefined,
errors: {
childAccountInfiniteError: false,
allChildAccountsError: null,
},
fetchNextPage: vi.fn(),
filter: {},
hasNextPage: false,
Expand Down
Loading