From 31bba0460737d6a96e31102e7b1cebb3784405f7 Mon Sep 17 00:00:00 2001 From: haworku Date: Mon, 16 Sep 2024 13:08:49 -0500 Subject: [PATCH] MCR-3849 Fix session timeout (#2719) * 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 --- .../feature-brief-session-expiration.md | 12 +- docs/technical-design/tealium-logging.md | 8 +- pnpm-lock.yaml | 14 + services/app-web/package.json | 1 + .../src/components/Header/Header.test.tsx | 61 ---- .../app-web/src/components/Header/Header.tsx | 17 +- .../src/components/Modal/Modal.module.scss | 14 + .../src/components/Modal/Modal.test.tsx | 28 +- .../app-web/src/components/Modal/Modal.tsx | 29 +- .../ModalOpenButton/ModalOpenButton.test.tsx | 19 ++ .../Modal/ModalOpenButton/ModalOpenButton.tsx | 51 ++++ .../components/Modal/SessionTimeoutModal.tsx | 67 +++++ .../Modal/UnlockSubmitModal.module.scss | 18 -- .../Modal/UnlockSubmitModal.test.tsx | 2 +- ...ubmitModalV2.tsx => UnlockSubmitModal.tsx} | 16 +- .../app-web/src/components/Modal/index.ts | 3 +- .../UnlockRateButton.tsx | 2 +- services/app-web/src/contexts/AuthContext.tsx | 269 ++++++++---------- services/app-web/src/contexts/PageContext.tsx | 25 +- services/app-web/src/contexts/S3Context.tsx | 15 +- services/app-web/src/localAuth/LocalLogin.tsx | 28 +- services/app-web/src/pages/App/App.tsx | 4 +- .../app-web/src/pages/App/AppRoutes.test.tsx | 6 +- services/app-web/src/pages/App/AppRoutes.tsx | 30 +- services/app-web/src/pages/Auth/Auth.test.tsx | 32 ++- .../app-web/src/pages/Auth/Login.test.tsx | 4 +- services/app-web/src/pages/Auth/Login.tsx | 56 ++-- services/app-web/src/pages/Auth/Signup.tsx | 32 +-- .../app-web/src/pages/Auth/cognitoAuth.ts | 153 +++++----- .../src/pages/Landing/Landing.test.tsx | 28 +- .../app-web/src/pages/Landing/Landing.tsx | 7 + .../V2/ReviewSubmit/ReviewSubmitV2.tsx | 22 +- .../SubmissionSummary/SubmissionSummary.tsx | 11 +- .../AuthenticatedRouteWrapper.test.tsx | 236 +++++++++++---- .../Wrapper/AuthenticatedRouteWrapper.tsx | 181 ++++++------ 35 files changed, 837 insertions(+), 664 deletions(-) create mode 100644 services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.test.tsx create mode 100644 services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.tsx create mode 100644 services/app-web/src/components/Modal/SessionTimeoutModal.tsx delete mode 100644 services/app-web/src/components/Modal/UnlockSubmitModal.module.scss rename services/app-web/src/components/Modal/{V2/UnlockSubmitModalV2.tsx => UnlockSubmitModal.tsx} (96%) diff --git a/docs/technical-design/feature-brief-session-expiration.md b/docs/technical-design/feature-brief-session-expiration.md index f497fdaafc..1bb6678445 100644 --- a/docs/technical-design/feature-brief-session-expiration.md +++ b/docs/technical-design/feature-brief-session-expiration.md @@ -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). @@ -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) @@ -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) @@ -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 diff --git a/docs/technical-design/tealium-logging.md b/docs/technical-design/tealium-logging.md index e4fa1f20fc..11df25e507 100644 --- a/docs/technical-design/tealium-logging.md +++ b/docs/technical-design/tealium-logging.md @@ -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 @@ -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) ... @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47b64c1a63..b5162b3e47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,6 +481,9 @@ importers: react-error-boundary: specifier: ^4.0.10 version: 4.0.13(react@18.3.1) + react-idle-timer: + specifier: ^5.7.2 + version: 5.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router: specifier: ^6.23.1 version: 6.23.1(react@18.3.1) @@ -11148,6 +11151,12 @@ packages: react-fast-compare@2.0.4: resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==} + react-idle-timer@5.7.2: + resolution: {integrity: sha512-+BaPfc7XEUU5JFkwZCx6fO1bLVK+RBlFH+iY4X34urvIzZiZINP6v2orePx3E6pAztJGE7t4DzvL7if2SL/0GQ==} + peerDependencies: + react: '>=16' + react-dom: '>=16' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -28641,6 +28650,11 @@ snapshots: react-fast-compare@2.0.4: {} + react-idle-timer@5.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/services/app-web/package.json b/services/app-web/package.json index 42cde0ef6e..2eb2786bd1 100644 --- a/services/app-web/package.json +++ b/services/app-web/package.json @@ -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", diff --git a/services/app-web/src/components/Header/Header.test.tsx b/services/app-web/src/components/Header/Header.test.tsx index ce26e22d03..e0f5988030 100644 --- a/services/app-web/src/components/Header/Header.test.tsx +++ b/services/app-web/src/components/Header/Header.test.tsx @@ -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( -
, - { - 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(
, { - 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() - }) }) }) diff --git a/services/app-web/src/components/Header/Header.tsx b/services/app-web/src/components/Header/Header.tsx index 21dac32cca..f62a3ccede 100644 --- a/services/app-web/src/components/Header/Header.tsx +++ b/services/app-web/src/components/Header/Header.tsx @@ -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 @@ -34,18 +32,9 @@ export const Header = ({ const { heading } = usePage() const { currentRoute: route } = useCurrentRoute() - const handleLogout = ( - e: React.MouseEvent - ) => { - if (!logout) { - console.info('Something went wrong ', e) - return - } - - logout({ sessionTimeout: false }).catch((e) => { - recordJSException(`Error with logout: ${e}`) - setAlert && setAlert() - }) + const handleLogout = async () => { + await logout({type: 'ERROR'}) + // no need to handle errors, logout will handle } return route !== 'GRAPHQL_EXPLORER' ? ( diff --git a/services/app-web/src/components/Modal/Modal.module.scss b/services/app-web/src/components/Modal/Modal.module.scss index 622e5b871b..e2c34833d7 100644 --- a/services/app-web/src/components/Modal/Modal.module.scss +++ b/services/app-web/src/components/Modal/Modal.module.scss @@ -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; + } +} + diff --git a/services/app-web/src/components/Modal/Modal.test.tsx b/services/app-web/src/components/Modal/Modal.test.tsx index 8693f39897..cee0f399ca 100644 --- a/services/app-web/src/components/Modal/Modal.test.tsx +++ b/services/app-web/src/components/Modal/Modal.test.tsx @@ -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', () => { @@ -199,13 +200,12 @@ describe('Modal', () => { >