From e4fb419bc13799e87efb54032e4b4918adeab9be Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Thu, 29 Aug 2024 15:57:36 -0500 Subject: [PATCH 01/26] Bring back changes from PR #2159 --- .../feature-brief-session-expiration.md | 6 +- services/app-web/src/contexts/AuthContext.tsx | 120 +++++++++++------- 2 files changed, 78 insertions(+), 48 deletions(-) diff --git a/docs/technical-design/feature-brief-session-expiration.md b/docs/technical-design/feature-brief-session-expiration.md index f497fdaafc..f75be3b097 100644 --- a/docs/technical-design/feature-brief-session-expiration.md +++ b/docs/technical-design/feature-brief-session-expiration.md @@ -2,7 +2,7 @@ ## 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 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 React (`AuthContext.tsx`) for the same time period. 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). @@ -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) diff --git a/services/app-web/src/contexts/AuthContext.tsx b/services/app-web/src/contexts/AuthContext.tsx index 5bf9345426..67080dde86 100644 --- a/services/app-web/src/contexts/AuthContext.tsx +++ b/services/app-web/src/contexts/AuthContext.tsx @@ -1,29 +1,39 @@ import React, { MutableRefObject, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { useLDClient } from 'launchdarkly-react-client-sdk' import * as ld from 'launchdarkly-js-client-sdk' import { AuthModeType } from '../common-code/config' -import { useFetchCurrentUserQuery, User as UserType } from '../gen/gqlClient' +import { + FetchCurrentUserQuery, + useFetchCurrentUserQuery, + User as UserType, +} from '../gen/gqlClient' import { logoutLocalUser } from '../localAuth' import { signOut as cognitoSignOut } from '../pages/Auth/cognitoAuth' import { featureFlags } from '../common-code/featureFlags' import { dayjs } from '../common-code/dateHelpers/dayjs' import { recordJSException } from '../otelHelpers/tracingHelper' import { handleApolloError } from '../gqlHelpers/apolloErrors' - -type LogoutFn = () => Promise +import { ApolloQueryResult } from '@apollo/client' export type LoginStatusType = 'LOADING' | 'LOGGED_OUT' | 'LOGGED_IN' export const MODAL_COUNTDOWN_DURATION = 2 * 60 // session expiration modal counts down for 120 seconds (2 minutes) type AuthContextType = { /* See docs/AuthContext.md for an explanation of some of these variables */ - checkAuth: () => Promise // this can probably be simpler, letting callers use the loading states etc instead. + checkAuth: ( + failureRedirect?: string + ) => Promise | Error> checkIfSessionsIsAboutToExpire: () => void loggedInUser: UserType | undefined loginStatus: LoginStatusType - logout: - | undefined - | (({ sessionTimeout }: { sessionTimeout: boolean }) => Promise) // sessionTimeout true when logout is due to session ending + logout: ({ + sessionTimeout, + redirectPath, + }: { + sessionTimeout: boolean + redirectPath?: string + }) => Promise logoutCountdownDuration: number sessionExpirationTime: MutableRefObject sessionIsExpiring: boolean @@ -37,7 +47,7 @@ const AuthContext = React.createContext({ checkIfSessionsIsAboutToExpire: () => void 0, loggedInUser: undefined, loginStatus: 'LOADING', - logout: undefined, + logout: () => Promise.resolve(undefined), logoutCountdownDuration: 120, sessionExpirationTime: { current: undefined }, sessionIsExpiring: false, @@ -61,6 +71,7 @@ function AuthProvider({ const [loginStatus, setLoginStatus] = useState('LOGGED_OUT') const ldClient = useLDClient() + const navigate = useNavigate() // session expiration modal const [sessionIsExpiring, setSessionIsExpiring] = useState(false) @@ -144,13 +155,15 @@ function AuthProvider({ `[User auth error]: Unable to authenticate user though user seems to be logged in. Message: ${error.message}` ) // since we have an auth request error but a potentially logged in user, we log out fully from Auth context and redirect to dashboard for clearer user experience - window.location.href = '/?session-timeout=true' + navigate(`/?session-timeout=true`) } } else if (data?.fetchCurrentUser) { if (!isAuthenticated) { const currentUser = data.fetchCurrentUser setLDUser(currentUser).catch((err) => { - console.info(err) + recordJSException( + new Error(`Cannot set LD User. ${JSON.stringify(err)}`) + ) }) setLoggedInUser(currentUser) setLoginStatus('LOGGED_IN') @@ -158,17 +171,27 @@ function AuthProvider({ } } - const checkAuth = () => { - return new Promise((resolve, reject) => { - refetch() - .then(() => { - resolve() - }) - .catch((e) => { - console.info('Check Auth Failed.', e) - reject(e) + /* + Refetches current user and confirms authentication + @param {failureRedirectPath} passed through to logout which is called on certain checkAuth failures + + Use this function to reconfirm the user is logged in. Also used in CognitoLogin + */ + const checkAuth: AuthContextType['checkAuth'] = async ( + failureRedirectPath = '/' + ) => { + try { + return await refetch() + } catch (e) { + // if we fail auth at a time we expected logged in user, the session may have timed out. Logout fully to reflect that and force Reat state to update + if (loggedInUser) { + await logout({ + sessionTimeout: true, + redirectPath: failureRedirectPath, }) - }) + } + return new Error(e) + } } const updateSessionExpirationState = (value: boolean) => { @@ -210,31 +233,38 @@ function AuthProvider({ } } - const realLogout: LogoutFn = - authMode === 'LOCAL' ? logoutLocalUser : cognitoSignOut + /* + Close user session and handle redirect afterward - const logout = - loggedInUser === undefined - ? undefined - : ({ sessionTimeout }: { sessionTimeout: boolean }) => { - return new Promise((_resolve, reject) => { - realLogout() - .then(() => { - // Hard navigate back to / - // this is more like how things work with IDM anyway. - if (sessionTimeout) { - window.location.href = - '/?session-timeout=true' - } else { - window.location.href = '/' - } - }) - .catch((e) => { - console.info('Logout Failed.', e) - reject(e) - }) - }) - } + @param {sessionTimeout} will pass along a URL query param to display session expired alert + @param {redirectPath} optionally changes redirect path on logout - useful for cognito and local login\ + + Logout is called when user clicks to logout from header or session expiration modal + Also called in the background with session times out + */ + const logout: AuthContextType['logout'] = async ({ + sessionTimeout, + redirectPath = '/', + }) => { + const realLogout = + authMode === 'LOCAL' ? logoutLocalUser : cognitoSignOut + + updateSessionExpirationState(false) + + try { + await realLogout() + if (sessionTimeout) { + window.location.href = `${redirectPath}?session-timeout=true` + } else { + window.location.href = redirectPath + } + return + } catch (e) { + recordJSException(new Error(`Logout Failed. ${JSON.stringify(e)}`)) + window.location.href = redirectPath + return + } + } return ( React.useContext(AuthContext) -export { AuthProvider, useAuth } +export { AuthProvider, useAuth } \ No newline at end of file From 68e208ea1b5185c4aa11a93741d6a3e40dd6f801 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Tue, 3 Sep 2024 10:51:01 -0500 Subject: [PATCH 02/26] Cleanup modals - inherit from base Modal - create SessionTimeoutModal as own component - simplify styles - removing V2 naming from UnlockResubmitModal --- .../src/components/Modal/Modal.module.scss | 14 +++++ .../app-web/src/components/Modal/Modal.tsx | 9 +-- .../components/Modal/SessionTimeoutModal.tsx | 57 +++++++++++++++++++ .../Modal/UnlockSubmitModal.module.scss | 18 ------ .../Modal/UnlockSubmitModal.test.tsx | 2 +- ...ubmitModalV2.tsx => UnlockSubmitModal.tsx} | 16 +++--- .../app-web/src/components/Modal/index.ts | 2 +- .../V2/ReviewSubmit/ReviewSubmitV2.tsx | 2 +- 8 files changed, 82 insertions(+), 38 deletions(-) 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/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.tsx b/services/app-web/src/components/Modal/Modal.tsx index 9fde5a5bae..759a3c5095 100644 --- a/services/app-web/src/components/Modal/Modal.tsx +++ b/services/app-web/src/components/Modal/Modal.tsx @@ -14,7 +14,6 @@ import { import styles from './Modal.module.scss' import { ActionButton } from '../ActionButton' -import { useAuth } from '../../contexts/AuthContext' import { ButtonWithLogging } from '../TealiumLogging' interface ModalComponentProps { @@ -48,15 +47,9 @@ export const Modal = ({ modalAlert, ...divProps }: ModalProps): React.ReactElement => { - const { sessionIsExpiring } = useAuth() - + // TODO figure out what to do here - make a global modal there was code about closing every other modal /* 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 cancelHandler = (e: React.MouseEvent): void => { if (onCancel) { diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx new file mode 100644 index 0000000000..853498882b --- /dev/null +++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { ModalRef } from '@trussworks/react-uswds' +import { Modal } from './Modal' +import styles from './Modal.module.scss' +import { useIdleTimerContext } from 'react-idle-timer' +import dayjs from 'dayjs' + +type SessionTimeoutModalProps = { + modalRef: React.RefObject + logoutSession: () => Promise, + refreshSession: () => Promise +} +export const SessionTimeoutModal = ({ + modalRef, + logoutSession, + refreshSession + +}: SessionTimeoutModalProps): React.ReactElement | null => { + + // TODO check if modal disappears when logged out + // TODO check if modal overrides another modal that is visible (e.g. unlock/resubmit modal) + // TODO retest cancel, continue, and do nothing + const idleTimer = useIdleTimerContext() + const countdownRemaining = idleTimer.getRemainingTime()* 1000 + + return ( + +

+ Your session is going to expire in{' '} + {dayjs + .duration(countdownRemaining, 'seconds') + .format('mm:ss')}{' '} +

+

+ If you would like to extend your session, click the + Continue Session button +

+

+ If you would like to end your session now, click the + Logout button +

+
+ ) +} diff --git a/services/app-web/src/components/Modal/UnlockSubmitModal.module.scss b/services/app-web/src/components/Modal/UnlockSubmitModal.module.scss deleted file mode 100644 index 6cb877323f..0000000000 --- a/services/app-web/src/components/Modal/UnlockSubmitModal.module.scss +++ /dev/null @@ -1,18 +0,0 @@ -@use '../../styles/custom.scss' as custom; -@use '../../styles/uswdsImports.scss' as uswds; - -.reviewSectionWrapper { - max-width: custom.$mcr-container-standard-width-fixed; -} - -.submitButton { - background: custom.$mcr-success-base; - &:hover { - background-color: custom.$mcr-success-hover !important; - } -} - -.modalInputTextarea { - max-width: 100%; - margin-bottom: 1rem; -} diff --git a/services/app-web/src/components/Modal/UnlockSubmitModal.test.tsx b/services/app-web/src/components/Modal/UnlockSubmitModal.test.tsx index 45a9cb38a0..942047f177 100644 --- a/services/app-web/src/components/Modal/UnlockSubmitModal.test.tsx +++ b/services/app-web/src/components/Modal/UnlockSubmitModal.test.tsx @@ -8,7 +8,7 @@ import { mockContractPackageSubmitted, unlockHealthPlanPackageMockError, } from '../../testHelpers/apolloMocks' -import { UnlockSubmitModal } from './V2/UnlockSubmitModalV2' +import { UnlockSubmitModal } from './UnlockSubmitModal' import { renderWithProviders } from '../../testHelpers/jestHelpers' import { Location } from 'react-router-dom' import { diff --git a/services/app-web/src/components/Modal/V2/UnlockSubmitModalV2.tsx b/services/app-web/src/components/Modal/UnlockSubmitModal.tsx similarity index 96% rename from services/app-web/src/components/Modal/V2/UnlockSubmitModalV2.tsx rename to services/app-web/src/components/Modal/UnlockSubmitModal.tsx index d9579f58fa..76cbf4ab5d 100644 --- a/services/app-web/src/components/Modal/V2/UnlockSubmitModalV2.tsx +++ b/services/app-web/src/components/Modal/UnlockSubmitModal.tsx @@ -8,19 +8,18 @@ import { useUnlockContractMutation, FetchHealthPlanPackageWithQuestionsDocument, FetchContractDocument, -} from '../../../gen/gqlClient' +} from '../../gen/gqlClient' import { useFormik } from 'formik' -import { usePrevious } from '../../../hooks/usePrevious' -import { Modal } from '../Modal' -import { PoliteErrorMessage } from '../../PoliteErrorMessage' +import { usePrevious } from '../../hooks/usePrevious' +import { Modal } from './Modal' +import { PoliteErrorMessage } from '../PoliteErrorMessage' import * as Yup from 'yup' -import styles from '../UnlockSubmitModal.module.scss' -import { GenericApiErrorProps } from '../../Banner/GenericApiErrorBanner/GenericApiErrorBanner' -import { ERROR_MESSAGES } from '../../../constants/errors' +import { GenericApiErrorProps } from '../Banner/GenericApiErrorBanner/GenericApiErrorBanner' +import { ERROR_MESSAGES } from '../../constants/errors' import { submitMutationWrapperV2, unlockMutationWrapperV2, -} from '../../../gqlHelpers/mutationWrappersForUserFriendlyErrors' +} from '../../gqlHelpers/mutationWrappersForUserFriendlyErrors' const RATE_UNLOCK_SUBMIT_TYPES = [ 'SUBMIT_RATE', @@ -338,7 +337,6 @@ export const UnlockSubmitModal = ({ name="unlockSubmitModalInput" data-testid="unlockSubmitModalInput" aria-labelledby="unlock-submit-modal-input-hint" - className={styles.modalInputTextarea} aria-required error={!!formik.errors.unlockSubmitModalInput} onChange={formik.handleChange} diff --git a/services/app-web/src/components/Modal/index.ts b/services/app-web/src/components/Modal/index.ts index c29ca56a86..7f0c9e2ca6 100644 --- a/services/app-web/src/components/Modal/index.ts +++ b/services/app-web/src/components/Modal/index.ts @@ -1,2 +1,2 @@ export { Modal } from './Modal' -export {UnlockSubmitModal} from './V2/UnlockSubmitModalV2' +export {UnlockSubmitModal} from './UnlockSubmitModal' diff --git a/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2.tsx b/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2.tsx index 1e534c70b0..b36c7566ca 100644 --- a/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2.tsx +++ b/services/app-web/src/pages/StateSubmission/ReviewSubmit/V2/ReviewSubmit/ReviewSubmitV2.tsx @@ -17,7 +17,7 @@ import { import { useLDClient } from 'launchdarkly-react-client-sdk' import { RoutesRecord } from '../../../../../constants' -import { UnlockSubmitModal } from '../../../../../components/Modal/V2/UnlockSubmitModalV2' +import { UnlockSubmitModal } from '../../../../../components/Modal/UnlockSubmitModal' import { getVisibleLatestContractFormData } from '../../../../../gqlHelpers/contractsAndRates' import { useAuth } from '../../../../../contexts/AuthContext' import { RateDetailsSummarySection } from '../../RateDetailsSummarySection' From d2b2cc1518d8b7d6bd48ed3b176357bf318cdf25 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Tue, 3 Sep 2024 10:53:42 -0500 Subject: [PATCH 03/26] Add IdleTimer - add to package.json - remove session timeout custom timekeeping, props from AuthContext - add IdleTimer to AuthenticatedRouteWrapper - start to add tests --- .../feature-brief-session-expiration.md | 8 +- pnpm-lock.yaml | 14 ++ services/app-web/package.json | 1 + .../app-web/src/components/Header/Header.tsx | 2 +- .../app-web/src/components/Modal/Modal.tsx | 2 +- services/app-web/src/contexts/AuthContext.tsx | 175 +++++------------- services/app-web/src/pages/App/AppRoutes.tsx | 30 +-- .../AuthenticatedRouteWrapper.test.tsx | 99 ++++------ .../Wrapper/AuthenticatedRouteWrapper.tsx | 130 ++++--------- 9 files changed, 146 insertions(+), 315 deletions(-) diff --git a/docs/technical-design/feature-brief-session-expiration.md b/docs/technical-design/feature-brief-session-expiration.md index f75be3b097..f521e083e9 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. Although 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 React (`AuthContext.tsx`) 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). +Importantly, this featured 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). @@ -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/pnpm-lock.yaml b/pnpm-lock.yaml index eb5feefc7e..df47c31704 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) @@ -11127,6 +11130,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==} @@ -28673,6 +28682,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 09bc73ec5a..8e9d32b419 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.tsx b/services/app-web/src/components/Header/Header.tsx index 21dac32cca..ebd6d59728 100644 --- a/services/app-web/src/components/Header/Header.tsx +++ b/services/app-web/src/components/Header/Header.tsx @@ -42,7 +42,7 @@ export const Header = ({ return } - logout({ sessionTimeout: false }).catch((e) => { + logout({ authMode, sessionTimeout: false }).catch((e) => { recordJSException(`Error with logout: ${e}`) setAlert && setAlert() }) diff --git a/services/app-web/src/components/Modal/Modal.tsx b/services/app-web/src/components/Modal/Modal.tsx index 759a3c5095..a9503a2c8d 100644 --- a/services/app-web/src/components/Modal/Modal.tsx +++ b/services/app-web/src/components/Modal/Modal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React from 'react' import { ButtonGroup, Modal as UswdsModal, diff --git a/services/app-web/src/contexts/AuthContext.tsx b/services/app-web/src/contexts/AuthContext.tsx index 67080dde86..73a34d45e4 100644 --- a/services/app-web/src/contexts/AuthContext.tsx +++ b/services/app-web/src/contexts/AuthContext.tsx @@ -1,8 +1,9 @@ -import React, { MutableRefObject, useEffect, useRef, useState } from 'react' +import React, { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useLDClient } from 'launchdarkly-react-client-sdk' import * as ld from 'launchdarkly-js-client-sdk' import { AuthModeType } from '../common-code/config' +import {extendSession} from '../pages/Auth/cognitoAuth' import { FetchCurrentUserQuery, useFetchCurrentUserQuery, @@ -10,50 +11,69 @@ import { } from '../gen/gqlClient' import { logoutLocalUser } from '../localAuth' import { signOut as cognitoSignOut } from '../pages/Auth/cognitoAuth' -import { featureFlags } from '../common-code/featureFlags' -import { dayjs } from '../common-code/dateHelpers/dayjs' import { recordJSException } from '../otelHelpers/tracingHelper' import { handleApolloError } from '../gqlHelpers/apolloErrors' import { ApolloQueryResult } from '@apollo/client' + /* + Close user session and handle redirect afterward + + @param {sessionTimeout} will pass along a URL query param to display session expired alert + @param {redirectPath} optionally changes redirect path on logout - useful for cognito and local login\ + + Logout is called when user clicks to logout from header or session expiration modal + Also called in the background with session times out + */ + const logout: AuthContextType['logout'] = async ({ + authMode, + sessionTimeout, + redirectPath = '/', + }) => { + const realLogout = + authMode === 'LOCAL' ? logoutLocalUser : cognitoSignOut + + try { + await realLogout() + if (sessionTimeout) { + window.location.href = `${redirectPath}?session-timeout=true` + } else { + window.location.href = redirectPath + } + return + } catch (e) { + recordJSException(new Error(`Logout Failed. ${JSON.stringify(e)}`)) + window.location.href = redirectPath + return + } + } + export type LoginStatusType = 'LOADING' | 'LOGGED_OUT' | 'LOGGED_IN' -export const MODAL_COUNTDOWN_DURATION = 2 * 60 // session expiration modal counts down for 120 seconds (2 minutes) type AuthContextType = { /* See docs/AuthContext.md for an explanation of some of these variables */ checkAuth: ( failureRedirect?: string ) => Promise | Error> - checkIfSessionsIsAboutToExpire: () => void + refreshAuth: () => Promise loggedInUser: UserType | undefined loginStatus: LoginStatusType logout: ({ + authMode, sessionTimeout, redirectPath, }: { + authMode: AuthModeType sessionTimeout: boolean redirectPath?: string }) => Promise - logoutCountdownDuration: number - sessionExpirationTime: MutableRefObject - sessionIsExpiring: boolean - setLogoutCountdownDuration: (value: number) => void - updateSessionExpirationState: (value: boolean) => void - updateSessionExpirationTime: () => void } const AuthContext = React.createContext({ checkAuth: () => Promise.reject(Error('Auth context error')), - checkIfSessionsIsAboutToExpire: () => void 0, loggedInUser: undefined, loginStatus: 'LOADING', + refreshAuth: () => Promise.resolve(undefined), logout: () => Promise.resolve(undefined), - logoutCountdownDuration: 120, - sessionExpirationTime: { current: undefined }, - sessionIsExpiring: false, - setLogoutCountdownDuration: () => void 0, - updateSessionExpirationState: () => void 0, - updateSessionExpirationTime: () => void 0, }) export type AuthProviderProps = { @@ -70,47 +90,12 @@ function AuthProvider({ ) const [loginStatus, setLoginStatus] = useState('LOGGED_OUT') - const ldClient = useLDClient() const navigate = useNavigate() - // session expiration modal - const [sessionIsExpiring, setSessionIsExpiring] = useState(false) - - const minutesUntilExpiration: number = ldClient?.variation( - featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, - featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue - ) - const sessionExpirationTime = useRef( - dayjs(Date.now()).add(minutesUntilExpiration, 'minute') - ) - const [logoutCountdownDuration, setLogoutCountdownDuration] = - useState(MODAL_COUNTDOWN_DURATION) - const modalCountdownTimers = useRef([]) - const sessionExpirationTimers = useRef([]) const { loading, data, error, refetch } = useFetchCurrentUserQuery({ notifyOnNetworkStatusChange: true, }) - useEffect(() => { - if (sessionIsExpiring) { - const timer = setInterval(() => { - // decrement the countdown timer by one second per second - modalCountdownTimers.current.push(timer) - if (logoutCountdownDuration > 0) { - setLogoutCountdownDuration( - (logoutCountdownDuration) => logoutCountdownDuration - 1 - ) - } - }, 1000) - } else { - modalCountdownTimers.current.forEach((timer) => - clearInterval(timer) - ) - modalCountdownTimers.current = [] - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessionIsExpiring]) // full dep array causes a loop, because we're resetting the dep in the useEffect - const isAuthenticated = loggedInUser !== undefined // add current authenticated user to launchdarkly client @@ -185,7 +170,9 @@ function AuthProvider({ } catch (e) { // if we fail auth at a time we expected logged in user, the session may have timed out. Logout fully to reflect that and force Reat state to update if (loggedInUser) { + // TODO get actual session timeout passing through await logout({ + authMode: authMode, sessionTimeout: true, redirectPath: failureRedirectPath, }) @@ -194,92 +181,22 @@ function AuthProvider({ } } - const updateSessionExpirationState = (value: boolean) => { - if (sessionIsExpiring !== value) { - setSessionIsExpiring(loggedInUser !== undefined && value) + const refreshAuth = async () => { + if (authMode !== 'LOCAL') { + await extendSession() } - } - - const updateSessionExpirationTime = () => { - sessionExpirationTime.current = dayjs(Date.now()).add( - minutesUntilExpiration, - 'minute' - ) - } - - const checkIfSessionsIsAboutToExpire = () => { - if (!sessionIsExpiring) { - const timer = setInterval(() => { - sessionExpirationTimers.current.push(timer) - let insideCountdownDurationPeriod = false - if (sessionExpirationTime.current) { - insideCountdownDurationPeriod = dayjs(Date.now()).isAfter( - dayjs(sessionExpirationTime.current).subtract( - MODAL_COUNTDOWN_DURATION, - 'second' - ) - ) - } - if (insideCountdownDurationPeriod) { - /* Once we're inside the countdown period, we can stop the interval that checks - whether we're inside the countdown period */ - sessionExpirationTimers.current.forEach((t) => - clearInterval(t) - ) - sessionExpirationTimers.current = [] - updateSessionExpirationState(true) - } - }, 1000 * 30) - } - } - - /* - Close user session and handle redirect afterward - - @param {sessionTimeout} will pass along a URL query param to display session expired alert - @param {redirectPath} optionally changes redirect path on logout - useful for cognito and local login\ - - Logout is called when user clicks to logout from header or session expiration modal - Also called in the background with session times out - */ - const logout: AuthContextType['logout'] = async ({ - sessionTimeout, - redirectPath = '/', - }) => { - const realLogout = - authMode === 'LOCAL' ? logoutLocalUser : cognitoSignOut - - updateSessionExpirationState(false) - - try { - await realLogout() - if (sessionTimeout) { - window.location.href = `${redirectPath}?session-timeout=true` - } else { - window.location.href = redirectPath - } return - } catch (e) { - recordJSException(new Error(`Logout Failed. ${JSON.stringify(e)}`)) - window.location.href = redirectPath - return - } } return ( diff --git a/services/app-web/src/pages/App/AppRoutes.tsx b/services/app-web/src/pages/App/AppRoutes.tsx index 3952f3c4b2..f73f1482a8 100644 --- a/services/app-web/src/pages/App/AppRoutes.tsx +++ b/services/app-web/src/pages/App/AppRoutes.tsx @@ -2,7 +2,7 @@ import React, { Fragment, useEffect, useState } from 'react' import { useLocation, Navigate } from 'react-router' import { Route, Routes } from 'react-router-dom' import { useLDClient } from 'launchdarkly-react-client-sdk' -import { extendSession, idmRedirectURL } from '../../pages/Auth/cognitoAuth' +import { idmRedirectURL } from '../../pages/Auth/cognitoAuth' import { assertNever, AuthModeType } from '../../common-code/config' import { PageTitlesRecord, RoutesRecord, RouteT } from '../../constants/routes' import { getRouteName } from '../../routeHelpers' @@ -81,7 +81,7 @@ const StateUserRoutes = ({ featureFlags.RATE_EDIT_UNLOCK.defaultValue ) return ( - + { return ( - + { const { loggedInUser, - sessionIsExpiring, - updateSessionExpirationState, - updateSessionExpirationTime, - checkIfSessionsIsAboutToExpire, } = useAuth() const { pathname } = useLocation() - const ldClient = useLDClient() const [redirectPath, setRedirectPath] = useLocalStorage( 'LOGIN_REDIRECT', null ) const stageName = import.meta.env.VITE_APP_STAGE_NAME - const showExpirationModal: boolean = ldClient?.variation( - featureFlags.SESSION_EXPIRING_MODAL.flag, - featureFlags.SESSION_EXPIRING_MODAL.defaultValue - ) const route = getRouteName(pathname) const { updateHeading } = usePage() const [initialPath] = useState(pathname) // this gets written on mount, so we don't call the effect on every path change - if ( - loggedInUser !== undefined && - sessionIsExpiring === false && - showExpirationModal - ) { - // whenever we load a page, reset the logout timer and refresh the session - updateSessionExpirationTime() - - if (authMode !== 'LOCAL') { - void extendSession() - } - updateSessionExpirationState(false) - // Every thirty seconds, check if the current time is within `countdownDurationSeconds` of the session expiration time - checkIfSessionsIsAboutToExpire() - } // This effect handles our initial redirect on login // This way, if you get a link to something and aren't logged in, you get diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx index 0211c6c9b2..76d6d2f653 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx @@ -1,9 +1,20 @@ -import { screen, waitFor } from '@testing-library/react' +import { act, screen } from '@testing-library/react' import { renderWithProviders } from '../../testHelpers/jestHelpers' import { AuthenticatedRouteWrapper } from './AuthenticatedRouteWrapper' -import * as AuthContext from '../../contexts/AuthContext' +import { createMocks } from 'react-idle-timer'; describe('AuthenticatedRouteWrapper', () => { + beforeAll(() => { + jest.useFakeTimers(); + createMocks(); + }); + + afterAll(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + it('renders without errors', async () => { renderWithProviders( { expect(kids).toBeInTheDocument() }) - it('hides the modal by default', async () => { + it('hides the session timeout modal by default', async () => { renderWithProviders( children go here} /> ) - const dialog = screen.getByRole('dialog') - await waitFor(() => expect(dialog).toHaveClass('is-hidden')) + const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveClass('is-hidden') }) - it('shows the modal when sessionIsExpiring is true', async () => { - vi.spyOn(AuthContext, 'useAuth').mockReturnValue({ - loggedInUser: { - __typename: 'StateUser' as const, - state: { - name: 'Minnesota', - code: 'MN', - programs: [ - { - id: 'abbdf9b0-c49e-4c4c-bb6f-040cb7b51cce', - fullName: 'Special Needs Basic Care', - name: 'SNBC', - isRateProgram: false, - }, - { - id: 'd95394e5-44d1-45df-8151-1cc1ee66f100', - fullName: 'Prepaid Medical Assistance Program', - name: 'PMAP', - isRateProgram: false, - }, - { - id: 'ea16a6c0-5fc6-4df8-adac-c627e76660ab', - fullName: 'Minnesota Senior Care Plus ', - name: 'MSC+', - isRateProgram: false, - }, - { - id: '3fd36500-bf2c-47bc-80e8-e7aa417184c5', - fullName: 'Minnesota Senior Health Options', - name: 'MSHO', - isRateProgram: false, - }, - ], - }, - id: 'foo-id', - givenName: 'Bob', - familyName: 'Dumas', - role: 'State User', - email: 'bob@dmas.mn.gov', - }, - loginStatus: 'LOGGED_IN', - checkAuth: () => Promise.reject(Error('Auth context error')), - logout: () => Promise.resolve(), - sessionIsExpiring: true, - updateSessionExpirationState: () => void 0, - logoutCountdownDuration: 120, - sessionExpirationTime: { current: undefined }, - updateSessionExpirationTime: () => void 0, - checkIfSessionsIsAboutToExpire: () => void 0, - setLogoutCountdownDuration: () => void 0, - }) + it("does not render session timeout modal before timeout period is exceeded", () => { renderWithProviders( children go here} /> ) - const dialog = screen.getByRole('dialog') - await waitFor(() => expect(dialog).toHaveClass('is-visible')) - }) + + act(() => jest.advanceTimersByTime(1000)); + expect(screen.queryByTestId("timeout-dialog")).not.toBeInTheDocument(); + }); + + // it("renders session timeout modal after idle prompt period is exceeded", async () => { + // act(() => jest.advanceTimersByTime(3000)); + // const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) + // expect(dialog).toBeVisible(); + // expect(mockHandleLogout).not.toHaveBeenCalled(); + // }); + + // it("logs out and closes session timeout modal after full timeout duration is exceeded", async () => { + // act(() => jest.advanceTimersByTime(3000)); + // const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) + // expect(dialog).toBeVisible(); + // expect(mockHandleLogout).toHaveBeenCalled(); + // }); }) diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx index 9a1e991145..bd13303a27 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx @@ -1,108 +1,58 @@ -import React, { useState } from 'react' -import { Modal } from '../../components/Modal/Modal' +import React from 'react' import { ModalRef } from '@trussworks/react-uswds' -import { createRef, useCallback, useEffect } from 'react' -import { MODAL_COUNTDOWN_DURATION, useAuth } from '../../contexts/AuthContext' +import { createRef} from 'react' +import { useAuth } from '../../contexts/AuthContext' import { AuthModeType } from '../../common-code/config' -import { extendSession } from '../Auth/cognitoAuth' -import styles from '../StateSubmission/ReviewSubmit/ReviewSubmit.module.scss' -import { dayjs } from '../../common-code/dateHelpers/dayjs' -import { recordJSException } from '../../otelHelpers' -import { ErrorAlertSignIn } from '../../components' +import { useLDClient } from 'launchdarkly-react-client-sdk' +import { featureFlags } from '../../common-code/featureFlags' +import { SessionTimeoutModal } from '../../components/Modal/SessionTimeoutModal' +import { IdleTimerProvider } from 'react-idle-timer' export const AuthenticatedRouteWrapper = ({ children, - setAlert, authMode, }: { children: React.ReactNode - setAlert?: React.Dispatch authMode: AuthModeType }): React.ReactElement => { - const { - logout, - sessionIsExpiring, - logoutCountdownDuration, - updateSessionExpirationState, - updateSessionExpirationTime, - setLogoutCountdownDuration, - } = useAuth() - const [announcementSeed] = useState(logoutCountdownDuration) - const announcementTimes: number[] = [] - for (let i = announcementSeed; i > 0; i -= 10) { - announcementTimes.push(i) - } const modalRef = createRef() + const ldClient = useLDClient() + const {logout, refreshAuth} = useAuth() - const logoutSession = useCallback( - (forcedSessionSignout: boolean) => { - updateSessionExpirationState(false) - if (logout) { - logout({ sessionTimeout: forcedSessionSignout }).catch((e) => { - recordJSException(`Error with logout: ${e}`) - setAlert && setAlert() - }) - } - }, - [logout, setAlert, updateSessionExpirationState] - ) - - const resetSessionTimeout = () => { - updateSessionExpirationState(false) - updateSessionExpirationTime() - setLogoutCountdownDuration(MODAL_COUNTDOWN_DURATION) - if (authMode !== 'LOCAL') { - void extendSession() - } + const openSessionTimeoutModal = () =>{ + modalRef.current?.toggleModal(undefined, true) } + const closeSessionTimeoutModal = () => { + modalRef.current?.toggleModal(undefined, false) + } + // Time increments for session timeout actions in milliseconds + const SESSION_DURATION: number = ldClient?.variation( + featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, + featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue + ) * 1000 + const SESSION_TIMEOUT_COUNTDOWN = 2 * 60 * 1000 // session expiration modal counts down 2 minutes + const RECHECK_FREQUENCY = 1000 - useEffect(() => { - modalRef.current?.toggleModal(undefined, sessionIsExpiring) - }, [sessionIsExpiring, modalRef]) - - useEffect(() => { - if (logoutCountdownDuration < 1) { - logoutSession(true) - } - }, [logoutCountdownDuration, logoutSession]) + const logoutWithSessionTimeout = async () => logout({ authMode, sessionTimeout: true }) + const logoutByUserChoice = async () => logout({ authMode, sessionTimeout: false}) return ( - <> - { - logoutSession(false)} - submitButtonProps={{ className: styles.submitButton }} - onSubmit={resetSessionTimeout} - forceAction={true} - > -

- Your session is going to expire in{' '} - {dayjs - .duration(logoutCountdownDuration, 'seconds') - .format('mm:ss')}{' '} -

-

- If you would like to extend your session, click the - Continue Session button -

-

- If you would like to end your session now, click the - Logout button -

-
- } + { + await refreshAuth() + closeSessionTimeoutModal() + }} + onPrompt={openSessionTimeoutModal} + promptBeforeIdle={SESSION_TIMEOUT_COUNTDOWN} + timeout={SESSION_DURATION} + throttle={RECHECK_FREQUENCY} + > {children} - + + ) } From 853c403b3abffcf871f670e5e90c2d22dd446f31 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Tue, 3 Sep 2024 14:01:51 -0500 Subject: [PATCH 04/26] Get existing tests passing --- .../src/components/Header/Header.test.tsx | 31 ------------ .../app-web/src/components/Header/Header.tsx | 8 +-- services/app-web/src/contexts/AuthContext.tsx | 7 ++- services/app-web/src/localAuth/LocalLogin.tsx | 28 ++++++----- services/app-web/src/pages/Auth/Auth.test.tsx | 30 ++++++----- services/app-web/src/pages/Auth/Login.tsx | 50 ++++++++----------- .../app-web/src/pages/Auth/cognitoAuth.ts | 22 ++++---- .../AuthenticatedRouteWrapper.test.tsx | 16 +++--- .../Wrapper/AuthenticatedRouteWrapper.tsx | 26 ++++++---- 9 files changed, 94 insertions(+), 124 deletions(-) diff --git a/services/app-web/src/components/Header/Header.test.tsx b/services/app-web/src/components/Header/Header.test.tsx index 233a1fff5d..bd453cec58 100644 --- a/services/app-web/src/components/Header/Header.test.tsx +++ b/services/app-web/src/components/Header/Header.test.tsx @@ -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( -
, - { - 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()) - }) }) }) diff --git a/services/app-web/src/components/Header/Header.tsx b/services/app-web/src/components/Header/Header.tsx index ebd6d59728..ab7819ff1e 100644 --- a/services/app-web/src/components/Header/Header.tsx +++ b/services/app-web/src/components/Header/Header.tsx @@ -34,13 +34,7 @@ export const Header = ({ const { heading } = usePage() const { currentRoute: route } = useCurrentRoute() - const handleLogout = ( - e: React.MouseEvent - ) => { - if (!logout) { - console.info('Something went wrong ', e) - return - } + const handleLogout = () => { logout({ authMode, sessionTimeout: false }).catch((e) => { recordJSException(`Error with logout: ${e}`) diff --git a/services/app-web/src/contexts/AuthContext.tsx b/services/app-web/src/contexts/AuthContext.tsx index 73a34d45e4..fa71fe899b 100644 --- a/services/app-web/src/contexts/AuthContext.tsx +++ b/services/app-web/src/contexts/AuthContext.tsx @@ -157,20 +157,19 @@ function AuthProvider({ } /* - Refetches current user and confirms authentication + Refetches current user and confirms authentication - primarily used with LocalLogin and CognitoLogin - not on IDM @param {failureRedirectPath} passed through to logout which is called on certain checkAuth failures Use this function to reconfirm the user is logged in. Also used in CognitoLogin */ const checkAuth: AuthContextType['checkAuth'] = async ( - failureRedirectPath = '/' + failureRedirectPath = '/?session-timeout' ) => { try { return await refetch() } catch (e) { - // if we fail auth at a time we expected logged in user, the session may have timed out. Logout fully to reflect that and force Reat state to update + // if we fail auth at a time we expected logged in user, the session may have timed out. Logout fully to reflect that and force React state update if (loggedInUser) { - // TODO get actual session timeout passing through await logout({ authMode: authMode, sessionTimeout: true, diff --git a/services/app-web/src/localAuth/LocalLogin.tsx b/services/app-web/src/localAuth/LocalLogin.tsx index 76d44ef575..d640eaeb03 100644 --- a/services/app-web/src/localAuth/LocalLogin.tsx +++ b/services/app-web/src/localAuth/LocalLogin.tsx @@ -26,7 +26,8 @@ import azulaAvatar from '../assets/images/azula.png' import { useAuth } from '../contexts/AuthContext' import { LocalUserType } from './LocalUserType' -import { ButtonWithLogging } from '../components' +import { ButtonWithLogging, ErrorAlertSignIn } from '../components' +import { recordJSException } from '../otelHelpers' const localUsers: LocalUserType[] = [ { @@ -113,20 +114,25 @@ const userAvatars: { [key: string]: string } = { } export function LocalLogin(): React.ReactElement { - const [showFormAlert, setShowFormAlert] = React.useState(false) + const hasSigninError = new URLSearchParams(location.search).get( + 'signin-error' + ) + const [showFormAlert, setShowFormAlert] = React.useState(hasSigninError? true : false) const navigate = useNavigate() const { checkAuth, loginStatus } = useAuth() async function login(user: LocalUserType) { loginLocalUser(user) + const result = await checkAuth('/auth?signin-error') + + if(result instanceof Error){ + setShowFormAlert(true) + recordJSException(result) + } else { + navigate(RoutesRecord.ROOT) + } + - try { - await checkAuth() - navigate(RoutesRecord.ROOT) - } catch (error) { - setShowFormAlert(true) - console.info('Log: Server Error') - } } return ( @@ -135,9 +141,7 @@ export function LocalLogin(): React.ReactElement {

Local Login

Login as one of our hard coded users:
{showFormAlert && ( - - Something went wrong - + )} {localUsers.map((user) => { diff --git a/services/app-web/src/pages/Auth/Auth.test.tsx b/services/app-web/src/pages/Auth/Auth.test.tsx index ef09ab3211..79a6226cef 100644 --- a/services/app-web/src/pages/Auth/Auth.test.tsx +++ b/services/app-web/src/pages/Auth/Auth.test.tsx @@ -13,11 +13,14 @@ import { import { CognitoLogin } from './CognitoLogin' import { LocalLogin } from '../../localAuth' import { fetchCurrentUserMock } from '../../testHelpers/apolloMocks' -/* +/* This file should only have basic user flows for auth. Form and implementation details are tested at the component level. */ describe('Auth', () => { + afterEach( ()=>{ + vi.clearAllMocks() + }) describe('Cognito Login', () => { const userLogin = async (screen: Screen) => { await userClickByRole(screen, 'button', { @@ -37,16 +40,16 @@ describe('Auth', () => { await userClickByRole(screen, 'button', { name: 'Login' }) } - it('displays signup form when logged out', () => { + it('displays signup form when logged out',async () => { renderWithProviders(, { apolloProvider: { mocks: [fetchCurrentUserMock({ statusCode: 403 })], }, }) - expect( - screen.getByRole('form', { name: 'Signup Form' }) - ).toBeInTheDocument() + + await screen.findByRole('form', { name: 'Signup Form' }) + expect( screen.getByRole('button', { name: /SignUp/i }) ).toBeInTheDocument() @@ -94,8 +97,8 @@ describe('Auth', () => { { apolloProvider: { mocks: [ - fetchCurrentUserMock({ statusCode: 403 }), - fetchCurrentUserMock({ statusCode: 403 }), + fetchCurrentUserMock({statusCode: 403}), + fetchCurrentUserMock({statusCode: 200}), ], }, routerProvider: { @@ -111,7 +114,7 @@ describe('Auth', () => { await waitFor(() => expect(testLocation.pathname).toBe('/')) }) - it('when login fails, stay on page and display error alert', async () => { + it('when login fails, display error alert', async () => { const loginSpy = vi .spyOn(CognitoAuthApi, 'signIn') .mockRejectedValue(new Error('Login failed')) @@ -137,7 +140,10 @@ describe('Auth', () => { await waitFor(() => { expect(loginSpy).toHaveBeenCalledTimes(1) expect(testLocation.pathname).toBe('/auth') + expect(screen.getByRole('heading', {name:'Sign in error'})).toBeInTheDocument() }) + + }) }) @@ -146,18 +152,17 @@ describe('Auth', () => { window.localStorage.clear() }) - it('displays aang and toph and zuko and roku and izumi and iroh when logged out', () => { + it('displays aang and toph and zuko and roku and izumi and iroh when logged out', async () => { renderWithProviders(, { apolloProvider: { mocks: [fetchCurrentUserMock({ statusCode: 403 })], }, }) - expect( - screen.getByRole('img', { + await screen.findByRole('img', { name: /Aang/i, }) - ).toBeInTheDocument() + expect( screen.getByRole('img', { @@ -273,6 +278,7 @@ describe('Auth', () => { await userClickByTestId(screen, 'TophButton') await waitFor(() => { expect(testLocation.pathname).toBe('/auth') + expect(screen.getByRole('heading', {name:'Sign in error'})).toBeInTheDocument() }) }) }) diff --git a/services/app-web/src/pages/Auth/Login.tsx b/services/app-web/src/pages/Auth/Login.tsx index 22757affa2..9f0ddb6bdd 100644 --- a/services/app-web/src/pages/Auth/Login.tsx +++ b/services/app-web/src/pages/Auth/Login.tsx @@ -1,17 +1,22 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { Form, FormGroup, Label, TextInput } from '@trussworks/react-uswds' import { useNavigate } from 'react-router-dom' import { signIn } from '../Auth/cognitoAuth' import { useAuth } from '../../contexts/AuthContext' -import { ButtonWithLogging, ErrorAlert } from '../../components' +import { ButtonWithLogging, ErrorAlertSignIn } from '../../components' +import { recordJSException } from '../../otelHelpers' +import { RoutesRecord } from '../../constants' type Props = { defaultEmail?: string } export function Login({ defaultEmail }: Props): React.ReactElement { - const [showFormAlert, setShowFormAlert] = React.useState(false) + const hasSigninError = new URLSearchParams(location.search).get( + 'signin-error' + ) + const [showFormAlert, setShowFormAlert] = React.useState(hasSigninError? true: false) const [fields, setFields] = useState({ loginEmail: defaultEmail || '', loginPassword: '', @@ -19,7 +24,12 @@ export function Login({ defaultEmail }: Props): React.ReactElement { const navigate = useNavigate() const { loginStatus, checkAuth } = useAuth() - if (loginStatus === 'LOGGED_IN') navigate('/') + useEffect( () => { + if (loginStatus === 'LOGGED_IN') { + navigate(RoutesRecord.ROOT) + } + },[loginStatus, navigate]) + const onFieldChange = (event: React.ChangeEvent) => { const { id, value } = event.target @@ -35,40 +45,24 @@ export function Login({ defaultEmail }: Props): React.ReactElement { event.preventDefault() try { - const result = await signIn(fields.loginEmail, fields.loginPassword) + await signIn(fields.loginEmail, fields.loginPassword) - if (result && 'code' in result) { - if (result.code === 'UserNotConfirmedException') { - // the user has not been confirmed, need to display the confirmation UI - console.info( - 'you need to confirm your account, enter the code below' - ) - } else if (result.code === 'NotAuthorizedException') { - // the password is bad - console.info('bad password') - } else { - console.info('Unknown error from Amplify: ', result) - } + // we think we signed in, double check that amplify - API connection agrees + const authResult = await checkAuth('/auth?signin-error') + if(authResult instanceof Error) { + recordJSException(`Cognito Login Error - unexpected error after succeeding on signIn – ${authResult}`) setShowFormAlert(true) - console.info('Error', result.message) } else { - try { - await checkAuth() - } catch (e) { - console.info('UNEXPECTED NOT LOGGED IN AFTER LOGGIN', e) - setShowFormAlert(true) - } - - navigate('/') + navigate(RoutesRecord.ROOT) } } catch (err) { - console.info('Unexpected error signing in:', err) + setShowFormAlert(true) } } return (
- {showFormAlert && Something went wrong} + {showFormAlert && } { +): Promise { try { const result = await AmplifyAuth.signIn(email, password) return result.user } catch (e) { if (isAmplifyError(e)) { if (e.code === 'UserNotConfirmedException') { - console.info( - 'you need to confirm your account, enter the code below' + recordJSException( + `AmplifyError ${e.code} – you need to confirm your account, enter the code below` ) - return e } else if (e.code === 'NotAuthorizedException') { - console.info('unknown user or password?') - return e + recordJSException(`AmplifyError ${e.code} – this is probably a bad password`) } else if (e.code === 'UserNotFoundException') { - console.info('user does not exist') - return e + recordJSException(`AmplifyError ${e.code} – user does not exist`) } else { - // if amplify returns an error in a format we don't expect, let's throw it for now. - // might be against the spirit of never throw, but this is our boundary with a system we don't control. - throw e + recordJSException(`UNEXPECTED SIGNIN ERROR AmplifyError ${e.code} – ${e.message}`) } } else { - console.info('didnt even get an amplify error back from login') - throw e + recordJSException(`UNEXPECTED SIGNIN ERROR – 'didnt even get an amplify error back from login`) } } + throw Error('Cognito SignIn error') } export async function signOut(): Promise { diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx index 76d6d2f653..e6aab7e78d 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx @@ -5,14 +5,14 @@ import { createMocks } from 'react-idle-timer'; describe('AuthenticatedRouteWrapper', () => { beforeAll(() => { - jest.useFakeTimers(); + vi.useFakeTimers(); createMocks(); }); afterAll(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - jest.clearAllMocks(); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); }); it('renders without errors', async () => { @@ -38,7 +38,7 @@ describe('AuthenticatedRouteWrapper', () => { expect(dialog).toHaveClass('is-hidden') }) - it("does not render session timeout modal before timeout period is exceeded", () => { + it("does not render session timeout modal before timeout period is exceeded", async() => { renderWithProviders( { /> ) - act(() => jest.advanceTimersByTime(1000)); + await vi.advanceTimersByTime(1000); expect(screen.queryByTestId("timeout-dialog")).not.toBeInTheDocument(); }); // it("renders session timeout modal after idle prompt period is exceeded", async () => { - // act(() => jest.advanceTimersByTime(3000)); + // act(() => vi.advanceTimersByTime(3000)); // const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) // expect(dialog).toBeVisible(); // expect(mockHandleLogout).not.toHaveBeenCalled(); // }); // it("logs out and closes session timeout modal after full timeout duration is exceeded", async () => { - // act(() => jest.advanceTimersByTime(3000)); + // act(() => vi.advanceTimersByTime(3000)); // const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) // expect(dialog).toBeVisible(); // expect(mockHandleLogout).toHaveBeenCalled(); diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx index bd13303a27..bb1d2d6fef 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx @@ -7,6 +7,7 @@ import { useLDClient } from 'launchdarkly-react-client-sdk' import { featureFlags } from '../../common-code/featureFlags' import { SessionTimeoutModal } from '../../components/Modal/SessionTimeoutModal' import { IdleTimerProvider } from 'react-idle-timer' +import { recordJSException } from '../../otelHelpers' export const AuthenticatedRouteWrapper = ({ children, @@ -25,16 +26,23 @@ export const AuthenticatedRouteWrapper = ({ const closeSessionTimeoutModal = () => { modalRef.current?.toggleModal(undefined, false) } - // Time increments for session timeout actions in milliseconds - const SESSION_DURATION: number = ldClient?.variation( - featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, - featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue - ) * 1000 - const SESSION_TIMEOUT_COUNTDOWN = 2 * 60 * 1000 // session expiration modal counts down 2 minutes - const RECHECK_FREQUENCY = 1000 - const logoutWithSessionTimeout = async () => logout({ authMode, sessionTimeout: true }) const logoutByUserChoice = async () => logout({ authMode, sessionTimeout: false}) + + // Time increments for session timeout actions must be in milliseconds + const SESSION_TIMEOUT_COUNTDOWN = 2 * 60 * 1000 + const RECHECK_FREQUENCY = 1000 + const SESSION_DURATION: number = ldClient?.variation( + featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, + featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue + ) * 60 * 1000 + let timeout = SESSION_DURATION + + if (SESSION_TIMEOUT_COUNTDOWN > SESSION_DURATION){ + recordJSException('SessionTimeoutModal error, duration must be longer than the timeout for idle prompt so we are overriding LD flag value') + timeout = SESSION_TIMEOUT_COUNTDOWN + 2000 + } + return ( {children} From e7dabb398ce1da077b62799537951c9f4c4eaee4 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Tue, 3 Sep 2024 15:47:07 -0500 Subject: [PATCH 05/26] Add more mocked out tests --- .../components/Modal/SessionTimeoutModal.tsx | 3 -- .../AuthenticatedRouteWrapper.test.tsx | 32 +++++++++++-------- .../Wrapper/AuthenticatedRouteWrapper.tsx | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx index 853498882b..5fba0a3aa6 100644 --- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx +++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx @@ -17,9 +17,6 @@ export const SessionTimeoutModal = ({ }: SessionTimeoutModalProps): React.ReactElement | null => { - // TODO check if modal disappears when logged out - // TODO check if modal overrides another modal that is visible (e.g. unlock/resubmit modal) - // TODO retest cancel, continue, and do nothing const idleTimer = useIdleTimerContext() const countdownRemaining = idleTimer.getRemainingTime()* 1000 diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx index e6aab7e78d..014f674ea7 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx @@ -38,7 +38,7 @@ describe('AuthenticatedRouteWrapper', () => { expect(dialog).toHaveClass('is-hidden') }) - it("does not render session timeout modal before timeout period is exceeded", async() => { + it("hides the session timeout modal when timeout period is not exceeded", async() => { renderWithProviders( { expect(screen.queryByTestId("timeout-dialog")).not.toBeInTheDocument(); }); - // it("renders session timeout modal after idle prompt period is exceeded", async () => { - // act(() => vi.advanceTimersByTime(3000)); - // const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) - // expect(dialog).toBeVisible(); - // expect(mockHandleLogout).not.toHaveBeenCalled(); - // }); - // it("logs out and closes session timeout modal after full timeout duration is exceeded", async () => { - // act(() => vi.advanceTimersByTime(3000)); - // const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) - // expect(dialog).toBeVisible(); - // expect(mockHandleLogout).toHaveBeenCalled(); - // }); + it("renders session timeout modal after idle prompt period is exceeded", async () => { + await act(() => vi.advanceTimersByTime(3000)); + const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialog).toBeVisible(); + // expect(mockHandleLogout).not.toHaveBeenCalled(); + }); + it.todo('renders countdown inside session timeout modal that updates every second') + it("if user does nothing, logs out and closes session timeout modal after full timeout duration is exceeded", async () => { + await act(() => vi.advanceTimersByTime(3000)); + const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialog).toBeVisible(); + // expect(mockHandleLogout).toHaveBeenCalled(); + }); + + it.todo('overrides any existing open modal with session timeout modal when idle prompt is displayed') + it.todo('hides session timeout after logout') // to test this we need to move a level up + it.todo('session timeout modal submit button click will refresh the user session') + it.todo('session timeout modal cancel button click will logout user session') }) diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx index bb1d2d6fef..d3f9c1a307 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx @@ -39,7 +39,7 @@ export const AuthenticatedRouteWrapper = ({ let timeout = SESSION_DURATION if (SESSION_TIMEOUT_COUNTDOWN > SESSION_DURATION){ - recordJSException('SessionTimeoutModal error, duration must be longer than the timeout for idle prompt so we are overriding LD flag value') + recordJSException('SessionTimeoutModal – session duration must be longer than the timeout for idle prompt. We are overriding LD flag value.') timeout = SESSION_TIMEOUT_COUNTDOWN + 2000 } From f81dc2ab8c1d588c269a6790425012f416c94208 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Tue, 3 Sep 2024 17:00:58 -0500 Subject: [PATCH 06/26] Handle cases where timeout countdown and session duration are set to incompatible values --- .../feature-brief-session-expiration.md | 2 +- .../pages/Wrapper/AuthenticatedRouteWrapper.tsx | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/technical-design/feature-brief-session-expiration.md b/docs/technical-design/feature-brief-session-expiration.md index f521e083e9..1bb6678445 100644 --- a/docs/technical-design/feature-brief-session-expiration.md +++ b/docs/technical-design/feature-brief-session-expiration.md @@ -4,7 +4,7 @@ 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 also 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). diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx index d3f9c1a307..e3d577d695 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx @@ -36,13 +36,18 @@ export const AuthenticatedRouteWrapper = ({ featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue ) * 60 * 1000 - let timeout = SESSION_DURATION + let promptCountdown = SESSION_TIMEOUT_COUNTDOWN - if (SESSION_TIMEOUT_COUNTDOWN > SESSION_DURATION){ + // Since session duration is controlled by feature flag, make sure it cannot be assigned to incompatible values for IdleTimeoutProvider + if (SESSION_DURATION <= SESSION_TIMEOUT_COUNTDOWN){ + console.log('IN HERE') recordJSException('SessionTimeoutModal – session duration must be longer than the timeout for idle prompt. We are overriding LD flag value.') - timeout = SESSION_TIMEOUT_COUNTDOWN + 2000 + promptCountdown = SESSION_DURATION - 2000 } + console.log('countdown: ', SESSION_TIMEOUT_COUNTDOWN, 'session duration: ', SESSION_DURATION, 'promptCountdown: ', promptCountdown + ) + return ( {children} From 618c367b54547bc0c2ec1b0d5e5cda70a23fba20 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Wed, 4 Sep 2024 10:44:35 -0500 Subject: [PATCH 07/26] Fix countdown --- .../components/Modal/SessionTimeoutModal.tsx | 18 ++++++++++---- .../Wrapper/AuthenticatedRouteWrapper.tsx | 24 ++++++++----------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx index 5fba0a3aa6..950d4c2535 100644 --- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx +++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useEffect, useState} from 'react' import { ModalRef } from '@trussworks/react-uswds' import { Modal } from './Modal' import styles from './Modal.module.scss' @@ -16,9 +16,19 @@ export const SessionTimeoutModal = ({ refreshSession }: SessionTimeoutModalProps): React.ReactElement | null => { - const idleTimer = useIdleTimerContext() - const countdownRemaining = idleTimer.getRemainingTime()* 1000 + const [countdownSeconds, setCountdownSeconds]= useState(idleTimer.getRemainingTime() / 1000) + + useEffect(() => { + const interval = setInterval(() => { + setCountdownSeconds(Math.ceil(idleTimer.getRemainingTime() / 1000)) + }, 500) + + return () => { + clearInterval(interval) + } + }) + return ( Your session is going to expire in{' '} {dayjs - .duration(countdownRemaining, 'seconds') + .duration(countdownSeconds, 'seconds') .format('mm:ss')}{' '}

diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx index e3d577d695..39a57f22be 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx @@ -28,8 +28,11 @@ export const AuthenticatedRouteWrapper = ({ } const logoutWithSessionTimeout = async () => logout({ authMode, sessionTimeout: true }) const logoutByUserChoice = async () => logout({ authMode, sessionTimeout: false}) - - // Time increments for session timeout actions must be in milliseconds + const refreshSession = async () => { + await refreshAuth() + closeSessionTimeoutModal() + } + // Time increments for all constants must be milliseconds const SESSION_TIMEOUT_COUNTDOWN = 2 * 60 * 1000 const RECHECK_FREQUENCY = 1000 const SESSION_DURATION: number = ldClient?.variation( @@ -38,23 +41,16 @@ export const AuthenticatedRouteWrapper = ({ ) * 60 * 1000 let promptCountdown = SESSION_TIMEOUT_COUNTDOWN - // Since session duration is controlled by feature flag, make sure it cannot be assigned to incompatible values for IdleTimeoutProvider if (SESSION_DURATION <= SESSION_TIMEOUT_COUNTDOWN){ - console.log('IN HERE') - recordJSException('SessionTimeoutModal – session duration must be longer than the timeout for idle prompt. We are overriding LD flag value.') - promptCountdown = SESSION_DURATION - 2000 + // Make sure we have compatible session duration timeout versus countdown values. Duration should be longer. + // IdleTimeoutProvider will throw an error otherwise + promptCountdown = SESSION_DURATION - 1000 } - console.log('countdown: ', SESSION_TIMEOUT_COUNTDOWN, 'session duration: ', SESSION_DURATION, 'promptCountdown: ', promptCountdown - ) - return ( { - await refreshAuth() - closeSessionTimeoutModal() - }} + onActive={refreshSession} onPrompt={openSessionTimeoutModal} promptBeforeIdle={promptCountdown} timeout={SESSION_DURATION} @@ -63,7 +59,7 @@ export const AuthenticatedRouteWrapper = ({ {children} From 57b658c64c2afd2c08ffff784ae8092a7335d60a Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Wed, 4 Sep 2024 15:18:20 -0500 Subject: [PATCH 08/26] Add cross-tab support --- .../components/Modal/SessionTimeoutModal.tsx | 17 +++++---- .../Wrapper/AuthenticatedRouteWrapper.tsx | 35 +++++++++++++------ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx index 950d4c2535..8398ebe92f 100644 --- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx +++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx @@ -7,18 +7,21 @@ import dayjs from 'dayjs' type SessionTimeoutModalProps = { modalRef: React.RefObject - logoutSession: () => Promise, - refreshSession: () => Promise } + export const SessionTimeoutModal = ({ modalRef, - logoutSession, - refreshSession - }: SessionTimeoutModalProps): React.ReactElement | null => { const idleTimer = useIdleTimerContext() const [countdownSeconds, setCountdownSeconds]= useState(idleTimer.getRemainingTime() / 1000) + const handleLogoutSession = async () => { + idleTimer.message({action:'LOGOUT_SESSION'}, true) + } + const handleContinueSession = async () => { + idleTimer.message({action:'CONTINUE_SESSION'}, true) + idleTimer.activate() + } useEffect(() => { const interval = setInterval(() => { setCountdownSeconds(Math.ceil(idleTimer.getRemainingTime() / 1000)) @@ -37,9 +40,9 @@ export const SessionTimeoutModal = ({ modalHeading="Session Expiring" onSubmitText="Continue Session" onCancelText="Logout" - onCancel={logoutSession} + onCancel={handleLogoutSession} submitButtonProps={{ className: styles.submitSuccessButton }} - onSubmit={refreshSession} + onSubmit={handleContinueSession} forceAction={true} >

{ modalRef.current?.toggleModal(undefined, false) } - const logoutWithSessionTimeout = async () => logout({ authMode, sessionTimeout: true }) + const logoutBySessionTimeout = async () => logout({ authMode, sessionTimeout: true }) const logoutByUserChoice = async () => logout({ authMode, sessionTimeout: false}) const refreshSession = async () => { await refreshAuth() closeSessionTimeoutModal() } - // Time increments for all constants must be milliseconds + + // For multi-tab support we emit messages related to user actions on the session timeout modal + const onMessage = async ({action}: {action: 'LOGOUT_SESSION' | 'CONTINUE_SESSION'}) => { + switch (action) { + case 'LOGOUT_SESSION': + await logoutByUserChoice() + break; + case 'CONTINUE_SESSION': + await refreshSession() + break + default: + // no op + } + } + + // IdleTimeoutProvider - time increments for all constants must be milliseconds const SESSION_TIMEOUT_COUNTDOWN = 2 * 60 * 1000 - const RECHECK_FREQUENCY = 1000 + const RECHECK_FREQUENCY = 500 const SESSION_DURATION: number = ldClient?.variation( featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue ) * 60 * 1000 let promptCountdown = SESSION_TIMEOUT_COUNTDOWN - if (SESSION_DURATION <= SESSION_TIMEOUT_COUNTDOWN){ - // Make sure we have compatible session duration timeout versus countdown values. Duration should be longer. - // IdleTimeoutProvider will throw an error otherwise + // IdleTimeoutProvider – session duration must be longer than prompt countdown, override if not + if (SESSION_DURATION <= SESSION_TIMEOUT_COUNTDOWN) { promptCountdown = SESSION_DURATION - 1000 } return ( {children} ) From 18af39d6b3d6a2d83f23fe07ebb2d6cba6bfe658 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Wed, 4 Sep 2024 16:10:37 -0500 Subject: [PATCH 09/26] Get s3Context to manually check auth if it fails and user logged out --- .../app-web/src/components/Header/Header.tsx | 2 +- services/app-web/src/contexts/AuthContext.tsx | 74 +++++++++---------- services/app-web/src/contexts/S3Context.tsx | 21 +++++- services/app-web/src/pages/App/App.tsx | 4 +- .../Wrapper/AuthenticatedRouteWrapper.tsx | 20 ++--- 5 files changed, 67 insertions(+), 54 deletions(-) diff --git a/services/app-web/src/components/Header/Header.tsx b/services/app-web/src/components/Header/Header.tsx index ab7819ff1e..5706cc86db 100644 --- a/services/app-web/src/components/Header/Header.tsx +++ b/services/app-web/src/components/Header/Header.tsx @@ -36,7 +36,7 @@ export const Header = ({ const handleLogout = () => { - logout({ authMode, sessionTimeout: false }).catch((e) => { + logout({sessionTimeout: false }).catch((e) => { recordJSException(`Error with logout: ${e}`) setAlert && setAlert() }) diff --git a/services/app-web/src/contexts/AuthContext.tsx b/services/app-web/src/contexts/AuthContext.tsx index fa71fe899b..418cf32897 100644 --- a/services/app-web/src/contexts/AuthContext.tsx +++ b/services/app-web/src/contexts/AuthContext.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react' -import { useNavigate } from 'react-router-dom' import { useLDClient } from 'launchdarkly-react-client-sdk' import * as ld from 'launchdarkly-js-client-sdk' import { AuthModeType } from '../common-code/config' @@ -15,42 +14,9 @@ import { recordJSException } from '../otelHelpers/tracingHelper' import { handleApolloError } from '../gqlHelpers/apolloErrors' import { ApolloQueryResult } from '@apollo/client' - /* - Close user session and handle redirect afterward - - @param {sessionTimeout} will pass along a URL query param to display session expired alert - @param {redirectPath} optionally changes redirect path on logout - useful for cognito and local login\ - - Logout is called when user clicks to logout from header or session expiration modal - Also called in the background with session times out - */ - const logout: AuthContextType['logout'] = async ({ - authMode, - sessionTimeout, - redirectPath = '/', - }) => { - const realLogout = - authMode === 'LOCAL' ? logoutLocalUser : cognitoSignOut - - try { - await realLogout() - if (sessionTimeout) { - window.location.href = `${redirectPath}?session-timeout=true` - } else { - window.location.href = redirectPath - } - return - } catch (e) { - recordJSException(new Error(`Logout Failed. ${JSON.stringify(e)}`)) - window.location.href = redirectPath - return - } - } - export type LoginStatusType = 'LOADING' | 'LOGGED_OUT' | 'LOGGED_IN' type AuthContextType = { - /* See docs/AuthContext.md for an explanation of some of these variables */ checkAuth: ( failureRedirect?: string ) => Promise | Error> @@ -58,11 +24,9 @@ type AuthContextType = { loggedInUser: UserType | undefined loginStatus: LoginStatusType logout: ({ - authMode, sessionTimeout, redirectPath, }: { - authMode: AuthModeType sessionTimeout: boolean redirectPath?: string }) => Promise @@ -90,7 +54,6 @@ function AuthProvider({ ) const [loginStatus, setLoginStatus] = useState('LOGGED_OUT') - const navigate = useNavigate() const { loading, data, error, refetch } = useFetchCurrentUserQuery({ notifyOnNetworkStatusChange: true, @@ -132,7 +95,6 @@ function AuthProvider({ if (!loading) { if (error) { handleApolloError(error, isAuthenticated) - if (isAuthenticated) { setLoggedInUser(undefined) setLoginStatus('LOGGED_OUT') @@ -140,7 +102,7 @@ function AuthProvider({ `[User auth error]: Unable to authenticate user though user seems to be logged in. Message: ${error.message}` ) // since we have an auth request error but a potentially logged in user, we log out fully from Auth context and redirect to dashboard for clearer user experience - navigate(`/?session-timeout=true`) + window.location.href = `/?signin-error=true` } } else if (data?.fetchCurrentUser) { if (!isAuthenticated) { @@ -155,6 +117,37 @@ function AuthProvider({ } } } + /* + Close user session and handle redirect afterward + + @param {sessionTimeout} will pass along a URL query param to display session expired alert + @param {redirectPath} optionally changes redirect path on logout - useful for cognito and local login\ + + Logout is called when user clicks to logout from header or session expiration modal + Also called in the background with session times out + */ + const logout: AuthContextType['logout'] = async ({ + sessionTimeout, + redirectPath = '/', + }) => { + const realLogout = + authMode === 'LOCAL' ? logoutLocalUser : cognitoSignOut + + try { + await realLogout() + if (sessionTimeout) { + window.location.href = `${redirectPath}?session-timeout=true` + } else { + window.location.href = redirectPath + } + return + } catch (e) { + recordJSException(new Error(`Logout Failed. ${JSON.stringify(e)}`)) + window.location.href = redirectPath + return + } + } + /* Refetches current user and confirms authentication - primarily used with LocalLogin and CognitoLogin - not on IDM @@ -171,7 +164,6 @@ function AuthProvider({ // if we fail auth at a time we expected logged in user, the session may have timed out. Logout fully to reflect that and force React state update if (loggedInUser) { await logout({ - authMode: authMode, sessionTimeout: true, redirectPath: failureRedirectPath, }) @@ -204,4 +196,4 @@ function AuthProvider({ const useAuth = (): AuthContextType => React.useContext(AuthContext) -export { AuthProvider, useAuth } \ No newline at end of file +export { AuthProvider, useAuth } diff --git a/services/app-web/src/contexts/S3Context.tsx b/services/app-web/src/contexts/S3Context.tsx index 3b508f2912..9db6c326fa 100644 --- a/services/app-web/src/contexts/S3Context.tsx +++ b/services/app-web/src/contexts/S3Context.tsx @@ -4,6 +4,7 @@ import { S3FileData } from '../components' import type { S3ClientT } from '../s3' import { BucketShortName } from '../s3/s3Amplify' import { recordJSException } from '../otelHelpers' +import { useAuth } from './AuthContext' type S3ContextT = { handleUploadFile: ( @@ -41,6 +42,7 @@ const useS3 = (): S3ContextT => { } const { deleteFile, uploadFile, scanFile, getS3URL } = context + const {checkAuth, logout} = useAuth() const handleUploadFile = async ( file: File, @@ -51,9 +53,17 @@ const useS3 = (): S3ContextT => { if (isS3Error(s3Key)) { const error = new Error(`Error in S3: ${file.name}`) recordJSException(error) + // s3 file upload failing could be due to IDM session timeout + // double check the user still has their session, if not, logout to update the React state with their login status + try { + await checkAuth() + } catch (e) { + await logout({ + sessionTimeout: true, + }) + } throw error } - const s3URL = await getS3URL(s3Key, file.name, bucket) return { key: s3Key, s3URL: s3URL } } @@ -70,6 +80,15 @@ const useS3 = (): S3ContextT => { recordJSException(error) throw error } + // s3 file upload failing could be due to IDM session timeout + // double check the user still has their session, if not, logout to update the React state with their login status + try { + await checkAuth() + } catch (e) { + await logout({ + sessionTimeout: true, + }) + } const error = new Error('Scanning error: Scanning retry timed out') recordJSException(error) throw error diff --git a/services/app-web/src/pages/App/App.tsx b/services/app-web/src/pages/App/App.tsx index 8d0c7f122e..87fb61d5bb 100644 --- a/services/app-web/src/pages/App/App.tsx +++ b/services/app-web/src/pages/App/App.tsx @@ -56,15 +56,15 @@ function App({ - + + - diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx index 0df2214a8b..46176ebe5e 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx @@ -8,6 +8,8 @@ import { featureFlags } from '../../common-code/featureFlags' import { SessionTimeoutModal } from '../../components/Modal/SessionTimeoutModal' import { IdleTimerProvider } from 'react-idle-timer' +// AuthenticatedRouteWrapper control access to protected routes and the session timeout modal +// For more on expected behavior for session timeout see feature-brief-session-expiration.md export const AuthenticatedRouteWrapper = ({ children, authMode, @@ -25,8 +27,8 @@ export const AuthenticatedRouteWrapper = ({ const closeSessionTimeoutModal = () => { modalRef.current?.toggleModal(undefined, false) } - const logoutBySessionTimeout = async () => logout({ authMode, sessionTimeout: true }) - const logoutByUserChoice = async () => logout({ authMode, sessionTimeout: false}) + const logoutBySessionTimeout = async () => logout({ sessionTimeout: true }) + const logoutByUserChoice = async () => logout({ sessionTimeout: false}) const refreshSession = async () => { await refreshAuth() closeSessionTimeoutModal() @@ -46,16 +48,16 @@ export const AuthenticatedRouteWrapper = ({ } } - // IdleTimeoutProvider - time increments for all constants must be milliseconds - const SESSION_TIMEOUT_COUNTDOWN = 2 * 60 * 1000 + // All time increment constants must be milliseconds const RECHECK_FREQUENCY = 500 + const SESSION_TIMEOUT_COUNTDOWN = 2 * 60 * 1000 const SESSION_DURATION: number = ldClient?.variation( - featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, - featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue - ) * 60 * 1000 - let promptCountdown = SESSION_TIMEOUT_COUNTDOWN + featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, + featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue + ) * 60 * 1000 // controlled by feature flag for testing in lower environments + let promptCountdown = SESSION_TIMEOUT_COUNTDOWN // may be reassigned if session duration is shorter time period - // IdleTimeoutProvider – session duration must be longer than prompt countdown, override if not + // Session duration must be longer than prompt countdown to allow IdleTimer to load if (SESSION_DURATION <= SESSION_TIMEOUT_COUNTDOWN) { promptCountdown = SESSION_DURATION - 1000 } From b0dbf524cb88e29fa8b4e8e7be1b941a74a805fa Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Wed, 4 Sep 2024 16:22:31 -0500 Subject: [PATCH 10/26] Fixup AuthneticatedRouteWrapper tests --- .../AuthenticatedRouteWrapper.test.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx index 014f674ea7..d7bff756c2 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx @@ -52,19 +52,35 @@ describe('AuthenticatedRouteWrapper', () => { it("renders session timeout modal after idle prompt period is exceeded", async () => { + renderWithProviders( + children go here} + />, + { featureFlags: { + 'session-expiration-minutes': 2 + }} + ) + await act(() => vi.advanceTimersByTime(3000)); const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) expect(dialog).toBeVisible(); - // expect(mockHandleLogout).not.toHaveBeenCalled(); }); - it.todo('renders countdown inside session timeout modal that updates every second') it("if user does nothing, logs out and closes session timeout modal after full timeout duration is exceeded", async () => { - await act(() => vi.advanceTimersByTime(3000)); + renderWithProviders( + children go here} + />, + { featureFlags: { + 'session-expiration-minutes': 2 + }} + ) const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) expect(dialog).toBeVisible(); // expect(mockHandleLogout).toHaveBeenCalled(); }); - + it.todo('renders countdown inside session timeout modal that updates every second') it.todo('overrides any existing open modal with session timeout modal when idle prompt is displayed') it.todo('hides session timeout after logout') // to test this we need to move a level up it.todo('session timeout modal submit button click will refresh the user session') From 8b3460bf62953b6af1018de6328cf12d17aa80b0 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Fri, 6 Sep 2024 13:00:49 -0500 Subject: [PATCH 11/26] Write more unit tests, mock out more tests as well to write --- .../app-web/src/pages/App/AppRoutes.test.tsx | 49 ++++++++++++- .../AuthenticatedRouteWrapper.test.tsx | 71 ++++++++++++++----- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/services/app-web/src/pages/App/AppRoutes.test.tsx b/services/app-web/src/pages/App/AppRoutes.test.tsx index 57cb6c0682..aef1910a5a 100644 --- a/services/app-web/src/pages/App/AppRoutes.test.tsx +++ b/services/app-web/src/pages/App/AppRoutes.test.tsx @@ -8,8 +8,8 @@ import { indexHealthPlanPackagesMockSuccess, } from '../../testHelpers/apolloMocks' -// Routing and routes configuration -describe('AppRoutes', () => { +// Routing and routes configuration tested here, best layer for testing behaviors that cross several pages +describe('AppRoutes and routing configuration', () => { Object.defineProperty(window, 'scrollTo', { writable: true, value: vi.fn(), @@ -247,4 +247,49 @@ describe('AppRoutes', () => { ) }) }) + + describe.todo('login, logout, and session timeout behaviors', () =>{ + it.todo('when login is successful, redirect to dashboard as display as logged in') + it.todo('when logout is successful, redirect to landing page and display as logged out with no warning banners') + it.skip("if user session times out, log out user and redirect to landing page with session expired banner", async () => { + const logoutSpy = vi + .spyOn(CognitoAuthApi, 'signOut') + .mockResolvedValue(null) + let testLocation: Location + + renderWithProviders( + children go here} + />, + { featureFlags: { + 'session-expiration-minutes': 2 + }} + ) + + const dialogOnLoad = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogOnLoad).toBeInTheDocument() + expect(dialogOnLoad).toHaveClass('is-hidden') + + await act(() => vi.advanceTimersByTime(1000)); + const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogAfterIdle).toHaveClass('is-visible') + + + await act(() => vi.advanceTimersByTime(2000)); + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', {name: 'Session Expiring'})) + + // check that we redirected to Landing page and we are signed out + // await waitFor(() => expect(testLocation.pathname).toBe('/')) + // expect(logoutSpy).toHaveBeenCalled() + const dialogAfterLogout = await screen.queryByRole('dialog', {name: 'Session Expiring'}) + expect(dialogAfterLogout).toHaveClass('is-hidden') + + // expect(screen.getByRole('link', {name:'Sign In'})).toBeInTheDocument() + // expect(screen.getByText('You have been logged out due to inactivity. Please sign in again.')).toBeInTheDocument(); + // expect(screen.getByText('Submit your managed care health plans to CMS for review')).toBeInTheDocument(); + + }); + it.todo('if user session goes idle, display session timeout modal and override the submit modal') + }) }) diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx index d7bff756c2..149a03ab44 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx @@ -1,8 +1,12 @@ -import { act, screen } from '@testing-library/react' +import { act, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react' import { renderWithProviders } from '../../testHelpers/jestHelpers' import { AuthenticatedRouteWrapper } from './AuthenticatedRouteWrapper' import { createMocks } from 'react-idle-timer'; - +import { Landing } from '../Landing/Landing'; +import { Location, Route, Routes } from 'react-router'; +import { RoutesRecord } from '../../constants'; +import * as CognitoAuthApi from '../Auth/cognitoAuth' +import { fetchCurrentUserMock } from '../../testHelpers/apolloMocks'; describe('AuthenticatedRouteWrapper', () => { beforeAll(() => { vi.useFakeTimers(); @@ -38,16 +42,23 @@ describe('AuthenticatedRouteWrapper', () => { expect(dialog).toHaveClass('is-hidden') }) - it("hides the session timeout modal when timeout period is not exceeded", async() => { + it("hides the session timeout modal by when timeout period is not exceeded", async() => { renderWithProviders( children go here} - /> + />, + { featureFlags: { + 'session-expiration-minutes': 3 + }} ) - await vi.advanceTimersByTime(1000); - expect(screen.queryByTestId("timeout-dialog")).not.toBeInTheDocument(); + const dialogOnLoad = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogOnLoad).toBeInTheDocument() + expect(dialogOnLoad).toHaveClass('is-hidden') + await act(() => vi.advanceTimersByTime(500)); + const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogAfterIdle).toHaveClass('is-hidden') }); @@ -61,28 +72,50 @@ describe('AuthenticatedRouteWrapper', () => { 'session-expiration-minutes': 2 }} ) + const dialogOnLoad = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogOnLoad).toBeInTheDocument() + expect(dialogOnLoad).toHaveClass('is-hidden') - await act(() => vi.advanceTimersByTime(3000)); - const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) - expect(dialog).toBeVisible(); + await act(() => vi.advanceTimersByTime(1000)); + + const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogAfterIdle).toHaveClass('is-visible') }); - it("if user does nothing, logs out and closes session timeout modal after full timeout duration is exceeded", async () => { + + + it("renders session timeout modal and if countdown elapses and user does nothing, calls sign out", async () => { + const logoutSpy = vi + .spyOn(CognitoAuthApi, 'signOut') + .mockResolvedValue(null) + + renderWithProviders( - + children go here} - />, + /> + , { featureFlags: { 'session-expiration-minutes': 2 }} ) - const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) - expect(dialog).toBeVisible(); - // expect(mockHandleLogout).toHaveBeenCalled(); + + const dialogOnLoad = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogOnLoad).toBeInTheDocument() + expect(dialogOnLoad).toHaveClass('is-hidden') + + await act(() => vi.advanceTimersByTime(1000)); + const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogAfterIdle).toHaveClass('is-visible') + + + await act(() => vi.advanceTimersByTime(120100)); + expect(logoutSpy).toHaveBeenCalled() + }); + it.todo('renders countdown inside session timeout modal that updates every second') - it.todo('overrides any existing open modal with session timeout modal when idle prompt is displayed') - it.todo('hides session timeout after logout') // to test this we need to move a level up - it.todo('session timeout modal submit button click will refresh the user session') - it.todo('session timeout modal cancel button click will logout user session') + it.todo('session timeout modal continue session button click will refresh the user session') + it.todo('session timeout modal logout button click will logout user session') }) From 15fbbf66674de6a44ae1422aa14af8d65d47d5d0 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Mon, 9 Sep 2024 16:35:06 -0500 Subject: [PATCH 12/26] Get remaining AuthenticatedRouteWrapper tests passing --- .../components/Modal/SessionTimeoutModal.tsx | 6 +- .../AuthenticatedRouteWrapper.test.tsx | 86 +++++++++++++++++-- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx index 8398ebe92f..82fd87794f 100644 --- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx +++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx @@ -49,10 +49,10 @@ export const SessionTimeoutModal = ({ aria-live={'assertive'} aria-atomic={true} > - Your session is going to expire in{' '} - {dayjs + Your session is going to expire in  + {dayjs .duration(countdownSeconds, 'seconds') - .format('mm:ss')}{' '} + .format('mm:ss')}.

If you would like to extend your session, click the diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx index 149a03ab44..8cd20a0dff 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx @@ -1,13 +1,12 @@ -import { act, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react' +import { act, screen} from '@testing-library/react' import { renderWithProviders } from '../../testHelpers/jestHelpers' import { AuthenticatedRouteWrapper } from './AuthenticatedRouteWrapper' import { createMocks } from 'react-idle-timer'; -import { Landing } from '../Landing/Landing'; -import { Location, Route, Routes } from 'react-router'; -import { RoutesRecord } from '../../constants'; import * as CognitoAuthApi from '../Auth/cognitoAuth' -import { fetchCurrentUserMock } from '../../testHelpers/apolloMocks'; -describe('AuthenticatedRouteWrapper', () => { +import { dayjs } from '../../common-code/dateHelpers'; + + +describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => { beforeAll(() => { vi.useFakeTimers(); createMocks(); @@ -115,7 +114,76 @@ describe('AuthenticatedRouteWrapper', () => { }); - it.todo('renders countdown inside session timeout modal that updates every second') - it.todo('session timeout modal continue session button click will refresh the user session') - it.todo('session timeout modal logout button click will logout user session') + it('renders countdown inside session timeout modal that updates every second', async() => { + renderWithProviders( +

+ children go here
} + /> + , + { featureFlags: { + 'session-expiration-minutes': 2 + }} + ) + await screen.findByRole('dialog', {name: 'Session Expiring'}) + await act(() => vi.advanceTimersByTime(1000)) + const timeElapsedBefore = screen.getByTestId('remaining').textContent + await act(() => vi.advanceTimersByTime(1000)) + const timeElapsedAfter = screen.getByTestId('remaining').textContent + + const diff = dayjs(timeElapsedBefore, 'mm:ss').diff(dayjs(timeElapsedAfter, 'mm:ss'), 'milliseconds') + expect(diff).toBe(1000) + }) + + it('session timeout modal continue session button click will refresh the user session', async ()=>{ + const refreshSpy = vi + .spyOn(CognitoAuthApi, 'extendSession') + .mockResolvedValue(null) + + renderWithProviders( +
+ children go here
} + /> + , + { featureFlags: { + 'session-expiration-minutes': 2 + }} + ) + + await act(() => vi.advanceTimersByTime(1000)); + const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogAfterIdle).toHaveClass('is-visible'); + (await screen.findByText('Continue Session')).click() + await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogAfterIdle).not.toHaveClass('is-visible'); + expect(refreshSpy).toHaveBeenCalled() + }) + + it('session timeout modal Logout button click will logout user session', async () =>{ + const logoutSpy = vi + .spyOn(CognitoAuthApi, 'signOut') + .mockResolvedValue(null) + + + renderWithProviders( +
+ children go here
} + /> + , + { featureFlags: { + 'session-expiration-minutes': 2 + }} + ) + + await act(() => vi.advanceTimersByTime(1000)); + const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) + expect(dialogAfterIdle).toHaveClass('is-visible'); + (await screen.findByText('Logout')).click() + expect(logoutSpy).toHaveBeenCalled() + }) }) From 5430248fc0388532228dc698a97771f4f130ae56 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Mon, 9 Sep 2024 17:49:37 -0500 Subject: [PATCH 13/26] Add back feature flag handling for showing the session expiration modal --- .../app-web/src/pages/App/AppRoutes.test.tsx | 47 +------------------ .../AuthenticatedRouteWrapper.test.tsx | 28 +++++++---- .../Wrapper/AuthenticatedRouteWrapper.tsx | 14 +++--- 3 files changed, 28 insertions(+), 61 deletions(-) diff --git a/services/app-web/src/pages/App/AppRoutes.test.tsx b/services/app-web/src/pages/App/AppRoutes.test.tsx index aef1910a5a..477d29a5a8 100644 --- a/services/app-web/src/pages/App/AppRoutes.test.tsx +++ b/services/app-web/src/pages/App/AppRoutes.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import { renderWithProviders } from '../../testHelpers/jestHelpers' import { AppRoutes } from './AppRoutes' @@ -247,49 +247,4 @@ describe('AppRoutes and routing configuration', () => { ) }) }) - - describe.todo('login, logout, and session timeout behaviors', () =>{ - it.todo('when login is successful, redirect to dashboard as display as logged in') - it.todo('when logout is successful, redirect to landing page and display as logged out with no warning banners') - it.skip("if user session times out, log out user and redirect to landing page with session expired banner", async () => { - const logoutSpy = vi - .spyOn(CognitoAuthApi, 'signOut') - .mockResolvedValue(null) - let testLocation: Location - - renderWithProviders( - children go here} - />, - { featureFlags: { - 'session-expiration-minutes': 2 - }} - ) - - const dialogOnLoad = await screen.findByRole('dialog', {name: 'Session Expiring'}) - expect(dialogOnLoad).toBeInTheDocument() - expect(dialogOnLoad).toHaveClass('is-hidden') - - await act(() => vi.advanceTimersByTime(1000)); - const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) - expect(dialogAfterIdle).toHaveClass('is-visible') - - - await act(() => vi.advanceTimersByTime(2000)); - await waitForElementToBeRemoved(() => screen.queryByRole('dialog', {name: 'Session Expiring'})) - - // check that we redirected to Landing page and we are signed out - // await waitFor(() => expect(testLocation.pathname).toBe('/')) - // expect(logoutSpy).toHaveBeenCalled() - const dialogAfterLogout = await screen.queryByRole('dialog', {name: 'Session Expiring'}) - expect(dialogAfterLogout).toHaveClass('is-hidden') - - // expect(screen.getByRole('link', {name:'Sign In'})).toBeInTheDocument() - // expect(screen.getByText('You have been logged out due to inactivity. Please sign in again.')).toBeInTheDocument(); - // expect(screen.getByText('Submit your managed care health plans to CMS for review')).toBeInTheDocument(); - - }); - it.todo('if user session goes idle, display session timeout modal and override the submit modal') - }) }) diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx index 8cd20a0dff..da7571d9d1 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx @@ -34,7 +34,11 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => { children go here} - /> + />, + { featureFlags: { + 'session-expiration-minutes': 3, + 'session-expiring-modal': true + }} ) const dialog = await screen.findByRole('dialog', {name: 'Session Expiring'}) expect(dialog).toBeInTheDocument() @@ -48,7 +52,8 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => { children={
children go here
} />, { featureFlags: { - 'session-expiration-minutes': 3 + 'session-expiration-minutes': 3, + 'session-expiring-modal': true }} ) @@ -68,7 +73,8 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => { children={
children go here
} />, { featureFlags: { - 'session-expiration-minutes': 2 + 'session-expiration-minutes': 2, + 'session-expiring-modal': true }} ) const dialogOnLoad = await screen.findByRole('dialog', {name: 'Session Expiring'}) @@ -96,7 +102,8 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => { /> , { featureFlags: { - 'session-expiration-minutes': 2 + 'session-expiration-minutes': 2, + 'session-expiring-modal': true }} ) @@ -123,7 +130,8 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => { /> , { featureFlags: { - 'session-expiration-minutes': 2 + 'session-expiration-minutes': 2, + 'session-expiring-modal': true }} ) await screen.findByRole('dialog', {name: 'Session Expiring'}) @@ -149,10 +157,11 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => { /> , { featureFlags: { - 'session-expiration-minutes': 2 + 'session-expiration-minutes': 2, + 'session-expiring-modal': true }} ) - + await screen.findByRole('dialog', {name: 'Session Expiring'}) await act(() => vi.advanceTimersByTime(1000)); const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) expect(dialogAfterIdle).toHaveClass('is-visible'); @@ -176,10 +185,11 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => { /> , { featureFlags: { - 'session-expiration-minutes': 2 + 'session-expiration-minutes': 2, + 'session-expiring-modal': true }} ) - + await screen.findByRole('dialog', {name: 'Session Expiring'}) await act(() => vi.advanceTimersByTime(1000)); const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'}) expect(dialogAfterIdle).toHaveClass('is-visible'); diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx index 46176ebe5e..ae061067fe 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { Children } from 'react' import { ModalRef } from '@trussworks/react-uswds' import { createRef} from 'react' import { useAuth } from '../../contexts/AuthContext' @@ -55,6 +55,10 @@ export const AuthenticatedRouteWrapper = ({ featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.flag, featureFlags.MINUTES_UNTIL_SESSION_EXPIRES.defaultValue ) * 60 * 1000 // controlled by feature flag for testing in lower environments + const SHOW_SESSION_EXPIRATION:boolean = ldClient?.variation( + featureFlags.SESSION_EXPIRING_MODAL.flag, + featureFlags.SESSION_EXPIRING_MODAL.defaultValue + )// controlled by feature flag for testing in lower environments let promptCountdown = SESSION_TIMEOUT_COUNTDOWN // may be reassigned if session duration is shorter time period // Session duration must be longer than prompt countdown to allow IdleTimer to load @@ -62,11 +66,10 @@ export const AuthenticatedRouteWrapper = ({ promptCountdown = SESSION_DURATION - 1000 } - return ( - - - ) +
) } From 620d06dc125c8091b3082c2d5b452aeb2d114cf9 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Mon, 9 Sep 2024 18:34:31 -0500 Subject: [PATCH 14/26] lint changed files --- .../app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx index ae061067fe..00ff686a3c 100644 --- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx +++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx @@ -1,4 +1,4 @@ -import React, { Children } from 'react' +import React from 'react' import { ModalRef } from '@trussworks/react-uswds' import { createRef} from 'react' import { useAuth } from '../../contexts/AuthContext' From 2c0e97b64085c69115206142624c328e9e767ea1 Mon Sep 17 00:00:00 2001 From: Hana Worku Date: Tue, 10 Sep 2024 19:57:46 -0500 Subject: [PATCH 15/26] Create ModalOpenButton and store activModalID in page context --- .../src/components/Modal/Modal.test.tsx | 28 +++++------ .../app-web/src/components/Modal/Modal.tsx | 17 +++++-- .../ModalOpenButton/ModalOpenButton.test.tsx | 19 ++++++++ .../Modal/ModalOpenButton/ModalOpenButton.tsx | 47 +++++++++++++++++++ .../components/Modal/SessionTimeoutModal.tsx | 2 +- .../app-web/src/components/Modal/index.ts | 1 + .../UnlockRateButton.tsx | 2 +- services/app-web/src/contexts/PageContext.tsx | 25 +++++++++- .../V2/ReviewSubmit/ReviewSubmitV2.tsx | 17 ++----- .../SubmissionSummary/SubmissionSummary.tsx | 11 ++--- 10 files changed, 127 insertions(+), 42 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 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', () => { >