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 (
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(
+
}
+ />
+ ,
+ { 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(
+ }
+ />
+ ,
+ { 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(
+ }
+ />
+ ,
+ { 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', () => {
>
-
Open modal
-
+
)
await userClickByTestId(screen, 'opener-button')
@@ -225,13 +225,13 @@ describe('Modal', () => {
>
-
Open modal
-
+
)
@@ -255,13 +255,12 @@ describe('Modal', () => {
>
-
Open modal
-
+
)
await userClickByTestId(screen, 'opener-button')
@@ -292,13 +291,12 @@ describe('Modal', () => {
>
-
Open modal
-
+
)
diff --git a/services/app-web/src/components/Modal/Modal.tsx b/services/app-web/src/components/Modal/Modal.tsx
index a9503a2c8d..9c277b8603 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 from 'react'
+import React, {useEffect} from 'react'
import {
ButtonGroup,
Modal as UswdsModal,
@@ -15,6 +15,8 @@ import styles from './Modal.module.scss'
import { ActionButton } from '../ActionButton'
import { ButtonWithLogging } from '../TealiumLogging'
+import { useIdleTimerContext } from 'react-idle-timer'
+import { usePage } from '../../contexts/PageContext'
interface ModalComponentProps {
id: string
@@ -47,9 +49,16 @@ export const Modal = ({
modalAlert,
...divProps
}: ModalProps): React.ReactElement => {
- // 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 */
+ const {activeModalID} = usePage()
+ /*
+ Session expiration modal should override all others - single modal handling state is found in usePage
+ */
+ useEffect(() => {
+ const overrideWithNewModal = activeModalID !== id
+ console.log('in effect', overrideWithNewModal)
+ if (overrideWithNewModal) {
+ modalRef.current?.toggleModal(undefined, false)
+ }}, [activeModalID, id, modalRef])
const cancelHandler = (e: React.MouseEvent): void => {
if (onCancel) {
diff --git a/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.test.tsx b/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.test.tsx
new file mode 100644
index 0000000000..4a280db9ad
--- /dev/null
+++ b/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.test.tsx
@@ -0,0 +1,19 @@
+import { screen} from '@testing-library/react'
+import { renderWithProviders } from '../../../testHelpers'
+import { ModalOpenButton } from './ModalOpenButton'
+import { createRef} from 'react'
+import { type ModalRef } from '@trussworks/react-uswds'
+
+describe('ModalOpenButton', () => {
+ it('renders without errors', () => {
+ const testRef = createRef()
+ renderWithProviders(
+ submit 123
+ )
+ expect(
+ screen.getByRole('button', {
+ name: 'submit 123',
+ })
+ ).toBeInTheDocument()
+ })
+})
diff --git a/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.tsx b/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.tsx
new file mode 100644
index 0000000000..2feec83d1a
--- /dev/null
+++ b/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.tsx
@@ -0,0 +1,47 @@
+import React, {ComponentProps} from 'react'
+import {
+ ModalToggleButton as UswdsModalToggleButton,
+ type ModalRef,
+} from '@trussworks/react-uswds'
+import { useTealium } from '../../../hooks'
+import { usePage } from '../../../contexts/PageContext'
+import { extractText } from '../../TealiumLogging/tealiamLoggingHelpers'
+
+type ModalOpenButtonProps = {
+ id: string,
+ modalRef: React.RefObject,
+ children: React.ReactNode
+} & ComponentProps
+
+export const ModalOpenButton = ({
+ modalRef,
+ id,
+ children,
+ ...restProps
+}: ModalOpenButtonProps): React.ReactElement => {
+ const { logButtonEvent } = useTealium()
+ const {activeModalID, updateModalID} = usePage()
+ const handleOnClick = () => {
+ if(activeModalID){
+ updateModalID({updatedModalID: id})
+ }
+ logButtonEvent({
+ text: extractText(children),
+ button_type: 'button',
+ button_style: 'success',
+ parent_component_type: 'page body',
+ })
+ }
+ return (
+
+ {children}
+
+ )
+}
diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
index 82fd87794f..474027c5c8 100644
--- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
+++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
@@ -52,7 +52,7 @@ export const SessionTimeoutModal = ({
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/components/Modal/index.ts b/services/app-web/src/components/Modal/index.ts
index 7f0c9e2ca6..abacd47183 100644
--- a/services/app-web/src/components/Modal/index.ts
+++ b/services/app-web/src/components/Modal/index.ts
@@ -1,2 +1,3 @@
export { Modal } from './Modal'
export {UnlockSubmitModal} from './UnlockSubmitModal'
+export {ModalOpenButton} from './ModalOpenButton/ModalOpenButton'
\ No newline at end of file
diff --git a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/UnlockRateButton.tsx b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/UnlockRateButton.tsx
index c8980e2600..4c050c6196 100644
--- a/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/UnlockRateButton.tsx
+++ b/services/app-web/src/components/SubmissionSummarySection/RateDetailsSummarySection/UnlockRateButton.tsx
@@ -1,7 +1,7 @@
import { ActionButton } from '../../ActionButton'
import { TealiumButtonEventObject } from '../../../tealium'
-// Eventually ActionButton will be entirely swapped out for ModalToggleButton - part MCR-3782 when unlock reason modal is added
+// Eventually ActionButton will be entirely swapped out for ModalOpenButton - part MCR-3782 when unlock reason modal is added
type UnlockRateButtonProps = JSX.IntrinsicElements['button'] &
Partial
diff --git a/services/app-web/src/contexts/PageContext.tsx b/services/app-web/src/contexts/PageContext.tsx
index d1bb9b748e..2319077377 100644
--- a/services/app-web/src/contexts/PageContext.tsx
+++ b/services/app-web/src/contexts/PageContext.tsx
@@ -1,6 +1,7 @@
import * as React from 'react'
import { PageHeadingsRecord } from '../constants/routes'
import { useCurrentRoute } from '../hooks/useCurrentRoute'
+import { ModalRef } from '@trussworks/react-uswds'
/*
Use sparingly.
@@ -8,11 +9,17 @@ import { useCurrentRoute } from '../hooks/useCurrentRoute'
*/
type PageContextType = {
heading?: string | React.ReactElement
+ activeModalID?: string,
updateHeading: ({
customHeading,
}: {
customHeading?: string | React.ReactElement
}) => void
+ updateModalID: ({
+ updatedModalID,
+ }: {
+ updatedModalID?: string
+ }) => void
}
const PageContext = React.createContext(null as unknown as PageContextType)
@@ -28,7 +35,9 @@ const PageProvider: React.FC<
const [heading, setHeading] = React.useState<
string | React.ReactElement | undefined
>(undefined)
+ const [activeModalID, setactiveModalID] = React.useState(undefined)
const { currentRoute: routeName } = useCurrentRoute()
+
/*
Set headings in priority order
1. If there a custom heading, use that (relevant for heading related to the api loaded resource, such as the submission name)
@@ -50,9 +59,23 @@ const PageProvider: React.FC<
})
}
+ /*
+ Set activeModalID - points to an instance of that is currently visible on the page
+ - reset to undefined when the modal closed and hideen
+ - reset to new string when a new modal opens
+ - ensure only one modal open at a time, any new new modal opened overrides previous modal
+ */
+ const updateModalID = ({
+ updatedModalID,
+ }: {
+ updatedModalID?:string
+ }) => {
+ setactiveModalID(updatedModalID)
+ }
+
return (
)
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 b36c7566ca..c32e27c711 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
@@ -1,7 +1,6 @@
import {
GridContainer,
ModalRef,
- ModalToggleButton,
} from '@trussworks/react-uswds'
import React, { useRef, useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
@@ -34,6 +33,7 @@ import { packageName } from '../../../../../common-code/healthPlanFormDataType'
import { usePage } from '../../../../../contexts/PageContext'
import { activeFormPages } from '../../../StateSubmissionForm'
import { featureFlags } from '../../../../../common-code/featureFlags'
+import { ModalOpenButton } from '../../../../../components/Modal'
export const ReviewSubmit = (): React.ReactElement => {
const navigate = useNavigate()
@@ -190,22 +190,13 @@ export const ReviewSubmit = (): React.ReactElement => {
>
Back
-
- logButtonEvent({
- text: 'Submit',
- button_type: 'button',
- button_style: 'success',
- parent_component_type: 'page body',
- })
- }
- opener
+ id="form-submit"
>
Submit
-
+
{/* if the session is expiring, close this modal so the countdown modal can appear */}
diff --git a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx
index 45ae4a77ad..f6fb3a5e11 100644
--- a/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx
+++ b/services/app-web/src/pages/SubmissionSummary/SubmissionSummary.tsx
@@ -2,7 +2,6 @@ import {
GridContainer,
Link,
ModalRef,
- ModalToggleButton,
} from '@trussworks/react-uswds'
import React, { useEffect, useRef, useState } from 'react'
import { useAuth } from '../../contexts/AuthContext'
@@ -24,7 +23,7 @@ import { Error404 } from '../Errors/Error404Page'
import { GenericErrorPage } from '../Errors/GenericErrorPage'
import styles from './SubmissionSummary.module.scss'
import { ChangeHistory } from '../../components/ChangeHistory'
-import { UnlockSubmitModal } from '../../components/Modal'
+import { ModalOpenButton, UnlockSubmitModal } from '../../components/Modal'
import { RoutesRecord } from '../../constants'
import { useRouteParams } from '../../hooks'
import { getVisibleLatestContractFormData } from '../../gqlHelpers/contractsAndRates'
@@ -39,16 +38,14 @@ function UnlockModalButton({
modalRef: React.RefObject
}) {
return (
-
Unlock submission
-
+
)
}
From fc707b5b92455c526063d66c9289279bc3e48cd6 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Tue, 10 Sep 2024 20:17:28 -0500
Subject: [PATCH 16/26] Get only one modal at a time behavior working
---
.../app-web/src/components/Modal/Modal.tsx | 25 ++++++++---------
.../Modal/ModalOpenButton/ModalOpenButton.tsx | 12 +++++---
services/app-web/src/contexts/PageContext.tsx | 28 +++++++++----------
.../V2/ReviewSubmit/ReviewSubmitV2.tsx | 3 --
.../Wrapper/AuthenticatedRouteWrapper.tsx | 12 ++++++--
5 files changed, 43 insertions(+), 37 deletions(-)
diff --git a/services/app-web/src/components/Modal/Modal.tsx b/services/app-web/src/components/Modal/Modal.tsx
index 9c277b8603..4e3470f159 100644
--- a/services/app-web/src/components/Modal/Modal.tsx
+++ b/services/app-web/src/components/Modal/Modal.tsx
@@ -15,7 +15,6 @@ import styles from './Modal.module.scss'
import { ActionButton } from '../ActionButton'
import { ButtonWithLogging } from '../TealiumLogging'
-import { useIdleTimerContext } from 'react-idle-timer'
import { usePage } from '../../contexts/PageContext'
interface ModalComponentProps {
@@ -49,24 +48,24 @@ export const Modal = ({
modalAlert,
...divProps
}: ModalProps): React.ReactElement => {
- const {activeModalID} = usePage()
- /*
- Session expiration modal should override all others - single modal handling state is found in usePage
- */
- useEffect(() => {
- const overrideWithNewModal = activeModalID !== id
- console.log('in effect', overrideWithNewModal)
- if (overrideWithNewModal) {
- modalRef.current?.toggleModal(undefined, false)
- }}, [activeModalID, id, modalRef])
-
+ const {updateModalRef} = usePage()
const cancelHandler = (e: React.MouseEvent): void => {
if (onCancel) {
onCancel()
}
+ updateModalRef({updatedModalRef: undefined})
modalRef.current?.toggleModal(undefined, false)
}
+ const submitHandler = (e: React.MouseEvent): void => {
+ if (onSubmit) {
+ onSubmit()
+ }
+ updateModalRef({updatedModalRef: undefined})
+ // do not close modal here - close in on submit
+ // sometimes validation will fail and we want to keep modal open but display errors
+ }
+
return (
diff --git a/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.tsx b/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.tsx
index 2feec83d1a..07c93696a7 100644
--- a/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.tsx
+++ b/services/app-web/src/components/Modal/ModalOpenButton/ModalOpenButton.tsx
@@ -20,11 +20,15 @@ export const ModalOpenButton = ({
...restProps
}: ModalOpenButtonProps): React.ReactElement => {
const { logButtonEvent } = useTealium()
- const {activeModalID, updateModalID} = usePage()
+ const {activeModalRef, updateModalRef} = usePage()
const handleOnClick = () => {
- if(activeModalID){
- updateModalID({updatedModalID: id})
- }
+
+ // Make sure our global state tracks what modal is now open
+ if(activeModalRef !== modalRef) {
+ updateModalRef({updatedModalRef: modalRef})
+ }
+
+
logButtonEvent({
text: extractText(children),
button_type: 'button',
diff --git a/services/app-web/src/contexts/PageContext.tsx b/services/app-web/src/contexts/PageContext.tsx
index 2319077377..7c2850e91e 100644
--- a/services/app-web/src/contexts/PageContext.tsx
+++ b/services/app-web/src/contexts/PageContext.tsx
@@ -9,16 +9,16 @@ import { ModalRef } from '@trussworks/react-uswds'
*/
type PageContextType = {
heading?: string | React.ReactElement
- activeModalID?: string,
+ activeModalRef?: React.RefObject
updateHeading: ({
customHeading,
}: {
customHeading?: string | React.ReactElement
}) => void
- updateModalID: ({
- updatedModalID,
+ updateModalRef: ({
+ updatedModalRef,
}: {
- updatedModalID?: string
+ updatedModalRef?: React.RefObject
}) => void
}
@@ -35,7 +35,7 @@ const PageProvider: React.FC<
const [heading, setHeading] = React.useState<
string | React.ReactElement | undefined
>(undefined)
- const [activeModalID, setactiveModalID] = React.useState(undefined)
+ const [activeModal, setActiveModal] = React.useState| undefined>(undefined)
const { currentRoute: routeName } = useCurrentRoute()
/*
@@ -60,22 +60,22 @@ const PageProvider: React.FC<
}
/*
- Set activeModalID - points to an instance of that is currently visible on the page
- - reset to undefined when the modal closed and hideen
- - reset to new string when a new modal opens
- - ensure only one modal open at a time, any new new modal opened overrides previous modal
+ Set activeModalRef to reference currently visible modal
+ - reset to undefined when a modal closed and hidden
+ - reset when a new modal opens in ModalOpenButton and in openSessionTimeoutModal
+ - ensurse only one modal open at a time, any new new modal opened overrides previous modal
*/
- const updateModalID = ({
- updatedModalID,
+ const updateModalRef = ({
+ updatedModalRef,
}: {
- updatedModalID?:string
+ updatedModalRef?: React.RefObject
}) => {
- setactiveModalID(updatedModalID)
+ setActiveModal(updatedModalRef)
}
return (
)
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 c32e27c711..b2d5aa4efc 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
@@ -11,7 +11,6 @@ import { ActionButton } from '../../../../../components/ActionButton'
import {
useRouteParams,
useStatePrograms,
- useTealium,
} from '../../../../../hooks'
import { useLDClient } from 'launchdarkly-react-client-sdk'
@@ -43,7 +42,6 @@ export const ReviewSubmit = (): React.ReactElement => {
const { updateHeading } = usePage()
const { id } = useRouteParams()
const [isSubmitting, setIsSubmitting] = useState(false)
- const { logButtonEvent } = useTealium()
const ldClient = useLDClient()
const hideSupportingDocs = ldClient?.variation(
@@ -199,7 +197,6 @@ export const ReviewSubmit = (): React.ReactElement => {
- {/* if the session is expiring, close this modal so the countdown modal can appear */}
{
const modalRef = createRef()
const ldClient = useLDClient()
const {logout, refreshAuth} = useAuth()
+ const {activeModalRef, updateModalRef} = usePage()
const openSessionTimeoutModal = () =>{
+ // Make sure we close any active modals for session timeout, which overrides the focus trap
+ console.log( 'session timeout modal open' , activeModalRef, modalRef)
+ if(activeModalRef && activeModalRef !== modalRef) {
+ activeModalRef.current?.toggleModal(undefined, false)
+ updateModalRef({updatedModalRef: modalRef})
+ }
+
modalRef.current?.toggleModal(undefined, true)
}
const closeSessionTimeoutModal = () => {
From 6ed777b2395a0a1b32bfa42f1295cacd0a19558a Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Tue, 10 Sep 2024 20:17:52 -0500
Subject: [PATCH 17/26] Get only one modal at a time behavior working
---
services/app-web/src/components/Modal/Modal.tsx | 6 ++++--
services/app-web/src/contexts/PageContext.tsx | 8 ++++----
.../src/pages/Wrapper/AuthenticatedRouteWrapper.tsx | 5 ++---
3 files changed, 10 insertions(+), 9 deletions(-)
diff --git a/services/app-web/src/components/Modal/Modal.tsx b/services/app-web/src/components/Modal/Modal.tsx
index 4e3470f159..4fd2004a56 100644
--- a/services/app-web/src/components/Modal/Modal.tsx
+++ b/services/app-web/src/components/Modal/Modal.tsx
@@ -62,8 +62,10 @@ export const Modal = ({
onSubmit()
}
updateModalRef({updatedModalRef: undefined})
- // do not close modal here - close in on submit
- // sometimes validation will fail and we want to keep modal open but display errors
+ // do not close modal here manually
+ // sometimes validation fails and we want to keep modal open but display errors
+ // consumer should determine wether or not to close modal in onSubmit
+
}
return (
diff --git a/services/app-web/src/contexts/PageContext.tsx b/services/app-web/src/contexts/PageContext.tsx
index 7c2850e91e..3bc0c8941d 100644
--- a/services/app-web/src/contexts/PageContext.tsx
+++ b/services/app-web/src/contexts/PageContext.tsx
@@ -60,10 +60,10 @@ const PageProvider: React.FC<
}
/*
- Set activeModalRef to reference currently visible modal
- - reset to undefined when a modal closed and hidden
- - reset when a new modal opens in ModalOpenButton and in openSessionTimeoutModal
- - ensurse only one modal open at a time, any new new modal opened overrides previous modal
+ Set a ref pointing to currently visible modal
+ - is reset in child components when new modal open or back to undefined when existing modal is closed
+ - help ensure only one modal open at a time
+ - used in AuthenticatedRouteWrapper to close open modals when session timeout hit
*/
const updateModalRef = ({
updatedModalRef,
diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
index 3cde2da5b1..944ee3a6e0 100644
--- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
+++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
@@ -21,9 +21,8 @@ export const AuthenticatedRouteWrapper = ({
const {activeModalRef, updateModalRef} = usePage()
const openSessionTimeoutModal = () =>{
- // Make sure we close any active modals for session timeout, which overrides the focus trap
- console.log( 'session timeout modal open' , activeModalRef, modalRef)
- if(activeModalRef && activeModalRef !== modalRef) {
+ // Make sure we close any active modals for session timeout, should overrides the focus trap
+ if(activeModalRef && activeModalRef !== modalRef) {
activeModalRef.current?.toggleModal(undefined, false)
updateModalRef({updatedModalRef: modalRef})
}
From 7170c15ed00da6a156a32d56f5f07eb8df2d6de5 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Wed, 11 Sep 2024 07:47:46 -0500
Subject: [PATCH 18/26] Add more async handling - try to address CI only unit
test flakes
---
.../AuthenticatedRouteWrapper.test.tsx | 50 +++++++++----------
1 file changed, 25 insertions(+), 25 deletions(-)
diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx
index da7571d9d1..75703a5b21 100644
--- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx
+++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx
@@ -1,4 +1,4 @@
-import { act, screen} from '@testing-library/react'
+import { act, screen, waitFor} from '@testing-library/react'
import { renderWithProviders } from '../../testHelpers/jestHelpers'
import { AuthenticatedRouteWrapper } from './AuthenticatedRouteWrapper'
import { createMocks } from 'react-idle-timer';
@@ -21,7 +21,6 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
it('renders without errors', async () => {
renderWithProviders(
children go here}
/>
)
@@ -32,7 +31,6 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
it('hides the session timeout modal by default', async () => {
renderWithProviders(
children go here}
/>,
{ featureFlags: {
@@ -48,7 +46,6 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
it("hides the session timeout modal by when timeout period is not exceeded", async() => {
renderWithProviders(
children go here}
/>,
{ featureFlags: {
@@ -58,9 +55,8 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
)
const dialogOnLoad = await screen.findByRole('dialog', {name: 'Session Expiring'})
- expect(dialogOnLoad).toBeInTheDocument()
expect(dialogOnLoad).toHaveClass('is-hidden')
- await act(() => vi.advanceTimersByTime(500));
+ await vi.advanceTimersByTimeAsync(500);
const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'})
expect(dialogAfterIdle).toHaveClass('is-hidden')
});
@@ -69,7 +65,6 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
it("renders session timeout modal after idle prompt period is exceeded", async () => {
renderWithProviders(
children go here}
/>,
{ featureFlags: {
@@ -81,10 +76,12 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
expect(dialogOnLoad).toBeInTheDocument()
expect(dialogOnLoad).toHaveClass('is-hidden')
- await act(() => vi.advanceTimersByTime(1000));
+ await vi.advanceTimersByTimeAsync(1000);
const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'})
- expect(dialogAfterIdle).toHaveClass('is-visible')
+ await waitFor( () => {
+ expect(dialogAfterIdle).toHaveClass('is-visible')
+ })
});
@@ -97,7 +94,6 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
renderWithProviders(
}
/>
,
@@ -111,12 +107,13 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
expect(dialogOnLoad).toBeInTheDocument()
expect(dialogOnLoad).toHaveClass('is-hidden')
- await act(() => vi.advanceTimersByTime(1000));
+ await vi.advanceTimersByTimeAsync(1000);
const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'})
- expect(dialogAfterIdle).toHaveClass('is-visible')
+ await waitFor ( () => {
+ expect(dialogAfterIdle).toHaveClass('is-visible')
+ })
-
- await act(() => vi.advanceTimersByTime(120100));
+ await vi.advanceTimersByTimeAsync(120100);
expect(logoutSpy).toHaveBeenCalled()
});
@@ -125,7 +122,6 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
renderWithProviders(
}
/>
,
@@ -135,9 +131,9 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
}}
)
await screen.findByRole('dialog', {name: 'Session Expiring'})
- await act(() => vi.advanceTimersByTime(1000))
+ await vi.advanceTimersByTimeAsync(1000)
const timeElapsedBefore = screen.getByTestId('remaining').textContent
- await act(() => vi.advanceTimersByTime(1000))
+ await vi.advanceTimersByTimeAsync(1000)
const timeElapsedAfter = screen.getByTestId('remaining').textContent
const diff = dayjs(timeElapsedBefore, 'mm:ss').diff(dayjs(timeElapsedAfter, 'mm:ss'), 'milliseconds')
@@ -152,7 +148,6 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
renderWithProviders(
}
/>
,
@@ -162,12 +157,16 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
}}
)
await screen.findByRole('dialog', {name: 'Session Expiring'})
- await act(() => vi.advanceTimersByTime(1000));
+ await vi.advanceTimersByTimeAsync(1000);
const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'})
- expect(dialogAfterIdle).toHaveClass('is-visible');
+ await waitFor( () => {
+ expect(dialogAfterIdle).toHaveClass('is-visible')
+ }) ;
(await screen.findByText('Continue Session')).click()
await screen.findByRole('dialog', {name: 'Session Expiring'})
- expect(dialogAfterIdle).not.toHaveClass('is-visible');
+ await waitFor(() => {
+ expect(dialogAfterIdle).not.toHaveClass('is-visible');
+ })
expect(refreshSpy).toHaveBeenCalled()
})
@@ -180,7 +179,6 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
renderWithProviders(
}
/>
,
@@ -190,9 +188,11 @@ describe('AuthenticatedRouteWrapper and SessionTimeoutModal', () => {
}}
)
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');
+ await vi.advanceTimersByTimeAsync(1000);
+ const dialogAfterIdle = await screen.findByRole('dialog', {name: 'Session Expiring'});
+ await waitFor( () => {
+ expect(dialogAfterIdle).toHaveClass('is-visible');
+ });
(await screen.findByText('Logout')).click()
expect(logoutSpy).toHaveBeenCalled()
})
From 33e059a04029c3e613b082f421d2f60fae93ab50 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Wed, 11 Sep 2024 10:57:14 -0500
Subject: [PATCH 19/26] Lint cleanup
---
services/app-web/src/pages/App/AppRoutes.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/services/app-web/src/pages/App/AppRoutes.tsx b/services/app-web/src/pages/App/AppRoutes.tsx
index 372247b15c..3a892032a6 100644
--- a/services/app-web/src/pages/App/AppRoutes.tsx
+++ b/services/app-web/src/pages/App/AppRoutes.tsx
@@ -87,7 +87,7 @@ const StateUserRoutes = ({
featureFlags.RATE_EDIT_UNLOCK.defaultValue
)
return (
-
+
{
return (
-
+
Date: Thu, 12 Sep 2024 11:16:50 -0500
Subject: [PATCH 20/26] Get doubleclick bug addressed
---
services/app-web/src/components/Modal/SessionTimeoutModal.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
index 474027c5c8..0593482cb8 100644
--- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
+++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
@@ -19,8 +19,8 @@ export const SessionTimeoutModal = ({
idleTimer.message({action:'LOGOUT_SESSION'}, true)
}
const handleContinueSession = async () => {
- idleTimer.message({action:'CONTINUE_SESSION'}, true)
idleTimer.activate()
+ idleTimer.message({action:'CONTINUE_SESSION'}, true)
}
useEffect(() => {
const interval = setInterval(() => {
From 7700079f32a0b0b3e2d0dc4a99ce2a27c95cd300 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Fri, 13 Sep 2024 09:49:29 -0500
Subject: [PATCH 21/26] Code review comments and cleanup
---
docs/technical-design/tealium-logging.md | 8 ++++----
.../app-web/src/components/Modal/SessionTimeoutModal.tsx | 5 +++--
.../src/pages/Wrapper/AuthenticatedRouteWrapper.tsx | 8 +++++++-
3 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/docs/technical-design/tealium-logging.md b/docs/technical-design/tealium-logging.md
index e4fa1f20fc..11df25e507 100644
--- a/docs/technical-design/tealium-logging.md
+++ b/docs/technical-design/tealium-logging.md
@@ -1,6 +1,6 @@
# Tealium Logging
## Background
-We use a tool called Tealium to hook into the CMS customer analytics tools. The data is then sent to Adobe Analytics (previously CMS used Google Analytics). Access to analytics tools is set up through another team, Blast Analytics. They are contractors with a focus on analytics and work across all CMS domains.
+We use a tool called Tealium to hook into the CMS customer analytics tools. The data is then sent to Adobe Analytics (previously CMS used Google Analytics). Access to analytics tools is set up through another team, Blast Analytics. They are contractors with a focus on analytics and work across all CMS domains.
## Implementation
@@ -27,7 +27,7 @@ The `useTealium` hook utilizes tealium client functions in the Context to provid
There are wrapper functions in this hook for specific user event types to constrain the event and parameter values before passing it to `logUserEvent`. This allows type safety and a reference to what event and parameter values the event accepts.
```typescript
-const context = React.useContext(TealiumContext)
+const context = React.useContext(TealiumContext)
...
@@ -47,11 +47,11 @@ const logButtonEvent = (
```
These `useTealium` functions are then used in components like `Button` and `Link` to log when they are clicked. Since most of these components are configured and used similarly, wrapper components were made with built-in logging to replace the default components for easy implementation of logging. They can be found in `components/TealiumLogging`.
-Not all components are wrapped like this. Some, like `ModalToggleButton` are rarely used, so in those cases we can directly use `logButtonEvent` in the `onClick` prop.
+Not all components are wrapped like this, it is still possible to directly use tealium actions.
### Adding user event types
-To add a new user event type:
+To add a new user event type:
- Define a type for the event parameter values for the event type. You can find all the data fields for a given event in the [Tagging Strategy](https://confluence.cms.gov/pages/viewpage.action?spaceKey=BLSTANALYT&title=mc-review.onemac.cms.gov+-+Tagging+Strategy) doc on confluence.
- Then we add that type to `TealiumEventObjectTypes`.
```typescript
diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
index 0593482cb8..ba0b2ee0eb 100644
--- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
+++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
@@ -4,6 +4,7 @@ import { Modal } from './Modal'
import styles from './Modal.module.scss'
import { useIdleTimerContext } from 'react-idle-timer'
import dayjs from 'dayjs'
+import { SESSION_ACTIONS } from '../../pages/Wrapper/AuthenticatedRouteWrapper'
type SessionTimeoutModalProps = {
modalRef: React.RefObject
@@ -16,11 +17,11 @@ export const SessionTimeoutModal = ({
const [countdownSeconds, setCountdownSeconds]= useState(idleTimer.getRemainingTime() / 1000)
const handleLogoutSession = async () => {
- idleTimer.message({action:'LOGOUT_SESSION'}, true)
+ idleTimer.message({action: SESSION_ACTIONS.LOGOUT_SESSION}, true)
}
const handleContinueSession = async () => {
idleTimer.activate()
- idleTimer.message({action:'CONTINUE_SESSION'}, true)
+ idleTimer.message({action:SESSION_ACTIONS.CONTINUE_SESSION}, true)
}
useEffect(() => {
const interval = setInterval(() => {
diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
index 944ee3a6e0..544c464705 100644
--- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
+++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
@@ -8,9 +8,13 @@ import { SessionTimeoutModal } from '../../components/Modal/SessionTimeoutModal'
import { IdleTimerProvider } from 'react-idle-timer'
import { usePage } from '../../contexts/PageContext'
+const SESSION_ACTIONS = {
+ LOGOUT_SESSION: 'LOGOUT_SESSION',
+ CONTINUE_SESSION: 'CONTINUE_SESSSION'
+}
// 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 = ({
+const AuthenticatedRouteWrapper = ({
children,
}: {
children: React.ReactNode
@@ -89,3 +93,5 @@ export const AuthenticatedRouteWrapper = ({
/>
)
}
+
+export {SESSION_ACTIONS, AuthenticatedRouteWrapper}
\ No newline at end of file
From ad846e8d7ea579596cee3ddf814f5dd720d20082 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Sun, 15 Sep 2024 20:00:44 -0500
Subject: [PATCH 22/26] Refactor cognitoAuth to return Errors, unify logout
handling for various cases
---
.../app-web/src/components/Header/Header.tsx | 11 +-
services/app-web/src/contexts/AuthContext.tsx | 93 ++++++++----
services/app-web/src/contexts/S3Context.tsx | 18 +--
services/app-web/src/localAuth/LocalLogin.tsx | 2 +-
services/app-web/src/pages/Auth/Auth.test.tsx | 2 +-
.../app-web/src/pages/Auth/Login.test.tsx | 4 +-
services/app-web/src/pages/Auth/Login.tsx | 12 +-
services/app-web/src/pages/Auth/Signup.tsx | 32 ++--
.../app-web/src/pages/Auth/cognitoAuth.ts | 141 +++++++++---------
.../src/pages/Landing/Landing.test.tsx | 28 +++-
.../app-web/src/pages/Landing/Landing.tsx | 7 +
.../AuthenticatedRouteWrapper.test.tsx | 2 +-
.../Wrapper/AuthenticatedRouteWrapper.tsx | 5 +-
13 files changed, 204 insertions(+), 153 deletions(-)
diff --git a/services/app-web/src/components/Header/Header.tsx b/services/app-web/src/components/Header/Header.tsx
index 5706cc86db..f62a3ccede 100644
--- a/services/app-web/src/components/Header/Header.tsx
+++ b/services/app-web/src/components/Header/Header.tsx
@@ -9,8 +9,6 @@ import { Logo } from '../Logo'
import styles from './Header.module.scss'
import { PageHeadingRow } from './PageHeadingRow/PageHeadingRow'
import { UserLoginInfo } from './UserLoginInfo/UserLoginInfo'
-import { recordJSException } from '../../otelHelpers'
-import { ErrorAlertSignIn } from '../ErrorAlert'
export type HeaderProps = {
authMode: AuthModeType
@@ -34,12 +32,9 @@ export const Header = ({
const { heading } = usePage()
const { currentRoute: route } = useCurrentRoute()
- const handleLogout = () => {
-
- logout({sessionTimeout: false }).catch((e) => {
- recordJSException(`Error with logout: ${e}`)
- setAlert && setAlert()
- })
+ const handleLogout = async () => {
+ await logout({type: 'ERROR'})
+ // no need to handle errors, logout will handle
}
return route !== 'GRAPHQL_EXPLORER' ? (
diff --git a/services/app-web/src/contexts/AuthContext.tsx b/services/app-web/src/contexts/AuthContext.tsx
index 418cf32897..ab85b68773 100644
--- a/services/app-web/src/contexts/AuthContext.tsx
+++ b/services/app-web/src/contexts/AuthContext.tsx
@@ -14,7 +14,19 @@ import { recordJSException } from '../otelHelpers/tracingHelper'
import { handleApolloError } from '../gqlHelpers/apolloErrors'
import { ApolloQueryResult } from '@apollo/client'
-export type LoginStatusType = 'LOADING' | 'LOGGED_OUT' | 'LOGGED_IN'
+// Constants and types
+type LoginStatusType = 'LOADING' | 'LOGGED_OUT' | 'LOGGED_IN'
+type LogoutType = 'TIMEOUT' | 'ERROR' | 'DEFAULT'
+const LOGOUT_TYPES: Record = {
+ TIMEOUT: 'SESSION_TIMEOUT',
+ ERROR: 'SESSION_ERROR',
+ DEFAULT: 'DEFAULT'
+}
+const LOGOUT_PATHS: Record = {
+ TIMEOUT: `/?session-timeout=true`,
+ ERROR: `/?signin-error=true`,
+ DEFAULT: `/`
+}
type AuthContextType = {
checkAuth: (
@@ -24,14 +36,13 @@ type AuthContextType = {
loggedInUser: UserType | undefined
loginStatus: LoginStatusType
logout: ({
- sessionTimeout,
- redirectPath,
+ type,
}: {
- sessionTimeout: boolean
- redirectPath?: string
+ type: LogoutType,
}) => Promise
}
+// MAIN
const AuthContext = React.createContext({
checkAuth: () => Promise.reject(Error('Auth context error')),
loggedInUser: undefined,
@@ -102,7 +113,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
- window.location.href = `/?signin-error=true`
+ window.location.href = LOGOUT_PATHS.ERROR
}
} else if (data?.fetchCurrentUser) {
if (!isAuthenticated) {
@@ -127,54 +138,75 @@ function AuthProvider({
Also called in the background with session times out
*/
const logout: AuthContextType['logout'] = async ({
- sessionTimeout,
- redirectPath = '/',
+ type = 'DEFAULT',
}) => {
const realLogout =
authMode === 'LOCAL' ? logoutLocalUser : cognitoSignOut
- try {
- await realLogout()
- if (sessionTimeout) {
- window.location.href = `${redirectPath}?session-timeout=true`
- } else {
- window.location.href = redirectPath
+ const logoutResponse = await realLogout()
+ if (logoutResponse instanceof Error) {
+ recordJSException(new Error(`Logout Failed. ${JSON.stringify(logoutResponse)}`))
+ window.location.href = LOGOUT_PATHS.ERROR
+ } else {
+ switch (type) {
+ case LOGOUT_TYPES.TIMEOUT: {
+ window.location.href = LOGOUT_PATHS.TIMEOUT
+ return
+ }
+ case LOGOUT_TYPES.ERROR: {
+ window.location.href = LOGOUT_PATHS.ERROR
+ return
+ }
+ default: {
+ window.location.href = LOGOUT_PATHS.DEFAULT
+ return
}
- 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
+ Refetches current user via graphql to confirm authentication
+ Also can reconfirm authentication, if unexpectedly logged out we know that session may have timed out or user was logged out of another tab
@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 = '/?session-timeout'
- ) => {
+ const checkAuth: AuthContextType['checkAuth'] = async () => {
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 React state update
if (loggedInUser) {
await logout({
- sessionTimeout: true,
- redirectPath: failureRedirectPath,
+ type: 'TIMEOUT'
+ })
+ } else {
+ await logout({
+ type: 'DEFAULT'
})
}
return new Error(e)
}
}
-
+ /*
+ Refreshes the cognito session token
+ Also can reconfirm authentication, if unexpectedly logged out we know that session may have timed out
+ @param {failureRedirectPath} passed through to logout which is called on certain checkAuth failures
+ */
const refreshAuth = async () => {
if (authMode !== 'LOCAL') {
- await extendSession()
+ const result = await extendSession()
+ if (result instanceof Error){
+ if (loggedInUser) {
+ await logout({
+ type: 'TIMEOUT'
+ })
+ }
+ }
}
return
}
@@ -196,4 +228,9 @@ function AuthProvider({
const useAuth = (): AuthContextType => React.useContext(AuthContext)
-export { AuthProvider, useAuth }
+export { LOGOUT_TYPES,LOGOUT_PATHS, AuthProvider, useAuth }
+
+export type {
+ LoginStatusType,
+ AuthContextType
+}
\ No newline at end of file
diff --git a/services/app-web/src/contexts/S3Context.tsx b/services/app-web/src/contexts/S3Context.tsx
index 9db6c326fa..44a29b4c5f 100644
--- a/services/app-web/src/contexts/S3Context.tsx
+++ b/services/app-web/src/contexts/S3Context.tsx
@@ -55,12 +55,9 @@ const useS3 = (): S3ContextT => {
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,
- })
+ const responseCheckAuth = await checkAuth()
+ if (responseCheckAuth instanceof Error){
+ await logout({type: 'TIMEOUT'})
}
throw error
}
@@ -82,12 +79,9 @@ const useS3 = (): S3ContextT => {
}
// 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 responseCheckAuth = await checkAuth()
+ if (responseCheckAuth instanceof Error){
+ await logout({type: 'TIMEOUT'})
}
const error = new Error('Scanning error: Scanning retry timed out')
recordJSException(error)
diff --git a/services/app-web/src/localAuth/LocalLogin.tsx b/services/app-web/src/localAuth/LocalLogin.tsx
index d640eaeb03..cde97d915e 100644
--- a/services/app-web/src/localAuth/LocalLogin.tsx
+++ b/services/app-web/src/localAuth/LocalLogin.tsx
@@ -123,7 +123,7 @@ export function LocalLogin(): React.ReactElement {
async function login(user: LocalUserType) {
loginLocalUser(user)
- const result = await checkAuth('/auth?signin-error')
+ const result = await checkAuth()
if(result instanceof Error){
setShowFormAlert(true)
diff --git a/services/app-web/src/pages/Auth/Auth.test.tsx b/services/app-web/src/pages/Auth/Auth.test.tsx
index 79a6226cef..2f561550db 100644
--- a/services/app-web/src/pages/Auth/Auth.test.tsx
+++ b/services/app-web/src/pages/Auth/Auth.test.tsx
@@ -117,7 +117,7 @@ describe('Auth', () => {
it('when login fails, display error alert', async () => {
const loginSpy = vi
.spyOn(CognitoAuthApi, 'signIn')
- .mockRejectedValue(new Error('Login failed'))
+ .mockResolvedValue(new Error('Login failed'))
let testLocation: Location
diff --git a/services/app-web/src/pages/Auth/Login.test.tsx b/services/app-web/src/pages/Auth/Login.test.tsx
index 1808c3d279..03781b3c21 100644
--- a/services/app-web/src/pages/Auth/Login.test.tsx
+++ b/services/app-web/src/pages/Auth/Login.test.tsx
@@ -121,7 +121,7 @@ describe('Cognito Login', () => {
it('when login fails, stay on page and display error alert', async () => {
const loginSpy = vi
.spyOn(CognitoAuthApi, 'signIn')
- .mockRejectedValue('Error has occurred')
+ .mockResolvedValue(new Error('Error has occurred'))
let testLocation: Location
@@ -148,7 +148,7 @@ describe('Cognito Login', () => {
it('when login is a failure, button is re-enabled', async () => {
const loginSpy = vi
.spyOn(CognitoAuthApi, 'signIn')
- .mockRejectedValue(null)
+ .mockResolvedValue(new Error('somethign went wrong'))
renderWithProviders(, {
apolloProvider: { mocks: [failedAuthMock, failedAuthMock] },
diff --git a/services/app-web/src/pages/Auth/Login.tsx b/services/app-web/src/pages/Auth/Login.tsx
index 9f0ddb6bdd..ba9e1489c9 100644
--- a/services/app-web/src/pages/Auth/Login.tsx
+++ b/services/app-web/src/pages/Auth/Login.tsx
@@ -44,20 +44,20 @@ export function Login({ defaultEmail }: Props): React.ReactElement {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
- try {
- await signIn(fields.loginEmail, fields.loginPassword)
-
+ const result = await signIn(fields.loginEmail, fields.loginPassword)
+ if (result instanceof Error) {
+ setShowFormAlert(true)
+ } else {
// we think we signed in, double check that amplify - API connection agrees
- const authResult = await checkAuth('/auth?signin-error')
+ const authResult = await checkAuth()
if(authResult instanceof Error) {
recordJSException(`Cognito Login Error - unexpected error after succeeding on signIn – ${authResult}`)
setShowFormAlert(true)
} else {
navigate(RoutesRecord.ROOT)
}
- } catch (err) {
- setShowFormAlert(true)
}
+
}
return (
diff --git a/services/app-web/src/pages/Auth/Signup.tsx b/services/app-web/src/pages/Auth/Signup.tsx
index feec77c56f..61434680d0 100644
--- a/services/app-web/src/pages/Auth/Signup.tsx
+++ b/services/app-web/src/pages/Auth/Signup.tsx
@@ -2,7 +2,6 @@ import React, { useState, Dispatch, SetStateAction } from 'react'
import { Form, FormGroup, Label, TextInput } from '@trussworks/react-uswds'
import { signUp } from './cognitoAuth'
-import { recordJSException } from '../../otelHelpers'
import { ButtonWithLogging } from '../../components'
export function showError(error: string): void {
@@ -47,30 +46,23 @@ export function Signup({
event.preventDefault()
setIsLoading(true)
- try {
- const result = await signUp({
- username: fields.email,
- password: fields.password,
- given_name: fields.firstName,
- family_name: fields.lastName,
- stateCode: 'MN',
- })
- setIsLoading(false)
+ const result = await signUp({
+ username: fields.email,
+ password: fields.password,
+ given_name: fields.firstName,
+ family_name: fields.lastName,
+ stateCode: 'MN',
+ })
+ setIsLoading(false)
+
+ if (result instanceof Error) {
+ showError('An unexpected error occurred!')
- if ('code' in result) {
- const err = result
- console.info('got an error back from signup: ', err)
} else {
- const user = result
- console.info('got a user back', user)
setEmail(fields.email)
triggerConfirmation()
}
- } catch (err) {
- setIsLoading(false)
- showError('An unexpected error occurred!')
- recordJSException(new Error(err))
- }
+
}
return (
diff --git a/services/app-web/src/pages/Auth/cognitoAuth.ts b/services/app-web/src/pages/Auth/cognitoAuth.ts
index c95d73d693..a74f76d9c6 100644
--- a/services/app-web/src/pages/Auth/cognitoAuth.ts
+++ b/services/app-web/src/pages/Auth/cognitoAuth.ts
@@ -20,7 +20,7 @@ type AmplifyErrorCodes =
| 'NetworkError'
| 'InvalidParameterException'
-export interface AmplifyError {
+interface AmplifyError {
code: AmplifyErrorCodes
name: string
message: string
@@ -52,11 +52,10 @@ export function idmRedirectURL(): string {
return url
}
-export async function signUp(
+async function signUp(
user: newUser
-): Promise {
- try {
- const result = await AmplifyAuth.signUp({
+): Promise {
+ const response = await AmplifyAuth.signUp({
username: user.username,
password: user.password,
attributes: {
@@ -66,110 +65,112 @@ export async function signUp(
'custom:role': 'macmcrrs-state-user',
},
})
- return result.user
- } catch (e) {
- console.info('ERROR SIGNUP', e)
- if (isAmplifyError(e)) {
- if (e.code === 'UsernameExistsException') {
+ if (response instanceof Error) {
+ if (isAmplifyError(response)) {
+ if (response.code === 'UsernameExistsException') {
console.info('that username already exists....')
- return e
- } else if (e.code === 'NetworkError') {
+ }
+ if (response.code === 'NetworkError') {
console.info(
'Failed to connect correctly to Amplify on Signup??'
)
- return e
- } 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.
- console.info('unexpected cognito error!')
- throw e
}
- } else {
- throw e
}
- }
+ return response
+ } else {
+ return response.user
+ }
+
}
-export async function confirmSignUp(
+async function confirmSignUp(
email: string,
code: string
-): Promise {
- try {
- await AmplifyAuth.confirmSignUp(email, code)
- return null
- } catch (e) {
- if (isAmplifyError(e)) {
- if (e.code === 'ExpiredCodeException') {
+): Promise {
+ const response = await AmplifyAuth.confirmSignUp(email, code)
+ if(response instanceof Error){
+ if (isAmplifyError(response) && (response.code === 'ExpiredCodeException')) {
console.info(
- 'your code is expired, we are sending another one.'
+ 'Your code is expired, amplify will send another one.'
)
- return e
- } else {
- throw e
- }
- } else {
- throw e
}
+ recordJSException(response)
+ return response
+ } else {
+ return null
}
}
-export async function resendSignUp(
+async function resendSignUp(
email: string
-): Promise {
- try {
- await AmplifyAuth.resendSignUp(email)
+): Promise {
+ const response = await AmplifyAuth.resendSignUp(email)
+ if (response instanceof Error) {
+ recordJSException(response)
+ return response
+ } else {
return null
- } catch (e) {
- // no known handleable errors for this one...
- console.info('unknown err', e)
- throw e
}
}
-export async function signIn(
+async function signIn(
email: string,
password: string
): Promise {
- try {
- const result = await AmplifyAuth.signIn(email, password)
- return result.user
- } catch (e) {
- if (isAmplifyError(e)) {
- if (e.code === 'UserNotConfirmedException') {
+ const response = await AmplifyAuth.signIn(email, password)
+ if (response instanceof Error) {
+ if(isAmplifyError(response)) {
+ if (response.code === 'UserNotConfirmedException') {
recordJSException(
- `AmplifyError ${e.code} – you need to confirm your account, enter the code below`
+ `AmplifyError ${response.code} – you need to confirm your account, enter the code below`
)
- } else if (e.code === 'NotAuthorizedException') {
- recordJSException(`AmplifyError ${e.code} – this is probably a bad password`)
- } else if (e.code === 'UserNotFoundException') {
- recordJSException(`AmplifyError ${e.code} – user does not exist`)
+ } else if (response.code === 'NotAuthorizedException') {
+ recordJSException(`AmplifyError ${response.code} – this is probably a bad password`)
+ } else if (response.code === 'UserNotFoundException') {
+ recordJSException(`AmplifyError ${response.code} – user does not exist`)
} else {
- recordJSException(`UNEXPECTED SIGNIN ERROR AmplifyError ${e.code} – ${e.message}`)
+ recordJSException(`UNEXPECTED AmplifyError ${response.code} – ${response.message}`)
}
} else {
recordJSException(`UNEXPECTED SIGNIN ERROR – 'didnt even get an amplify error back from login`)
}
}
- throw Error('Cognito SignIn error')
+
+ return response
}
-export async function signOut(): Promise {
- try {
- await AmplifyAuth.signOut()
+async function signOut(): Promise {
+ const response = await AmplifyAuth.signOut()
+ if (response instanceof Error) {
+ recordJSException( response)
+ return response
+ } else {
return null
- } catch (e) {
- console.info('error signing out: ', e)
- throw e
}
}
-export async function extendSession(): Promise {
- try {
- await AmplifyAuth.currentSession()
+async function extendSession(): Promise {
+ const response = await AmplifyAuth.currentSession()
+ if (response instanceof Error) {
+ recordJSException(response)
+ return response
+ } else {
return null
- } catch (e) {
- console.info('error extending session: ', e)
- throw e
}
+
+}
+
+export {
+ extendSession,
+ confirmSignUp,
+ resendSignUp,
+ signIn,
+ signUp,
+ signOut,
}
+
+export type {
+ AmplifyError,
+ AmplifyErrorCodes
+}
\ No newline at end of file
diff --git a/services/app-web/src/pages/Landing/Landing.test.tsx b/services/app-web/src/pages/Landing/Landing.test.tsx
index 4db33a461e..45a95871c9 100644
--- a/services/app-web/src/pages/Landing/Landing.test.tsx
+++ b/services/app-web/src/pages/Landing/Landing.test.tsx
@@ -1,24 +1,48 @@
import { screen } from '@testing-library/react'
import { renderWithProviders } from '../../testHelpers/jestHelpers'
import { Landing } from './Landing'
+import { LOGOUT_PATHS } from '../../contexts/AuthContext'
describe('Landing', () => {
afterAll(() => vi.clearAllMocks())
it('displays session expired when query parameter included', async () => {
renderWithProviders(, {
- routerProvider: { route: '/?session-timeout' },
+ routerProvider: { route: LOGOUT_PATHS.TIMEOUT },
featureFlags: {
'site-under-maintenance-banner': false,
},
})
expect(
screen.queryByRole('heading', { name: 'Session expired' })
- ).toBeNull()
+ ).toBeDefined()
expect(
screen.queryByText(/You have been logged out due to inactivity/)
+ ).toBeDefined()
+ expect(
+ screen.queryByRole('heading', { name: 'Sign in error' })
+ ).toBeNull()
+ })
+
+
+ it('displays signin error when query parameter included', async () => {
+ renderWithProviders(, {
+ routerProvider: { route: '/?signin-error' },
+ featureFlags: {
+ 'site-under-maintenance-banner': false,
+ },
+ })
+ expect(
+ screen.queryByRole('heading', { name: 'Sign in error' })
+ ).toBeDefined()
+ expect(
+ screen.queryByText(/There has been an error signing in/)
+ ).toBeDefined()
+ expect(
+ screen.queryByRole('heading', { name: 'Session expired' })
).toBeNull()
})
+
it('does not display session expired by default', async () => {
renderWithProviders(, {
routerProvider: { route: '/' },
diff --git a/services/app-web/src/pages/Landing/Landing.tsx b/services/app-web/src/pages/Landing/Landing.tsx
index d202d5ed4e..c05f3d0690 100644
--- a/services/app-web/src/pages/Landing/Landing.tsx
+++ b/services/app-web/src/pages/Landing/Landing.tsx
@@ -9,6 +9,7 @@ import {
ErrorAlertSessionExpired,
ErrorAlertScheduledMaintenance,
LinkWithLogging,
+ ErrorAlertSignIn,
} from '../../components'
function maintenanceBannerForVariation(flag: string): React.ReactNode {
@@ -36,6 +37,9 @@ export const Landing = (): React.ReactElement => {
'session-timeout'
)
+ const redirectFromSigninError = new URLSearchParams(location.search).get(
+ 'signin-error'
+ )
return (
<>
@@ -43,6 +47,9 @@ export const Landing = (): React.ReactElement => {
{maybeMaintenaceBanner}
{redirectFromSessionTimeout && !maybeMaintenaceBanner && (
+ )}
+ {redirectFromSigninError && !maybeMaintenaceBanner && (
+
)}
diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx
index 75703a5b21..6628a362ef 100644
--- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx
+++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.test.tsx
@@ -1,4 +1,4 @@
-import { act, screen, waitFor} from '@testing-library/react'
+import { screen, waitFor} from '@testing-library/react'
import { renderWithProviders } from '../../testHelpers/jestHelpers'
import { AuthenticatedRouteWrapper } from './AuthenticatedRouteWrapper'
import { createMocks } from 'react-idle-timer';
diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
index 544c464705..db295d56ea 100644
--- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
+++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
@@ -12,6 +12,7 @@ const SESSION_ACTIONS = {
LOGOUT_SESSION: 'LOGOUT_SESSION',
CONTINUE_SESSION: 'CONTINUE_SESSSION'
}
+
// 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
const AuthenticatedRouteWrapper = ({
@@ -36,8 +37,8 @@ const AuthenticatedRouteWrapper = ({
const closeSessionTimeoutModal = () => {
modalRef.current?.toggleModal(undefined, false)
}
- const logoutBySessionTimeout = async () => logout({ sessionTimeout: true })
- const logoutByUserChoice = async () => logout({ sessionTimeout: false})
+ const logoutBySessionTimeout = async () => await logout({type: 'TIMEOUT'})
+ const logoutByUserChoice = async () => await logout({type: 'DEFAULT'})
const refreshSession = async () => {
await refreshAuth()
closeSessionTimeoutModal()
From b806f8af352b6c7c2e8ea74f4638e3b24dbbe501 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Sun, 15 Sep 2024 20:41:25 -0500
Subject: [PATCH 23/26] make modal close more snappy
---
.../src/pages/Wrapper/AuthenticatedRouteWrapper.tsx | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
index db295d56ea..ae3aeeb4ed 100644
--- a/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
+++ b/services/app-web/src/pages/Wrapper/AuthenticatedRouteWrapper.tsx
@@ -37,11 +37,16 @@ const AuthenticatedRouteWrapper = ({
const closeSessionTimeoutModal = () => {
modalRef.current?.toggleModal(undefined, false)
}
- const logoutBySessionTimeout = async () => await logout({type: 'TIMEOUT'})
- const logoutByUserChoice = async () => await logout({type: 'DEFAULT'})
+ const logoutBySessionTimeout = async () => {
+ closeSessionTimeoutModal()
+ await logout({type: 'TIMEOUT'})}
+ const logoutByUserChoice = async () => {
+ closeSessionTimeoutModal()
+ await logout({type: 'DEFAULT'})
+ }
const refreshSession = async () => {
- await refreshAuth()
closeSessionTimeoutModal()
+ await refreshAuth()
}
// For multi-tab support we emit messages related to user actions on the session timeout modal
From a3121469edf8d323b0921a3dea667e2032d87713 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Mon, 16 Sep 2024 09:51:02 -0500
Subject: [PATCH 24/26] cleanup
---
.../app-web/src/pages/Auth/cognitoAuth.ts | 76 +++++++++----------
1 file changed, 38 insertions(+), 38 deletions(-)
diff --git a/services/app-web/src/pages/Auth/cognitoAuth.ts b/services/app-web/src/pages/Auth/cognitoAuth.ts
index a74f76d9c6..abe1d893bd 100644
--- a/services/app-web/src/pages/Auth/cognitoAuth.ts
+++ b/services/app-web/src/pages/Auth/cognitoAuth.ts
@@ -55,7 +55,8 @@ export function idmRedirectURL(): string {
async function signUp(
user: newUser
): Promise {
- const response = await AmplifyAuth.signUp({
+ try {
+ const response = await AmplifyAuth.signUp({
username: user.username,
password: user.password,
attributes: {
@@ -65,8 +66,8 @@ async function signUp(
'custom:role': 'macmcrrs-state-user',
},
})
-
- if (response instanceof Error) {
+ return response.user
+ } catch (response) {
if (isAmplifyError(response)) {
if (response.code === 'UsernameExistsException') {
console.info('that username already exists....')
@@ -78,48 +79,47 @@ async function signUp(
}
}
return response
- } else {
- return response.user
- }
-
+ }
}
async function confirmSignUp(
email: string,
code: string
): Promise {
- const response = await AmplifyAuth.confirmSignUp(email, code)
- if(response instanceof Error){
- if (isAmplifyError(response) && (response.code === 'ExpiredCodeException')) {
- console.info(
- 'Your code is expired, amplify will send another one.'
- )
- }
- recordJSException(response)
- return response
- } else {
+ try {
+ await AmplifyAuth.confirmSignUp(email, code)
return null
+ } catch (response) {
+ if (isAmplifyError(response) && (response.code === 'ExpiredCodeException')) {
+ console.info(
+ 'Your code is expired, amplify will send another one.'
+ )
+ }
+ recordJSException(response)
+ return response
}
}
async function resendSignUp(
email: string
): Promise {
- const response = await AmplifyAuth.resendSignUp(email)
- if (response instanceof Error) {
- recordJSException(response)
- return response
- } else {
- return null
- }
+ try {
+ await AmplifyAuth.resendSignUp(email)
+ return null
+ } catch (response ) {
+ recordJSException(response)
+ return response
+ }
}
async function signIn(
email: string,
password: string
): Promise {
- const response = await AmplifyAuth.signIn(email, password)
- if (response instanceof Error) {
+ try {
+ const response = await AmplifyAuth.signIn(email, password)
+ return response.user
+ } catch (response) {
if(isAmplifyError(response)) {
if (response.code === 'UserNotConfirmedException') {
recordJSException(
@@ -135,28 +135,28 @@ async function signIn(
} else {
recordJSException(`UNEXPECTED SIGNIN ERROR – 'didnt even get an amplify error back from login`)
}
+ return response
}
-
- return response
}
async function signOut(): Promise {
- const response = await AmplifyAuth.signOut()
- if (response instanceof Error) {
- recordJSException( response)
- return response
- } else {
- return null
+ try {
+ await AmplifyAuth.signOut()
+ return null
+ } catch (response) {
+ recordJSException( response)
+ return response
}
+
}
async function extendSession(): Promise {
- const response = await AmplifyAuth.currentSession()
- if (response instanceof Error) {
+ try {
+ await AmplifyAuth.currentSession()
+ return null
+ } catch (response) {
recordJSException(response)
return response
- } else {
- return null
}
}
From 3037cc871f4dca7e1354f6753344f3f6b745d0e2 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Mon, 16 Sep 2024 11:51:42 -0500
Subject: [PATCH 25/26] Don't allow continue session if timer elapsed (but
logout failed due to machine sleep
---
services/app-web/src/components/Modal/SessionTimeoutModal.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
index ba0b2ee0eb..be3acb6c28 100644
--- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
+++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
@@ -42,7 +42,7 @@ export const SessionTimeoutModal = ({
onSubmitText="Continue Session"
onCancelText="Logout"
onCancel={handleLogoutSession}
- submitButtonProps={{ className: styles.submitSuccessButton }}
+ submitButtonProps={{ className: styles.submitSuccessButton, disabled: countdownSeconds == 0}}
onSubmit={handleContinueSession}
forceAction={true}
>
From cece00bc24f13a840e5ba3dcf5e58179daa3c367 Mon Sep 17 00:00:00 2001
From: Hana Worku
Date: Mon, 16 Sep 2024 11:59:54 -0500
Subject: [PATCH 26/26] Also update copy when session expired but modal still
open
---
.../app-web/src/components/Modal/SessionTimeoutModal.tsx | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
index be3acb6c28..a7feb032dc 100644
--- a/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
+++ b/services/app-web/src/components/Modal/SessionTimeoutModal.tsx
@@ -33,7 +33,7 @@ export const SessionTimeoutModal = ({
}
})
-
+ const countdownElapsed = countdownSeconds == 0
return (
@@ -56,12 +56,11 @@ export const SessionTimeoutModal = ({
.format('mm:ss')}
- If you would like to extend your session, click the
- Continue Session button
+ {countdownElapsed ? 'Your session is now expired.' : '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
+ Logout button.
)