Skip to content

Commit

Permalink
Merge pull request #58 from adhocteam/js-79-logout-on-inactive
Browse files Browse the repository at this point in the history
Logout on inactive
  • Loading branch information
jasalisbury authored Nov 5, 2020
2 parents de34c26 + 5d32c27 commit 43d213c
Show file tree
Hide file tree
Showing 15 changed files with 316 additions and 339 deletions.
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ parameters:
default: "main"
type: string
sandbox_git_branch: # change to feature branch to test deployment
default: "js-81-first-activity-report-fields"
default: "js-79-logout-on-inactive"
type: string
jobs:
build:
Expand Down
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=60000
REACT_APP_SESSION_TIMEOUT=120000
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 be automatically 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 unstyled>
<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 unstyled>
<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 43d213c

Please sign in to comment.