Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

upcoming: [M3-7716] - Add session expiry confirmation dialog for proxy users #10152

Merged
merged 16 commits into from
Feb 13, 2024
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Add session expiry confirmation dialog for proxy to parent user account switching ([#10152](https://github.com/linode/manager/pull/10152))
189 changes: 100 additions & 89 deletions packages/manager/src/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities';

import { ENABLE_MAINTENANCE_MODE } from './constants';
import { complianceUpdateContext } from './context/complianceUpdateContext';
import { switchAccountSessionContext } from './context/switchAccountSessionContext';
import { FlagSet } from './featureFlags';
import { useIsACLBEnabled } from './features/LoadBalancers/utils';
import { useGlobalErrors } from './hooks/useGlobalErrors';
Expand Down Expand Up @@ -192,6 +193,11 @@ export const MainContent = () => {
const ComplianceUpdateProvider = complianceUpdateContext.Provider;
const complianceUpdateContextValue = useDialogContext();

const SwitchAccountSessionProvider = switchAccountSessionContext.Provider;
const switchAccountSessionContextValue = useDialogContext({
isOpen: false,
});

const [menuIsOpen, toggleMenu] = React.useState<boolean>(false);
const {
_isManagedAccount,
Expand Down Expand Up @@ -294,100 +300,105 @@ export const MainContent = () => {
*/
return (
<div className={classes.appFrame}>
<ComplianceUpdateProvider value={complianceUpdateContextValue}>
<NotificationProvider value={contextValue}>
<>
{shouldDisplayMainContentBanner ? (
<MainContentBanner
bannerKey={flags.mainContentBanner?.key ?? ''}
bannerText={flags.mainContentBanner?.text ?? ''}
linkText={flags.mainContentBanner?.link?.text ?? ''}
onClose={() => setBannerDismissed(true)}
url={flags.mainContentBanner?.link?.url ?? ''}
/>
) : null}
<SideMenu
closeMenu={() => toggleMenu(false)}
collapse={desktopMenuIsOpen || false}
open={menuIsOpen}
/>
<div
className={cx(classes.content, {
[classes.fullWidthContent]:
desktopMenuIsOpen ||
(desktopMenuIsOpen && desktopMenuIsOpen === true),
})}
>
<TopMenu
desktopMenuToggle={desktopMenuToggle}
isSideMenuOpen={!desktopMenuIsOpen}
openSideMenu={() => toggleMenu(true)}
username={username}
<SwitchAccountSessionProvider value={switchAccountSessionContextValue}>
Copy link
Contributor Author

@jaalah-akamai jaalah-akamai Feb 6, 2024

Choose a reason for hiding this comment

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

Only change here was adding the Provider wrapper (rest is context shift)

<ComplianceUpdateProvider value={complianceUpdateContextValue}>
<NotificationProvider value={contextValue}>
<>
{shouldDisplayMainContentBanner ? (
<MainContentBanner
bannerKey={flags.mainContentBanner?.key ?? ''}
bannerText={flags.mainContentBanner?.text ?? ''}
linkText={flags.mainContentBanner?.link?.text ?? ''}
onClose={() => setBannerDismissed(true)}
url={flags.mainContentBanner?.link?.url ?? ''}
/>
) : null}
<SideMenu
closeMenu={() => toggleMenu(false)}
collapse={desktopMenuIsOpen || false}
open={menuIsOpen}
/>
<main
className={classes.cmrWrapper}
id="main-content"
role="main"
<div
className={cx(classes.content, {
[classes.fullWidthContent]:
desktopMenuIsOpen ||
(desktopMenuIsOpen && desktopMenuIsOpen === true),
})}
>
<Grid className={classes.grid} container spacing={0}>
<Grid className={cx(classes.switchWrapper, 'p0')}>
<GlobalNotifications />
<React.Suspense fallback={<SuspenseLoader />}>
<Switch>
<Route component={LinodesRoutes} path="/linodes" />
<Route
component={PlacementGroups}
path="/placement-groups"
/>
<Route component={Volumes} path="/volumes" />
<Redirect path="/volumes*" to="/volumes" />
{isACLBEnabled && (
<TopMenu
desktopMenuToggle={desktopMenuToggle}
isSideMenuOpen={!desktopMenuIsOpen}
openSideMenu={() => toggleMenu(true)}
username={username}
/>
<main
className={classes.cmrWrapper}
id="main-content"
role="main"
>
<Grid className={classes.grid} container spacing={0}>
<Grid className={cx(classes.switchWrapper, 'p0')}>
<GlobalNotifications />
<React.Suspense fallback={<SuspenseLoader />}>
<Switch>
<Route component={LinodesRoutes} path="/linodes" />
<Route
component={PlacementGroups}
path="/placement-groups"
/>
<Route component={Volumes} path="/volumes" />
<Redirect path="/volumes*" to="/volumes" />
{isACLBEnabled && (
<Route
component={LoadBalancers}
path="/loadbalancer*"
/>
)}
<Route
component={NodeBalancers}
path="/nodebalancers"
/>
<Route component={Domains} path="/domains" />
<Route component={Managed} path="/managed" />
<Route component={Longview} path="/longview" />
<Route component={Images} path="/images" />
<Route
component={StackScripts}
path="/stackscripts"
/>
<Route
component={LoadBalancers}
path="/loadbalancer*"
component={ObjectStorage}
path="/object-storage"
/>
)}
<Route
component={NodeBalancers}
path="/nodebalancers"
/>
<Route component={Domains} path="/domains" />
<Route component={Managed} path="/managed" />
<Route component={Longview} path="/longview" />
<Route component={Images} path="/images" />
<Route component={StackScripts} path="/stackscripts" />
<Route
component={ObjectStorage}
path="/object-storage"
/>
<Route component={Kubernetes} path="/kubernetes" />
<Route component={Account} path="/account" />
<Route component={Profile} path="/profile" />
<Route component={Help} path="/support" />
<Route component={SearchLanding} path="/search" />
<Route component={EventsLanding} path="/events" />
<Route component={Firewalls} path="/firewalls" />
{showDatabases && (
<Route component={Databases} path="/databases" />
)}
{flags.selfServeBetas && (
<Route component={BetaRoutes} path="/betas" />
)}
{showVPCs && <Route component={VPC} path="/vpcs" />}
<Redirect exact from="/" to={defaultRoot} />
{/** We don't want to break any bookmarks. This can probably be removed eventually. */}
<Redirect from="/dashboard" to={defaultRoot} />
<Route component={NotFound} />
</Switch>
</React.Suspense>
<Route component={Kubernetes} path="/kubernetes" />
<Route component={Account} path="/account" />
<Route component={Profile} path="/profile" />
<Route component={Help} path="/support" />
<Route component={SearchLanding} path="/search" />
<Route component={EventsLanding} path="/events" />
<Route component={Firewalls} path="/firewalls" />
{showDatabases && (
<Route component={Databases} path="/databases" />
)}
{flags.selfServeBetas && (
<Route component={BetaRoutes} path="/betas" />
)}
{showVPCs && <Route component={VPC} path="/vpcs" />}
<Redirect exact from="/" to={defaultRoot} />
{/** We don't want to break any bookmarks. This can probably be removed eventually. */}
<Redirect from="/dashboard" to={defaultRoot} />
<Route component={NotFound} />
</Switch>
</React.Suspense>
</Grid>
</Grid>
</Grid>
</main>
</div>
</>
</NotificationProvider>
<Footer desktopMenuIsOpen={desktopMenuIsOpen} />
</ComplianceUpdateProvider>
</main>
</div>
</>
</NotificationProvider>
<Footer desktopMenuIsOpen={desktopMenuIsOpen} />
</ComplianceUpdateProvider>
</SwitchAccountSessionProvider>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,6 @@ export const LandingHeader = ({

const Actions = styled('div')(() => ({
display: 'flex',
gap: '24px',
justifyContent: 'flex-end',
}));
7 changes: 7 additions & 0 deletions packages/manager/src/context/switchAccountSessionContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';

import { DialogContextProps, defaultContext } from './useDialogContext';

export const switchAccountSessionContext = React.createContext<DialogContextProps>(
defaultContext
);
41 changes: 33 additions & 8 deletions packages/manager/src/context/useDialogContext.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
import { createContext, useCallback, useState } from 'react';
import { useCallback, useState } from 'react';

export interface DialogContextProps {
[key: string]:
| (() => void)
| ((newState: UseDialogContextOptions) => void)
| boolean;
close: () => void;
isOpen: boolean;
open: () => void;
updateState: (newState: UseDialogContextOptions) => void;
}

export const defaultContext = {
export type UseDialogContextOptions = {
[key: string]: boolean;
};

export const defaultContext: DialogContextProps = {
close: () => void 0,
isOpen: false,
open: () => void 0,
updateState: () => void 0,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We'll be able to remove these 3 as well in upcoming PR

};

export const dialogContext = createContext<DialogContextProps>(defaultContext);
export const useDialogContext = (
initialState: UseDialogContextOptions = {}
): DialogContextProps => {
const [state, setState] = useState({ ...defaultContext, ...initialState });

// TODO: We no longer need the open and close functions after we update other references
const open = useCallback(
() => setState((prevState) => ({ ...prevState, isOpen: true })),
[]
);
const close = useCallback(
() => setState((prevState) => ({ ...prevState, isOpen: false })),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added TODO since open and close are no longer necessary, but I didn't want to break existing API.

[]
);
const updateState = useCallback(
(newState: UseDialogContextOptions) =>
setState((prevState) => ({ ...prevState, ...newState })),
[]
);

export const useDialogContext = (): DialogContextProps => {
const [isOpen, setIsOpen] = useState(false);
const open = useCallback(() => setIsOpen(true), [setIsOpen]);
const close = useCallback(() => setIsOpen(false), [setIsOpen]);
return {
...state,
close,
isOpen,
open,
updateState,
};
};
17 changes: 16 additions & 1 deletion packages/manager/src/features/Account/AccountLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { TabLinkList } from 'src/components/Tabs/TabLinkList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
import { switchAccountSessionContext } from 'src/context/switchAccountSessionContext';
import { useParentTokenManagement } from 'src/features/Account/SwitchAccounts/useParentTokenManagement';
import { useFlags } from 'src/hooks/useFlags';
import { useAccount } from 'src/queries/account';
import { useGrants, useProfile } from 'src/queries/profile';
Expand Down Expand Up @@ -50,13 +52,16 @@ const AccountLanding = () => {

const flags = useFlags();
const [isDrawerOpen, setIsDrawerOpen] = React.useState<boolean>(false);
const sessionContext = React.useContext(switchAccountSessionContext);

const accountAccessGrant = grants?.global?.account_access;
const readOnlyAccountAccess = accountAccessGrant === 'read_only';
const isAkamaiAccount = account?.billing_source === 'akamai';
const isProxyUser = profile?.user_type === 'proxy';
const isParentUser = profile?.user_type === 'parent';

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

const tabs = [
{
routeName: '/account/billing',
Expand Down Expand Up @@ -90,6 +95,16 @@ const AccountLanding = () => {
'/account/billing/edit',
];

const handleAccountSwitch = () => {
if (isParentTokenExpired) {
return sessionContext.updateState({
isOpen: true,
});
}

setIsDrawerOpen(true);
};

const getDefaultTabIndex = () => {
const tabChoice = tabs.findIndex((tab) =>
Boolean(matchPath(tab.routeName, { path: location.pathname }))
Expand Down Expand Up @@ -137,7 +152,7 @@ const AccountLanding = () => {
}
landingHeaderProps.disabledCreateButton = readOnlyAccountAccess;
landingHeaderProps.extraActions = canSwitchBetweenParentOrProxyAccount ? (
<SwitchAccountButton onClick={() => setIsDrawerOpen(true)} />
<SwitchAccountButton onClick={handleAccountSwitch} />
) : undefined;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { StyledLinkButton } from 'src/components/Button/StyledLinkButton';
import { Drawer } from 'src/components/Drawer';
import { Notice } from 'src/components/Notice/Notice';
import { Typography } from 'src/components/Typography';
import { PARENT_SESSION_EXPIRED } from 'src/features/Account/constants';
import {
isParentTokenValid,
setTokenInLocalStorage,
Expand Down Expand Up @@ -153,11 +154,10 @@ export const SwitchAccountDrawer = (props: Props) => {
);

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

setIsParentTokenError([expiredTokenError]);
Expand Down
Loading
Loading