mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 19:30:39 -03:30
Merge pull request #8250 from jakemcdermott/session-timeout
Add session timeout support Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
de0967a587
27
awx/ui_next/docs/APP_ARCHITECTURE.md
Normal file
27
awx/ui_next/docs/APP_ARCHITECTURE.md
Normal file
@ -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`.
|
||||
|
||||
|
||||

|
||||
|
||||
BIN
awx/ui_next/docs/images/sessionExpiration.png
Normal file
BIN
awx/ui_next/docs/images/sessionExpiration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
awx/ui_next/docs/images/useStorage.png
Normal file
BIN
awx/ui_next/docs/images/useStorage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
@ -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;
|
||||
|
||||
@ -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.`)}
|
||||
<ErrorDetail error={configError} />
|
||||
</AlertModal>
|
||||
<AlertModal
|
||||
title={i18n._(t`Your session is about to expire`)}
|
||||
isOpen={timeoutWarning && sessionTimeout > 0 && timeRemaining !== null}
|
||||
onClose={handleLogout}
|
||||
showClose={false}
|
||||
variant="warning"
|
||||
actions={[
|
||||
<Button
|
||||
key="confirm"
|
||||
variant="primary"
|
||||
onClick={handleSessionContinue}
|
||||
>
|
||||
{i18n._(t`Continue`)}
|
||||
</Button>,
|
||||
<Button key="logout" variant="secondary" onClick={handleLogout}>
|
||||
{i18n._(t`Logout`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{i18n._(
|
||||
t`You will be logged out in ${Number(
|
||||
Math.max(Math.floor(timeRemaining / 1000), 0)
|
||||
)} seconds due to inactivity.`
|
||||
)}
|
||||
</AlertModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
19
awx/ui_next/src/util/debounce.js
Normal file
19
awx/ui_next/src/util/debounce.js
Normal file
@ -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;
|
||||
40
awx/ui_next/src/util/debounce.test.js
Normal file
40
awx/ui_next/src/util/debounce.test.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user