Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Logout on inactive #58

Merged
merged 6 commits into from
Nov 5, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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