-
Notifications
You must be signed in to change notification settings - Fork 367
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
upcoming: [M3-7430] - Implement Account Switching Functionality (#10064)
Co-authored-by: Jaalah Ramos <jaalah.ramos@gmail.com> Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com>
- Loading branch information
1 parent
3c31836
commit b84275a
Showing
11 changed files
with
533 additions
and
185 deletions.
There are no files selected for viewing
5 changes: 5 additions & 0 deletions
5
packages/manager/.changeset/pr-10064-upcoming-features-1705092306140.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
packages/manager/src/features/Account/SwitchAccountButton.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
241 changes: 172 additions & 69 deletions
241
packages/manager/src/features/Account/SwitchAccountDrawer.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
})); |
Oops, something went wrong.