Skip to content

Commit

Permalink
Add Session context and redirect on auth
Browse files Browse the repository at this point in the history
  • Loading branch information
marshmalien committed Apr 15, 2021
1 parent 32200cd commit 99e82eb
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 158 deletions.
55 changes: 31 additions & 24 deletions awx/ui_next/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { i18n } from '@lingui/core';
import { Card, PageSection } from '@patternfly/react-core';

import { ConfigProvider, useAuthorizedPath } from './contexts/Config';
import { SessionProvider, useSession } from './contexts/Session';
import AppContainer from './components/AppContainer';
import Background from './components/Background';
import NotFound from './screens/NotFound';
Expand Down Expand Up @@ -66,12 +67,16 @@ const AuthorizedRoutes = ({ routeConfig }) => {
);
};

const ProtectedRoute = ({ children, ...rest }) =>
isAuthenticated(document.cookie) ? (
<Route {...rest}>{children}</Route>
) : (
<Redirect to="/login" />
);
const ProtectedRoute = ({ children, ...rest }) => {
const { authRedirectTo, setAuthRedirectTo } = useSession();
const { pathname } = useLocation();

if (isAuthenticated(document.cookie)) {
return <Route {...rest}>{children}</Route>;
}
if (authRedirectTo !== null) setAuthRedirectTo(pathname);
return <Redirect to="/login" />;
};

function App() {
let language = getLanguageWithoutRegionCode(navigator);
Expand All @@ -89,24 +94,26 @@ function App() {
return (
<I18nProvider i18n={i18n}>
<Background>
<Switch>
<Route exact strict path="/*/">
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
</Route>
<Route path="/login">
<Login isAuthenticated={isAuthenticated} />
</Route>
<Route exact path="/">
<Redirect to="/home" />
</Route>
<ProtectedRoute>
<ConfigProvider>
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
<AuthorizedRoutes routeConfig={getRouteConfig(i18n)} />
</AppContainer>
</ConfigProvider>
</ProtectedRoute>
</Switch>
<SessionProvider>
<Switch>
<Route exact strict path="/*/">
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
</Route>
<Route path="/login">
<Login isAuthenticated={isAuthenticated} />
</Route>
<Route exact path="/">
<Redirect to="/home" />
</Route>
<ProtectedRoute>
<ConfigProvider>
<AppContainer navRouteConfig={getRouteConfig(i18n)}>
<AuthorizedRoutes routeConfig={getRouteConfig(i18n)} />
</AppContainer>
</ConfigProvider>
</ProtectedRoute>
</Switch>
</SessionProvider>
</Background>
</I18nProvider>
);
Expand Down
12 changes: 11 additions & 1 deletion awx/ui_next/src/App.test.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../testUtils/enzymeHelpers';

import * as SessionContext from './contexts/Session';
import App from './App';

jest.mock('./api');

describe('<App />', () => {
test('renders ok', async () => {
const contextValues = {
setAuthRedirectTo: jest.fn(),
isSessionExpired: false,
};
jest
.spyOn(SessionContext, 'useSession')
.mockImplementation(() => contextValues);

let wrapper;
await act(async () => {
wrapper = mountWithContexts(<App />);
});
expect(wrapper.length).toBe(1);
jest.clearAllMocks();
wrapper.unmount();
});
});
121 changes: 11 additions & 110 deletions awx/ui_next/src/components/AppContainer/AppContainer.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useHistory, withRouter } from 'react-router-dom';
import React, { useState } from 'react';
import { withRouter } from 'react-router-dom';
import {
Button,
Nav,
Expand All @@ -15,128 +15,33 @@ import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import styled from 'styled-components';

import { MeAPI, RootAPI } from '../../api';
import { useConfig, useAuthorizedPath } from '../../contexts/Config';
import { SESSION_TIMEOUT_KEY } from '../../constants';
import { isAuthenticated } from '../../util/auth';
import { useSession } from '../../contexts/Session';
import About from '../About';
import AlertModal from '../AlertModal';
import BrandLogo from './BrandLogo';
import NavExpandableGroup from './NavExpandableGroup';
import PageHeaderToolbar from './PageHeaderToolbar';

// The maximum supported timeout for setTimeout(), in milliseconds,
// is the highest number you can represent as a signed 32bit
// integer (approximately 25 days)
const MAX_TIMEOUT = 2 ** (32 - 1) - 1;

// The number of seconds the session timeout warning is displayed
// before the user is logged out. Increasing this number (up to
// the total session time, which is 1800s by default) will cause
// the session timeout warning to display sooner.
const SESSION_WARNING_DURATION = 10;
import AlertModal from '../AlertModal';

const PageHeader = styled(PFPageHeader)`
& .pf-c-page__header-brand-link {
color: inherit;
&:hover {
color: inherit;
}
}
`;

/**
* The useStorage hook integrates with the browser's localStorage api.
* It accepts a storage key as its only argument and returns a state
* variable and setter function for that state variable.
*
* This utility behaves much like the standard useState hook with some
* key differences:
* 1. You don't pass it an initial value. Instead, the provided key
* is used to retrieve the initial value from local storage. If
* the key doesn't exist in local storage, null is returned.
* 2. Behind the scenes, this hook registers an event listener with
* the Web Storage api to establish a two-way binding between the
* state variable and its corresponding local storage value. This
* means that updates to the state variable with the setter
* function will produce a corresponding update to the local
* storage value and vice-versa.
* 3. When local storage is shared across browser tabs, the data
* binding is also shared across browser tabs. This means that
* updates to the state variable using the setter function on
* one tab will also update the state variable on any other tab
* using this hook with the same key and vice-versa.
*/
function useStorage(key) {
const [storageVal, setStorageVal] = useState(
window.localStorage.getItem(key)
);
window.addEventListener('storage', () => {
const newVal = window.localStorage.getItem(key);
if (newVal !== storageVal) {
setStorageVal(newVal);
}
});
const setValue = val => {
window.localStorage.setItem(key, val);
setStorageVal(val);
};
return [storageVal, setValue];
}

function AppContainer({ i18n, navRouteConfig = [], children }) {
const history = useHistory();
const config = useConfig();
const { logout, handleSessionContinue, sessionCountdown } = useSession();

const isReady = !!config.license_info;
const isSidebarVisible = useAuthorizedPath();
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);

const sessionTimeoutId = useRef();
const sessionIntervalId = useRef();
const [sessionTimeout, setSessionTimeout] = useStorage(SESSION_TIMEOUT_KEY);
const [timeoutWarning, setTimeoutWarning] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(null);

const handleAboutModalOpen = () => setIsAboutModalOpen(true);
const handleAboutModalClose = () => setIsAboutModalOpen(false);
const handleSessionTimeout = () => setTimeoutWarning(true);

const handleLogout = useCallback(async () => {
await RootAPI.logout();
setSessionTimeout(null);
}, [setSessionTimeout]);

const handleSessionContinue = () => {
MeAPI.read();
setTimeoutWarning(false);
};

useEffect(() => {
if (!isAuthenticated(document.cookie)) history.replace('/login');
const calcRemaining = () =>
parseInt(sessionTimeout, 10) - new Date().getTime();
const updateRemaining = () => setTimeRemaining(calcRemaining());
setTimeoutWarning(false);
clearTimeout(sessionTimeoutId.current);
clearInterval(sessionIntervalId.current);
sessionTimeoutId.current = setTimeout(
handleSessionTimeout,
Math.min(calcRemaining() - SESSION_WARNING_DURATION * 1000, MAX_TIMEOUT)
);
sessionIntervalId.current = setInterval(updateRemaining, 1000);
return () => {
clearTimeout(sessionTimeoutId.current);
clearInterval(sessionIntervalId.current);
};
}, [history, sessionTimeout]);

useEffect(() => {
if (timeRemaining !== null && timeRemaining <= 1) {
handleLogout();
}
}, [handleLogout, timeRemaining]);

const brandName = config?.license_info?.product_name;
const alt = brandName ? i18n._(t`${brandName} logo`) : i18n._(t`brand logo`);
Expand All @@ -151,7 +56,7 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
loggedInUser={config?.me}
isAboutDisabled={!config?.version}
onAboutClick={handleAboutModalOpen}
onLogoutClick={handleLogout}
onLogoutClick={logout}
/>
}
/>
Expand All @@ -164,7 +69,7 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
<PageHeaderTools>
<PageHeaderToolsGroup>
<PageHeaderToolsItem>
<Button onClick={handleLogout} variant="tertiary" ouiaId="logout">
<Button onClick={logout} variant="tertiary" ouiaId="logout">
{i18n._(t`Logout`)}
</Button>
</PageHeaderToolsItem>
Expand Down Expand Up @@ -211,8 +116,8 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
<AlertModal
ouiaId="session-expiration-modal"
title={i18n._(t`Your session is about to expire`)}
isOpen={timeoutWarning && sessionTimeout > 0 && timeRemaining !== null}
onClose={handleLogout}
isOpen={sessionCountdown && sessionCountdown > 0}
onClose={logout}
showClose={false}
variant="warning"
actions={[
Expand All @@ -228,17 +133,13 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
ouiaId="session-expiration-logout-button"
key="logout"
variant="secondary"
onClick={handleLogout}
onClick={logout}
>
{i18n._(t`Logout`)}
</Button>,
]}
>
{i18n._(
t`You will be logged out in ${Number(
Math.max(Math.floor(timeRemaining / 1000), 0)
)} seconds due to inactivity.`
)}
{t`You will be logged out in ${sessionCountdown} seconds due to inactivity`}
</AlertModal>
</>
);
Expand Down
15 changes: 10 additions & 5 deletions awx/ui_next/src/components/AppContainer/AppContainer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
import { ConfigAPI, MeAPI } from '../../api';
import { useAuthorizedPath } from '../../contexts/Config';
import AppContainer from './AppContainer';

Expand Down Expand Up @@ -106,19 +106,24 @@ describe('<AppContainer />', () => {
test('logout makes expected call to api client', async () => {
const userMenuButton = 'UserIcon';
const logoutButton = '#logout-button button';

const logout = jest.fn();
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AppContainer />);
wrapper = mountWithContexts(<AppContainer />, {
context: {
session: {
logout,
},
},
});
});

// open the user menu
expect(wrapper.find(logoutButton)).toHaveLength(0);
wrapper.find(userMenuButton).simulate('click');
expect(wrapper.find(logoutButton)).toHaveLength(1);

// logout
wrapper.find(logoutButton).simulate('click');
expect(RootAPI.logout).toHaveBeenCalledTimes(1);
expect(logout).toHaveBeenCalledTimes(1);
});
});
9 changes: 3 additions & 6 deletions awx/ui_next/src/components/ContentError/ContentError.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,12 @@ import {
EmptyStateBody,
} from '@patternfly/react-core';
import { ExclamationTriangleIcon } from '@patternfly/react-icons';
import { RootAPI } from '../../api';
import { useSession } from '../../contexts/Session';
import ErrorDetail from '../ErrorDetail';

async function logout() {
await RootAPI.logout();
window.location.replace('/#/login');
}

function ContentError({ error, children, isNotFound, i18n }) {
const { logout } = useSession();

if (error && error.response && error.response.status === 401) {
if (!error.response.headers['session-timeout']) {
logout();
Expand Down
10 changes: 6 additions & 4 deletions awx/ui_next/src/contexts/Config.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { useLocation, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';

import { ConfigAPI, MeAPI, RootAPI } from '../api';
import { ConfigAPI, MeAPI } from '../api';
import useRequest, { useDismissableError } from '../util/useRequest';
import AlertModal from '../components/AlertModal';
import ErrorDetail from '../components/ErrorDetail';
import { useSession } from './Session';

// eslint-disable-next-line import/prefer-default-export
export const ConfigContext = React.createContext([{}, () => {}]);
export const ConfigContext = React.createContext({});
ConfigContext.displayName = 'ConfigContext';

export const Config = ConfigContext.Consumer;
Expand All @@ -23,6 +24,7 @@ export const useConfig = () => {

export const ConfigProvider = withI18n()(({ i18n, children }) => {
const { pathname } = useLocation();
const { logout } = useSession();

const {
error: configError,
Expand Down Expand Up @@ -55,9 +57,9 @@ export const ConfigProvider = withI18n()(({ i18n, children }) => {

useEffect(() => {
if (error?.response?.status === 401) {
RootAPI.logout();
logout();
}
}, [error]);
}, [error, logout]);

const value = useMemo(() => ({ ...config, isLoading, setConfig }), [
config,
Expand Down
Loading

0 comments on commit 99e82eb

Please sign in to comment.