Skip to content

Commit

Permalink
upcoming: [M3-7430] - Implement Account Switching Functionality (#10064)
Browse files Browse the repository at this point in the history
Co-authored-by: Jaalah Ramos <jaalah.ramos@gmail.com>
Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
  • Loading branch information
3 people authored Jan 23, 2024
1 parent 3c31836 commit b84275a
Show file tree
Hide file tree
Showing 11 changed files with 533 additions and 185 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Implement Account Switching Functionality ([#10064](https://github.com/linode/manager/pull/10064))
10 changes: 10 additions & 0 deletions packages/manager/src/assets/icons/error-state-cloud.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/manager/src/features/Account/AccountLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,9 @@ const AccountLanding = () => {
</React.Suspense>
</Tabs>
<SwitchAccountDrawer
isProxyUser={user?.user_type === 'proxy'}
onClose={() => setIsDrawerOpen(false)}
open={isDrawerOpen}
username={user?.username ?? ''}
/>
</React.Fragment>
);
Expand Down
25 changes: 25 additions & 0 deletions packages/manager/src/features/Account/SwitchAccountButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { screen } 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';

describe('SwitchAccountButton', () => {
test('renders Switch Account button with SwapIcon', () => {
renderWithTheme(<SwitchAccountButton />);

expect(screen.getByText('Switch Account')).toBeInTheDocument();

expect(screen.getByTestId('swap-icon')).toBeInTheDocument();
});

test('calls onClick handler when button is clicked', () => {
const onClickMock = vi.fn();
renderWithTheme(<SwitchAccountButton onClick={onClickMock} />);

userEvent.click(screen.getByText('Switch Account'));

expect(onClickMock).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Button, ButtonProps } from 'src/components/Button/Button';

export const SwitchAccountButton = (props: ButtonProps) => {
return (
<Button startIcon={<SwapIcon />} {...props}>
<Button startIcon={<SwapIcon data-testid="swap-icon" />} {...props}>
Switch Account
</Button>
);
Expand Down
33 changes: 3 additions & 30 deletions packages/manager/src/features/Account/SwitchAccountDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { fireEvent, within } from '@testing-library/react';
import { fireEvent } from '@testing-library/react';
import * as React from 'react';

import { accountFactory } from 'src/factories/account';
import { accountUserFactory } from 'src/factories/accountUsers';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { rest, server } from 'src/mocks/testServer';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { SwitchAccountDrawer } from './SwitchAccountDrawer';

const props = {
isProxyUser: false,
onClose: vi.fn(),
open: true,
username: 'mock-user',
Expand Down Expand Up @@ -39,7 +38,7 @@ describe('SwitchAccountDrawer', () => {
);

const { findByLabelText, getByText } = renderWithTheme(
<SwitchAccountDrawer {...props} />
<SwitchAccountDrawer {...props} isProxyUser />
);

expect(
Expand All @@ -53,32 +52,6 @@ describe('SwitchAccountDrawer', () => {
);
});

it('should display a list of child accounts', async () => {
server.use(
rest.get('*/account/users/*', (req, res, ctx) => {
return res(ctx.json(accountUserFactory.build({ user_type: 'parent' })));
}),
rest.get('*/account/child-accounts', (req, res, ctx) => {
return res(
ctx.json(
makeResourcePage(
accountFactory.buildList(5, { company: 'Child Co.' })
)
)
);
})
);

const { findByTestId } = renderWithTheme(
<SwitchAccountDrawer {...props} />
);

const childAccounts = await findByTestId('child-account-list');
expect(
within(childAccounts).getAllByText('Child Co.', { exact: false })
).toHaveLength(5);
});

it('should close when the close icon is clicked', () => {
const { getByLabelText } = renderWithTheme(
<SwitchAccountDrawer {...props} />
Expand Down
241 changes: 172 additions & 69 deletions packages/manager/src/features/Account/SwitchAccountDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,104 +1,207 @@
import { Typography, styled } from '@mui/material';
import { AxiosHeaders } from 'axios';
import { createChildAccountPersonalAccessToken } from '@linode/api-v4';
import React from 'react';
import { useHistory } from 'react-router-dom';

import { StyledLinkButton } from 'src/components/Button/StyledLinkButton';
import { CircleProgress } from 'src/components/CircleProgress';
import { Drawer } from 'src/components/Drawer';
import { Notice } from 'src/components/Notice/Notice';
import { Stack } from 'src/components/Stack';
import { useFlags } from 'src/hooks/useFlags';
import { useChildAccounts } from 'src/queries/account';
import { useAccountUser } from 'src/queries/accountUsers';
import { useProfile } from 'src/queries/profile';
import { authentication } from 'src/utilities/storage';
import { Typography } from 'src/components/Typography';
import {
isParentTokenValid,
setTokenInLocalStorage,
updateCurrentTokenBasedOnUserType,
} from 'src/features/Account/utils';
import { useCurrentToken } from 'src/hooks/useAuthentication';
import { getStorage } from 'src/utilities/storage';

import { ChildAccountList } from './SwitchAccounts/ChildAccountList';

import type { APIError, ChildAccountPayload, UserType } from '@linode/api-v4';
import type { State as AuthState } from 'src/store/authentication';

interface Props {
isProxyUser: boolean;
onClose: () => void;
open: boolean;
username: string;
}

export const SwitchAccountDrawer = (props: Props) => {
const { onClose, open } = props;
const { isProxyUser, onClose, open } = props;

const [isParentTokenError, setIsParentTokenError] = React.useState<
APIError[]
>([]);
const [isProxyTokenError, setIsProxyTokenError] = React.useState<APIError[]>(
[]
);

const flags = useFlags();
const currentTokenWithBearer = useCurrentToken() ?? '';
const history = useHistory();

const handleClose = () => {
const handleClose = React.useCallback(() => {
onClose();
};

const { data: profile } = useProfile();
const { data: user } = useAccountUser(profile?.username ?? '');

// From proxy accounts, make a request on behalf of the parent account to fetch child accounts.
const headers =
flags.parentChildAccountAccess && user?.user_type === 'proxy'
? new AxiosHeaders({ Authorization: authentication.token.get() }) // TODO: Parent/Child - M3-7430: replace this token with the parent token in local storage.
: undefined;
const { data: childAccounts, error, isLoading } = useChildAccounts({
headers,
});

const renderChildAccounts = React.useCallback(() => {
if (isLoading) {
return <CircleProgress mini />;
}
}, [onClose]);

if (childAccounts?.results === 0) {
return <Notice variant="info">There are no child accounts.</Notice>;
}
/**
* Headers are required for proxy users when obtaining a proxy token.
* For 'proxy' userType, use the stored parent token in the request.
*/
const getProxyToken = React.useCallback(
async ({
euuid,
token,
userType,
}: {
euuid: ChildAccountPayload['euuid'];
token: string;
userType: Omit<UserType, 'child'>;
}) => {
try {
return await createChildAccountPersonalAccessToken({
euuid,
headers:
userType === 'proxy'
? {
Authorization: `Bearer ${token}`,
}
: undefined,
});
} catch (error) {
setIsProxyTokenError(error as APIError[]);
throw error;
}
},
[]
);

// Navigate to the current location, triggering a re-render without a full page reload.
const refreshPage = React.useCallback(() => {
// TODO: Parent/Child: We need to test this against the real API.
history.push(history.location.pathname);
}, [history]);

const handleSwitchToChildAccount = React.useCallback(
async ({
currentTokenWithBearer,
euuid,
event,
handleClose,
isProxyUser,
}: {
currentTokenWithBearer?: AuthState['token'];
euuid: string;
event: React.MouseEvent<HTMLElement>;
handleClose: (e: React.SyntheticEvent<HTMLElement>) => void;
isProxyUser: boolean;
}) => {
try {
// TODO: Parent/Child: FOR MSW ONLY, REMOVE WHEN API IS READY
// ================================================================
// throw new Error(
// `Account switching failed. Try again.`
// );
// ================================================================

// We don't need to worry about this if we're a proxy user.
if (!isProxyUser) {
const parentToken = {
expiry: getStorage('authenication/expire'),
scopes: getStorage('authenication/scopes'),
token: currentTokenWithBearer ?? '',
};

setTokenInLocalStorage({
prefix: 'authentication/parent_token',
token: parentToken,
});
}

const proxyToken = await getProxyToken({
euuid,
token: isProxyUser
? getStorage('authentication/parent_token/token')
: currentTokenWithBearer,
userType: isProxyUser ? 'proxy' : 'parent',
});

if (error) {
return (
<Notice variant="error">
There was an error loading child accounts.
</Notice>
);
setTokenInLocalStorage({
prefix: 'authentication/proxy_token',
token: proxyToken,
});

updateCurrentTokenBasedOnUserType({
userType: 'proxy',
});

handleClose(event);
refreshPage();
} catch (error) {
setIsProxyTokenError(error as APIError[]);

// TODO: Parent/Child: FOR MSW ONLY, REMOVE WHEN API IS READY
// ================================================================
// setIsProxyTokenError([
// {
// field: 'token',
// reason: error.message,
// },
// ]);
// ================================================================
}
},
[getProxyToken, refreshPage]
);

const handleSwitchToParentAccount = React.useCallback(() => {
if (!isParentTokenValid({ isProxyUser })) {
const expiredTokenError: APIError = {
field: 'token',
reason:
'The reseller account token has expired. You must log back into the account manually.',
};

setIsParentTokenError([expiredTokenError]);

return;
}

return childAccounts?.data.map((childAccount, idx) => (
<StyledChildAccountLinkButton
onClick={() => {
// TODO: Parent/Child - M3-7430
// handleAccountSwitch();
}}
key={`child-account-link-button-${idx}`}
>
{childAccount.company}
</StyledChildAccountLinkButton>
));
}, [childAccounts, error, isLoading]);
updateCurrentTokenBasedOnUserType({ userType: 'parent' });
handleClose();
}, [handleClose, isProxyUser]);

return (
<Drawer onClose={handleClose} open={open} title="Switch Account">
<StyledTypography>
{isProxyTokenError.length > 0 && (
<Notice text={isProxyTokenError[0].reason} variant="error" />
)}
{isParentTokenError.length > 0 && (
<Notice text={isParentTokenError[0].reason} variant="error" />
)}
<Typography
sx={(theme) => ({
margin: `${theme.spacing(3)} 0`,
})}
>
Select an account to view and manage its settings and configurations
{user?.user_type === 'proxy' && (
{isProxyUser && (
<>
{' '}
or {/* TODO: Parent/Child - M3-7430 */}
{' or '}
<StyledLinkButton
aria-label="parent-account-link"
onClick={() => null}
onClick={handleSwitchToParentAccount}
>
switch back to your account
</StyledLinkButton>
</>
)}
.
</StyledTypography>
<Stack alignItems={'flex-start'} data-testid="child-account-list">
{renderChildAccounts()}
</Stack>
</Typography>
<ChildAccountList
currentTokenWithBearer={currentTokenWithBearer}
isProxyUser={isProxyUser}
onClose={handleClose}
onSwitchAccount={handleSwitchToChildAccount}
/>
</Drawer>
);
};

const StyledTypography = styled(Typography)(({ theme }) => ({
margin: `${theme.spacing(3)} 0`,
}));

const StyledChildAccountLinkButton = styled(StyledLinkButton)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
Loading

0 comments on commit b84275a

Please sign in to comment.