diff --git a/awx/ui_next/src/App.jsx b/awx/ui_next/src/App.jsx
index e98fdc3035..ca280f7c15 100644
--- a/awx/ui_next/src/App.jsx
+++ b/awx/ui_next/src/App.jsx
@@ -13,6 +13,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 ContentError from './components/ContentError';
@@ -82,16 +83,23 @@ const AuthorizedRoutes = ({ routeConfig }) => {
);
};
-const ProtectedRoute = ({ children, ...rest }) =>
- isAuthenticated(document.cookie) ? (
-
-
- {children}
-
-
- ) : (
-
- );
+const ProtectedRoute = ({ children, ...rest }) => {
+ const { authRedirectTo, setAuthRedirectTo } = useSession();
+ const { pathname } = useLocation();
+
+ if (isAuthenticated(document.cookie)) {
+ return (
+
+
+ {children}
+
+
+ )
+ }
+
+ if (authRedirectTo !== null) setAuthRedirectTo(pathname);
+ return ;
+};
function App() {
let language = getLanguageWithoutRegionCode(navigator);
@@ -109,24 +117,26 @@ function App() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/awx/ui_next/src/App.test.jsx b/awx/ui_next/src/App.test.jsx
index a6fe414b39..0e732b1574 100644
--- a/awx/ui_next/src/App.test.jsx
+++ b/awx/ui_next/src/App.test.jsx
@@ -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('', () => {
test('renders ok', async () => {
+ const contextValues = {
+ setAuthRedirectTo: jest.fn(),
+ isSessionExpired: false,
+ };
+ jest
+ .spyOn(SessionContext, 'useSession')
+ .mockImplementation(() => contextValues);
+
let wrapper;
await act(async () => {
wrapper = mountWithContexts();
});
expect(wrapper.length).toBe(1);
+ jest.clearAllMocks();
+ wrapper.unmount();
});
});
diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.jsx
index e697d27de7..6dd0632af4 100644
--- a/awx/ui_next/src/components/AppContainer/AppContainer.jsx
+++ b/awx/ui_next/src/components/AppContainer/AppContainer.jsx
@@ -1,5 +1,5 @@
-import React, { useEffect, useState, useCallback, useRef } from 'react';
-import { useHistory, withRouter } from 'react-router-dom';
+import React, { useState, useEffect } from 'react';
+import { withRouter } from 'react-router-dom';
import {
Button,
Nav,
@@ -11,133 +11,38 @@ import {
PageHeaderToolsItem,
PageSidebar,
} from '@patternfly/react-core';
-import { t } from '@lingui/macro';
+import { t, Plural } from '@lingui/macro';
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 issuePendoIdentity from '../../util/issuePendoIdentity';
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({ 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]);
useEffect(() => {
if ('analytics_status' in config) {
@@ -159,7 +64,7 @@ function AppContainer({ navRouteConfig = [], children }) {
loggedInUser={config?.me}
isAboutDisabled={!config?.version}
onAboutClick={handleAboutModalOpen}
- onLogoutClick={handleLogout}
+ onLogoutClick={logout}
/>
}
/>
@@ -172,7 +77,7 @@ function AppContainer({ navRouteConfig = [], children }) {
-
@@ -219,8 +124,8 @@ function AppContainer({ navRouteConfig = [], children }) {
0 && timeRemaining !== null}
- onClose={handleLogout}
+ isOpen={sessionCountdown && sessionCountdown > 0}
+ onClose={logout}
showClose={false}
variant="warning"
actions={[
@@ -236,15 +141,17 @@ function AppContainer({ navRouteConfig = [], children }) {
ouiaId="session-expiration-logout-button"
key="logout"
variant="secondary"
- onClick={handleLogout}
+ onClick={logout}
>
{t`Logout`}
,
]}
>
- {t`You will be logged out in ${Number(
- Math.max(Math.floor(timeRemaining / 1000), 0)
- )} seconds due to inactivity.`}
+
>
);
diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx
index df5039a6bb..ef168f0278 100644
--- a/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx
+++ b/awx/ui_next/src/components/AppContainer/AppContainer.test.jsx
@@ -181,12 +181,17 @@ describe('', () => {
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();
+ wrapper = mountWithContexts(, {
+ context: {
+ session: {
+ logout,
+ },
+ },
+ });
});
-
// open the user menu
expect(wrapper.find(logoutButton)).toHaveLength(0);
wrapper.find(userMenuButton).simulate('click');
@@ -194,6 +199,6 @@ describe('', () => {
// logout
wrapper.find(logoutButton).simulate('click');
- expect(RootAPI.logout).toHaveBeenCalledTimes(1);
+ expect(logout).toHaveBeenCalledTimes(1);
});
});
diff --git a/awx/ui_next/src/components/ContentError/ContentError.jsx b/awx/ui_next/src/components/ContentError/ContentError.jsx
index 1d54e3ba10..6ac256ec26 100644
--- a/awx/ui_next/src/components/ContentError/ContentError.jsx
+++ b/awx/ui_next/src/components/ContentError/ContentError.jsx
@@ -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 }) {
+ const { logout } = useSession();
+
if (error && error.response && error.response.status === 401) {
if (!error.response.headers['session-timeout']) {
logout();
diff --git a/awx/ui_next/src/contexts/Config.jsx b/awx/ui_next/src/contexts/Config.jsx
index f95e5017a5..e87c2a24f2 100644
--- a/awx/ui_next/src/contexts/Config.jsx
+++ b/awx/ui_next/src/contexts/Config.jsx
@@ -3,13 +3,14 @@ import { useRouteMatch } from 'react-router-dom';
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;
@@ -22,6 +23,8 @@ export const useConfig = () => {
};
export const ConfigProvider = ({ children }) => {
+ const { logout } = useSession();
+
const { error: configError, isLoading, request, result: config } = useRequest(
useCallback(async () => {
const [
@@ -45,9 +48,9 @@ export const ConfigProvider = ({ children }) => {
useEffect(() => {
if (error?.response?.status === 401) {
- RootAPI.logout();
+ logout();
}
- }, [error]);
+ }, [error, logout]);
const value = useMemo(() => ({ ...config, request, isLoading }), [
config,
diff --git a/awx/ui_next/src/contexts/Session.jsx b/awx/ui_next/src/contexts/Session.jsx
new file mode 100644
index 0000000000..54dd72fa59
--- /dev/null
+++ b/awx/ui_next/src/contexts/Session.jsx
@@ -0,0 +1,163 @@
+import React, {
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ useRef,
+ useCallback,
+} from 'react';
+import { useHistory } from 'react-router-dom';
+import { RootAPI, MeAPI } from '../api';
+import { SESSION_TIMEOUT_KEY } from '../constants';
+import { isAuthenticated } from '../util/auth';
+
+// 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;
+
+/**
+ * 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];
+}
+
+const SessionContext = React.createContext({});
+SessionContext.displayName = 'SessionContext';
+
+function SessionProvider({ children }) {
+ const history = useHistory();
+ const isSessionExpired = useRef(false);
+ const sessionTimeoutId = useRef();
+ const sessionIntervalId = useRef();
+ const [sessionTimeout, setSessionTimeout] = useStorage(SESSION_TIMEOUT_KEY);
+ const [sessionCountdown, setSessionCountdown] = useState(0);
+ const [authRedirectTo, setAuthRedirectTo] = useState('/home');
+
+ const logout = useCallback(async () => {
+ if (!isSessionExpired.current) setAuthRedirectTo(null);
+ await RootAPI.logout();
+ setSessionTimeout(null);
+ setSessionCountdown(0);
+ clearTimeout(sessionTimeoutId.current);
+ clearInterval(sessionIntervalId.current);
+ }, [setSessionTimeout, setSessionCountdown]);
+
+ useEffect(() => {
+ if (!isAuthenticated(document.cookie)) {
+ history.replace('/login');
+ return () => {};
+ }
+
+ const calcRemaining = () =>
+ parseInt(sessionTimeout, 10) - new Date().getTime();
+
+ const handleSessionTimeout = () => {
+ let countDown = SESSION_WARNING_DURATION;
+ setSessionCountdown(countDown);
+
+ sessionIntervalId.current = setInterval(() => {
+ if (countDown > 0) {
+ setSessionCountdown(--countDown);
+ } else {
+ isSessionExpired.current = true;
+ logout();
+ }
+ }, 1000);
+ };
+
+ clearTimeout(sessionTimeoutId.current);
+ clearInterval(sessionIntervalId.current);
+
+ isSessionExpired.current = false;
+ sessionTimeoutId.current = setTimeout(
+ handleSessionTimeout,
+ Math.min(calcRemaining() - SESSION_WARNING_DURATION * 1000, MAX_TIMEOUT)
+ );
+
+ return () => {
+ clearTimeout(sessionTimeoutId.current);
+ clearInterval(sessionIntervalId.current);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [history, sessionTimeout]);
+
+ const handleSessionContinue = useCallback(async () => {
+ await MeAPI.read();
+ setSessionCountdown(0);
+ clearTimeout(sessionTimeoutId.current);
+ clearInterval(sessionIntervalId.current);
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ logout,
+ authRedirectTo,
+ setAuthRedirectTo,
+ handleSessionContinue,
+ sessionCountdown,
+ isSessionExpired,
+ }),
+ [
+ logout,
+ authRedirectTo,
+ setAuthRedirectTo,
+ handleSessionContinue,
+ sessionCountdown,
+ isSessionExpired,
+ ]
+ );
+
+ return (
+ {children}
+ );
+}
+
+function useSession() {
+ const context = useContext(SessionContext);
+ if (context === undefined) {
+ throw new Error('useSession must be used within a SessionProvider');
+ }
+ return context;
+}
+
+export { SessionContext, SessionProvider, useSession };
diff --git a/awx/ui_next/src/screens/Login/Login.jsx b/awx/ui_next/src/screens/Login/Login.jsx
index 024741ca11..f74a74c17c 100644
--- a/awx/ui_next/src/screens/Login/Login.jsx
+++ b/awx/ui_next/src/screens/Login/Login.jsx
@@ -6,6 +6,7 @@ import { Formik } from 'formik';
import styled from 'styled-components';
import sanitizeHtml from 'sanitize-html';
import {
+ Alert,
Brand,
LoginMainFooterLinksItem,
LoginForm,
@@ -28,6 +29,7 @@ import useRequest, { useDismissableError } from '../../util/useRequest';
import { AuthAPI, RootAPI } from '../../api';
import AlertModal from '../../components/AlertModal';
import ErrorDetail from '../../components/ErrorDetail';
+import { useSession } from '../../contexts/Session';
const loginLogoSrc = '/static/media/logo-login.svg';
@@ -37,7 +39,9 @@ const Login = styled(PFLogin)`
}
`;
-function AWXLogin({ alt, isAuthenticated }) {
+function AWXLogin({ alt, i18n, isAuthenticated }) {
+ const { authRedirectTo, isSessionExpired, setAuthRedirectTo } = useSession();
+
const {
isLoading: isCustomLoginInfoLoading,
error: customLoginInfoError,
@@ -110,6 +114,7 @@ function AWXLogin({ alt, isAuthenticated }) {
const handleSubmit = async values => {
dismissAuthError();
await authenticate(values);
+ setAuthRedirectTo('/home');
};
if (isCustomLoginInfoLoading) {
@@ -120,7 +125,7 @@ function AWXLogin({ alt, isAuthenticated }) {
return null;
}
if (isAuthenticated(document.cookie)) {
- return ;
+ return ;
}
let helperText;
@@ -151,6 +156,13 @@ function AWXLogin({ alt, isAuthenticated }) {
subtitle={t`Please log in`}
/>
+ {isSessionExpired.current ? (
+
+ ) : null}
({
useConfig: () => React.useContext(MockConfigContext),
useAuthorizedPath: jest.fn(),
}));
+
+// ?
+const MockSessionContext = React.createContext({});
+jest.doMock('./contexts/Session', () => ({
+ __esModule: true,
+ SessionContext: MockSessionContext,
+ SessionProvider: MockSessionContext.Provider,
+ useSession: () => React.useContext(MockSessionContext),
+}));
diff --git a/awx/ui_next/testUtils/enzymeHelpers.jsx b/awx/ui_next/testUtils/enzymeHelpers.jsx
index 1184deb998..18deb63e0e 100644
--- a/awx/ui_next/testUtils/enzymeHelpers.jsx
+++ b/awx/ui_next/testUtils/enzymeHelpers.jsx
@@ -10,6 +10,7 @@ import { I18nProvider } from '@lingui/react';
import { i18n } from '@lingui/core';
import { en } from 'make-plural/plurals';
import english from '../src/locales/en/messages';
+import { SessionProvider } from '../src/contexts/Session';
import { ConfigProvider } from '../src/contexts/Config';
i18n.loadLocaleData({ en: { plurals: en } });
@@ -57,10 +58,14 @@ const defaultContexts = {
},
toJSON: () => '/router/',
},
+ session: {
+ isSessionExpired: false,
+ logout: () => {},
+ },
};
function wrapContexts(node, context) {
- const { config, router } = context;
+ const { config, router, session } = context;
class Wrap extends React.Component {
render() {
// eslint-disable-next-line react/no-this-in-sfc
@@ -69,17 +74,21 @@ function wrapContexts(node, context) {
if (router.history) {
return (
-
- {component}
-
+
+
+ {component}
+
+
);
}
return (
-
- {component}
-
+
+
+ {component}
+
+
);
}
@@ -122,6 +131,7 @@ export function mountWithContexts(node, options = {}) {
}).isRequired,
history: shape({}),
}),
+ session: shape({}),
...options.childContextTypes,
};
return mount(wrapContexts(node, context), { context, childContextTypes });