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. + + +![Sequence Diagram for useStorage](images/useStorage.png) + +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`. + + +![Sequence Diagram for session expiration](images/sessionExpiration.png) + 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); + }); +});