mirror of
https://github.com/ansible/awx.git
synced 2026-05-07 01:17:37 -02:30
Add Session context and redirect on auth
This commit is contained in:
@@ -13,6 +13,7 @@ import { i18n } from '@lingui/core';
|
|||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
|
|
||||||
import { ConfigProvider, useAuthorizedPath } from './contexts/Config';
|
import { ConfigProvider, useAuthorizedPath } from './contexts/Config';
|
||||||
|
import { SessionProvider, useSession } from './contexts/Session';
|
||||||
import AppContainer from './components/AppContainer';
|
import AppContainer from './components/AppContainer';
|
||||||
import Background from './components/Background';
|
import Background from './components/Background';
|
||||||
import ContentError from './components/ContentError';
|
import ContentError from './components/ContentError';
|
||||||
@@ -82,16 +83,23 @@ const AuthorizedRoutes = ({ routeConfig }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProtectedRoute = ({ children, ...rest }) =>
|
const ProtectedRoute = ({ children, ...rest }) => {
|
||||||
isAuthenticated(document.cookie) ? (
|
const { authRedirectTo, setAuthRedirectTo } = useSession();
|
||||||
<Route {...rest}>
|
const { pathname } = useLocation();
|
||||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
||||||
{children}
|
if (isAuthenticated(document.cookie)) {
|
||||||
</ErrorBoundary>
|
return (
|
||||||
</Route>
|
<Route {...rest}>
|
||||||
) : (
|
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||||
<Redirect to="/login" />
|
{children}
|
||||||
);
|
</ErrorBoundary>
|
||||||
|
</Route>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authRedirectTo !== null) setAuthRedirectTo(pathname);
|
||||||
|
return <Redirect to="/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
let language = getLanguageWithoutRegionCode(navigator);
|
let language = getLanguageWithoutRegionCode(navigator);
|
||||||
@@ -109,24 +117,26 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<Background>
|
<Background>
|
||||||
<Switch>
|
<SessionProvider>
|
||||||
<Route exact strict path="/*/">
|
<Switch>
|
||||||
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
|
<Route exact strict path="/*/">
|
||||||
</Route>
|
<Redirect to={`${pathname.slice(0, -1)}${search}${hash}`} />
|
||||||
<Route path="/login">
|
</Route>
|
||||||
<Login isAuthenticated={isAuthenticated} />
|
<Route path="/login">
|
||||||
</Route>
|
<Login isAuthenticated={isAuthenticated} />
|
||||||
<Route exact path="/">
|
</Route>
|
||||||
<Redirect to="/home" />
|
<Route exact path="/">
|
||||||
</Route>
|
<Redirect to="/home" />
|
||||||
<ProtectedRoute>
|
</Route>
|
||||||
<ConfigProvider>
|
<ProtectedRoute>
|
||||||
<AppContainer navRouteConfig={getRouteConfig()}>
|
<ConfigProvider>
|
||||||
<AuthorizedRoutes routeConfig={getRouteConfig()} />
|
<AppContainer navRouteConfig={getRouteConfig()}>
|
||||||
</AppContainer>
|
<AuthorizedRoutes routeConfig={getRouteConfig()} />
|
||||||
</ConfigProvider>
|
</AppContainer>
|
||||||
</ProtectedRoute>
|
</ConfigProvider>
|
||||||
</Switch>
|
</ProtectedRoute>
|
||||||
|
</Switch>
|
||||||
|
</SessionProvider>
|
||||||
</Background>
|
</Background>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../testUtils/enzymeHelpers';
|
||||||
|
import * as SessionContext from './contexts/Session';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
jest.mock('./api');
|
jest.mock('./api');
|
||||||
|
|
||||||
describe('<App />', () => {
|
describe('<App />', () => {
|
||||||
test('renders ok', async () => {
|
test('renders ok', async () => {
|
||||||
|
const contextValues = {
|
||||||
|
setAuthRedirectTo: jest.fn(),
|
||||||
|
isSessionExpired: false,
|
||||||
|
};
|
||||||
|
jest
|
||||||
|
.spyOn(SessionContext, 'useSession')
|
||||||
|
.mockImplementation(() => contextValues);
|
||||||
|
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<App />);
|
wrapper = mountWithContexts(<App />);
|
||||||
});
|
});
|
||||||
expect(wrapper.length).toBe(1);
|
expect(wrapper.length).toBe(1);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useHistory, withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Nav,
|
Nav,
|
||||||
@@ -11,133 +11,38 @@ import {
|
|||||||
PageHeaderToolsItem,
|
PageHeaderToolsItem,
|
||||||
PageSidebar,
|
PageSidebar,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { t } from '@lingui/macro';
|
import { t, Plural } from '@lingui/macro';
|
||||||
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { MeAPI, RootAPI } from '../../api';
|
|
||||||
import { useConfig, useAuthorizedPath } from '../../contexts/Config';
|
import { useConfig, useAuthorizedPath } from '../../contexts/Config';
|
||||||
import { SESSION_TIMEOUT_KEY } from '../../constants';
|
import { useSession } from '../../contexts/Session';
|
||||||
import { isAuthenticated } from '../../util/auth';
|
|
||||||
import issuePendoIdentity from '../../util/issuePendoIdentity';
|
import issuePendoIdentity from '../../util/issuePendoIdentity';
|
||||||
import About from '../About';
|
import About from '../About';
|
||||||
import AlertModal from '../AlertModal';
|
|
||||||
import BrandLogo from './BrandLogo';
|
import BrandLogo from './BrandLogo';
|
||||||
import NavExpandableGroup from './NavExpandableGroup';
|
import NavExpandableGroup from './NavExpandableGroup';
|
||||||
import PageHeaderToolbar from './PageHeaderToolbar';
|
import PageHeaderToolbar from './PageHeaderToolbar';
|
||||||
|
import AlertModal from '../AlertModal';
|
||||||
// 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)`
|
const PageHeader = styled(PFPageHeader)`
|
||||||
& .pf-c-page__header-brand-link {
|
& .pf-c-page__header-brand-link {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: inherit;
|
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 }) {
|
function AppContainer({ navRouteConfig = [], children }) {
|
||||||
const history = useHistory();
|
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
const { logout, handleSessionContinue, sessionCountdown } = useSession();
|
||||||
|
|
||||||
const isReady = !!config.license_info;
|
const isReady = !!config.license_info;
|
||||||
const isSidebarVisible = useAuthorizedPath();
|
const isSidebarVisible = useAuthorizedPath();
|
||||||
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
|
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 handleAboutModalOpen = () => setIsAboutModalOpen(true);
|
||||||
const handleAboutModalClose = () => setIsAboutModalOpen(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if ('analytics_status' in config) {
|
if ('analytics_status' in config) {
|
||||||
@@ -159,7 +64,7 @@ function AppContainer({ navRouteConfig = [], children }) {
|
|||||||
loggedInUser={config?.me}
|
loggedInUser={config?.me}
|
||||||
isAboutDisabled={!config?.version}
|
isAboutDisabled={!config?.version}
|
||||||
onAboutClick={handleAboutModalOpen}
|
onAboutClick={handleAboutModalOpen}
|
||||||
onLogoutClick={handleLogout}
|
onLogoutClick={logout}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -172,7 +77,7 @@ function AppContainer({ navRouteConfig = [], children }) {
|
|||||||
<PageHeaderTools>
|
<PageHeaderTools>
|
||||||
<PageHeaderToolsGroup>
|
<PageHeaderToolsGroup>
|
||||||
<PageHeaderToolsItem>
|
<PageHeaderToolsItem>
|
||||||
<Button onClick={handleLogout} variant="tertiary" ouiaId="logout">
|
<Button onClick={logout} variant="tertiary" ouiaId="logout">
|
||||||
{t`Logout`}
|
{t`Logout`}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeaderToolsItem>
|
</PageHeaderToolsItem>
|
||||||
@@ -219,8 +124,8 @@ function AppContainer({ navRouteConfig = [], children }) {
|
|||||||
<AlertModal
|
<AlertModal
|
||||||
ouiaId="session-expiration-modal"
|
ouiaId="session-expiration-modal"
|
||||||
title={t`Your session is about to expire`}
|
title={t`Your session is about to expire`}
|
||||||
isOpen={timeoutWarning && sessionTimeout > 0 && timeRemaining !== null}
|
isOpen={sessionCountdown && sessionCountdown > 0}
|
||||||
onClose={handleLogout}
|
onClose={logout}
|
||||||
showClose={false}
|
showClose={false}
|
||||||
variant="warning"
|
variant="warning"
|
||||||
actions={[
|
actions={[
|
||||||
@@ -236,15 +141,17 @@ function AppContainer({ navRouteConfig = [], children }) {
|
|||||||
ouiaId="session-expiration-logout-button"
|
ouiaId="session-expiration-logout-button"
|
||||||
key="logout"
|
key="logout"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleLogout}
|
onClick={logout}
|
||||||
>
|
>
|
||||||
{t`Logout`}
|
{t`Logout`}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{t`You will be logged out in ${Number(
|
<Plural
|
||||||
Math.max(Math.floor(timeRemaining / 1000), 0)
|
value={sessionCountdown}
|
||||||
)} seconds due to inactivity.`}
|
one="You will be logged out in # second due to inactivity"
|
||||||
|
other="You will be logged out in # seconds due to inactivity"
|
||||||
|
/>
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -181,12 +181,17 @@ describe('<AppContainer />', () => {
|
|||||||
test('logout makes expected call to api client', async () => {
|
test('logout makes expected call to api client', async () => {
|
||||||
const userMenuButton = 'UserIcon';
|
const userMenuButton = 'UserIcon';
|
||||||
const logoutButton = '#logout-button button';
|
const logoutButton = '#logout-button button';
|
||||||
|
const logout = jest.fn();
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<AppContainer />);
|
wrapper = mountWithContexts(<AppContainer />, {
|
||||||
|
context: {
|
||||||
|
session: {
|
||||||
|
logout,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// open the user menu
|
// open the user menu
|
||||||
expect(wrapper.find(logoutButton)).toHaveLength(0);
|
expect(wrapper.find(logoutButton)).toHaveLength(0);
|
||||||
wrapper.find(userMenuButton).simulate('click');
|
wrapper.find(userMenuButton).simulate('click');
|
||||||
@@ -194,6 +199,6 @@ describe('<AppContainer />', () => {
|
|||||||
|
|
||||||
// logout
|
// logout
|
||||||
wrapper.find(logoutButton).simulate('click');
|
wrapper.find(logoutButton).simulate('click');
|
||||||
expect(RootAPI.logout).toHaveBeenCalledTimes(1);
|
expect(logout).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,15 +10,12 @@ import {
|
|||||||
EmptyStateBody,
|
EmptyStateBody,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { ExclamationTriangleIcon } from '@patternfly/react-icons';
|
import { ExclamationTriangleIcon } from '@patternfly/react-icons';
|
||||||
import { RootAPI } from '../../api';
|
import { useSession } from '../../contexts/Session';
|
||||||
import ErrorDetail from '../ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await RootAPI.logout();
|
|
||||||
window.location.replace('#/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContentError({ error, children, isNotFound }) {
|
function ContentError({ error, children, isNotFound }) {
|
||||||
|
const { logout } = useSession();
|
||||||
|
|
||||||
if (error && error.response && error.response.status === 401) {
|
if (error && error.response && error.response.status === 401) {
|
||||||
if (!error.response.headers['session-timeout']) {
|
if (!error.response.headers['session-timeout']) {
|
||||||
logout();
|
logout();
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { useRouteMatch } from 'react-router-dom';
|
|||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { ConfigAPI, MeAPI, RootAPI } from '../api';
|
import { ConfigAPI, MeAPI } from '../api';
|
||||||
import useRequest, { useDismissableError } from '../util/useRequest';
|
import useRequest, { useDismissableError } from '../util/useRequest';
|
||||||
import AlertModal from '../components/AlertModal';
|
import AlertModal from '../components/AlertModal';
|
||||||
import ErrorDetail from '../components/ErrorDetail';
|
import ErrorDetail from '../components/ErrorDetail';
|
||||||
|
import { useSession } from './Session';
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
export const ConfigContext = React.createContext([{}, () => {}]);
|
export const ConfigContext = React.createContext({});
|
||||||
ConfigContext.displayName = 'ConfigContext';
|
ConfigContext.displayName = 'ConfigContext';
|
||||||
|
|
||||||
export const Config = ConfigContext.Consumer;
|
export const Config = ConfigContext.Consumer;
|
||||||
@@ -22,6 +23,8 @@ export const useConfig = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ConfigProvider = ({ children }) => {
|
export const ConfigProvider = ({ children }) => {
|
||||||
|
const { logout } = useSession();
|
||||||
|
|
||||||
const { error: configError, isLoading, request, result: config } = useRequest(
|
const { error: configError, isLoading, request, result: config } = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const [
|
const [
|
||||||
@@ -45,9 +48,9 @@ export const ConfigProvider = ({ children }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error?.response?.status === 401) {
|
if (error?.response?.status === 401) {
|
||||||
RootAPI.logout();
|
logout();
|
||||||
}
|
}
|
||||||
}, [error]);
|
}, [error, logout]);
|
||||||
|
|
||||||
const value = useMemo(() => ({ ...config, request, isLoading }), [
|
const value = useMemo(() => ({ ...config, request, isLoading }), [
|
||||||
config,
|
config,
|
||||||
|
|||||||
163
awx/ui_next/src/contexts/Session.jsx
Normal file
163
awx/ui_next/src/contexts/Session.jsx
Normal file
@@ -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 (
|
||||||
|
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -6,6 +6,7 @@ import { Formik } from 'formik';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import sanitizeHtml from 'sanitize-html';
|
import sanitizeHtml from 'sanitize-html';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Brand,
|
Brand,
|
||||||
LoginMainFooterLinksItem,
|
LoginMainFooterLinksItem,
|
||||||
LoginForm,
|
LoginForm,
|
||||||
@@ -28,6 +29,7 @@ import useRequest, { useDismissableError } from '../../util/useRequest';
|
|||||||
import { AuthAPI, RootAPI } from '../../api';
|
import { AuthAPI, RootAPI } from '../../api';
|
||||||
import AlertModal from '../../components/AlertModal';
|
import AlertModal from '../../components/AlertModal';
|
||||||
import ErrorDetail from '../../components/ErrorDetail';
|
import ErrorDetail from '../../components/ErrorDetail';
|
||||||
|
import { useSession } from '../../contexts/Session';
|
||||||
|
|
||||||
const loginLogoSrc = '/static/media/logo-login.svg';
|
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 {
|
const {
|
||||||
isLoading: isCustomLoginInfoLoading,
|
isLoading: isCustomLoginInfoLoading,
|
||||||
error: customLoginInfoError,
|
error: customLoginInfoError,
|
||||||
@@ -110,6 +114,7 @@ function AWXLogin({ alt, isAuthenticated }) {
|
|||||||
const handleSubmit = async values => {
|
const handleSubmit = async values => {
|
||||||
dismissAuthError();
|
dismissAuthError();
|
||||||
await authenticate(values);
|
await authenticate(values);
|
||||||
|
setAuthRedirectTo('/home');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCustomLoginInfoLoading) {
|
if (isCustomLoginInfoLoading) {
|
||||||
@@ -120,7 +125,7 @@ function AWXLogin({ alt, isAuthenticated }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (isAuthenticated(document.cookie)) {
|
if (isAuthenticated(document.cookie)) {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to={authRedirectTo || '/'} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let helperText;
|
let helperText;
|
||||||
@@ -151,6 +156,13 @@ function AWXLogin({ alt, isAuthenticated }) {
|
|||||||
subtitle={t`Please log in`}
|
subtitle={t`Please log in`}
|
||||||
/>
|
/>
|
||||||
<LoginMainBody>
|
<LoginMainBody>
|
||||||
|
{isSessionExpired.current ? (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
isInline
|
||||||
|
title={t`Your session has expired. Please log in to continue where you left off.`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
password: '',
|
password: '',
|
||||||
|
|||||||
@@ -35,3 +35,12 @@ jest.doMock('./contexts/Config', () => ({
|
|||||||
useConfig: () => React.useContext(MockConfigContext),
|
useConfig: () => React.useContext(MockConfigContext),
|
||||||
useAuthorizedPath: jest.fn(),
|
useAuthorizedPath: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ?
|
||||||
|
const MockSessionContext = React.createContext({});
|
||||||
|
jest.doMock('./contexts/Session', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
SessionContext: MockSessionContext,
|
||||||
|
SessionProvider: MockSessionContext.Provider,
|
||||||
|
useSession: () => React.useContext(MockSessionContext),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { I18nProvider } from '@lingui/react';
|
|||||||
import { i18n } from '@lingui/core';
|
import { i18n } from '@lingui/core';
|
||||||
import { en } from 'make-plural/plurals';
|
import { en } from 'make-plural/plurals';
|
||||||
import english from '../src/locales/en/messages';
|
import english from '../src/locales/en/messages';
|
||||||
|
import { SessionProvider } from '../src/contexts/Session';
|
||||||
import { ConfigProvider } from '../src/contexts/Config';
|
import { ConfigProvider } from '../src/contexts/Config';
|
||||||
|
|
||||||
i18n.loadLocaleData({ en: { plurals: en } });
|
i18n.loadLocaleData({ en: { plurals: en } });
|
||||||
@@ -57,10 +58,14 @@ const defaultContexts = {
|
|||||||
},
|
},
|
||||||
toJSON: () => '/router/',
|
toJSON: () => '/router/',
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
isSessionExpired: false,
|
||||||
|
logout: () => {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function wrapContexts(node, context) {
|
function wrapContexts(node, context) {
|
||||||
const { config, router } = context;
|
const { config, router, session } = context;
|
||||||
class Wrap extends React.Component {
|
class Wrap extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
// eslint-disable-next-line react/no-this-in-sfc
|
// eslint-disable-next-line react/no-this-in-sfc
|
||||||
@@ -69,17 +74,21 @@ function wrapContexts(node, context) {
|
|||||||
if (router.history) {
|
if (router.history) {
|
||||||
return (
|
return (
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<ConfigProvider value={config}>
|
<SessionProvider value={session}>
|
||||||
<Router history={router.history}>{component}</Router>
|
<ConfigProvider value={config}>
|
||||||
</ConfigProvider>
|
<Router history={router.history}>{component}</Router>
|
||||||
|
</ConfigProvider>
|
||||||
|
</SessionProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<ConfigProvider value={config}>
|
<SessionProvider value={session}>
|
||||||
<MemoryRouter>{component}</MemoryRouter>
|
<ConfigProvider value={config}>
|
||||||
</ConfigProvider>
|
<MemoryRouter>{component}</MemoryRouter>
|
||||||
|
</ConfigProvider>
|
||||||
|
</SessionProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,6 +131,7 @@ export function mountWithContexts(node, options = {}) {
|
|||||||
}).isRequired,
|
}).isRequired,
|
||||||
history: shape({}),
|
history: shape({}),
|
||||||
}),
|
}),
|
||||||
|
session: shape({}),
|
||||||
...options.childContextTypes,
|
...options.childContextTypes,
|
||||||
};
|
};
|
||||||
return mount(wrapContexts(node, context), { context, childContextTypes });
|
return mount(wrapContexts(node, context), { context, childContextTypes });
|
||||||
|
|||||||
Reference in New Issue
Block a user