mirror of
https://github.com/ansible/awx.git
synced 2026-05-08 09:57:35 -02: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:
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 axios from 'axios';
|
||||||
|
|
||||||
|
import { SESSION_TIMEOUT_KEY } from '../constants';
|
||||||
import { encodeQueryString } from '../util/qs';
|
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({
|
const defaultHttp = axios.create({
|
||||||
xsrfCookieName: 'csrftoken',
|
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 {
|
class Base {
|
||||||
constructor(http = defaultHttp, baseURL) {
|
constructor(http = defaultHttp, baseURL) {
|
||||||
this.http = http;
|
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 { useHistory, useLocation, withRouter } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
Nav,
|
Nav,
|
||||||
NavList,
|
NavList,
|
||||||
Page,
|
Page,
|
||||||
@@ -13,6 +14,8 @@ import styled from 'styled-components';
|
|||||||
|
|
||||||
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
|
import { ConfigAPI, MeAPI, RootAPI } from '../../api';
|
||||||
import { ConfigProvider } from '../../contexts/Config';
|
import { ConfigProvider } from '../../contexts/Config';
|
||||||
|
import { SESSION_TIMEOUT_KEY } from '../../constants';
|
||||||
|
import { isAuthenticated } from '../../util/auth';
|
||||||
import About from '../About';
|
import About from '../About';
|
||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import ErrorDetail from '../ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
@@ -20,6 +23,17 @@ import BrandLogo from './BrandLogo';
|
|||||||
import NavExpandableGroup from './NavExpandableGroup';
|
import NavExpandableGroup from './NavExpandableGroup';
|
||||||
import PageHeaderToolbar from './PageHeaderToolbar';
|
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)`
|
const PageHeader = styled(PFPageHeader)`
|
||||||
& .pf-c-page__header-brand-link {
|
& .pf-c-page__header-brand-link {
|
||||||
color: inherit;
|
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 }) {
|
function AppContainer({ i18n, navRouteConfig = [], children }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -38,14 +91,51 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
|
|||||||
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
|
const [isAboutModalOpen, setIsAboutModalOpen] = useState(false);
|
||||||
const [isReady, setIsReady] = 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 handleAboutModalOpen = () => setIsAboutModalOpen(true);
|
||||||
const handleAboutModalClose = () => setIsAboutModalOpen(false);
|
const handleAboutModalClose = () => setIsAboutModalOpen(false);
|
||||||
const handleConfigErrorClose = () => setConfigError(null);
|
const handleConfigErrorClose = () => setConfigError(null);
|
||||||
|
const handleSessionTimeout = () => setTimeoutWarning(true);
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
await RootAPI.logout();
|
await RootAPI.logout();
|
||||||
history.replace('/login');
|
setSessionTimeout(null);
|
||||||
}, [history]);
|
}, [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(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -128,6 +218,31 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
|
|||||||
{i18n._(t`Failed to retrieve configuration.`)}
|
{i18n._(t`Failed to retrieve configuration.`)}
|
||||||
<ErrorDetail error={configError} />
|
<ErrorDetail error={configError} />
|
||||||
</AlertModal>
|
</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',
|
ad_hoc_command: 'command',
|
||||||
workflow_job: 'workflow',
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user