Skip to content

Commit

Permalink
MCR-3849 Fix session timeout (#2719)
Browse files Browse the repository at this point in the history
* Bring back changes from PR #2159

* Cleanup modals
- inherit from base Modal
- create SessionTimeoutModal as own component
- simplify styles
- removing V2 naming from UnlockResubmitModal

* Add IdleTimer
- add  to package.json
- remove session timeout custom timekeeping, props from AuthContext
- add IdleTimer to AuthenticatedRouteWrapper
- start to add tests

* Get existing tests passing

* Add more mocked out tests

* Handle cases where timeout countdown and session duration are set to incompatible values

* Fix countdown

* Add cross-tab support

* Get s3Context to manually check auth if it fails and user logged out

* Fixup AuthneticatedRouteWrapper tests

* Write more unit tests, mock out more tests as well to write

* Get remaining AuthenticatedRouteWrapper tests passing

* Add back feature flag handling for showing the session expiration modal

* lint changed files

* Create ModalOpenButton and store activModalID in page context

* Get only one modal at a time behavior working

* Get only one modal at a time behavior working

* Add more async handling - try to address CI only unit test flakes

* Lint cleanup

* Get doubleclick bug addressed

* Code review comments and cleanup

* Refactor cognitoAuth to return Errors, unify logout handling for various cases

* make modal close more snappy

* cleanup

* Don't allow continue session if timer elapsed (but logout failed due to machine sleep

* Also update copy when session expired but modal still open
  • Loading branch information
haworku authored Sep 16, 2024
1 parent 14efdfb commit 31bba04
Show file tree
Hide file tree
Showing 35 changed files with 837 additions and 664 deletions.
12 changes: 4 additions & 8 deletions docs/technical-design/feature-brief-session-expiration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

## Introduction

We use a third-party authentication provider ([IDM](https://confluenceent.cms.gov/display/IDM/IDM+Trainings+and+Guides)) which automatically logs out sessions due to inactivity after about 30 minutes. We don't have direct access to their timekeeping, but we know this is about how long they allow a session to be inactive. Thus, we manually track sessions internally for the same time period.
We use a third-party authentication provider ([IDM](https://confluenceent.cms.gov/display/IDM/IDM+Trainings+and+Guides)) which automatically logs out sessions due to inactivity after about 30 minutes. Although MC-Review user sessions are handled by AWS Amplify/Cognito after login, we follow the same rules. We currently don't access Cognito to check if our session is active. Instead, we manually track sessions internally in state via [`react-idle-timer`](https://idletimer.dev/). This also allows us to follow the CMS Acceptable Risk Safeguards(ARS) controls `AC-11 Idle Session Timeout` and `AC-12(03) Timeout Warning Message`.

Importantly this featured is permanently feature-flagged since we have different requirements between production/staging and lower environments. More details about feature flags [below](#implementation-details).
The session timeout duration is also permanently feature-flagged since we have different requirements between production/staging and lower environments. More details about feature flags [below](#implementation-details).

## Expected behavior
Two minutes before the session will expire due to inactivity we show a warning modal. The modal displays a live countdown and has CTA buttons with 1. the ability to log out immediately 2. the ability to extend the session. This helps us fulfill accessibility requirements around [WCAG 2.2.1 Timing Adjustable](https://www.w3.org/WAI/WCAG21/Understanding/timing-adjustable.html).
Expand All @@ -14,7 +14,7 @@ Importantly this featured is permanently feature-flagged since we have different
### Possible outcomes after the session expiration modal is displayed:
1. If the user chooses to logout, we redirect to landing page.
2. If the user extends the session, we refresh their tokens and restart our counter for the session.
3. If the user takes no action and the browser is still active, the 2 minute countdown will complete and the user will be automatically logged out and a error banner will appear on the landing page notifying them what happened.
3. If the user takes no action and the browser is still active, the countdown completes and the user is automatically logged out and redirected to landing page. A session expired banner notifies them what happened.

![session expired banner - relevant for outcome 3](../../.images/session-expired-banner.png)

Expand All @@ -24,7 +24,7 @@ This auto logout behavior will happen even if the browser tab is in the backgrou
These are edge cases we decided not to address. Documenting for visibility.

- The user puts computer to sleep while logged in (before session expiration modal is visible) and comes back after the session has expired.
- In this case, the session expiration modal will not display. The user will still appear to be logged in when they relaunch their computer. However, as soon as the user takes an action that hits the API and we get a 403, we will follow the code path for outcome #3 above - automatically log the user and show the session expired error banner.
- In this case, the session expiration modal will not display. The user will still appear logged in when they relaunch their computer and browser. However, as soon as the user takes an action that hits the graphql API or tries to upload a file to S3, we will follow the code path for outcome #3 above - logout the user, redirect to landing page, show session expired error banner.
- The user has MC-Review open in multiple tabs and then logs out of only one tab manually before session expiration.
- This is not an ideal user experience. This is why we recommend users navigate the application in one tab at time. If the user logs out then goes to another tab that is still open and starts using the application, they will be able to make some requests with the cached user but at a certain point the requests will error (this may or may not be auth errors, sometimes the API may fail first with 400s and thus the generic failed request banner will show)

Expand All @@ -35,7 +35,3 @@ These are edge cases we decided not to address. Documenting for visibility.
- Primary logic for the feature is found in `AuthContext.tsx`
- `MODAL_COUNTDOWN_DURATION` is the hard-coded constant that holds the amount of time the modal will be visible prior to logout for inactivity. It is set to 2 minutes.
- use of `session-timeout`query param to ensure error banner displays
- `sessionExpirationTime` is the date and time at which the session will expire and `updateSessionExpirationTime` is the method to extend the session when the user is active
- `setLogoutCountdownDuration` used to decrement the countdown duration for display in the modal
- `sessionIsExpiring` boolean tracks whether we're inside the countdown duration (if true, modal is visible) this is updated with `updateSessionExpirationState`
- `checkIfSessionsIsAboutToExpire` check current session time on an interval in the background. It determines if it is time to show modal
8 changes: 4 additions & 4 deletions docs/technical-design/tealium-logging.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Tealium Logging
## Background
We use a tool called Tealium to hook into the CMS customer analytics tools. The data is then sent to Adobe Analytics (previously CMS used Google Analytics). Access to analytics tools is set up through another team, Blast Analytics. They are contractors with a focus on analytics and work across all CMS domains.
We use a tool called Tealium to hook into the CMS customer analytics tools. The data is then sent to Adobe Analytics (previously CMS used Google Analytics). Access to analytics tools is set up through another team, Blast Analytics. They are contractors with a focus on analytics and work across all CMS domains.

## Implementation

Expand All @@ -27,7 +27,7 @@ The `useTealium` hook utilizes tealium client functions in the Context to provid
There are wrapper functions in this hook for specific user event types to constrain the event and parameter values before passing it to `logUserEvent`. This allows type safety and a reference to what event and parameter values the event accepts.

```typescript
const context = React.useContext(TealiumContext)
const context = React.useContext(TealiumContext)

...

Expand All @@ -47,11 +47,11 @@ const logButtonEvent = (
```
These `useTealium` functions are then used in components like `Button` and `Link` to log when they are clicked. Since most of these components are configured and used similarly, wrapper components were made with built-in logging to replace the default components for easy implementation of logging. They can be found in `components/TealiumLogging`.

Not all components are wrapped like this. Some, like `ModalToggleButton` are rarely used, so in those cases we can directly use `logButtonEvent` in the `onClick` prop.
Not all components are wrapped like this, it is still possible to directly use tealium actions.

### Adding user event types

To add a new user event type:
To add a new user event type:
- Define a type for the event parameter values for the event type. You can find all the data fields for a given event in the [Tagging Strategy](https://confluence.cms.gov/pages/viewpage.action?spaceKey=BLSTANALYT&title=mc-review.onemac.cms.gov+-+Tagging+Strategy) doc on confluence.
- Then we add that type to `TealiumEventObjectTypes`.
```typescript
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions services/app-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.10",
"react-idle-timer": "^5.7.2",
"react-router": "^6.23.1",
"react-router-dom": "^6.23.1",
"react-select": "^5.7.7",
Expand Down
61 changes: 0 additions & 61 deletions services/app-web/src/components/Header/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,67 +167,6 @@ describe('Header', () => {

await waitFor(() => expect(spy).toHaveBeenCalledTimes(1))
})

it('calls setAlert when logout is unsuccessful', async () => {
const spy = vi
.spyOn(CognitoAuthApi, 'signOut')
.mockRejectedValue('This logout failed!')
const mockAlert = vi.fn()

const { user } = renderWithProviders(
<Header authMode={'AWS_COGNITO'} setAlert={mockAlert} />,
{
apolloProvider: {
mocks: [
fetchCurrentUserMock({ statusCode: 200 }),
fetchCurrentUserMock({ statusCode: 403 }),
],
},
}
)

await waitFor(() => {
const yourAccountButton = screen.getByRole('button', {
name: 'Your account',
})
expect(yourAccountButton).toBeInTheDocument()
void user.click(yourAccountButton)
})

await waitFor(() => {
const signOutButton = screen.getByRole('button', {
name: 'Sign out',
})
expect(signOutButton).toBeInTheDocument()
void user.click(signOutButton)
})

await waitFor(() => expect(spy).toHaveBeenCalledTimes(1))
await waitFor(() => expect(mockAlert).toHaveBeenCalled())
})

it('does not render MC-review settings link for State users', async () => {
renderWithProviders(<Header authMode={'AWS_COGNITO'} />, {
apolloProvider: {
mocks: [
fetchCurrentUserMock({
statusCode: 200,
}),
],
},
})

await waitFor(() => {
const yourAccountButton = screen.getByRole('button', {
name: 'Your account',
})
expect(yourAccountButton).toBeInTheDocument()
})

expect(
screen.queryByRole('link', { name: 'MC-Review settings' })
).toBeNull()
})
})
})

Expand Down
17 changes: 3 additions & 14 deletions services/app-web/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import { Logo } from '../Logo'
import styles from './Header.module.scss'
import { PageHeadingRow } from './PageHeadingRow/PageHeadingRow'
import { UserLoginInfo } from './UserLoginInfo/UserLoginInfo'
import { recordJSException } from '../../otelHelpers'
import { ErrorAlertSignIn } from '../ErrorAlert'

export type HeaderProps = {
authMode: AuthModeType
Expand All @@ -34,18 +32,9 @@ export const Header = ({
const { heading } = usePage()
const { currentRoute: route } = useCurrentRoute()

const handleLogout = (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
if (!logout) {
console.info('Something went wrong ', e)
return
}

logout({ sessionTimeout: false }).catch((e) => {
recordJSException(`Error with logout: ${e}`)
setAlert && setAlert(<ErrorAlertSignIn />)
})
const handleLogout = async () => {
await logout({type: 'ERROR'})
// no need to handle errors, logout will handle
}

return route !== 'GRAPHQL_EXPLORER' ? (
Expand Down
14 changes: 14 additions & 0 deletions services/app-web/src/components/Modal/Modal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,18 @@
margin: 0;
}
}

textarea {
max-width: 100%;
margin-bottom: 1rem;
}

}

.submitSuccessButton {
background: custom.$mcr-success-base;
&:hover {
background-color: custom.$mcr-success-hover !important;
}
}

28 changes: 13 additions & 15 deletions services/app-web/src/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
userClickByTestId,
renderWithProviders,
} from '../../testHelpers/jestHelpers'
import { ModalOpenButton } from './ModalOpenButton/ModalOpenButton'

describe('Modal', () => {
it('Renders element by default with modal hidden', () => {
Expand Down Expand Up @@ -199,13 +200,12 @@ describe('Modal', () => {
>
<textarea data-testid="modal-children" />
</Modal>
<ModalToggleButton
<ModalOpenButton
modalRef={modalRef}
data-testid="opener-button"
opener
id="opener-button"
>
Open modal
</ModalToggleButton>
</ModalOpenButton>
</div>
)
await userClickByTestId(screen, 'opener-button')
Expand All @@ -225,13 +225,13 @@ describe('Modal', () => {
>
<textarea data-testid="modal-children" />
</Modal>
<ModalToggleButton
<ModalOpenButton
modalRef={modalRef}
data-testid="opener-button"
id="opener-button"
opener
>
Open modal
</ModalToggleButton>
</ModalOpenButton>
</div>
)

Expand All @@ -255,13 +255,12 @@ describe('Modal', () => {
>
<textarea data-testid="modal-children" />
</Modal>
<ModalToggleButton
<ModalOpenButton
modalRef={modalRef}
data-testid="opener-button"
opener
id="opener-button"
>
Open modal
</ModalToggleButton>
</ModalOpenButton>
</div>
)
await userClickByTestId(screen, 'opener-button')
Expand Down Expand Up @@ -292,13 +291,12 @@ describe('Modal', () => {
>
<textarea data-testid="modal-children" />
</Modal>
<ModalToggleButton
<ModalOpenButton
modalRef={modalRef}
data-testid="opener-button"
opener
id="opener-button"
>
Open modal
</ModalToggleButton>
</ModalOpenButton>
</div>
)

Expand Down
29 changes: 16 additions & 13 deletions services/app-web/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'
import React, {useEffect} from 'react'
import {
ButtonGroup,
Modal as UswdsModal,
Expand All @@ -14,8 +14,8 @@ import {
import styles from './Modal.module.scss'

import { ActionButton } from '../ActionButton'
import { useAuth } from '../../contexts/AuthContext'
import { ButtonWithLogging } from '../TealiumLogging'
import { usePage } from '../../contexts/PageContext'

interface ModalComponentProps {
id: string
Expand Down Expand Up @@ -48,23 +48,26 @@ export const Modal = ({
modalAlert,
...divProps
}: ModalProps): React.ReactElement => {
const { sessionIsExpiring } = useAuth()

/* unless it's the session expiring modal, close it if the session is expiring, so the user can interact
with the session expiring modal */
useEffect(() => {
if (id !== 'extend-session-modal' && sessionIsExpiring) {
modalRef.current?.toggleModal(undefined, false)
}
}, [sessionIsExpiring, modalRef, id])

const {updateModalRef} = usePage()
const cancelHandler = (e: React.MouseEvent): void => {
if (onCancel) {
onCancel()
}
updateModalRef({updatedModalRef: undefined})
modalRef.current?.toggleModal(undefined, false)
}

const submitHandler = (e: React.MouseEvent): void => {
if (onSubmit) {
onSubmit()
}
updateModalRef({updatedModalRef: undefined})
// do not close modal here manually
// sometimes validation fails and we want to keep modal open but display errors
// consumer should determine wether or not to close modal in onSubmit

}

return (
<UswdsModal
aria-labelledby={`${id}-heading`}
Expand Down Expand Up @@ -108,7 +111,7 @@ export const Modal = ({
variant="success"
id={`${id}-submit`}
parent_component_type="modal"
onClick={onSubmit}
onClick={submitHandler}
loading={isSubmitting}
{...submitButtonProps}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { screen} from '@testing-library/react'
import { renderWithProviders } from '../../../testHelpers'
import { ModalOpenButton } from './ModalOpenButton'
import { createRef} from 'react'
import { type ModalRef } from '@trussworks/react-uswds'

describe('ModalOpenButton', () => {
it('renders without errors', () => {
const testRef = createRef<ModalRef>()
renderWithProviders(
<ModalOpenButton id='123' modalRef={testRef}>submit 123</ModalOpenButton>
)
expect(
screen.getByRole('button', {
name: 'submit 123',
})
).toBeInTheDocument()
})
})
Loading

0 comments on commit 31bba04

Please sign in to comment.