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

MCR-3849 Fix session timeout #2719

Merged
merged 30 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e4fb419
Bring back changes from PR #2159
haworku Aug 29, 2024
68e208e
Cleanup modals
haworku Sep 3, 2024
d2b2cc1
Add IdleTimer
haworku Sep 3, 2024
853c403
Get existing tests passing
haworku Sep 3, 2024
e7dabb3
Add more mocked out tests
haworku Sep 3, 2024
f81dc2a
Handle cases where timeout countdown and session duration are set to …
haworku Sep 3, 2024
618c367
Fix countdown
haworku Sep 4, 2024
57b658c
Add cross-tab support
haworku Sep 4, 2024
18af39d
Get s3Context to manually check auth if it fails and user logged out
haworku Sep 4, 2024
b0dbf52
Fixup AuthneticatedRouteWrapper tests
haworku Sep 4, 2024
793a5a1
Merge remote-tracking branch 'origin/main' into MCR-3849-sessiontimeout
haworku Sep 6, 2024
8b3460b
Write more unit tests, mock out more tests as well to write
haworku Sep 6, 2024
15fbbf6
Get remaining AuthenticatedRouteWrapper tests passing
haworku Sep 9, 2024
3f4dce2
Merge remote-tracking branch 'origin/main' into MCR-3849-sessiontimeout
haworku Sep 9, 2024
5430248
Add back feature flag handling for showing the session expiration modal
haworku Sep 9, 2024
620d06d
lint changed files
haworku Sep 9, 2024
2c0e97b
Create ModalOpenButton and store activModalID in page context
haworku Sep 11, 2024
fc707b5
Get only one modal at a time behavior working
haworku Sep 11, 2024
6ed777b
Get only one modal at a time behavior working
haworku Sep 11, 2024
7170c15
Add more async handling - try to address CI only unit test flakes
haworku Sep 11, 2024
33e059a
Lint cleanup
haworku Sep 11, 2024
0dd47e3
Get doubleclick bug addressed
haworku Sep 12, 2024
b511302
Merge remote-tracking branch 'origin/main' into MCR-3849-sessiontimeout
haworku Sep 12, 2024
7700079
Code review comments and cleanup
haworku Sep 13, 2024
ad846e8
Refactor cognitoAuth to return Errors, unify logout handling for vari…
haworku Sep 16, 2024
2a37d3b
merge main
haworku Sep 16, 2024
b806f8a
make modal close more snappy
haworku Sep 16, 2024
a312146
cleanup
haworku Sep 16, 2024
3037cc8
Don't allow continue session if timer elapsed (but logout failed due …
haworku Sep 16, 2024
cece00b
Also update copy when session expired but modal still open
haworku Sep 16, 2024
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
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
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
31 changes: 0 additions & 31 deletions services/app-web/src/components/Header/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,36 +141,5 @@ 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()

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

await waitFor(() => {
const signOutButton = screen.getByRole('button', {
name: /Sign out/i,
})

expect(signOutButton).toBeInTheDocument()
void userEvent.click(signOutButton)
})

await waitFor(() => expect(spy).toHaveBeenCalledTimes(1))
await waitFor(() => expect(mockAlert).toHaveBeenCalled())
})
})
})
10 changes: 2 additions & 8 deletions services/app-web/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,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
}
const handleLogout = () => {

logout({ sessionTimeout: false }).catch((e) => {
logout({sessionTimeout: false }).catch((e) => {
recordJSException(`Error with logout: ${e}`)
setAlert && setAlert(<ErrorAlertSignIn />)
})
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()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, {ComponentProps} from 'react'
import {
ModalToggleButton as UswdsModalToggleButton,
type ModalRef,
} from '@trussworks/react-uswds'
import { useTealium } from '../../../hooks'
import { usePage } from '../../../contexts/PageContext'
import { extractText } from '../../TealiumLogging/tealiamLoggingHelpers'

type ModalOpenButtonProps = {
id: string,
modalRef: React.RefObject<ModalRef>,
children: React.ReactNode
} & ComponentProps<typeof UswdsModalToggleButton>

export const ModalOpenButton = ({
modalRef,
id,
children,
...restProps
}: ModalOpenButtonProps): React.ReactElement => {
const { logButtonEvent } = useTealium()
const {activeModalRef, updateModalRef} = usePage()
const handleOnClick = () => {

// Make sure our global state tracks what modal is now open
if(activeModalRef !== modalRef) {
updateModalRef({updatedModalRef: modalRef})
}


logButtonEvent({
text: extractText(children),
button_type: 'button',
button_style: 'success',
parent_component_type: 'page body',
})
}
return (
<UswdsModalToggleButton
{...restProps}
modalRef={modalRef}
data-testid={id}
id={id}
onClick={handleOnClick}
opener
>
{children}
</UswdsModalToggleButton>
)
}
Loading
Loading