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 });