Skip to content

Commit 163ab16

Browse files
authored
CUMULUS-4048: Dashboard - Launchpad Timeout Modal (#1186)
* first commit * removing script * adding test (not working properly) * removing script code * updated Test * CHANGELOG.md * cleaning code + extending test * changing naming * adding small header test * fixing CHANGELOG description * grammar fix * changing from refresh to re-login * updating comment * adding back tokenExpiration
1 parent 13a56f5 commit 163ab16

File tree

6 files changed

+153
-2
lines changed

6 files changed

+153
-2
lines changed

.eslintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"root": false,
2+
"root": true,
33
"extends": [
44
"airbnb-base",
55
"standard",

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [v13.1.0] - 2025-03-25
99

10+
### Added
11+
12+
- **CUMULUS-4048**
13+
- Added a session timeout warning modal that pops up five minutes before the session expires
14+
1015
### Changed
1116

1217
- **CUMULUS-4028**

app/src/js/components/Header/header.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { window } from '../../utils/browser';
1515
import { strings } from '../locale';
1616
import linkToKibana from '../../utils/kibana';
1717
import { getPersistentQueryParams } from '../../utils/url-helper';
18+
import SessionTimeoutModal from '../SessionTimeoutModal/session-timeout-modal';
1819

1920
const paths = [
2021
[strings.collections, '/collections/all'],
@@ -122,6 +123,7 @@ const Header = ({
122123
)}
123124
</nav>
124125
</div>
126+
<div className="session-timeout-modal"><SessionTimeoutModal/></div>
125127
</div>
126128
);
127129
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React, { useEffect, useState, useCallback } from 'react';
2+
import PropTypes from 'prop-types';
3+
import get from 'lodash/get';
4+
import { connect } from 'react-redux';
5+
import { decode as jwtDecode } from 'jsonwebtoken';
6+
import DefaultModal from '../Modal/modal';
7+
import { logout } from '../../actions';
8+
9+
const SessionTimeoutModal = ({
10+
tokenExpiration,
11+
title = 'Session Expiration Warning',
12+
children = 'Your session will expire in 5 minutes. Please re-login if you would like to stay signed in.',
13+
dispatch,
14+
}) => {
15+
const [hasModal, setHasModal] = useState(false);
16+
const [modalClosed, setModalClosed] = useState(false);
17+
18+
const handleLogout = useCallback(() => {
19+
dispatch(logout()).then(() => {
20+
if (get(window, 'location.reload')) {
21+
window.location.reload();
22+
}
23+
});
24+
}, [dispatch]);
25+
26+
const handleClose = () => {
27+
setHasModal(false);
28+
setModalClosed(true);
29+
};
30+
31+
useEffect(() => {
32+
const interval = setInterval(() => {
33+
// the modal will only pop up when the user is logged in, not already shown, or closed
34+
if (!tokenExpiration || hasModal || modalClosed) {
35+
return;
36+
}
37+
38+
const currentTime = Math.ceil(Date.now() / 1000);
39+
const secondsLeft = tokenExpiration - currentTime;
40+
41+
if (secondsLeft <= 300) {
42+
setHasModal(true);
43+
}
44+
}, 1000);
45+
46+
return () => clearInterval(interval);
47+
}, [tokenExpiration, hasModal, modalClosed]);
48+
49+
return (
50+
<DefaultModal
51+
title={title}
52+
className="SessionTimeoutModal"
53+
onCancel={handleClose}
54+
onCloseModal={handleClose}
55+
onConfirm={handleLogout}
56+
showModal={hasModal}
57+
hasConfirmButton={true}
58+
hasCancelButton={true}
59+
cancelButtonText="Dismiss"
60+
confirmButtonText="Re-login"
61+
>
62+
{children}
63+
</DefaultModal>
64+
);
65+
};
66+
67+
SessionTimeoutModal.propTypes = {
68+
tokenExpiration: PropTypes.number,
69+
title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
70+
children: PropTypes.string,
71+
dispatch: PropTypes.func,
72+
};
73+
74+
export default connect((state) => {
75+
const token = get(state, 'api.tokens.token');
76+
const jwtData = token ? jwtDecode(token) : null;
77+
const tokenExpiration = get(jwtData, 'exp');
78+
79+
return { tokenExpiration };
80+
})(SessionTimeoutModal);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import test from 'ava';
2+
import React from 'react';
3+
import { Provider } from 'react-redux';
4+
import { render, screen, act } from '@testing-library/react';
5+
import sinon from 'sinon';
6+
import thunk from 'redux-thunk';
7+
import configureMockStore from 'redux-mock-store';
8+
import { requestMiddleware } from '../../../app/src/js/middleware/request';
9+
import jwt from 'jsonwebtoken';
10+
import SessionTimeoutModal from '../../../app/src/js/components/SessionTimeoutModal/session-timeout-modal';
11+
12+
const middlewares = [requestMiddleware, thunk];
13+
const mockStore = configureMockStore(middlewares);
14+
15+
function createDummyToken(expiration) {
16+
return jwt.sign({ exp: expiration }, '', { algorithm: 'none' });
17+
}
18+
19+
let clock;
20+
21+
test.before(() => {
22+
clock = sinon.useFakeTimers();
23+
});
24+
25+
test.after.always(() => {
26+
clock.restore();
27+
});
28+
29+
test('SessionTimeout modal shows up 5 minutes before token expiration', async (t) => {
30+
const futureExp = Math.floor(Date.now() / 1000) + 400; // expires in 400 seconds
31+
const dummyToken = createDummyToken(futureExp);
32+
33+
const store = mockStore({
34+
api: {
35+
tokens: { token: dummyToken },
36+
},
37+
});
38+
39+
render(
40+
<Provider store={store}>
41+
<SessionTimeoutModal />
42+
</Provider>
43+
);
44+
45+
t.falsy(screen.queryByText('Your session will expire in 5 minutes'));
46+
47+
await act(async () => {
48+
clock.tick(100000); // fast-forwards a 100 seconds, to within 5 minutes of expiration
49+
await Promise.resolve();
50+
});
51+
52+
const modalText = screen.getByText(/Your session will expire in 5 minutes/);
53+
t.truthy(modalText);
54+
55+
const logoutButton = screen.getByRole('button', { name: /Dismiss/i });
56+
const closeButton = screen.getByRole('button', { name: /Re-login/i });
57+
58+
t.truthy(logoutButton);
59+
t.truthy(closeButton);
60+
});

test/components/header/header.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const initialState = {
2828
}
2929
};
3030

31-
test('Header contains correct number of nav items and excludes PDRs and Logs', function (t) {
31+
test('Header contains sessionTimeoutModal, correct number of nav items and excludes PDRs and Logs', function (t) {
3232
const dispatch = () => {};
3333
const api = {
3434
authenticated: true
@@ -53,6 +53,10 @@ test('Header contains correct number of nav items and excludes PDRs and Logs', f
5353
</MemoryRouter>
5454
</Provider>
5555
);
56+
57+
const modal = container.querySelector('.session-timeout-modal');
58+
t.truthy(modal);
59+
5660
const navigation = container.querySelectorAll('nav li');
5761
t.is(navigation.length, 9);
5862

0 commit comments

Comments
 (0)