Skip to content

Commit

Permalink
Add idle modal
Browse files Browse the repository at this point in the history
The idle modal shows after 25 minutes of inactivity. After 30 minutes of
inactivity the user is logged out of the TTA smart hub. If the user
becomes active while the modal is displayed it is hidden and the 25 and
30 minute count is reset.

Currently when the modal is shown the message says how long the user has
until logout when the modal was first shown. I see two ways to improve
the user experience:

 1. We have a running countdown that updates until the user is logged
out
 2. We display the actual time at which the user is logged out (e.g. 10:55
am)
  • Loading branch information
jasalisbury committed Nov 2, 2020
1 parent 1ce53c2 commit 6c9f26e
Show file tree
Hide file tree
Showing 13 changed files with 314 additions and 337 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ POSTGRES_HOST=localhost
AUTH_CLIENT_ID=clientId
AUTH_CLIENT_SECRET=clientSecret
SESSION_SECRET=secret
# In milliseconds, 30 minutes
SESSION_TIMEOUT=1800000
TTA_SMART_HUB_URI=http://localhost:3000
AUTH_BASE=https://uat.hsesinfo.org
# This env variable should go away soon in favor of TTA_SMART_HUB_URI
Expand Down
2 changes: 1 addition & 1 deletion cucumber/features/steps/activityReportSteps.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ Then('I see {string} as an option in the {string} dropdown', async (expectedOpti
const el = await page.$x(selector);
const selected = await el[0].$x(`//option[text()='${expectedOption}']`);

assert(selected.length === 1);
assert(selected !== null || selected !== undefined);
assert(selected.length === 1);
});
2 changes: 2 additions & 0 deletions frontend/.env
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
BACKEND_PROXY=http://localhost:8080
REACT_APP_INACTIVE_MODAL_TIMEOUT=1500000
REACT_APP_SESSION_TIMEOUT=1800000
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"react-dom": "^16.13.1",
"react-dropzone": "^11.2.0",
"react-hook-form": "^6.9.0",
"react-idle-timer": "^4.4.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-router-prop-types": "^1.0.5",
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { fetchUser, fetchLogout } from './fetchers/Auth';

import UserContext from './UserContext';
import Header from './components/Header';
import IdleModal from './components/IdleModal';
import Page from './pages';
import Admin from './pages/Admin';
import Unauthenticated from './pages/Unauthenticated';
Expand All @@ -21,6 +22,7 @@ function App() {
const [loading, updateLoading] = useState(true);
const [loggedOut, updateLoggedOut] = useState(false);
const authenticated = user !== undefined;
const [timedOut, updateTimedOut] = useState(false);

useEffect(() => {
const fetchData = async () => {
Expand All @@ -36,10 +38,11 @@ function App() {
fetchData();
}, []);

const logout = async () => {
const logout = async (timeout = false) => {
await fetchLogout();
updateUser();
updateLoggedOut(true);
updateTimedOut(timeout);
};

if (loading) {
Expand All @@ -52,6 +55,11 @@ function App() {

const renderAuthenticatedRoutes = () => (
<div role="main" id="main-content">
<IdleModal
modalTimeout={Number(process.env.REACT_APP_INACTIVE_MODAL_TIMEOUT)}
logoutTimeout={Number(process.env.REACT_APP_SESSION_TIMEOUT)}
logoutUser={logout}
/>
<Route
exact
path="/"
Expand Down Expand Up @@ -89,7 +97,7 @@ function App() {
<section className="usa-section">
<GridContainer>
{!authenticated
&& <Unauthenticated loggedOut={loggedOut} />}
&& <Unauthenticated loggedOut={loggedOut} timedOut={timedOut} />}
{authenticated
&& renderAuthenticatedRoutes()}
</GridContainer>
Expand Down
104 changes: 104 additions & 0 deletions frontend/src/components/IdleModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
Displays a modal after $modalTimeout milliseconds if the user is inactive. If
the user becomes active when the modal is shown it will disappear. After $logoutTimeout
milliseconds the logout prop is called.
*/

import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useIdleTimer } from 'react-idle-timer';
import {
Button, Modal, connectModal, useModal, Alert,
} from '@trussworks/react-uswds';

// list of events to determine activity
// https://github.com/SupremeTechnopriest/react-idle-timer#default-events
const EVENTS = [
'keydown',
'wheel',
'DOMMouseSc',
'mousewheel',
'mousedown',
'touchstart',
'touchmove',
'MSPointerDown',
'MSPointerMove',
'visibilitychange',
];

function IdleModal({ modalTimeout, logoutTimeout, logoutUser }) {
const [inactiveTimeout, updateInactiveTimeout] = useState();
const { isOpen, openModal, closeModal } = useModal();
const modalVisibleTime = logoutTimeout - modalTimeout;
const timeoutMinutes = Math.floor(modalVisibleTime / 1000 / 60);

let timeToLogoutMsg = '';
if (timeoutMinutes < 1) {
timeToLogoutMsg = 'less than a minute';
} else if (timeoutMinutes === 1) {
timeToLogoutMsg = 'a minute';
} else {
timeToLogoutMsg = `${timeoutMinutes} minutes`;
}

const Connected = connectModal(() => (
<Modal
title={<h3>Are you still there?</h3>}
actions={(
<Button type="button">
Stay logged in
</Button>
)}
>
<Alert role="alert" type="warning">
You will automatically be logged out due to inactivity in
{' '}
{ timeToLogoutMsg }
{' '}
unless you become active again.
</Alert>
</Modal>
));

// Make sure we clean up any timeout functions when this component
// is unmounted
useEffect(() => function cleanup() {
if (inactiveTimeout) {
clearTimeout(inactiveTimeout);
}
});

const onIdle = () => {
const timer = setTimeout(() => {
closeModal();
logoutUser(true);
}, modalVisibleTime);
openModal();
updateInactiveTimeout(timer);
};

const onActive = () => {
closeModal();
clearTimeout(inactiveTimeout);
};

useIdleTimer({
timeout: modalTimeout,
onIdle,
onActive,
events: EVENTS,
debounce: 500,
});

return (
<Connected isOpen={isOpen} />
);
}

IdleModal.propTypes = {
modalTimeout: PropTypes.number.isRequired,
logoutTimeout: PropTypes.number.isRequired,
logoutUser: PropTypes.func.isRequired,
};

export default IdleModal;
98 changes: 98 additions & 0 deletions frontend/src/components/__tests__/IdleModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import '@testing-library/jest-dom';
import { act } from 'react-dom/test-utils';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

import IdleModal from '../IdleModal';

describe('IdleModal', () => {
beforeAll(() => {
jest.useFakeTimers();
});

const renderIdleModal = (
logoutTimeout = 20,
modalTimeout = 10,
logoutUser = () => {},
) => {
render(
<>
<div data-testid="test" />
<IdleModal
logoutTimeout={logoutTimeout}
modalTimeout={modalTimeout}
logoutUser={logoutUser}
/>
</>,
);
};

it('modal is shown after modalTimeout', () => {
renderIdleModal();
act(() => {
jest.advanceTimersByTime(11);
});
expect(screen.getByTestId('modal')).toBeVisible();
});

it('logout is called after logoutTimeout milliseconds of inactivity', () => {
const logout = jest.fn();
renderIdleModal(20, 10, logout);
act(() => {
jest.advanceTimersByTime(21);
});
expect(logout).toHaveBeenCalled();
});

it('modal is not shown after modalTimeout if there is activity', () => {
const logout = jest.fn();
renderIdleModal(20, 10, logout);
act(() => {
jest.advanceTimersByTime(7);
const testDiv = screen.getByTestId('test');
fireEvent.keyDown(testDiv, { key: 'Enter', code: 'Enter' });
jest.advanceTimersByTime(7);
});
expect(logout).not.toHaveBeenCalled();
expect(screen.queryByTestId('modal')).toBeNull();
});

it('a shown modal is removed after action is taken', () => {
const logout = jest.fn();
renderIdleModal(20, 10, logout);
act(() => {
jest.advanceTimersByTime(12);
expect(screen.getByTestId('modal')).toBeVisible();
const testDiv = screen.getByTestId('test');
fireEvent.keyDown(testDiv, { key: 'Enter', code: 'Enter' });
});
expect(logout).not.toHaveBeenCalled();
expect(screen.queryByTestId('modal')).toBeNull();
});

describe('modal message', () => {
it('shows less than a minute if logoutTimeout - modalTimeout is less than a minute', () => {
renderIdleModal(20, 10);
act(() => {
jest.advanceTimersByTime(11);
expect(screen.getByRole('alert').textContent).toContain('in less than a minute');
});
});

it('shows a minute if logoutTimeout - modalTimeout is a minute', () => {
renderIdleModal(1000 * 60 + 10, 10);
act(() => {
jest.advanceTimersByTime(11);
expect(screen.getByRole('alert').textContent).toContain('in a minute');
});
});

it('shows logoutTimeout - modalTimeout minutes when greater than one minute', () => {
renderIdleModal((1000 * 60 * 5) + 10, 10);
act(() => {
jest.advanceTimersByTime(11);
expect(screen.getByRole('alert').textContent).toContain('in 5 minutes');
});
});
});
});
72 changes: 38 additions & 34 deletions frontend/src/pages/ActivityReport/SectionOne.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,25 @@ const PageOne = ({
<>
<Fieldset className="smart-hub--report-legend smart-hub--form-section" legend="General Information">
<div className="smart-hub--form-section">
<legend>Was this activity for a grantee or a non-grantee?</legend>
<Radio
id="category-grantee"
name="participant-category"
label="Grantee"
value="grantee"
className="smart-hub--report-checkbox"
inputRef={register({ required: true })}
/>
<Radio
id="category-non-grantee"
name="participant-category"
label="Non-Grantee"
value="non-grantee"
className="smart-hub--report-checkbox"
inputRef={register({ required: true })}
/>
<fieldset>
<legend>Was this activity for a grantee or a non-grantee?</legend>
<Radio
id="category-grantee"
name="participant-category"
label="Grantee"
value="grantee"
className="smart-hub--report-checkbox"
inputRef={register({ required: true })}
/>
<Radio
id="category-non-grantee"
name="participant-category"
label="Non-Grantee"
value="non-grantee"
className="smart-hub--report-checkbox"
inputRef={register({ required: true })}
/>
</fieldset>
</div>
<div className="smart-hub--form-section">
<Label htmlFor="grantees">Who was this activity for?</Label>
Expand Down Expand Up @@ -104,23 +106,25 @@ const PageOne = ({
</Fieldset>
<Fieldset className="smart-hub--report-legend smart-hub--form-section" legend="Reason for Activity">
<div className="smart-hub--form-section">
<legend>Who requested this activity?</legend>
<Radio
id="grantee-request"
name="requester"
label="Grantee"
value="grantee"
className="smart-hub--report-checkbox"
inputRef={register({ required: true })}
/>
<Radio
id="regional-office-request"
name="requester"
label="Regional Office"
value="regional-office"
className="smart-hub--report-checkbox"
inputRef={register({ required: true })}
/>
<fieldset>
<legend>Who requested this activity?</legend>
<Radio
id="grantee-request"
name="requester"
label="Grantee"
value="grantee"
className="smart-hub--report-checkbox"
inputRef={register({ required: true })}
/>
<Radio
id="regional-office-request"
name="requester"
label="Regional Office"
value="regional-office"
className="smart-hub--report-checkbox"
inputRef={register({ required: true })}
/>
</fieldset>
</div>
<div className="smart-hub--form-section">
<Label htmlFor="reason">What was the reason for this activity?</Label>
Expand Down
Loading

0 comments on commit 6c9f26e

Please sign in to comment.