diff --git a/cypress.config.ts b/cypress.config.ts index b7e107ea875..d8b11899dd1 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -14,10 +14,15 @@ import { getNewAccountVerificationCode, toggleFeatureFlag, } from './cypress/helpers/cypressTasks/dynamo/dynamo-helpers'; +import { overrideIdleTimeouts } from './cypress/local-only/support/idleLogoutHelpers'; import { unzipFile } from './cypress/helpers/file/unzip-file'; import { waitForNoce } from './cypress/helpers/cypressTasks/wait-for-noce'; import { waitForPractitionerEmailUpdate } from './cypress/helpers/cypressTasks/wait-for-practitioner-email-update'; +import type { Page } from 'puppeteer-core'; + +import { retry, setup } from '@cypress/puppeteer'; + // eslint-disable-next-line import/no-default-export export default defineConfig({ chromeWebSecurity: false, @@ -77,6 +82,66 @@ export default defineConfig({ }); }, }); + // Setup for puppeteer, which supports multi-tab tests + // Define your function in onMessage, and call it like cy.puppeteer('yourFunctionName', arg1, arg2 ...) + setup({ + on, + onMessage: { + async closeTab(browser: any, url: string) { + const desiredPage = await retry>(async () => { + const pages = await browser.pages(); + const page = pages.find(p => p.url().includes(url)); + if (!page) throw new Error('Could not find page'); + return page; + }); + await desiredPage.close(); + }, + async openExistingTabAndCheckSelectorExists( + browser: any, + url: string, + selector: string, + close: boolean = true, + ) { + // Note that browser.pages is *not* sorted in any particular order. + // Therefore we pass in the URL we want to find rather than an index. + + // Wait until the new tab loads + const desiredPage = await retry>(async () => { + const pages = await browser.pages(); + const page = pages.find(p => p.url().includes(url)); + if (!page) throw new Error('Could not find page'); + return page; + }); + + // Activate it + await desiredPage.bringToFront(); + + // Make sure selector exists + await desiredPage.waitForSelector(selector, { timeout: 30000 }); + + if (close) { + await desiredPage.close(); + } + return true; + }, + async openNewTab( + browser: any, + url: string, + sessionModalTimeout: number, + sessionTimeout: number, + ) { + const page = await browser.newPage(); + await page.goto(url, { waitUntil: 'networkidle2' }); + + await page.evaluate(overrideIdleTimeouts, { + sessionModalTimeout, + sessionTimeout, + }); + + return page; + }, + }, + }); }, specPattern: 'cypress/local-only/tests/**/*.cy.ts', supportFile: 'cypress/local-only/support/index.ts', diff --git a/cypress/CYPRESS-README.md b/cypress/CYPRESS-README.md index 678769e315a..48d5e915f6b 100644 --- a/cypress/CYPRESS-README.md +++ b/cypress/CYPRESS-README.md @@ -1,7 +1,7 @@ # Best Practices -In order to write a realiable cypress test suites, there are some best practices we should follow that our outlined in the cypress documentation and also some best practices we have learned from trying to write realiable tests. +In order to write a reliable cypress test suites, there are some best practices we should follow that our outlined in the cypress documentation and also some best practices we have learned from trying to write reliable tests. ## DO'S - Access DOM elements using `data-testid selector`. @@ -11,7 +11,7 @@ In order to write a realiable cypress test suites, there are some best practices - Avoid cy.get('#my-id'). - Wait for actions to finish explicitly. - Always verify something on the page after running an action or loading a new page. For example, if you click on a button which updates a practitioner name, be sure to wait for a success alert to appear before trying to move onto the next steps in your test. Failing to do this will result in race conditions and flaky tests. - - This is especially important for accessibilty tests, wait explicitly for the page to full load before running an accessibility scan. If you are seeing 'color-contrast' violations that are intermittent you are most likely not waiting for the right element to be loaded before running a scan. + - This is especially important for accessibility tests, wait explicitly for the page to full load before running an accessibility scan. If you are seeing 'color-contrast' violations that are intermittent you are most likely not waiting for the right element to be loaded before running a scan. - Extract reusable steps. - Try to find ways to create helper functions which we can re-use in other tests. For example, creating a case as a petitioner is a good re-usable flow. When writing these helpers, be sure they do not contain asserts related to the high level test you are writing. They should just login as a user, create or modify the data, then return any new created values we may need. - Test should be re-runnable. diff --git a/cypress/helpers/ITestableWindow.ts b/cypress/helpers/ITestableWindow.ts new file mode 100644 index 00000000000..1139d70f12f --- /dev/null +++ b/cypress/helpers/ITestableWindow.ts @@ -0,0 +1,8 @@ +// An interface for exposing the cerebral controller on the window object, which +// can be useful for temporarily overwriting constants in cypress. +export interface ITestableWindow { + cerebral: { + getState: () => any; + getModel: () => any; + }; +} diff --git a/cypress/local-only/support/idleLogoutHelpers.ts b/cypress/local-only/support/idleLogoutHelpers.ts new file mode 100644 index 00000000000..4ca63667316 --- /dev/null +++ b/cypress/local-only/support/idleLogoutHelpers.ts @@ -0,0 +1,19 @@ +import { ITestableWindow } from '../../helpers/ITestableWindow'; + +// This is a hack, but I do not know a better way. +export const overrideIdleTimeouts = ({ + modalTimeout, + sessionTimeout, + windowObj, // For native cypress, this needs to be defined. For the puppeteer plugin, it should be left blank. +}: { + modalTimeout: number; + sessionTimeout: number; + windowObj?: ITestableWindow; +}) => { + const currentWindow = windowObj || (window as unknown as ITestableWindow); + currentWindow.cerebral.getModel().set(['constants'], { + ...currentWindow.cerebral.getState().constants, + SESSION_MODAL_TIMEOUT: modalTimeout, + SESSION_TIMEOUT: sessionTimeout, + }); +}; diff --git a/cypress/local-only/support/index.ts b/cypress/local-only/support/index.ts index 39d02917811..5b97cee03dd 100644 --- a/cypress/local-only/support/index.ts +++ b/cypress/local-only/support/index.ts @@ -1,4 +1,5 @@ import './commands'; +import '@cypress/puppeteer/support'; import 'cypress-axe'; before(() => { diff --git a/cypress/local-only/tests/integration/logoutBehavior/idle-logout.cy.ts b/cypress/local-only/tests/integration/logoutBehavior/idle-logout.cy.ts new file mode 100644 index 00000000000..7e492f147fa --- /dev/null +++ b/cypress/local-only/tests/integration/logoutBehavior/idle-logout.cy.ts @@ -0,0 +1,91 @@ +import { ITestableWindow } from '../../../../helpers/ITestableWindow'; +import { loginAsColvin } from '../../../../helpers/authentication/login-as-helpers'; +import { overrideIdleTimeouts } from '../../../support/idleLogoutHelpers'; +import { retry } from '../../../../helpers/retry'; + +describe('Idle Logout Behavior', () => { + const DEFAULT_IDLE_TIMEOUT = 500; + it('should automatically log user out after refresh with option to log back in', () => { + loginAsColvin(); + cy.reload(); // Refresh ensures we track idle time even without interaction on the page + cy.get('[data-testid="header-text"]'); + cy.window().then((window: Window) => { + overrideIdleTimeouts({ + modalTimeout: DEFAULT_IDLE_TIMEOUT, + sessionTimeout: DEFAULT_IDLE_TIMEOUT, + windowObj: window as unknown as ITestableWindow, + }); + }); + + retry(() => { + return cy.get('body').then(body => { + return body.find('[data-testid="idle-logout-login-button"]').length > 0; + }); + }); + + cy.get('[data-testid="idle-logout-login-button"]').click(); + cy.get('[data-testid="login-button"]').should('exist'); + }); + + it('should close modal in other tab when loading new tab', () => { + loginAsColvin(); + cy.get('[data-testid="header-text"]'); + cy.window().then((window: Window) => { + overrideIdleTimeouts({ + modalTimeout: 30000, // We want the modal to appear relatively quickly, but we do not want to sign out + sessionTimeout: 1000, + windowObj: window as unknown as ITestableWindow, + }); + }); + + // Wait until modal is there + cy.get('[data-testid="are-you-still-there-modal"]').should('exist'); + + const newTabUrl = Cypress.config('baseUrl') + '/messages/my/inbox'; + cy.puppeteer('openNewTab', newTabUrl); + + // Then confirm opening a new tab closed the modal + cy.get('[data-testid="are-you-still-there-modal"]').should('not.exist'); + cy.puppeteer('closeTab', newTabUrl); + }); + + it('should sign out of all tabs after idle', () => { + // Note that throughout this test, we interact with the first tab via cypress + // and all other tabs through the puppeteer plugin. Mixing this up will cause errors. + + loginAsColvin(); + const urls = [ + '/messages/my/inbox', + '/document-qc/section/inbox', + '/trial-sessions', + ]; + urls.forEach(url => { + cy.puppeteer( + 'openNewTab', + Cypress.config('baseUrl') + url, + DEFAULT_IDLE_TIMEOUT, + DEFAULT_IDLE_TIMEOUT, + ); + }); + cy.window().then((window: Window) => { + overrideIdleTimeouts({ + modalTimeout: DEFAULT_IDLE_TIMEOUT, + sessionTimeout: DEFAULT_IDLE_TIMEOUT, + windowObj: window as unknown as ITestableWindow, + }); + }); + + // We sync all the tabs to timeout at the same time by clicking, which broadcasts a "last active" time across tabs. + // They should all sign out at the same time. + cy.get('body').click(); + + cy.get('[data-testid="idle-logout-login-button"]').should('exist'); + urls.forEach(url => + cy.puppeteer( + 'openExistingTabAndCheckSelectorExists', + url, + '[data-testid="idle-logout-login-button"]', + ), + ); + }); +}); diff --git a/package-lock.json b/package-lock.json index 4f8c2c22744..445ae6d4c25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "@babel/preset-react": "7.24.7", "@babel/preset-typescript": "7.24.7", "@babel/register": "7.24.6", + "@cypress/puppeteer": "^0.1.5", "@faker-js/faker": "8.4.1", "@miovision/eslint-plugin-disallow-date": "2.0.0", "@types/aws-lambda": "8.10.143", @@ -7829,6 +7830,195 @@ "postcss": "^8.4" } }, + "node_modules/@cypress/puppeteer": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@cypress/puppeteer/-/puppeteer-0.1.5.tgz", + "integrity": "sha512-7b7C/VnrDj2U9Sdy5a4oHzTDgmeC3yxILVU+e9DzBnuy6DtxkqvyZyp40QyD39i4t+fiLmocIMrcZm5Day7cgg==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21", + "puppeteer-core": "^21.2.1" + }, + "peerDependencies": { + "cypress": ">=13.6.0" + } + }, + "node_modules/@cypress/puppeteer/node_modules/@puppeteer/browsers": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz", + "integrity": "sha512-PuvK6xZzGhKPvlx3fpfdM2kYY3P/hB1URtK8wA7XUJ6prn6pp22zvJHu48th0SGcHL9SutbPHrFuQgfXTFobWA==", + "dev": true, + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "progress": "2.0.3", + "proxy-agent": "6.3.1", + "tar-fs": "3.0.4", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=16.3.0" + } + }, + "node_modules/@cypress/puppeteer/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@cypress/puppeteer/node_modules/chromium-bidi": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.5.8.tgz", + "integrity": "sha512-blqh+1cEQbHBKmok3rVJkBlBxt9beKBgOsxbFgs7UJcoVbbeZ+K7+6liAsjgpc8l1Xd55cQUy14fXZdGSb4zIw==", + "dev": true, + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/@cypress/puppeteer/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@cypress/puppeteer/node_modules/devtools-protocol": { + "version": "0.0.1232444", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1232444.tgz", + "integrity": "sha512-pM27vqEfxSxRkTMnF+XCmxSEb6duO5R+t8A9DEEJgy4Wz2RVanje2mmj99B6A3zv2r/qGfYlOvYznUhuokizmg==", + "dev": true + }, + "node_modules/@cypress/puppeteer/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@cypress/puppeteer/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cypress/puppeteer/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@cypress/puppeteer/node_modules/proxy-agent": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.1.tgz", + "integrity": "sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@cypress/puppeteer/node_modules/puppeteer-core": { + "version": "21.11.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-21.11.0.tgz", + "integrity": "sha512-ArbnyA3U5SGHokEvkfWjW+O8hOxV1RSJxOgriX/3A4xZRqixt9ZFHD0yPgZQF05Qj0oAqi8H/7stDorjoHY90Q==", + "dev": true, + "dependencies": { + "@puppeteer/browsers": "1.9.1", + "chromium-bidi": "0.5.8", + "cross-fetch": "4.0.0", + "debug": "4.3.4", + "devtools-protocol": "0.0.1232444", + "ws": "8.16.0" + }, + "engines": { + "node": ">=16.13.2" + } + }, + "node_modules/@cypress/puppeteer/node_modules/tar-fs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "dev": true, + "dependencies": { + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, + "node_modules/@cypress/puppeteer/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@cypress/puppeteer/node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@cypress/request": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", @@ -16929,6 +17119,15 @@ "create-serve": "src/bin.js" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -26868,6 +27067,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "node_modules/mnemonist": { "version": "0.38.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", diff --git a/package.json b/package.json index 6da97f98c44..6757c236710 100644 --- a/package.json +++ b/package.json @@ -253,6 +253,7 @@ "@babel/preset-react": "7.24.7", "@babel/preset-typescript": "7.24.7", "@babel/register": "7.24.6", + "@cypress/puppeteer": "^0.1.5", "@faker-js/faker": "8.4.1", "@miovision/eslint-plugin-disallow-date": "2.0.0", "@types/aws-lambda": "8.10.143", diff --git a/shared/src/business/entities/EntityConstants.ts b/shared/src/business/entities/EntityConstants.ts index 8a7cb6e74ac..102e40adc07 100644 --- a/shared/src/business/entities/EntityConstants.ts +++ b/shared/src/business/entities/EntityConstants.ts @@ -1686,6 +1686,27 @@ export type CreatedCaseType = { }; }; +export const BROADCAST_MESSAGES = { + appHasUpdated: 'appHasUpdated', + userLogout: 'userLogout', + idleLogout: 'idleLogout', + idleStatusActive: 'idleStatusActive', + stayLoggedIn: 'stayLoggedIn', +}; + +export const IDLE_LOGOUT_STATES = { + INITIAL: 'INITIAL', + MONITORING: 'MONITORING', + COUNTDOWN: 'COUNTDOWN', +}; + +export type IdleLogoutStateType = + (typeof IDLE_LOGOUT_STATES)[keyof typeof IDLE_LOGOUT_STATES]; + +export type IdleLogoutType = + | (typeof BROADCAST_MESSAGES)['idleLogout'] + | (typeof BROADCAST_MESSAGES)['userLogout']; + export const STATUS_REPORT_ORDER_OPTIONS = { issueOrderOptions: { justThisCase: 'justThisCase', diff --git a/web-client/src/AppInstanceManager.tsx b/web-client/src/AppInstanceManager.tsx index 6a30eb029dd..1de9e970cc1 100644 --- a/web-client/src/AppInstanceManager.tsx +++ b/web-client/src/AppInstanceManager.tsx @@ -1,3 +1,4 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; import { connect } from '@web-client/presenter/shared.cerebral'; import { sequences } from '@web-client/presenter/app.cerebral'; import { state } from '@web-client/presenter/app.cerebral'; @@ -11,36 +12,49 @@ import React from 'react'; * or tabs also open to the same domain/path) and takes appropriate * action according to the message subject received. Currently * it monitors idle time activity and coordinates "I am active" messages - * and "I am still here" messages. + * and "I am still here" messages, as well as "DAWSON has been updated, + * please refresh" messages. */ export const AppInstanceManager = connect( { appInstanceManagerHelper: state.appInstanceManagerHelper, confirmStayLoggedInSequence: sequences.confirmStayLoggedInSequence, + handleAppHasUpdatedSequence: sequences.handleAppHasUpdatedSequence, resetIdleTimerSequence: sequences.resetIdleTimerSequence, - signOutSequence: sequences.signOutSequence, + signOutIdleSequence: sequences.signOutIdleSequence, + signOutUserInitiatedSequence: sequences.signOutUserInitiatedSequence, }, function AppInstanceManager({ appInstanceManagerHelper, confirmStayLoggedInSequence, + handleAppHasUpdatedSequence, resetIdleTimerSequence, - signOutSequence, + signOutIdleSequence, + signOutUserInitiatedSequence, }) { const { channelHandle } = appInstanceManagerHelper; channelHandle.onmessage = msg => { switch (msg.subject) { - case 'idleStatusActive': + case BROADCAST_MESSAGES.idleStatusActive: resetIdleTimerSequence(); break; - case 'stayLoggedIn': + case BROADCAST_MESSAGES.stayLoggedIn: confirmStayLoggedInSequence(); break; - case 'logout': - signOutSequence({ + case BROADCAST_MESSAGES.idleLogout: + signOutIdleSequence({ skipBroadcast: true, }); break; + case BROADCAST_MESSAGES.userLogout: + signOutUserInitiatedSequence({ + skipBroadcast: true, + }); + break; + case BROADCAST_MESSAGES.appHasUpdated: + handleAppHasUpdatedSequence({ skipBroadcast: true }); + break; default: console.warn('unhandled broadcast event', msg); break; diff --git a/web-client/src/app.tsx b/web-client/src/app.tsx index 381b12043c3..334485c06dc 100644 --- a/web-client/src/app.tsx +++ b/web-client/src/app.tsx @@ -31,6 +31,7 @@ import { faTimesCircle as faTimesCircleRegular } from '@fortawesome/free-regular import { faUser } from '@fortawesome/free-regular-svg-icons/faUser'; //if you see a console error saying could not get icon, make sure the prefix matches the import (eg fas should be imported from free-solid-svg-icons) +import { ITestableWindow } from '../../cypress/helpers/ITestableWindow'; import { config, library } from '@fortawesome/fontawesome-svg-core'; import { createRoot } from 'react-dom/client'; import { faArrowAltCircleLeft as faArrowAltCircleLeftSolid } from '@fortawesome/free-solid-svg-icons/faArrowAltCircleLeft'; @@ -262,8 +263,13 @@ const app = { returnSequencePromise: true, }); + // Expose Cerebral for testing + if (process.env.ENV === 'local' || process.env.ENV === 'test') { + (window as unknown as ITestableWindow).cerebral = cerebralApp; + } + applicationContext.setForceRefreshCallback(async () => { - await cerebralApp.getSequence('openAppUpdatedModalSequence')(); + await cerebralApp.getSequence('handleAppHasUpdatedSequence')(); }); const container = window.document.querySelector('#app'); @@ -271,13 +277,11 @@ const app = { root.render( - {!process.env.CI && ( - <> - - - - - )} + <> + + + + {process.env.CI &&
CI Test Environment
} diff --git a/web-client/src/appPublic.tsx b/web-client/src/appPublic.tsx index 115260701c2..2abd1ae7174 100644 --- a/web-client/src/appPublic.tsx +++ b/web-client/src/appPublic.tsx @@ -127,7 +127,7 @@ const appPublic = { const cerebralApp = App(presenter, debugTools); applicationContext.setForceRefreshCallback(async () => { - await cerebralApp.getSequence('openAppUpdatedModalSequence')(); + await cerebralApp.getSequence('handleAppHasUpdatedSequence')(); }); router.initialize(cerebralApp); diff --git a/web-client/src/presenter/actions/Login/navigateToLoginAction.test.ts b/web-client/src/presenter/actions/Login/navigateToLoginAction.test.ts index 79e23e35759..8ef45c50f5a 100644 --- a/web-client/src/presenter/actions/Login/navigateToLoginAction.test.ts +++ b/web-client/src/presenter/actions/Login/navigateToLoginAction.test.ts @@ -2,7 +2,7 @@ import { navigateToLoginAction } from '@web-client/presenter/actions/Login/navig import { presenter } from '@web-client/presenter/presenter-mock'; import { runAction } from '@web-client/presenter/test.cerebral'; -describe('navigateToForgotPasswordAction', () => { +describe('navigateToLoginAction', () => { let routeStub; beforeAll(() => { diff --git a/web-client/src/presenter/actions/broadcastAppUpdatedAction.test.ts b/web-client/src/presenter/actions/broadcastAppUpdatedAction.test.ts new file mode 100644 index 00000000000..abff2d9a2f5 --- /dev/null +++ b/web-client/src/presenter/actions/broadcastAppUpdatedAction.test.ts @@ -0,0 +1,41 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; +import { applicationContextForClient as applicationContext } from '@web-client/test/createClientTestApplicationContext'; +import { broadcastAppUpdatedAction } from '@web-client/presenter/actions/broadcastAppUpdatedAction'; +import { presenter } from '../presenter-mock'; +import { runAction } from '@web-client/presenter/test.cerebral'; + +describe('broadcastAppUpdatedAction', () => { + presenter.providers.applicationContext = applicationContext; + + it('should broadcast message when skipBroadcast is false (the default)', async () => { + await runAction(broadcastAppUpdatedAction, { + modules: { + presenter, + }, + }); + + expect( + applicationContext.getBroadcastGateway().postMessage, + ).toHaveBeenCalled(); + expect( + applicationContext.getBroadcastGateway().postMessage.mock.calls[0][0], + ).toMatchObject({ + subject: BROADCAST_MESSAGES.appHasUpdated, + }); + }); + + it('should not broadcast message when skipBroadcast is true', async () => { + await runAction(broadcastAppUpdatedAction, { + modules: { + presenter, + }, + props: { + skipBroadcast: true, + }, + }); + + expect( + applicationContext.getBroadcastGateway().postMessage, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/web-client/src/presenter/actions/broadcastAppUpdatedAction.ts b/web-client/src/presenter/actions/broadcastAppUpdatedAction.ts new file mode 100644 index 00000000000..c280af84118 --- /dev/null +++ b/web-client/src/presenter/actions/broadcastAppUpdatedAction.ts @@ -0,0 +1,13 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; + +export const broadcastAppUpdatedAction = async ({ + applicationContext, + props, +}: ActionProps) => { + if (!props.skipBroadcast) { + const broadcastChannel = applicationContext.getBroadcastGateway(); + await broadcastChannel.postMessage({ + subject: BROADCAST_MESSAGES.appHasUpdated, + }); + } +}; diff --git a/web-client/src/presenter/actions/broadcastIdleStatusActiveAction.test.ts b/web-client/src/presenter/actions/broadcastIdleStatusActiveAction.test.ts index 9c9e4284172..91b03269e8d 100644 --- a/web-client/src/presenter/actions/broadcastIdleStatusActiveAction.test.ts +++ b/web-client/src/presenter/actions/broadcastIdleStatusActiveAction.test.ts @@ -1,3 +1,4 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; import { applicationContextForClient as applicationContext } from '@web-client/test/createClientTestApplicationContext'; import { broadcastIdleStatusActiveAction } from './broadcastIdleStatusActiveAction'; import { presenter } from '../presenter-mock'; @@ -6,11 +7,34 @@ import { runAction } from '@web-client/presenter/test.cerebral'; describe('broadcastIdleStatusActiveAction', () => { presenter.providers.applicationContext = applicationContext; - it('should invoke postMessage with the expected arguments', async () => { + it('should invoke postMessage idleStatusActive message when props.closeModal is false', async () => { await runAction(broadcastIdleStatusActiveAction, { modules: { presenter, }, + props: { + closeModal: false, + }, + }); + + expect( + applicationContext.getBroadcastGateway().postMessage, + ).toHaveBeenCalled(); + expect( + applicationContext.getBroadcastGateway().postMessage.mock.calls[0][0], + ).toMatchObject({ + subject: BROADCAST_MESSAGES.idleStatusActive, + }); + }); + + it('should invoke postMessage stayLoggedIn message when props.closeModal is true', async () => { + await runAction(broadcastIdleStatusActiveAction, { + modules: { + presenter, + }, + props: { + closeModal: true, + }, }); expect( @@ -19,7 +43,7 @@ describe('broadcastIdleStatusActiveAction', () => { expect( applicationContext.getBroadcastGateway().postMessage.mock.calls[0][0], ).toMatchObject({ - subject: 'idleStatusActive', + subject: BROADCAST_MESSAGES.stayLoggedIn, }); }); }); diff --git a/web-client/src/presenter/actions/broadcastIdleStatusActiveAction.ts b/web-client/src/presenter/actions/broadcastIdleStatusActiveAction.ts index 50ed59ed557..0d74d76c7d9 100644 --- a/web-client/src/presenter/actions/broadcastIdleStatusActiveAction.ts +++ b/web-client/src/presenter/actions/broadcastIdleStatusActiveAction.ts @@ -1,3 +1,4 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; import { state } from '@web-client/presenter/app.cerebral'; /** @@ -8,9 +9,15 @@ import { state } from '@web-client/presenter/app.cerebral'; */ export const broadcastIdleStatusActiveAction = async ({ applicationContext, + props, store, }: ActionProps) => { store.set(state.lastIdleAction, Date.now()); const broadcastChannel = applicationContext.getBroadcastGateway(); - await broadcastChannel.postMessage({ subject: 'idleStatusActive' }); + const message = props.closeModal + ? BROADCAST_MESSAGES.stayLoggedIn + : BROADCAST_MESSAGES.idleStatusActive; + await broadcastChannel.postMessage({ + subject: message, + }); }; diff --git a/web-client/src/presenter/actions/broadcastLogoutAction.test.ts b/web-client/src/presenter/actions/broadcastLogoutAction.test.ts index bbab5281690..b7002d4c268 100644 --- a/web-client/src/presenter/actions/broadcastLogoutAction.test.ts +++ b/web-client/src/presenter/actions/broadcastLogoutAction.test.ts @@ -6,25 +6,7 @@ import { runAction } from '@web-client/presenter/test.cerebral'; presenter.providers.applicationContext = applicationContext; describe('broadcastLogoutAction', () => { - it('does not broadcast an event if skipBroadcast is true and CI is undefined', async () => { - delete process.env.CI; - - await runAction(broadcastLogoutAction, { - modules: { - presenter, - }, - props: { - skipBroadcast: true, - }, - }); - - expect( - applicationContext.getBroadcastGateway().postMessage, - ).not.toHaveBeenCalled(); - }); - - it('does not broadcast an event if skipBroadcast is true and CI is defined', async () => { - process.env.CI = 'true'; + it('does not broadcast an event when skipBroadcast is true', async () => { await runAction(broadcastLogoutAction, { modules: { presenter, @@ -39,23 +21,7 @@ describe('broadcastLogoutAction', () => { ).not.toHaveBeenCalled(); }); - it('does not broadcast an event if skipBroadcast is false and CI is defined', async () => { - process.env.CI = 'true'; - await runAction(broadcastLogoutAction, { - modules: { - presenter, - }, - props: { - skipBroadcast: false, - }, - }); - - expect( - applicationContext.getBroadcastGateway().postMessage, - ).not.toHaveBeenCalled(); - }); - - it('will only broad an event when both skipBroadcast is false and CI is undefined', async () => { + it('does broadcast an event when skipBroadcast is false', async () => { delete process.env.CI; await runAction(broadcastLogoutAction, { modules: { diff --git a/web-client/src/presenter/actions/broadcastLogoutAction.ts b/web-client/src/presenter/actions/broadcastLogoutAction.ts index faf574009e9..73cef7a6383 100644 --- a/web-client/src/presenter/actions/broadcastLogoutAction.ts +++ b/web-client/src/presenter/actions/broadcastLogoutAction.ts @@ -1,3 +1,5 @@ +import { state } from '@web-client/presenter/app.cerebral'; + /** * tells all open tabs to also logout * @param {object} providers the providers object @@ -6,11 +8,13 @@ */ export const broadcastLogoutAction = async ({ applicationContext, + get, props, }: ActionProps) => { - // for some reason this causes jest integration tests to never finish, so don't run in CI - if (!process.env.CI && !props.skipBroadcast) { + if (!props.skipBroadcast) { const broadcastChannel = applicationContext.getBroadcastGateway(); - await broadcastChannel.postMessage({ subject: 'logout' }); + await broadcastChannel.postMessage({ + subject: get(state.logoutType), + }); } }; diff --git a/web-client/src/presenter/actions/broadcastStayLoggedInAction.test.ts b/web-client/src/presenter/actions/broadcastStayLoggedInAction.test.ts index 70df89ed64f..b6025ff2e66 100644 --- a/web-client/src/presenter/actions/broadcastStayLoggedInAction.test.ts +++ b/web-client/src/presenter/actions/broadcastStayLoggedInAction.test.ts @@ -1,3 +1,4 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; import { applicationContextForClient as applicationContext } from '@web-client/test/createClientTestApplicationContext'; import { broadcastStayLoggedInAction } from './broadcastStayLoggedInAction'; import { presenter } from '../presenter-mock'; @@ -19,7 +20,7 @@ describe('broadcastStayLoggedInAction', () => { expect( applicationContext.getBroadcastGateway().postMessage.mock.calls[0][0], ).toMatchObject({ - subject: 'stayLoggedIn', + subject: BROADCAST_MESSAGES.stayLoggedIn, }); }); }); diff --git a/web-client/src/presenter/actions/broadcastStayLoggedInAction.ts b/web-client/src/presenter/actions/broadcastStayLoggedInAction.ts index 43d9998aade..08f5f7fd45b 100644 --- a/web-client/src/presenter/actions/broadcastStayLoggedInAction.ts +++ b/web-client/src/presenter/actions/broadcastStayLoggedInAction.ts @@ -1,3 +1,5 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; + /** * broadcasts a stay logged in message to all app instances * @param {object} providers the providers object @@ -9,5 +11,7 @@ export const broadcastStayLoggedInAction = async ({ }: ActionProps) => { const broadcastChannel = applicationContext.getBroadcastGateway(); - await broadcastChannel.postMessage({ subject: 'stayLoggedIn' }); + await broadcastChannel.postMessage({ + subject: BROADCAST_MESSAGES.stayLoggedIn, + }); }; diff --git a/web-client/src/presenter/actions/checkClientNeedsToRefresh.test.ts b/web-client/src/presenter/actions/checkClientNeedsToRefresh.test.ts new file mode 100644 index 00000000000..b677b3be732 --- /dev/null +++ b/web-client/src/presenter/actions/checkClientNeedsToRefresh.test.ts @@ -0,0 +1,44 @@ +import { checkClientNeedsToRefresh } from '@web-client/presenter/actions/checkClientNeedsToRefresh'; +import { presenter } from '../presenter-mock'; +import { runAction } from '@web-client/presenter/test.cerebral'; + +describe('checkClientNeedsToRefresh', () => { + let pathClientNeedsToRefresh; + let pathClientDoesNotNeedToRefresh; + + beforeEach(() => { + pathClientNeedsToRefresh = jest.fn(); + pathClientDoesNotNeedToRefresh = jest.fn(); + + presenter.providers.path = { + clientDoesNotNeedToRefresh: pathClientDoesNotNeedToRefresh, + clientNeedsToRefresh: pathClientNeedsToRefresh, + }; + }); + + it('properly handles the case when clientNeedsToRefresh is false', async () => { + await runAction(checkClientNeedsToRefresh, { + modules: { + presenter, + }, + state: { + clientNeedsToRefresh: false, + }, + }); + + expect(pathClientDoesNotNeedToRefresh).toHaveBeenCalled(); + }); + + it('properly handles the case when clientNeedsToRefresh is true', async () => { + await runAction(checkClientNeedsToRefresh, { + modules: { + presenter, + }, + state: { + clientNeedsToRefresh: true, + }, + }); + + expect(pathClientNeedsToRefresh).toHaveBeenCalled(); + }); +}); diff --git a/web-client/src/presenter/actions/checkClientNeedsToRefresh.ts b/web-client/src/presenter/actions/checkClientNeedsToRefresh.ts new file mode 100644 index 00000000000..d6c38bc974a --- /dev/null +++ b/web-client/src/presenter/actions/checkClientNeedsToRefresh.ts @@ -0,0 +1,7 @@ +import { state } from '@web-client/presenter/app.cerebral'; + +export const checkClientNeedsToRefresh = ({ get, path }: ActionProps) => { + return get(state.clientNeedsToRefresh) + ? path.clientNeedsToRefresh() + : path.clientDoesNotNeedToRefresh(); +}; diff --git a/web-client/src/presenter/actions/clearLogoutTypeAction.test.ts b/web-client/src/presenter/actions/clearLogoutTypeAction.test.ts new file mode 100644 index 00000000000..3920e3da952 --- /dev/null +++ b/web-client/src/presenter/actions/clearLogoutTypeAction.test.ts @@ -0,0 +1,15 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; +import { clearLogoutTypeAction } from './clearLogoutTypeAction'; +import { runAction } from '@web-client/presenter/test.cerebral'; + +describe('clearLogoutTypeAction', () => { + it('should clear the logout type', async () => { + const result = await runAction(clearLogoutTypeAction, { + state: { + logoutType: BROADCAST_MESSAGES.userLogout, + }, + }); + + expect(result.state.logoutType).toBe(''); + }); +}); diff --git a/web-client/src/presenter/actions/clearLogoutTypeAction.ts b/web-client/src/presenter/actions/clearLogoutTypeAction.ts new file mode 100644 index 00000000000..7ae1e532801 --- /dev/null +++ b/web-client/src/presenter/actions/clearLogoutTypeAction.ts @@ -0,0 +1,5 @@ +import { state } from '@web-client/presenter/app.cerebral'; + +export const clearLogoutTypeAction = ({ store }: ActionProps) => { + store.set(state.logoutType, ''); +}; diff --git a/web-client/src/presenter/actions/clearRefreshTokenIntervalAction.test.ts b/web-client/src/presenter/actions/clearRefreshTokenIntervalAction.test.ts new file mode 100644 index 00000000000..27e8b959cd5 --- /dev/null +++ b/web-client/src/presenter/actions/clearRefreshTokenIntervalAction.test.ts @@ -0,0 +1,17 @@ +import { clearRefreshTokenIntervalAction } from '@web-client/presenter/actions/clearRefreshTokenIntervalAction'; +import { runAction } from '@web-client/presenter/test.cerebral'; + +describe('clearRefreshTokenIntervalAction', () => { + it('should clear refreshTokenInterval (both in state and with clearInterval)', async () => { + const mockInterval = 10; + const mockClearInterval = jest.spyOn(global, 'clearInterval'); + const result = await runAction(clearRefreshTokenIntervalAction, { + state: { + refreshTokenInterval: mockInterval, + }, + }); + + expect(result.state.refreshTokenInterval).toBeUndefined(); + expect(mockClearInterval).toHaveBeenCalledWith(mockInterval); + }); +}); diff --git a/web-client/src/presenter/actions/clearRefreshTokenIntervalAction.ts b/web-client/src/presenter/actions/clearRefreshTokenIntervalAction.ts new file mode 100644 index 00000000000..49537e1c065 --- /dev/null +++ b/web-client/src/presenter/actions/clearRefreshTokenIntervalAction.ts @@ -0,0 +1,10 @@ +import { state } from '@web-client/presenter/app.cerebral'; + +export const clearRefreshTokenIntervalAction = ({ + get, + store, +}: ActionProps) => { + const oldInterval = get(state.refreshTokenInterval); + clearInterval(oldInterval); + store.unset(state.refreshTokenInterval); +}; diff --git a/web-client/src/presenter/actions/handleIdleLogoutAction.test.ts b/web-client/src/presenter/actions/handleIdleLogoutAction.test.ts index 20da394b7a6..2ba2440c30b 100644 --- a/web-client/src/presenter/actions/handleIdleLogoutAction.test.ts +++ b/web-client/src/presenter/actions/handleIdleLogoutAction.test.ts @@ -1,3 +1,4 @@ +import { IDLE_LOGOUT_STATES } from '@shared/business/entities/EntityConstants'; import { handleIdleLogoutAction } from './handleIdleLogoutAction'; import { runAction } from '@web-client/presenter/test.cerebral'; @@ -15,7 +16,7 @@ describe('handleIdleLogoutAction', () => { }; }); - it('should stay in the INITIAL state if the user is not logged in', async () => { + it('should do nothing when the user is not logged in', async () => { const result = await runAction(handleIdleLogoutAction, { modules: { presenter, @@ -30,20 +31,51 @@ describe('handleIdleLogoutAction', () => { }, idleLogoutState: { logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }, lastIdleAction: 0, - user: undefined, + token: undefined, }, }); expect(result.state.idleLogoutState).toEqual({ logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }); + expect(presenter.providers.path.continue).toHaveBeenCalled(); }); - it('should stay in the INITIAL state if the user is uploading', async () => { + it('should do nothing when the client needs to be refreshed', async () => { + const result = await runAction(handleIdleLogoutAction, { + modules: { + presenter, + }, + state: { + clientNeedsToRefresh: true, + constants: { + SESSION_MODAL_TIMEOUT: 5000, + SESSION_TIMEOUT: 10000, + }, + fileUploadProgress: { + isUploading: false, + }, + idleLogoutState: { + logoutAt: undefined, + state: IDLE_LOGOUT_STATES.INITIAL, + }, + lastIdleAction: 0, + token: '92c17761-d382-4231-b497-bc8c9e3ffea1', + }, + }); + + expect(result.state.idleLogoutState).toEqual({ + logoutAt: undefined, + state: IDLE_LOGOUT_STATES.INITIAL, + }); + expect(presenter.providers.path.continue).toHaveBeenCalled(); + }); + + it('should stay in the INITIAL state when the user is uploading', async () => { const result = await runAction(handleIdleLogoutAction, { modules: { presenter, @@ -58,20 +90,21 @@ describe('handleIdleLogoutAction', () => { }, idleLogoutState: { logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }, lastIdleAction: 0, - user: {}, + token: '92c17761-d382-4231-b497-bc8c9e3ffea1', }, }); expect(result.state.idleLogoutState).toEqual({ logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }); + expect(presenter.providers.path.continue).toHaveBeenCalled(); }); - it('should stay move into the MONITORING state if the user is logged in and currently in initial state', async () => { + it('should move into the MONITORING state when the user is logged in and currently in initial state', async () => { const result = await runAction(handleIdleLogoutAction, { modules: { presenter, @@ -86,20 +119,21 @@ describe('handleIdleLogoutAction', () => { }, idleLogoutState: { logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }, lastIdleAction: 0, - user: {}, + token: '9dfbf86d-d4e7-41da-a85a-1b57910a5eaa', }, }); expect(result.state.idleLogoutState).toEqual({ logoutAt: expect.any(Number), - state: 'MONITORING', + state: IDLE_LOGOUT_STATES.MONITORING, }); + expect(presenter.providers.path.continue).toHaveBeenCalled(); }); - it('should move into the COUNTDOWN if the current time is passed the session timeout limits', async () => { + it('should move into the COUNTDOWN when the current time is past the session timeout limits', async () => { jest.spyOn(Date, 'now').mockReturnValue(11000); const result = await runAction(handleIdleLogoutAction, { modules: { @@ -115,20 +149,21 @@ describe('handleIdleLogoutAction', () => { }, idleLogoutState: { logoutAt: undefined, - state: 'MONITORING', + state: IDLE_LOGOUT_STATES.MONITORING, }, lastIdleAction: 0, - user: {}, + token: '0e4d3b74-89bc-44a0-a9b9-59f5eece40a5', }, }); expect(result.state.idleLogoutState).toEqual({ logoutAt: expect.any(Number), - state: 'COUNTDOWN', + state: IDLE_LOGOUT_STATES.COUNTDOWN, }); + expect(presenter.providers.path.continue).toHaveBeenCalled(); }); - it('should logout if in COUNTDOWN and the total time has elasped the total elasped time', async () => { + it('should logout when in COUNTDOWN and the total time has elapsed the total elapsed time', async () => { jest.spyOn(Date, 'now').mockReturnValue(16000); const result = await runAction(handleIdleLogoutAction, { modules: { @@ -144,16 +179,16 @@ describe('handleIdleLogoutAction', () => { }, idleLogoutState: { logoutAt: 15000, - state: 'COUNTDOWN', + state: IDLE_LOGOUT_STATES.COUNTDOWN, }, lastIdleAction: 0, - user: {}, + token: 'd53d2132-860e-41a0-9f42-f73c7285721b', }, }); expect(result.state.idleLogoutState).toEqual({ logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }); expect(presenter.providers.path.logout).toHaveBeenCalled(); }); diff --git a/web-client/src/presenter/actions/handleIdleLogoutAction.ts b/web-client/src/presenter/actions/handleIdleLogoutAction.ts index 34156d3ec30..826d741584d 100644 --- a/web-client/src/presenter/actions/handleIdleLogoutAction.ts +++ b/web-client/src/presenter/actions/handleIdleLogoutAction.ts @@ -1,3 +1,4 @@ +import { IDLE_LOGOUT_STATES } from '@shared/business/entities/EntityConstants'; import { state } from '@web-client/presenter/app.cerebral'; export const handleIdleLogoutAction = ({ get, path, store }: ActionProps) => { @@ -5,31 +6,43 @@ export const handleIdleLogoutAction = ({ get, path, store }: ActionProps) => { const lastIdleAction = get(state.lastIdleAction); const idleLogoutState = get(state.idleLogoutState); const isUploading = get(state.fileUploadProgress.isUploading); - const user = get(state.user); + const userIsLoggedIn = !!get(state.token); + const clientNeedsToRefresh = get(state.clientNeedsToRefresh); - if (user && !isUploading && idleLogoutState.state === 'INITIAL') { + // Short-circuit here to prevent showing the "Are you still there?" modal, etc. when inappropriate + if (!userIsLoggedIn || clientNeedsToRefresh) { + return path.continue(); + } + + if (!isUploading && idleLogoutState.state === IDLE_LOGOUT_STATES.INITIAL) { store.set(state.idleLogoutState, { logoutAt: Date.now() + constants.SESSION_MODAL_TIMEOUT + constants.SESSION_TIMEOUT, - state: 'MONITORING', + state: IDLE_LOGOUT_STATES.MONITORING, }); - } else if (idleLogoutState.state === 'MONITORING') { + return path.continue(); + } + + if (idleLogoutState.state === IDLE_LOGOUT_STATES.MONITORING) { if (Date.now() > lastIdleAction + constants.SESSION_TIMEOUT) { store.set(state.idleLogoutState, { logoutAt: lastIdleAction + constants.SESSION_MODAL_TIMEOUT + constants.SESSION_TIMEOUT, - state: 'COUNTDOWN', + state: IDLE_LOGOUT_STATES.COUNTDOWN, }); } - } else if (idleLogoutState.state === 'COUNTDOWN') { + return path.continue(); + } + + if (idleLogoutState.state === IDLE_LOGOUT_STATES.COUNTDOWN) { if (Date.now() > idleLogoutState.logoutAt) { store.set(state.idleLogoutState, { logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }); return path.logout(); } diff --git a/web-client/src/presenter/actions/handlePeerResetIdleTimerAction.test.ts b/web-client/src/presenter/actions/handlePeerResetIdleTimerAction.test.ts index 224c5713830..76b730332ac 100644 --- a/web-client/src/presenter/actions/handlePeerResetIdleTimerAction.test.ts +++ b/web-client/src/presenter/actions/handlePeerResetIdleTimerAction.test.ts @@ -1,3 +1,4 @@ +import { IDLE_LOGOUT_STATES } from '@shared/business/entities/EntityConstants'; import { applicationContextForClient as applicationContext } from '@web-client/test/createClientTestApplicationContext'; import { handlePeerResetIdleTimerAction } from './handlePeerResetIdleTimerAction'; import { presenter } from '../presenter-mock'; @@ -15,12 +16,14 @@ describe('handlePeerResetIdleTimerAction', () => { }, state: { idleLogoutState: { - state: 'COUNTDOWN', + state: IDLE_LOGOUT_STATES.COUNTDOWN, }, }, }); - expect(result.state.idleLogoutState.state).toEqual('COUNTDOWN'); + expect(result.state.idleLogoutState.state).toEqual( + IDLE_LOGOUT_STATES.COUNTDOWN, + ); }); it('should reset the idle logout state when not COUNTDOWN', async () => { @@ -30,11 +33,13 @@ describe('handlePeerResetIdleTimerAction', () => { }, state: { idleLogoutState: { - state: 'MONITORING', + state: IDLE_LOGOUT_STATES.MONITORING, }, }, }); - expect(result.state.idleLogoutState.state).toEqual('INITIAL'); + expect(result.state.idleLogoutState.state).toEqual( + IDLE_LOGOUT_STATES.INITIAL, + ); }); }); diff --git a/web-client/src/presenter/actions/handlePeerResetIdleTimerAction.ts b/web-client/src/presenter/actions/handlePeerResetIdleTimerAction.ts index b6ac62e2703..0bb1a11935c 100644 --- a/web-client/src/presenter/actions/handlePeerResetIdleTimerAction.ts +++ b/web-client/src/presenter/actions/handlePeerResetIdleTimerAction.ts @@ -1,3 +1,4 @@ +import { IDLE_LOGOUT_STATES } from '@shared/business/entities/EntityConstants'; import { state } from '@web-client/presenter/app.cerebral'; /** @@ -7,11 +8,11 @@ import { state } from '@web-client/presenter/app.cerebral'; */ export const handlePeerResetIdleTimerAction = ({ get, store }: ActionProps) => { const idleLogoutState = get(state.idleLogoutState); - if (idleLogoutState.state !== 'COUNTDOWN') { + if (idleLogoutState.state !== IDLE_LOGOUT_STATES.COUNTDOWN) { store.set(state.lastIdleAction, Date.now()); store.set(state.idleLogoutState, { logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }); } }; diff --git a/web-client/src/presenter/actions/isLoggedInAction.test.ts b/web-client/src/presenter/actions/isLoggedInAction.test.ts new file mode 100644 index 00000000000..16904df9827 --- /dev/null +++ b/web-client/src/presenter/actions/isLoggedInAction.test.ts @@ -0,0 +1,42 @@ +import { isLoggedInAction } from './isLoggedInAction'; +import { presenter } from '../presenter-mock'; +import { runAction } from '@web-client/presenter/test.cerebral'; + +describe('isLoggedInAction', () => { + let pathYesStub; + let pathNoStub; + + beforeEach(() => { + pathYesStub = jest.fn(); + pathNoStub = jest.fn(); + + presenter.providers.path = { + no: pathNoStub, + yes: pathYesStub, + }; + }); + + it('should say user is logged in when logged in', async () => { + await runAction(isLoggedInAction, { + modules: { + presenter, + }, + state: { + token: '1234', + }, + }); + + expect(pathYesStub).toHaveBeenCalled(); + }); + + it('should say user is not logged in when not logged in', async () => { + await runAction(isLoggedInAction, { + modules: { + presenter, + }, + state: {}, + }); + + expect(pathNoStub).toHaveBeenCalled(); + }); +}); diff --git a/web-client/src/presenter/actions/isLoggedInAction.ts b/web-client/src/presenter/actions/isLoggedInAction.ts new file mode 100644 index 00000000000..136d45d3d82 --- /dev/null +++ b/web-client/src/presenter/actions/isLoggedInAction.ts @@ -0,0 +1,8 @@ +import { state } from '@web-client/presenter/app.cerebral'; + +export const isLoggedInAction = ({ get, path }: ActionProps) => { + if (get(state.token)) { + return path.yes(); + } + return path.no(); +}; diff --git a/web-client/src/presenter/actions/resetIdleTimerAction.test.ts b/web-client/src/presenter/actions/resetIdleTimerAction.test.ts index 77aae849de6..134b9c1257b 100644 --- a/web-client/src/presenter/actions/resetIdleTimerAction.test.ts +++ b/web-client/src/presenter/actions/resetIdleTimerAction.test.ts @@ -1,3 +1,4 @@ +import { IDLE_LOGOUT_STATES } from '@shared/business/entities/EntityConstants'; import { resetIdleTimerAction } from './resetIdleTimerAction'; import { runAction } from '@web-client/presenter/test.cerebral'; describe('resetIdleTimerAction', () => { @@ -6,7 +7,7 @@ describe('resetIdleTimerAction', () => { state: { idleLogoutState: { logoutAt: 300, - state: 'MONITORING', + state: IDLE_LOGOUT_STATES.MONITORING, }, lastIdleAction: 23423, }, @@ -14,7 +15,7 @@ describe('resetIdleTimerAction', () => { expect(output.state).toMatchObject({ idleLogoutState: { logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }, lastIdleAction: expect.any(Number), }); diff --git a/web-client/src/presenter/actions/resetIdleTimerAction.ts b/web-client/src/presenter/actions/resetIdleTimerAction.ts index 2aa30bfe9f3..9728f1f63a0 100644 --- a/web-client/src/presenter/actions/resetIdleTimerAction.ts +++ b/web-client/src/presenter/actions/resetIdleTimerAction.ts @@ -1,3 +1,4 @@ +import { IDLE_LOGOUT_STATES } from '@shared/business/entities/EntityConstants'; import { state } from '@web-client/presenter/app.cerebral'; /** @@ -9,6 +10,6 @@ export const resetIdleTimerAction = ({ store }: ActionProps) => { store.set(state.lastIdleAction, Date.now()); store.set(state.idleLogoutState, { logoutAt: undefined, - state: 'INITIAL', + state: IDLE_LOGOUT_STATES.INITIAL, }); }; diff --git a/web-client/src/presenter/actions/setClientNeedsToRefresh.test.ts b/web-client/src/presenter/actions/setClientNeedsToRefresh.test.ts new file mode 100644 index 00000000000..da35c7f9bf3 --- /dev/null +++ b/web-client/src/presenter/actions/setClientNeedsToRefresh.test.ts @@ -0,0 +1,14 @@ +import { runAction } from '@web-client/presenter/test.cerebral'; +import { setClientNeedsToRefresh } from '@web-client/presenter/actions/setClientNeedsToRefresh'; + +describe('setClientNeedsToRefresh', () => { + it('should set state properly', async () => { + const { state } = await runAction(setClientNeedsToRefresh, { + state: { + clientNeedsToRefresh: false, + }, + }); + + expect(state.clientNeedsToRefresh).toEqual(true); + }); +}); diff --git a/web-client/src/presenter/actions/setClientNeedsToRefresh.ts b/web-client/src/presenter/actions/setClientNeedsToRefresh.ts new file mode 100644 index 00000000000..2e9ad2e4ae3 --- /dev/null +++ b/web-client/src/presenter/actions/setClientNeedsToRefresh.ts @@ -0,0 +1,5 @@ +import { state } from '@web-client/presenter/app.cerebral'; + +export const setClientNeedsToRefresh = ({ store }: ActionProps) => { + store.set(state.clientNeedsToRefresh, true); +}; diff --git a/web-client/src/presenter/actions/setLogoutTypeAction.test.ts b/web-client/src/presenter/actions/setLogoutTypeAction.test.ts new file mode 100644 index 00000000000..a6e57921b39 --- /dev/null +++ b/web-client/src/presenter/actions/setLogoutTypeAction.test.ts @@ -0,0 +1,14 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; +import { runAction } from '@web-client/presenter/test.cerebral'; +import { setLogoutTypeAction } from './setLogoutTypeAction'; + +describe('setLogoutTypeAction', () => { + it('sets state.logoutType when called', async () => { + const { state } = await runAction( + setLogoutTypeAction(BROADCAST_MESSAGES.userLogout), + {}, + ); + + expect(state.logoutType).toEqual(BROADCAST_MESSAGES.userLogout); + }); +}); diff --git a/web-client/src/presenter/actions/setLogoutTypeAction.ts b/web-client/src/presenter/actions/setLogoutTypeAction.ts new file mode 100644 index 00000000000..c1653b78598 --- /dev/null +++ b/web-client/src/presenter/actions/setLogoutTypeAction.ts @@ -0,0 +1,8 @@ +import { IdleLogoutType } from '@shared/business/entities/EntityConstants'; +import { state } from '@web-client/presenter/app.cerebral'; + +export const setLogoutTypeAction = + (logoutType: IdleLogoutType) => + ({ store }: ActionProps) => { + store.set(state.logoutType, logoutType); + }; diff --git a/web-client/src/presenter/computeds/showAppTimeoutModalHelper.test.ts b/web-client/src/presenter/computeds/showAppTimeoutModalHelper.test.ts index a6568fb6cf3..8ed98759924 100644 --- a/web-client/src/presenter/computeds/showAppTimeoutModalHelper.test.ts +++ b/web-client/src/presenter/computeds/showAppTimeoutModalHelper.test.ts @@ -1,3 +1,4 @@ +import { IDLE_LOGOUT_STATES } from '@shared/business/entities/EntityConstants'; import { runCompute } from '@web-client/presenter/test.cerebral'; import { showAppTimeoutModalHelper } from './showAppTimeoutModalHelper'; @@ -6,9 +7,9 @@ describe('showAppTimeoutModalHelper', () => { const result = runCompute(showAppTimeoutModalHelper, { state: { idleLogoutState: { - state: 'COUNTDOWN', + state: IDLE_LOGOUT_STATES.COUNTDOWN, }, - user: {}, + user: { userId: '123' }, }, }); @@ -18,10 +19,13 @@ describe('showAppTimeoutModalHelper', () => { it('does not show the modal due to no user', () => { const result = runCompute(showAppTimeoutModalHelper, { state: { + idleLogoutState: { + state: IDLE_LOGOUT_STATES.COUNTDOWN, + }, modal: { showModal: 'AppTimeoutModal', }, - user: undefined, + user: {}, }, }); diff --git a/web-client/src/presenter/computeds/showAppTimeoutModalHelper.ts b/web-client/src/presenter/computeds/showAppTimeoutModalHelper.ts index 243d92f9c91..7011db625b3 100644 --- a/web-client/src/presenter/computeds/showAppTimeoutModalHelper.ts +++ b/web-client/src/presenter/computeds/showAppTimeoutModalHelper.ts @@ -1,4 +1,5 @@ import { Get } from 'cerebral'; +import { IDLE_LOGOUT_STATES } from '@shared/business/entities/EntityConstants'; import { state } from '@web-client/presenter/app.cerebral'; export const showAppTimeoutModalHelper = (get: Get): any => { @@ -7,6 +8,7 @@ export const showAppTimeoutModalHelper = (get: Get): any => { return { currentUser, - showModal: modalState === 'COUNTDOWN', + showModal: + !!currentUser?.userId && modalState === IDLE_LOGOUT_STATES.COUNTDOWN, }; }; diff --git a/web-client/src/presenter/presenter.ts b/web-client/src/presenter/presenter.ts index 271858a65b1..b0c1626252b 100644 --- a/web-client/src/presenter/presenter.ts +++ b/web-client/src/presenter/presenter.ts @@ -181,7 +181,6 @@ import { gotoEditTrialSessionSequence } from './sequences/gotoEditTrialSessionSe import { gotoEditUploadCourtIssuedDocumentSequence } from './sequences/gotoEditUploadCourtIssuedDocumentSequence'; import { gotoFileDocumentSequence } from './sequences/gotoFileDocumentSequence'; import { gotoFilePetitionSuccessSequence } from './sequences/gotoFilePetitionSuccessSequence'; -import { gotoIdleLogoutSequence } from './sequences/gotoIdleLogoutSequence'; import { gotoJudgeActivityReportSequence } from './sequences/JudgeActivityReport/gotoJudgeActivityReportSequence'; import { gotoLoginSequence } from '@web-client/presenter/sequences/Login/gotoLoginSequence'; import { gotoMaintenanceSequence } from './sequences/gotoMaintenanceSequence'; @@ -221,6 +220,7 @@ import { gotoUserContactEditSequence } from './sequences/gotoUserContactEditSequ import { gotoVerifyEmailSequence } from './sequences/gotoVerifyEmailSequence'; import { gotoViewAllDocumentsSequence } from './sequences/gotoViewAllDocumentsSequence'; import { gotoWorkQueueSequence } from './sequences/gotoWorkQueueSequence'; +import { handleAppHasUpdatedSequence } from './sequences/handleAppHasUpdatedSequence'; import { handleIdleLogoutSequence } from './sequences/handleIdleLogoutSequence'; import { initAppSequence } from '@web-client/presenter/sequences/Init/initAppSequence'; import { initialState } from '@web-client/presenter/state'; @@ -417,7 +417,9 @@ import { showMoreResultsSequence } from './sequences/showMoreResultsSequence'; import { showPaperServiceProgressSequence } from './sequences/showPaperServiceProgressSequence'; import { showThirtyDayNoticeModalSequence } from './sequences/showThirtyDayNoticeModalSequence'; import { showViewPetitionerCounselModalSequence } from './sequences/showViewPetitionerCounselModalSequence'; +import { signOutIdleSequence } from './sequences/signOutIdleSequence'; import { signOutSequence } from './sequences/signOutSequence'; +import { signOutUserInitiatedSequence } from './sequences/signOutUserInitiatedSequence'; import { skipSigningOrderSequence } from './sequences/skipSigningOrderSequence'; import { sortTableSequence } from './sequences/sortTableSequence'; import { startRefreshIntervalSequence } from './sequences/startRefreshIntervalSequence'; @@ -900,7 +902,6 @@ export const presenterSequences = { gotoFileDocumentSequence: gotoFileDocumentSequence as unknown as Function, gotoFilePetitionSuccessSequence: gotoFilePetitionSuccessSequence as unknown as Function, - gotoIdleLogoutSequence: gotoIdleLogoutSequence as unknown as Function, gotoJudgeActivityReportSequence: gotoJudgeActivityReportSequence as unknown as Function, gotoLoginSequence, @@ -964,6 +965,7 @@ export const presenterSequences = { gotoViewAllDocumentsSequence: gotoViewAllDocumentsSequence as unknown as Function, gotoWorkQueueSequence: gotoWorkQueueSequence as unknown as Function, + handleAppHasUpdatedSequence, handleIdleLogoutSequence: handleIdleLogoutSequence as unknown as Function, initAppSequence, leaveCaseForLaterServiceSequence: @@ -1029,8 +1031,7 @@ export const presenterSequences = { openAddToTrialModalSequence as unknown as Function, openAppMaintenanceModalSequence: openAppMaintenanceModalSequence as unknown as Function, - openAppUpdatedModalSequence: - openAppUpdatedModalSequence as unknown as Function, + openAppUpdatedModalSequence, openBlockFromTrialModalSequence: openBlockFromTrialModalSequence as unknown as Function, openCancelDraftDocumentModalSequence: @@ -1298,7 +1299,9 @@ export const presenterSequences = { showThirtyDayNoticeModalSequence as unknown as Function, showViewPetitionerCounselModalSequence: showViewPetitionerCounselModalSequence as unknown as Function, + signOutIdleSequence, signOutSequence: signOutSequence as unknown as Function, + signOutUserInitiatedSequence, skipSigningOrderSequence: skipSigningOrderSequence as unknown as Function, sortTableSequence, startRefreshIntervalSequence: diff --git a/web-client/src/presenter/sequences/gotoIdleLogoutSequence.ts b/web-client/src/presenter/sequences/gotoIdleLogoutSequence.ts deleted file mode 100644 index 26319f2b8ce..00000000000 --- a/web-client/src/presenter/sequences/gotoIdleLogoutSequence.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { broadcastLogoutAction } from '../actions/broadcastLogoutAction'; -import { clearModalAction } from '../actions/clearModalAction'; -import { clearUserAction } from '../actions/clearUserAction'; -import { deleteAuthCookieAction } from '../actions/deleteAuthCookieAction'; -import { setupCurrentPageAction } from '../actions/setupCurrentPageAction'; - -export const gotoIdleLogoutSequence = [ - setupCurrentPageAction('Interstitial'), - deleteAuthCookieAction, - broadcastLogoutAction, - clearModalAction, - clearUserAction, - setupCurrentPageAction('IdleLogout'), -]; diff --git a/web-client/src/presenter/sequences/handleAppHasUpdatedSequence.ts b/web-client/src/presenter/sequences/handleAppHasUpdatedSequence.ts new file mode 100644 index 00000000000..41889e22fea --- /dev/null +++ b/web-client/src/presenter/sequences/handleAppHasUpdatedSequence.ts @@ -0,0 +1,11 @@ +import { broadcastAppUpdatedAction } from '@web-client/presenter/actions/broadcastAppUpdatedAction'; +import { clearRefreshTokenIntervalAction } from '@web-client/presenter/actions/clearRefreshTokenIntervalAction'; +import { openAppUpdatedModalSequence } from '@web-client/presenter/sequences/openAppUpdatedModalSequence'; +import { setClientNeedsToRefresh } from '@web-client/presenter/actions/setClientNeedsToRefresh'; + +export const handleAppHasUpdatedSequence = [ + setClientNeedsToRefresh, + clearRefreshTokenIntervalAction, // Clear the refresh token interval since all subsequent requests until refresh will fail + broadcastAppUpdatedAction, // Ensure consistent behavior across tabs + openAppUpdatedModalSequence, +] as unknown as (props: { skipBroadcast?: boolean }) => void; diff --git a/web-client/src/presenter/sequences/handleIdleLogoutSequence.ts b/web-client/src/presenter/sequences/handleIdleLogoutSequence.ts index ec777c5a9b6..3457425a4ff 100644 --- a/web-client/src/presenter/sequences/handleIdleLogoutSequence.ts +++ b/web-client/src/presenter/sequences/handleIdleLogoutSequence.ts @@ -1,10 +1,10 @@ -import { gotoIdleLogoutSequence } from '@web-client/presenter/sequences/gotoIdleLogoutSequence'; import { handleIdleLogoutAction } from '@web-client/presenter/actions/handleIdleLogoutAction'; +import { signOutIdleSequence } from '@web-client/presenter/sequences/signOutIdleSequence'; export const handleIdleLogoutSequence = [ handleIdleLogoutAction, { continue: [], - logout: gotoIdleLogoutSequence, + logout: signOutIdleSequence, }, ]; diff --git a/web-client/src/presenter/sequences/openAppUpdatedModalSequence.ts b/web-client/src/presenter/sequences/openAppUpdatedModalSequence.ts index 3e32655a5ac..362bb0b3d97 100644 --- a/web-client/src/presenter/sequences/openAppUpdatedModalSequence.ts +++ b/web-client/src/presenter/sequences/openAppUpdatedModalSequence.ts @@ -4,4 +4,4 @@ import { setShowModalFactoryAction } from '../actions/setShowModalFactoryAction' export const openAppUpdatedModalSequence = [ clearModalStateAction, setShowModalFactoryAction('AppUpdatedModal'), -]; +] as unknown as () => void; diff --git a/web-client/src/presenter/sequences/signOutIdleSequence.ts b/web-client/src/presenter/sequences/signOutIdleSequence.ts new file mode 100644 index 00000000000..1b6fb8d644a --- /dev/null +++ b/web-client/src/presenter/sequences/signOutIdleSequence.ts @@ -0,0 +1,32 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; +import { checkClientNeedsToRefresh } from '@web-client/presenter/actions/checkClientNeedsToRefresh'; +import { isLoggedInAction } from '@web-client/presenter/actions/isLoggedInAction'; +import { setLogoutTypeAction } from '@web-client/presenter/actions/setLogoutTypeAction'; +import { setupCurrentPageAction } from '../actions/setupCurrentPageAction'; +import { signOutSequence } from '@web-client/presenter/sequences/signOutSequence'; + +// The sequence to call when the user is forced to sign out due to idle activity +export const signOutIdleSequence = [ + isLoggedInAction, + { + // To avoid race conditions, we ignore redundant calls to sign out that arise when + // multiple tabs broadcast the idle sign out event. + no: [], + yes: [ + checkClientNeedsToRefresh, + { + clientDoesNotNeedToRefresh: [ + setLogoutTypeAction(BROADCAST_MESSAGES.idleLogout), + signOutSequence, + setupCurrentPageAction('IdleLogout'), + ], + // If the client needs to refresh, the sign-out will fail, + // and this can lead to inconsistent front-end behavior. + clientNeedsToRefresh: [], + }, + ], + }, +] as unknown as (props: { + skipBroadcast?: boolean; + fromModal?: boolean; +}) => void; diff --git a/web-client/src/presenter/sequences/signOutSequence.ts b/web-client/src/presenter/sequences/signOutSequence.ts index c36b399c98f..709b012240b 100644 --- a/web-client/src/presenter/sequences/signOutSequence.ts +++ b/web-client/src/presenter/sequences/signOutSequence.ts @@ -1,10 +1,11 @@ import { broadcastLogoutAction } from '../actions/broadcastLogoutAction'; import { clearAlertsAction } from '../actions/clearAlertsAction'; import { clearLoginFormAction } from '../actions/clearLoginFormAction'; +import { clearLogoutTypeAction } from '@web-client/presenter/actions/clearLogoutTypeAction'; import { clearMaintenanceModeAction } from '../actions/clearMaintenanceModeAction'; import { clearUserAction } from '../actions/clearUserAction'; import { deleteAuthCookieAction } from '../actions/deleteAuthCookieAction'; -import { navigateToLoginSequence } from '@web-client/presenter/sequences/Login/navigateToLoginSequence'; +import { resetIdleTimerAction } from '@web-client/presenter/actions/resetIdleTimerAction'; import { setupCurrentPageAction } from '../actions/setupCurrentPageAction'; import { stopWebSocketConnectionAction } from '../actions/WebSocketConnection/stopWebSocketConnectionAction'; @@ -17,5 +18,6 @@ export const signOutSequence = [ clearUserAction, clearMaintenanceModeAction, clearLoginFormAction, - navigateToLoginSequence, + clearLogoutTypeAction, + resetIdleTimerAction, ]; diff --git a/web-client/src/presenter/sequences/signOutUserInitiatedSequence.ts b/web-client/src/presenter/sequences/signOutUserInitiatedSequence.ts new file mode 100644 index 00000000000..11b98db2233 --- /dev/null +++ b/web-client/src/presenter/sequences/signOutUserInitiatedSequence.ts @@ -0,0 +1,14 @@ +import { BROADCAST_MESSAGES } from '@shared/business/entities/EntityConstants'; +import { navigateToLoginSequence } from '@web-client/presenter/sequences/Login/navigateToLoginSequence'; +import { setLogoutTypeAction } from '@web-client/presenter/actions/setLogoutTypeAction'; +import { signOutSequence } from '@web-client/presenter/sequences/signOutSequence'; + +// The sequence to call when the user voluntarily decides to sign out +export const signOutUserInitiatedSequence = [ + setLogoutTypeAction(BROADCAST_MESSAGES.userLogout), + signOutSequence, + navigateToLoginSequence, +] as unknown as (props: { + skipBroadcast?: boolean; + fromModal?: boolean; +}) => void; diff --git a/web-client/src/presenter/state.ts b/web-client/src/presenter/state.ts index 700e2df412f..7934d399339 100644 --- a/web-client/src/presenter/state.ts +++ b/web-client/src/presenter/state.ts @@ -1,6 +1,10 @@ /* eslint-disable max-lines */ import { FormattedPendingMotionWithWorksheet } from '@web-api/business/useCases/pendingMotion/getPendingMotionDocketEntriesForCurrentJudgeInteractor'; import { GetCasesByStatusAndByJudgeResponse } from '@web-api/business/useCases/judgeActivityReport/getCaseWorksheetsByJudgeInteractor'; +import { + IDLE_LOGOUT_STATES, + IdleLogoutStateType, +} from '@shared/business/entities/EntityConstants'; import { IrsNoticeForm } from '@shared/business/entities/startCase/IrsNoticeForm'; import { JudgeActivityReportState } from '@web-client/ustc-ui/Utils/types'; import { RawCaseDeadline } from '@shared/business/entities/CaseDeadline'; @@ -612,6 +616,7 @@ export const baseState = { caseDeadlines: [] as RawCaseDeadline[], caseDetail: {} as RawCase, clientConnectionId: '', + clientNeedsToRefresh: false, closedCases: [] as TAssociatedCase[], cognito: {} as any, coldCaseReport: { @@ -674,7 +679,7 @@ export const baseState = { health: undefined as any, idleLogoutState: { logoutAt: undefined, - state: 'INITIAL' as 'INITIAL' | 'MONITORING' | 'COUNTDOWN', + state: IDLE_LOGOUT_STATES.INITIAL as IdleLogoutStateType, }, idleStatus: IDLE_STATUS.ACTIVE, iframeSrc: '', @@ -691,6 +696,7 @@ export const baseState = { lastIdleAction: undefined, legacyAndCurrentJudges: [], login: {} as any, + logoutType: '', maintenanceMode: false, messages: [] as RawMessage[], messagesInboxCount: 0, diff --git a/web-client/src/router.ts b/web-client/src/router.ts index ada90955d3d..78e8cf7ac8a 100644 --- a/web-client/src/router.ts +++ b/web-client/src/router.ts @@ -1161,7 +1161,14 @@ const router = { ); registerRoute('/idle-logout', () => { - return app.getSequence('gotoIdleLogoutSequence')(); + if (app.getState('token')) { + return app.getSequence('signOutIdleSequence')(); + } else { + // If not signed in, saying "we logged you off" doesn't make sense + return app.getSequence('navigateToPathSequence')({ + path: BASE_ROUTE, + }); + } }); registerRoute('/login', () => { diff --git a/web-client/src/views/AppTimeoutModal.tsx b/web-client/src/views/AppTimeoutModal.tsx index ffc729910d9..5459f611566 100644 --- a/web-client/src/views/AppTimeoutModal.tsx +++ b/web-client/src/views/AppTimeoutModal.tsx @@ -14,7 +14,7 @@ export const AppTimeoutModal = connect( confirmLabel="Yes!" confirmSequence={confirmSequence} > -
Are you still there?
+
Are you still there?
); }, diff --git a/web-client/src/views/Header/AccountMenu.tsx b/web-client/src/views/Header/AccountMenu.tsx index 74de7709167..5d3df390de1 100644 --- a/web-client/src/views/Header/AccountMenu.tsx +++ b/web-client/src/views/Header/AccountMenu.tsx @@ -10,14 +10,14 @@ export const AccountMenu = connect( { headerHelper: state.headerHelper, navigateToPathSequence: sequences.navigateToPathSequence, - signOutSequence: sequences.signOutSequence, + signOutUserInitiatedSequence: sequences.signOutUserInitiatedSequence, toggleMenuSequence: sequences.toggleMenuSequence, }, function AccountMenu({ headerHelper, isExpanded, navigateToPathSequence, - signOutSequence, + signOutUserInitiatedSequence, toggleMenuSequence, }) { return ( @@ -71,7 +71,7 @@ export const AccountMenu = connect( className="account-menu-item usa-button usa-button--unstyled" data-testid="logout-button-desktop" id="log-out" - onClick={() => signOutSequence()} + onClick={() => signOutUserInitiatedSequence({})} > Log Out diff --git a/web-client/src/views/Header/Header.tsx b/web-client/src/views/Header/Header.tsx index 0fa6475a1fb..dd6f640f604 100644 --- a/web-client/src/views/Header/Header.tsx +++ b/web-client/src/views/Header/Header.tsx @@ -44,7 +44,7 @@ const NavigationItems = ( isDocumentQCMenuOpen, isMessagesMenuOpen, isReportsMenuOpen, - signOutSequence, + signOutUserInitiatedSequence, toggleMobileMenuSequence, }, ) => { @@ -205,7 +205,7 @@ const NavigationItems = ( id="log-out" onClick={() => { toggleMobileMenuSequence(); - signOutSequence(); + signOutUserInitiatedSequence(); }} > Log Out @@ -221,7 +221,7 @@ export const Header = connect( menuHelper: state.menuHelper, resetHeaderAccordionsSequence: sequences.resetHeaderAccordionsSequence, showMobileMenu: state.header.showMobileMenu, - signOutSequence: sequences.signOutSequence, + signOutUserInitiatedSequence: sequences.signOutUserInitiatedSequence, templateHelper: state.templateHelper, toggleBetaBarSequence: sequences.toggleBetaBarSequence, toggleMobileMenuSequence: sequences.toggleMobileMenuSequence, @@ -231,7 +231,7 @@ export const Header = connect( menuHelper, resetHeaderAccordionsSequence, showMobileMenu, - signOutSequence, + signOutUserInitiatedSequence, templateHelper, toggleBetaBarSequence, toggleMobileMenuSequence, @@ -316,7 +316,7 @@ export const Header = connect( isDocumentQCMenuOpen: menuHelper.isDocumentQCMenuOpen, isMessagesMenuOpen: menuHelper.isMessagesMenuOpen, isReportsMenuOpen: menuHelper.isReportsMenuOpen, - signOutSequence, + signOutUserInitiatedSequence, toggleMobileMenuSequence, })} {headerHelper.showSearchInHeader && } diff --git a/web-client/src/views/IdleActivityMonitor.tsx b/web-client/src/views/IdleActivityMonitor.tsx index e70e3ec76b4..dc3bae4529a 100644 --- a/web-client/src/views/IdleActivityMonitor.tsx +++ b/web-client/src/views/IdleActivityMonitor.tsx @@ -9,6 +9,7 @@ export const IdleActivityMonitor = connect( { broadcastIdleStatusActiveSequence: sequences.broadcastIdleStatusActiveSequence, + clientNeedsToRefresh: state.clientNeedsToRefresh, constants: state.constants, handleIdleLogoutSequence: sequences.handleIdleLogoutSequence, lastIdleAction: state.lastIdleAction, @@ -17,6 +18,7 @@ export const IdleActivityMonitor = connect( }, function IdleActivityMonitor({ broadcastIdleStatusActiveSequence, + clientNeedsToRefresh, constants, handleIdleLogoutSequence, lastIdleAction, @@ -42,6 +44,18 @@ export const IdleActivityMonitor = connect( }); useEffect(() => { + // Broadcast activity as soon as a new tab loads to keep idle sign in times in sync across browser tabs. + // Also, dismiss modals in other, potentially unseen tabs to ensure + // unseen tabs do not trigger a surprise logout in the current tab. + broadcastIdleStatusActiveSequence({ closeModal: true }); + }, []); + + useEffect(() => { + // The user needs to refresh, so stop tracking idle timeout + if (clientNeedsToRefresh) { + return; + } + const interval = setInterval(() => { handleIdleLogoutSequence(); }, 1000); diff --git a/web-client/src/views/IdleLogout.tsx b/web-client/src/views/IdleLogout.tsx index 1ec9128e56d..450366e4c28 100644 --- a/web-client/src/views/IdleLogout.tsx +++ b/web-client/src/views/IdleLogout.tsx @@ -4,7 +4,9 @@ import { sequences } from '@web-client/presenter/app.cerebral'; import React from 'react'; export const IdleLogout = connect( - { navigateToLoginSequence: sequences.navigateToLoginSequence }, + { + navigateToLoginSequence: sequences.navigateToLoginSequence, + }, function IdleLogout({ navigateToLoginSequence }) { return (
@@ -16,7 +18,14 @@ export const IdleLogout = connect( United States Tax Court website for information on court services and contact information.

- +