diff --git a/awx/ui_next/docs/APP_ARCHITECTURE.md b/awx/ui_next/docs/APP_ARCHITECTURE.md
new file mode 100644
index 0000000000..4be18f17d2
--- /dev/null
+++ b/awx/ui_next/docs/APP_ARCHITECTURE.md
@@ -0,0 +1,27 @@
+# Application Architecture
+
+## Local Storage Integration
+The `useStorage` hook integrates with the browser's localStorage api.
+It accepts a localStorage key as its only argument and returns a state
+variable and setter function for that state variable. The hook enables
+bidirectional data transfer between tabs via an event listener that
+is registered with the Web Storage api.
+
+
+
+
+The `useStorage` hook currently lives in the `AppContainer` component. It
+can be relocated to a more general location should and if the need
+ever arise
+
+## Session Expiration
+Session timeout state is communicated to the client in the HTTP(S)
+response headers. Every HTTP(S) response is intercepted to read the
+session expiration time before being passed into the rest of the
+application. A timeout date is computed from the intercepted HTTP(S)
+headers and is pushed into local storage, where it can be read using
+standard Web Storage apis or other utilities, such as `useStorage`.
+
+
+
+
diff --git a/awx/ui_next/docs/images/sessionExpiration.png b/awx/ui_next/docs/images/sessionExpiration.png
new file mode 100644
index 0000000000..fa740c44a5
Binary files /dev/null and b/awx/ui_next/docs/images/sessionExpiration.png differ
diff --git a/awx/ui_next/docs/images/useStorage.png b/awx/ui_next/docs/images/useStorage.png
new file mode 100644
index 0000000000..712b477121
Binary files /dev/null and b/awx/ui_next/docs/images/useStorage.png differ
diff --git a/awx/ui_next/src/api/Base.js b/awx/ui_next/src/api/Base.js
index 56492715fb..cd0a76c1ec 100644
--- a/awx/ui_next/src/api/Base.js
+++ b/awx/ui_next/src/api/Base.js
@@ -1,6 +1,13 @@
import axios from 'axios';
+import { SESSION_TIMEOUT_KEY } from '../constants';
import { encodeQueryString } from '../util/qs';
+import debounce from '../util/debounce';
+
+const updateStorage = debounce((key, val) => {
+ window.localStorage.setItem(key, val);
+ window.dispatchEvent(new Event('storage'));
+}, 500);
const defaultHttp = axios.create({
xsrfCookieName: 'csrftoken',
@@ -10,6 +17,15 @@ const defaultHttp = axios.create({
},
});
+defaultHttp.interceptors.response.use(response => {
+ const timeout = response?.headers['session-timeout'];
+ if (timeout) {
+ const timeoutDate = new Date().getTime() + timeout * 1000;
+ updateStorage(SESSION_TIMEOUT_KEY, String(timeoutDate));
+ }
+ return response;
+});
+
class Base {
constructor(http = defaultHttp, baseURL) {
this.http = http;
diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.jsx
index b107fd0f40..0abd198c07 100644
--- a/awx/ui_next/src/components/AppContainer/AppContainer.jsx
+++ b/awx/ui_next/src/components/AppContainer/AppContainer.jsx
@@ -1,6 +1,7 @@
-import React, { useEffect, useState, useCallback } from 'react';
+import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useHistory, useLocation, withRouter } from 'react-router-dom';
import {
+ Button,
Nav,
NavList,
Page,
@@ -13,6 +14,8 @@ import styled from 'styled-components';
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
import { ConfigProvider } from '../../contexts/Config';
+import { SESSION_TIMEOUT_KEY } from '../../constants';
+import { isAuthenticated } from '../../util/auth';
import About from '../About';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
@@ -20,6 +23,17 @@ 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;
+
const PageHeader = styled(PFPageHeader)`
& .pf-c-page__header-brand-link {
color: inherit;
@@ -30,6 +44,45 @@ const PageHeader = styled(PFPageHeader)`
}
`;
+/**
+ * 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 { pathname } = useLocation();
@@ -38,14 +91,51 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
const [isReady, setIsReady] = 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 handleConfigErrorClose = () => setConfigError(null);
+ const handleSessionTimeout = () => setTimeoutWarning(true);
const handleLogout = useCallback(async () => {
await RootAPI.logout();
- history.replace('/login');
- }, [history]);
+ 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(() => {
const loadConfig = async () => {
@@ -128,6 +218,31 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
{i18n._(t`Failed to retrieve configuration.`)}
+ 0 && timeRemaining !== null}
+ onClose={handleLogout}
+ showClose={false}
+ variant="warning"
+ actions={[
+ ,
+ ,
+ ]}
+ >
+ {i18n._(
+ 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/constants.js b/awx/ui_next/src/constants.js
index b7fa1fc83e..663814439e 100644
--- a/awx/ui_next/src/constants.js
+++ b/awx/ui_next/src/constants.js
@@ -7,3 +7,5 @@ export const JOB_TYPE_URL_SEGMENTS = {
ad_hoc_command: 'command',
workflow_job: 'workflow',
};
+
+export const SESSION_TIMEOUT_KEY = 'awx-session-timeout';
diff --git a/awx/ui_next/src/util/debounce.js b/awx/ui_next/src/util/debounce.js
new file mode 100644
index 0000000000..d2ab3441ca
--- /dev/null
+++ b/awx/ui_next/src/util/debounce.js
@@ -0,0 +1,19 @@
+/**
+ * The debounce utility creates a debounced version of the provided
+ * function. The debounced function delays invocation until after
+ * the given time interval (milliseconds) has elapsed since the last
+ * time the function was called. This means that if you call the
+ * debounced function repeatedly, it will only run once after it
+ * stops being called.
+ */
+const debounce = (func, interval) => {
+ let timeout;
+ return (...args) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ func(...args);
+ }, interval);
+ };
+};
+
+export default debounce;
diff --git a/awx/ui_next/src/util/debounce.test.js b/awx/ui_next/src/util/debounce.test.js
new file mode 100644
index 0000000000..a5c448dcf5
--- /dev/null
+++ b/awx/ui_next/src/util/debounce.test.js
@@ -0,0 +1,40 @@
+import debounce from './debounce';
+
+jest.useFakeTimers();
+
+describe('debounce', () => {
+ test('it debounces', () => {
+ let count = 0;
+ const func = increment => {
+ count += increment;
+ };
+ const debounced = debounce(func, 1000);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ expect(count).toEqual(0);
+ jest.advanceTimersByTime(1000);
+ expect(count).toEqual(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ jest.advanceTimersByTime(1000);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ jest.advanceTimersByTime(1000);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ debounced(2);
+ jest.advanceTimersByTime(1000);
+ expect(count).toEqual(8);
+ });
+});