From 9a7b8d499844dd2abb8c60d65d6f432bc274f209 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 22 Jul 2024 16:50:04 -0400 Subject: [PATCH] STCOR-862 terminate session when fixed-length session expires (#1503) RTR may be implemented such that each refresh extends the session by a fixed interval, or the session-length may be fixed causing the RT TTL to gradually shrink until the session ends and the user is forced to re-authenticate. This PR implements handling for the latter scenario, showing a non-interactive "this session will expire" banner before the session expires and then redirecting to `/logout` to clear out session data. By default the warning is visible for one minute. It may be changed at build-time by setting the `stripes.config.js` value `config.rtr.fixedLengthSessionWarningTTL` to any value parseable by `ms()`, e.g. `30s`, `1m`, `1h`. Cache the current path in session storage prior to a timeout-logout, allowing the user to return directly to that page when re-authenticating. The "interesting" bits are mostly in `FFetch` where, in addition to scheduling AT rotation, there are two new `setTimer()` calls to dispatch the FLS-warning and FLS-timeout events. Handlers for these are events are located with other RTR event handlers in `SessionEventContainer`. There are corresponding reducer functions in `okapiActions`. Both it and `okapiReducer` were refactored to use constants instead of strings for their action-types. The refactor is otherwise insignificant. Refs STCOR-862 (cherry picked from commit 8b5274e7a2070ec54a3fd30cfc9f375b024cd29e) --- CHANGELOG.md | 2 +- src/components/AuthnLogin/AuthnLogin.js | 21 +++- src/components/Root/FFetch.js | 110 +++++++++++++----- src/components/Root/FFetch.test.js | 96 +++++++++++++-- src/components/Root/Root.js | 10 ++ src/components/Root/constants.js | 34 +++++- src/components/Root/token-util.js | 7 ++ .../FixedLengthSessionWarning.js | 54 +++++++++ .../FixedLengthSessionWarning.test.js | 59 ++++++++++ .../SessionEventContainer.js | 50 ++++++-- .../SessionEventContainer.test.js | 6 +- src/loginServices.js | 7 +- src/okapiActions.js | 68 +++++++---- src/okapiReducer.js | 97 ++++++++++----- src/okapiReducer.test.js | 49 ++++++-- translations/stripes-core/en.json | 5 +- translations/stripes-core/en_US.json | 4 +- 17 files changed, 553 insertions(+), 126 deletions(-) create mode 100644 src/components/SessionEventContainer/FixedLengthSessionWarning.js create mode 100644 src/components/SessionEventContainer/FixedLengthSessionWarning.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 528c5811f..1ecadea6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,5 @@ # Change history for stripes-core ->>>>>>> f93f21d6 (STCOR-866 include `/users-keycloak/_self` in auth-n requests (#1502)) ## [10.1.1](https://github.com/folio-org/stripes-core/tree/v10.1.1) (2024-03-25) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.0...v10.1.1) @@ -10,6 +9,7 @@ * Always retrieve `clientId` and `tenant` values from `config.tenantOptions` in stripes.config.js. Retires `okapi.tenant`, `okapi.clientId`, and `config.isSingleTenant`. Refs STCOR-787. * Correctly evaluate `stripes.okapi` before rendering ``. Refs STCOR-864. * `/users-keycloak/_self` is an authentication request. Refs STCOR-866. +* Terminate the session when the fixed-length session expires. Refs STCOR-862. ## [10.1.0](https://github.com/folio-org/stripes-core/tree/v10.1.0) (2024-03-12) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.1.0) diff --git a/src/components/AuthnLogin/AuthnLogin.js b/src/components/AuthnLogin/AuthnLogin.js index 0b005de14..093c3db68 100644 --- a/src/components/AuthnLogin/AuthnLogin.js +++ b/src/components/AuthnLogin/AuthnLogin.js @@ -19,10 +19,23 @@ const AuthnLogin = ({ stripes }) => { }; useEffect(() => { - if (okapi.authnUrl) { - /** Store unauthorized pathname to session storage. Refs STCOR-789 - * @see OIDCRedirect - */ + /** + * Cache the current path so we can return to it after authenticating. + * In RootWithIntl, unauthenticated visits to protected paths will be + * handled by this component, i.e. + * /some-interesting-path + * but if the user was de-authenticated due to a session timeout, they + * will have a history something like + * /some-interesting-path + * /logout + * / + * but we still want to return to /some-interesting-path, which will + * have been cached by the logout-timeout handler, and must not be + * overwritten here. + * + * @see OIDCRedirect + */ + if (okapi.authnUrl && window.location.pathname !== '/') { setUnauthorizedPathToSession(window.location.pathname); } diff --git a/src/components/Root/FFetch.js b/src/components/Root/FFetch.js index 90eade94e..a689523f8 100644 --- a/src/components/Root/FFetch.js +++ b/src/components/Root/FFetch.js @@ -44,7 +44,8 @@ import ms from 'ms'; import { okapi as okapiConfig } from 'stripes-config'; import { - setRtrTimeout + setRtrTimeout, + setRtrFlsTimeout, } from '../../okapiActions'; import { getTokenExpiry } from '../../loginServices'; @@ -62,6 +63,9 @@ import { RTR_AT_EXPIRY_IF_UNKNOWN, RTR_AT_TTL_FRACTION, RTR_ERROR_EVENT, + RTR_FLS_TIMEOUT_EVENT, + RTR_FLS_WARNING_EVENT, + RTR_RT_EXPIRY_IF_UNKNOWN, } from './constants'; import FXHR from './FXHR'; @@ -71,9 +75,10 @@ const OKAPI_FETCH_OPTIONS = { }; export class FFetch { - constructor({ logger, store }) { + constructor({ logger, store, rtrConfig }) { this.logger = logger; this.store = store; + this.rtrConfig = rtrConfig; } /** @@ -92,6 +97,52 @@ export class FFetch { global.XMLHttpRequest = FXHR(this); }; + /** + * scheduleRotation + * Given a promise that resolves with timestamps for the AT's and RT's + * expiration, configure relevant corresponding timers: + * * before the AT expires, conduct RTR + * * when the RT is about to expire, send a "session will end" event + * * when the RT expires, send a "session ended" event" + * + * @param {Promise} rotationP + */ + scheduleRotation = (rotationP) => { + rotationP.then((rotationInterval) => { + // AT refresh interval: a large fraction of the actual AT TTL + const atInterval = (rotationInterval.accessTokenExpiration - Date.now()) * RTR_AT_TTL_FRACTION; + + // RT timeout interval (session will end) and warning interval (warning that session will end) + const rtTimeoutInterval = (rotationInterval.refreshTokenExpiration - Date.now()); + const rtWarningInterval = (rotationInterval.refreshTokenExpiration - Date.now()) - ms(this.rtrConfig.fixedLengthSessionWarningTTL); + + // schedule AT rotation IFF the AT will expire before the RT. this avoids + // refresh-thrashing near the end of the FLS with progressively shorter + // AT TTL windows. + if (rotationInterval.accessTokenExpiration < rotationInterval.refreshTokenExpiration) { + this.logger.log('rtr', `rotation scheduled from rotateCallback; next callback in ${ms(atInterval)}`); + this.store.dispatch(setRtrTimeout(setTimeout(() => { + const { okapi } = this.store.getState(); + rtr(this.nativeFetch, this.logger, this.rotateCallback, okapi); + }, atInterval))); + } else { + this.logger.log('rtr', 'rotation canceled; AT and RT will expire simultaneously'); + } + + // schedule FLS end-of-session warning + this.logger.log('rtr-fls', `end-of-session warning at ${new Date(rotationInterval.refreshTokenExpiration - ms(this.rtrConfig.fixedLengthSessionWarningTTL))}`); + this.store.dispatch(setRtrFlsTimeout(setTimeout(() => { + window.dispatchEvent(new Event(RTR_FLS_WARNING_EVENT)); + }, rtWarningInterval))); + + // schedule FLS end-of-session logout + this.logger.log('rtr-fls', `session will end at ${new Date(rotationInterval.refreshTokenExpiration)}`); + setTimeout(() => { + window.dispatchEvent(new Event(RTR_FLS_TIMEOUT_EVENT)); + }, rtTimeoutInterval); + }); + }; + /** * rotateCallback * Set a timeout to rotate the AT before it expires. Stash the timer-id @@ -106,44 +157,47 @@ export class FFetch { * where the values are ISO-8601 datestamps like YYYY-MM-DDTHH:mm:ssZ */ rotateCallback = (res) => { - this.logger.log('rtr', 'rotation callback setup'); - - const scheduleRotation = (rotationP) => { - rotationP.then((rotationInterval) => { - this.logger.log('rtr', `rotation fired from rotateCallback; next callback in ${ms(rotationInterval)}`); - this.store.dispatch(setRtrTimeout(setTimeout(() => { - const { okapi } = this.store.getState(); - rtr(this.nativeFetch, this.logger, this.rotateCallback, okapi); - }, rotationInterval))); - }); - }; + this.logger.log('rtr', 'rotation callback setup', res); // When starting a new session, the response from /bl-users/login-with-expiry - // will contain AT expiration info, but when restarting an existing session, + // will contain token expiration info, but when restarting an existing session, // the response from /bl-users/_self will NOT, although that information should - // have been cached in local-storage. - // - // This means there are many places we have to check to figure out when the - // AT is likely to expire, and thus when we want to rotate. First inspect - // the response, then the session, then default to 10 seconds. + // have been cached in local-storage. Thus, we check the following places for + // token expiration data: + // 1. response + // 2. session storage + // 3. hard-coded default if (res?.accessTokenExpiration) { this.logger.log('rtr', 'rotation scheduled with login response data'); - const rotationPromise = Promise.resolve((new Date(res.accessTokenExpiration).getTime() - Date.now()) * RTR_AT_TTL_FRACTION); + const rotationPromise = Promise.resolve({ + accessTokenExpiration: new Date(res.accessTokenExpiration).getTime(), + refreshTokenExpiration: new Date(res.refreshTokenExpiration).getTime(), + }); - scheduleRotation(rotationPromise); + this.scheduleRotation(rotationPromise); } else { const rotationPromise = getTokenExpiry().then((expiry) => { - if (expiry?.atExpires) { - this.logger.log('rtr', 'rotation scheduled with cached session data'); - return (new Date(expiry.atExpires).getTime() - Date.now()) * RTR_AT_TTL_FRACTION; + if (expiry?.atExpires && expiry?.atExpires >= Date.now()) { + this.logger.log('rtr', 'rotation scheduled with cached session data', expiry); + return { + accessTokenExpiration: new Date(expiry.atExpires).getTime(), + refreshTokenExpiration: new Date(expiry.rtExpires).getTime(), + }; } - // default: 10 seconds + // default: session data was corrupt but the resume-session request + // succeeded so we know the cookies were valid at the time. short-term + // expiry-values will kick off RTR in the very new future, allowing us + // to grab values from the response. this.logger.log('rtr', 'rotation scheduled with default value'); - return ms(RTR_AT_EXPIRY_IF_UNKNOWN); + + return { + accessTokenExpiration: Date.now() + ms(RTR_AT_EXPIRY_IF_UNKNOWN), + refreshTokenExpiration: Date.now() + ms(RTR_RT_EXPIRY_IF_UNKNOWN), + }; }); - scheduleRotation(rotationPromise); + this.scheduleRotation(rotationPromise); } } @@ -180,7 +234,7 @@ export class FFetch { // on authentication, grab the response to kick of the rotation cycle, // then return the response if (isAuthenticationRequest(resource, okapiConfig.url)) { - this.logger.log('rtr', 'authn request'); + this.logger.log('rtr', 'authn request', resource); return this.nativeFetch.apply(global, [resource, options && { ...options, ...OKAPI_FETCH_OPTIONS }]) .then(res => { // a response can only be read once, so we clone it to grab the diff --git a/src/components/Root/FFetch.test.js b/src/components/Root/FFetch.test.js index 8ada9a7c4..048dce81b 100644 --- a/src/components/Root/FFetch.test.js +++ b/src/components/Root/FFetch.test.js @@ -10,6 +10,7 @@ import { RTRError, UnexpectedResourceError } from './Errors'; import { RTR_AT_EXPIRY_IF_UNKNOWN, RTR_AT_TTL_FRACTION, + RTR_FLS_WARNING_TTL, } from './constants'; jest.mock('../../loginServices', () => ({ @@ -159,6 +160,7 @@ describe('FFetch class', () => { // a static timestamp of when the AT will expire, in the future // this value will be pushed into the response returned from the fetch const accessTokenExpiration = whatTimeIsItMrFox + 5000; + const refreshTokenExpiration = whatTimeIsItMrFox + ms('20m'); const st = jest.spyOn(window, 'setTimeout'); @@ -168,7 +170,7 @@ describe('FFetch class', () => { const cloneJson = jest.fn(); const clone = () => ({ ok: true, - json: () => Promise.resolve({ tokenExpiration: { accessTokenExpiration } }) + json: () => Promise.resolve({ tokenExpiration: { accessTokenExpiration, refreshTokenExpiration } }) }); mockFetch.mockResolvedValueOnce({ @@ -181,7 +183,10 @@ describe('FFetch class', () => { logger: { log }, store: { dispatch: jest.fn(), - } + }, + rtrConfig: { + fixedLengthSessionWarningTTL: '1m', + }, }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -193,7 +198,15 @@ describe('FFetch class', () => { // gross, but on the other, since we're deliberately pushing rotation // into a separate thread, I'm note sure of a better way to handle this. await setTimeout(Promise.resolve(), 2000); + + // AT rotation expect(st).toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION); + + // FLS warning + expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox) - ms(RTR_FLS_WARNING_TTL)); + + // FLS timeout + expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox)); }); it('handles RTR data in the session', async () => { @@ -203,10 +216,11 @@ describe('FFetch class', () => { // a static timestamp of when the AT will expire, in the future // this value will be retrieved from local storage via getTokenExpiry const atExpires = whatTimeIsItMrFox + 5000; + const rtExpires = whatTimeIsItMrFox + 15000; const st = jest.spyOn(window, 'setTimeout'); - getTokenExpiry.mockResolvedValue({ atExpires }); + getTokenExpiry.mockResolvedValue({ atExpires, rtExpires }); Date.now = () => whatTimeIsItMrFox; const cloneJson = jest.fn(); @@ -225,7 +239,10 @@ describe('FFetch class', () => { logger: { log }, store: { dispatch: jest.fn(), - } + }, + rtrConfig: { + fixedLengthSessionWarningTTL: '1m', + }, }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -260,7 +277,10 @@ describe('FFetch class', () => { logger: { log }, store: { dispatch: jest.fn(), - } + }, + rtrConfig: { + fixedLengthSessionWarningTTL: '1m', + }, }); testFfetch.replaceFetch(); testFfetch.replaceXMLHttpRequest(); @@ -270,10 +290,10 @@ describe('FFetch class', () => { // promise in a separate thread fired off by setTimout, and we need to // give it the chance to complete. on the one hand, this feels super // gross, but on the other, since we're deliberately pushing rotation - // into a separate thread, I'm note sure of a better way to handle this. + // into a separate thread, I'm not sure of a better way to handle this. await setTimeout(Promise.resolve(), 2000); - expect(st).toHaveBeenCalledWith(expect.any(Function), ms(RTR_AT_EXPIRY_IF_UNKNOWN)); + expect(st).toHaveBeenCalledWith(expect.any(Function), ms(RTR_AT_EXPIRY_IF_UNKNOWN) * RTR_AT_TTL_FRACTION); }); it('handles unsuccessful responses', async () => { @@ -305,6 +325,62 @@ describe('FFetch class', () => { expect(mockFetch.mock.calls).toHaveLength(1); expect(cloneJson).not.toHaveBeenCalled(); }); + + it('avoids rotation when AT and RT expire together', async () => { + // a static timestamp representing "now" + const whatTimeIsItMrFox = 1718042609734; + + // a static timestamp of when the AT will expire, in the future + // this value will be pushed into the response returned from the fetch + const accessTokenExpiration = whatTimeIsItMrFox + 5000; + const refreshTokenExpiration = accessTokenExpiration; + + const st = jest.spyOn(window, 'setTimeout'); + + // dummy date data: assume session + Date.now = () => whatTimeIsItMrFox; + + const cloneJson = jest.fn(); + const clone = () => ({ + ok: true, + json: () => Promise.resolve({ tokenExpiration: { accessTokenExpiration, refreshTokenExpiration } }) + }); + + mockFetch.mockResolvedValueOnce({ + ok: false, + clone, + }); + + mockFetch.mockResolvedValueOnce('okapi success'); + const testFfetch = new FFetch({ + logger: { log }, + store: { + dispatch: jest.fn(), + }, + rtrConfig: { + fixedLengthSessionWarningTTL: '1m', + }, + }); + testFfetch.replaceFetch(); + testFfetch.replaceXMLHttpRequest(); + + const response = await global.fetch('okapiUrl/bl-users/_self', { testOption: 'test' }); + // why this extra await/setTimeout? Because RTR happens in an un-awaited + // promise in a separate thread fired off by setTimout, and we need to + // give it the chance to complete. on the one hand, this feels super + // gross, but on the other, since we're deliberately pushing rotation + // into a separate thread, I'm note sure of a better way to handle this. + await setTimeout(Promise.resolve(), 2000); + + // AT rotation + expect(st).not.toHaveBeenCalledWith(expect.any(Function), (accessTokenExpiration - whatTimeIsItMrFox) * RTR_AT_TTL_FRACTION); + + // FLS warning + expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox) - ms(RTR_FLS_WARNING_TTL)); + + // FLS timeout + expect(st).toHaveBeenCalledWith(expect.any(Function), (refreshTokenExpiration - whatTimeIsItMrFox)); + }); }); @@ -387,7 +463,7 @@ describe('FFetch class', () => { .mockResolvedValueOnce(new Response( JSON.stringify({ errors: ['missing token-getting ability'] }), { - status: 303, + status: 403, headers: { 'content-type': 'application/json', } @@ -417,7 +493,7 @@ describe('FFetch class', () => { .mockResolvedValueOnce(new Response( JSON.stringify({ errors: ['missing token-getting ability'] }), { - status: 303, + status: 403, headers: { 'content-type': 'application/json', } @@ -447,7 +523,7 @@ describe('FFetch class', () => { .mockResolvedValueOnce(new Response( JSON.stringify({ errors: ['missing token-getting ability'] }), { - status: 303, + status: 403, headers: { 'content-type': 'application/json', } diff --git a/src/components/Root/Root.js b/src/components/Root/Root.js index cddcaedae..52094d172 100644 --- a/src/components/Root/Root.js +++ b/src/components/Root/Root.js @@ -71,9 +71,12 @@ class Root extends Component { // * configure fetch and xhr interceptors to conduct RTR // * see SessionEventContainer for RTR handling if (this.props.config.useSecureTokens) { + const rtrConfig = configureRtr(this.props.config.rtr); + this.ffetch = new FFetch({ logger: this.props.logger, store, + rtrConfig, }); this.ffetch.replaceFetch(); this.ffetch.replaceXMLHttpRequest(); @@ -124,6 +127,7 @@ class Root extends Component { const { logger, store, epics, config, okapi, actionNames, token, isAuthenticated, disableAuth, currentUser, currentPerms, locale, defaultTranslations, timezone, currency, plugins, bindings, discovery, translations, history, serverDown } = this.props; if (serverDown) { + // note: this isn't i18n'ed because we haven't rendered an IntlProvider yet. return
Error: server is down.
; } @@ -133,6 +137,12 @@ class Root extends Component { } // make sure RTR is configured + // gross: this overwrites whatever is currently stored at config.rtr + // gross: technically, this may be different than what is configured + // in the constructor since the constructor only runs once but + // render runs when props change. realistically, that'll never happen + // since config values are read only once from a static file at build + // time, but still, props are props so technically it's possible. config.rtr = configureRtr(this.props.config.rtr); const stripes = new Stripes({ diff --git a/src/components/Root/constants.js b/src/components/Root/constants.js index 771467234..1ec4b5623 100644 --- a/src/components/Root/constants.js +++ b/src/components/Root/constants.js @@ -9,10 +9,34 @@ export const RTR_ERROR_EVENT = '@folio/stripes/core::RTRError'; */ export const RTR_TIMEOUT_EVENT = '@folio/stripes/core::RTRIdleSessionTimeout'; +/** dispatched when the fixed-length session is about to end */ +export const RTR_FLS_WARNING_EVENT = '@folio/stripes/core::RTRFLSWarning'; + +/** dispatched when the fixed-length session ends */ +export const RTR_FLS_TIMEOUT_EVENT = '@folio/stripes/core::RTRFLSTimeout'; + +/** + * how long is the FLS warning visible? + * When a fixed-length session expires, the session ends immediately and the + * user is forcibly logged out. This interval describes how much warning they + * get before the session ends. + * + * overridden in stripes.configs.js::config.rtr.fixedLengthSessionWarningTTL + * value must be a string parsable by ms() + */ +export const RTR_FLS_WARNING_TTL = '1m'; + /** BroadcastChannel for cross-window activity pings */ export const RTR_ACTIVITY_CHANNEL = '@folio/stripes/core::RTRActivityChannel'; -/** how much of an AT's lifespan can elapse before it is considered expired */ +/** + * how much of a token's lifespan can elapse before it is considered expired? + * For the AT, we want a very safe margin because we don't ever want to fall + * off the end of the AT since it would be a very misleading failure given + * the RT is still good at that point. Since rotation happens in the background + * (i.e. it isn't a user-visible feature), rotating early has no user-visible + * impact. + */ export const RTR_AT_TTL_FRACTION = 0.8; /** @@ -56,8 +80,10 @@ export const RTR_IDLE_MODAL_TTL = '1m'; * token-expiration data in its response * 3. the session _should_ contain a value, but maybe the session * was corrupt. - * Given the resume-session API call succeeded, we know the AT must have been - * valid at the time, so we punt and schedule rotation in the future by this - * (relatively short) interval. + * Given the resume-session API call succeeded, we know the tokens were valid + * at the time so we punt and schedule rotation in the very near future because + * the rotation-response _will_ contain token-expiration values we can use to + * replace these. */ export const RTR_AT_EXPIRY_IF_UNKNOWN = '10s'; +export const RTR_RT_EXPIRY_IF_UNKNOWN = '10m'; diff --git a/src/components/Root/token-util.js b/src/components/Root/token-util.js index 0e70ba012..91abf3400 100644 --- a/src/components/Root/token-util.js +++ b/src/components/Root/token-util.js @@ -5,6 +5,7 @@ import { RTRError, UnexpectedResourceError } from './Errors'; import { RTR_ACTIVITY_EVENTS, RTR_ERROR_EVENT, + RTR_FLS_WARNING_TTL, RTR_IDLE_MODAL_TTL, RTR_IDLE_SESSION_TTL, RTR_SUCCESS_EVENT, @@ -326,5 +327,11 @@ export const configureRtr = (config = {}) => { conf.activityEvents = RTR_ACTIVITY_EVENTS; } + // how long is the "your session is gonna die!" warning shown + // before the session is, in fact, killed? + if (!conf.fixedLengthSessionWarningTTL) { + conf.fixedLengthSessionWarningTTL = RTR_FLS_WARNING_TTL; + } + return conf; }; diff --git a/src/components/SessionEventContainer/FixedLengthSessionWarning.js b/src/components/SessionEventContainer/FixedLengthSessionWarning.js new file mode 100644 index 000000000..09456ca79 --- /dev/null +++ b/src/components/SessionEventContainer/FixedLengthSessionWarning.js @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import ms from 'ms'; + +import { + MessageBanner +} from '@folio/stripes-components'; + +import { useStripes } from '../../StripesContext'; + +/** + * FixedLengthSessionWarning + * Show a callout with a countdown timer representing the number of seconds + * remaining until the session expires. + * + * @param {function} callback function to call when clicking "Keep working" button + */ +const FixedLengthSessionWarning = () => { + const stripes = useStripes(); + const [remainingMillis, setRemainingMillis] = useState(ms(stripes.config.rtr.fixedLengthSessionWarningTTL)); + + // configure an interval timer that sets state each second, + // counting down to 0. + useEffect(() => { + const interval = setInterval(() => { + setRemainingMillis(i => i - 1000); + }, 1000); + + // cleanup: clear the timer + return () => { + clearInterval(interval); + }; + }, []); + + /** + * timestampFormatter + * convert time-remaining to mm:ss. Given the remaining time can easily be + * represented as elapsed-time since the JSDate epoch, convert to a + * Date object, format it, and extract the minutes and seconds. + * That is, given we have 99 seconds left, that converts to a Date + * like `1970-01-01T00:01:39.000Z`; extract the `01:39`. + */ + const timestampFormatter = () => { + if (remainingMillis >= 1000) { + return new Date(remainingMillis).toISOString().substring(14, 19); + } + + return '00:00'; + }; + + return {timestampFormatter()}; +}; + +export default FixedLengthSessionWarning; diff --git a/src/components/SessionEventContainer/FixedLengthSessionWarning.test.js b/src/components/SessionEventContainer/FixedLengthSessionWarning.test.js new file mode 100644 index 000000000..d20719c9c --- /dev/null +++ b/src/components/SessionEventContainer/FixedLengthSessionWarning.test.js @@ -0,0 +1,59 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import Harness from '../../../test/jest/helpers/harness'; +import FixedLengthSessionWarning from './FixedLengthSessionWarning'; + +jest.mock('../Root/token-util'); + +const stripes = { + config: { + rtr: { + fixedLengthSessionWarningTTL: '99s' + } + } +}; + +describe('FixedLengthSessionWarning', () => { + it('renders a warning with seconds remaining', async () => { + render(); + screen.getByText(/stripes-core.rtr.fixedLengthSession.timeRemaining/); + screen.getByText(/01:39/); + }); + + it('renders 0:00 when time expires', async () => { + const zeroSecondsStripes = { + config: { + rtr: { + fixedLengthSessionWarningTTL: '0s' + } + } + }; + + render(); + screen.getByText(/stripes-core.rtr.fixedLengthSession.timeRemaining/); + screen.getByText(/0:00/); + }); + + // I've never had great luck with jest's fake timers, https://jestjs.io/docs/timer-mocks + // The modal counts down one second at a time so this test just waits for + // two seconds. Great? Nope. Good enough? Sure is. + describe('uses timers', () => { + it('"like sand through an hourglass, so are the elapsed seconds of this warning" -- Soh Kraits', async () => { + jest.spyOn(global, 'setInterval'); + const zeroSecondsStripes = { + config: { + rtr: { + fixedLengthSessionWarningTTL: '10s' + } + } + }; + + render(); + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 1000); + + await waitFor(() => screen.getByText(/00:09/), { timeout: 2000 }); + }); + }); +}); diff --git a/src/components/SessionEventContainer/SessionEventContainer.js b/src/components/SessionEventContainer/SessionEventContainer.js index 21ca8f4e1..fa162d666 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.js +++ b/src/components/SessionEventContainer/SessionEventContainer.js @@ -3,15 +3,18 @@ import PropTypes from 'prop-types'; import createInactivityTimer from 'inactivity-timer'; import ms from 'ms'; -import { SESSION_NAME } from '../../loginServices'; +import { SESSION_NAME, setUnauthorizedPathToSession } from '../../loginServices'; import KeepWorkingModal from './KeepWorkingModal'; import { useStripes } from '../../StripesContext'; import { RTR_ACTIVITY_CHANNEL, RTR_ERROR_EVENT, + RTR_FLS_TIMEOUT_EVENT, + RTR_FLS_WARNING_EVENT, RTR_TIMEOUT_EVENT } from '../Root/constants'; import { toggleRtrModal } from '../../okapiActions'; +import FixedLengthSessionWarning from './FixedLengthSessionWarning'; // // event listeners @@ -21,15 +24,30 @@ import { toggleRtrModal } from '../../okapiActions'; // RTR error in this window: logout export const thisWindowRtrError = (_e, stripes, history) => { console.warn('rtr error; logging out'); // eslint-disable-line no-console + setUnauthorizedPathToSession(); history.push('/logout-timeout'); }; // idle session timeout in this window: logout -export const thisWindowRtrTimeout = (_e, stripes, history) => { +export const thisWindowRtrIstTimeout = (_e, stripes, history) => { stripes.logger.log('rtr', 'idle session timeout; logging out'); + setUnauthorizedPathToSession(); history.push('/logout-timeout'); }; +// fixed-length session warning in this window: logout +export const thisWindowRtrFlsWarning = (_e, stripes, setIsFlsVisible) => { + stripes.logger.log('rtr', 'fixed-length session warning'); + setIsFlsVisible(true); +}; + +// fixed-length session timeout in this window: logout +export const thisWindowRtrFlsTimeout = (_e, stripes, history) => { + stripes.logger.log('rtr', 'fixed-length session timeout; logging out'); + setUnauthorizedPathToSession(); + history.push('/logout'); +}; + // localstorage change in another window: logout? // logout if it was a timeout event or if SESSION_NAME is being // removed from localStorage, an indicator that logout is in-progress @@ -37,9 +55,11 @@ export const thisWindowRtrTimeout = (_e, stripes, history) => { export const otherWindowStorage = (e, stripes, history) => { if (e.key === RTR_TIMEOUT_EVENT) { stripes.logger.log('rtr', 'idle session timeout; logging out'); + setUnauthorizedPathToSession(); history.push('/logout-timeout'); } else if (!localStorage.getItem(SESSION_NAME)) { stripes.logger.log('rtr', 'external localstorage change; logging out'); + setUnauthorizedPathToSession(); history.push('/logout'); } return Promise.resolve(); @@ -113,6 +133,9 @@ const SessionEventContainer = ({ history }) => { // is the "keep working?" modal visible? const [isVisible, setIsVisible] = useState(false); + // is the fixed-length-session warning visible? + const [isFlsVisible, setIsFlsVisible] = useState(false); + // inactivity timers const timers = useRef(); const stripes = useStripes(); @@ -191,7 +214,7 @@ const SessionEventContainer = ({ history }) => { channels.window[RTR_ERROR_EVENT] = (e) => thisWindowRtrError(e, stripes, history); // idle session timeout in this window: logout - channels.window[RTR_TIMEOUT_EVENT] = (e) => thisWindowRtrTimeout(e, stripes, history); + channels.window[RTR_TIMEOUT_EVENT] = (e) => thisWindowRtrIstTimeout(e, stripes, history); // localstorage change in another window: logout? channels.window.storage = (e) => otherWindowStorage(e, stripes, history); @@ -204,6 +227,13 @@ const SessionEventContainer = ({ history }) => { channels.window[eventName] = (e) => thisWindowActivity(e, stripes, timers, bc); }); + // fixed-length session: show session-is-ending warning + channels.window[RTR_FLS_WARNING_EVENT] = (e) => thisWindowRtrFlsWarning(e, stripes, setIsFlsVisible); + + // fixed-length session: terminate session + channels.window[RTR_FLS_TIMEOUT_EVENT] = (e) => thisWindowRtrFlsTimeout(e, stripes, history); + + // add listeners Object.entries(channels).forEach(([k, channel]) => { Object.entries(channel).forEach(([e, h]) => { @@ -236,13 +266,19 @@ const SessionEventContainer = ({ history }) => { // array. }, []); // eslint-disable-line react-hooks/exhaustive-deps - // show the idle-session warning modal if necessary; - // otherwise return null + const renderList = []; + + // show the idle-session warning modal? if (isVisible) { - return ; + renderList.push(); + } + + // show the fixed-length session warning? + if (isFlsVisible) { + renderList.push(); } - return null; + return renderList.length ? renderList : null; }; SessionEventContainer.propTypes = { diff --git a/src/components/SessionEventContainer/SessionEventContainer.test.js b/src/components/SessionEventContainer/SessionEventContainer.test.js index 24d9fd04c..06f9c2a02 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.test.js +++ b/src/components/SessionEventContainer/SessionEventContainer.test.js @@ -7,7 +7,7 @@ import SessionEventContainer, { otherWindowStorage, thisWindowActivity, thisWindowRtrError, - thisWindowRtrTimeout, + thisWindowRtrIstTimeout, } from './SessionEventContainer'; import { SESSION_NAME } from '../../loginServices'; import { RTR_TIMEOUT_EVENT } from '../Root/constants'; @@ -74,7 +74,7 @@ describe('SessionEventContainer event listeners', () => { expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); - it('thisWindowRtrTimeout', async () => { + it('thisWindowRtrIstTimeout', async () => { const s = { okapi: { url: 'http' @@ -87,7 +87,7 @@ describe('SessionEventContainer event listeners', () => { const history = { push: jest.fn() }; - thisWindowRtrTimeout(null, s, history); + thisWindowRtrIstTimeout(null, s, history); expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); diff --git a/src/loginServices.js b/src/loginServices.js index ba966b3f0..f50b2a4f9 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -114,13 +114,16 @@ export const setTokenExpiry = async (te) => { * removeUnauthorizedPathFromSession, setUnauthorizedPathToSession, getUnauthorizedPathFromSession * remove/set/get unauthorized_path to/from session storage. * Used to restore path on returning from login if user accessed a bookmarked - * URL while unauthenticated and was redirected to login. + * URL while unauthenticated and was redirected to login, and when a session + * times out, forcing the user to re-authenticate. * * @see components/OIDCRedirect */ const UNAUTHORIZED_PATH = 'unauthorized_path'; export const removeUnauthorizedPathFromSession = () => sessionStorage.removeItem(UNAUTHORIZED_PATH); -export const setUnauthorizedPathToSession = (pathname) => sessionStorage.setItem(UNAUTHORIZED_PATH, pathname); +export const setUnauthorizedPathToSession = (pathname) => { + sessionStorage.setItem(UNAUTHORIZED_PATH, pathname ?? `${window.location.pathname}${window.location.search}`); +}; export const getUnauthorizedPathFromSession = () => sessionStorage.getItem(UNAUTHORIZED_PATH); // export config values for storing user locale diff --git a/src/okapiActions.js b/src/okapiActions.js index b6ff9554f..c9d3795ab 100644 --- a/src/okapiActions.js +++ b/src/okapiActions.js @@ -1,54 +1,56 @@ +import { OKAPI_REDUCER_ACTIONS } from './okapiReducer'; + function setCurrentUser(currentUser) { return { - type: 'SET_CURRENT_USER', + type: OKAPI_REDUCER_ACTIONS.SET_CURRENT_USER, currentUser, }; } function clearCurrentUser() { return { - type: 'CLEAR_CURRENT_USER', + type: OKAPI_REDUCER_ACTIONS.CLEAR_CURRENT_USER, }; } function setCurrentPerms(currentPerms) { return { - type: 'SET_CURRENT_PERMS', + type: OKAPI_REDUCER_ACTIONS.SET_CURRENT_PERMS, currentPerms, }; } function setLocale(locale) { return { - type: 'SET_LOCALE', + type: OKAPI_REDUCER_ACTIONS.SET_LOCALE, locale, }; } function setTimezone(timezone) { return { - type: 'SET_TIMEZONE', + type: OKAPI_REDUCER_ACTIONS.SET_TIMEZONE, timezone, }; } function setCurrency(currency) { return { - type: 'SET_CURRENCY', + type: OKAPI_REDUCER_ACTIONS.SET_CURRENCY, currency, }; } function setPlugins(plugins) { return { - type: 'SET_PLUGINS', + type: OKAPI_REDUCER_ACTIONS.SET_PLUGINS, plugins, }; } function setSinglePlugin(name, value) { return { - type: 'SET_SINGLE_PLUGIN', + type: OKAPI_REDUCER_ACTIONS.SET_SINGLE_PLUGIN, name, value, }; @@ -56,115 +58,129 @@ function setSinglePlugin(name, value) { function setBindings(bindings) { return { - type: 'SET_BINDINGS', + type: OKAPI_REDUCER_ACTIONS.SET_BINDINGS, bindings, }; } function setOkapiToken(token) { return { - type: 'SET_OKAPI_TOKEN', + type: OKAPI_REDUCER_ACTIONS.SET_OKAPI_TOKEN, token, }; } function clearOkapiToken() { return { - type: 'CLEAR_OKAPI_TOKEN', + type: OKAPI_REDUCER_ACTIONS.CLEAR_OKAPI_TOKEN, }; } function setIsAuthenticated(b) { return { - type: 'SET_IS_AUTHENTICATED', + type: OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED, isAuthenticated: Boolean(b), }; } function setAuthError(message) { return { - type: 'SET_AUTH_FAILURE', + type: OKAPI_REDUCER_ACTIONS.SET_AUTH_FAILURE, message, }; } function setTranslations(translations) { return { - type: 'SET_TRANSLATIONS', + type: OKAPI_REDUCER_ACTIONS.SET_TRANSLATIONS, translations, }; } function checkSSO(ssoEnabled) { return { - type: 'CHECK_SSO', + type: OKAPI_REDUCER_ACTIONS.CHECK_SSO, ssoEnabled, }; } function setOkapiReady() { return { - type: 'OKAPI_READY', + type: OKAPI_REDUCER_ACTIONS.OKAPI_READY, }; } function setServerDown() { return { - type: 'SERVER_DOWN', + type: OKAPI_REDUCER_ACTIONS.SERVER_DOWN, }; } function setSessionData(session) { return { - type: 'SET_SESSION_DATA', + type: OKAPI_REDUCER_ACTIONS.SET_SESSION_DATA, session, }; } function setLoginData(loginData) { return { - type: 'SET_LOGIN_DATA', + type: OKAPI_REDUCER_ACTIONS.SET_LOGIN_DATA, loginData, }; } function updateCurrentUser(data) { return { - type: 'UPDATE_CURRENT_USER', + type: OKAPI_REDUCER_ACTIONS.UPDATE_CURRENT_USER, data, }; } function setOkapiTenant(payload) { return { - type: 'SET_OKAPI_TENANT', + type: OKAPI_REDUCER_ACTIONS.SET_OKAPI_TENANT, payload }; } function setTokenExpiration(tokenExpiration) { return { - type: 'SET_TOKEN_EXPIRATION', + type: OKAPI_REDUCER_ACTIONS.SET_TOKEN_EXPIRATION, tokenExpiration, }; } function setRtrTimeout(rtrTimeout) { return { - type: 'SET_RTR_TIMEOUT', + type: OKAPI_REDUCER_ACTIONS.SET_RTR_TIMEOUT, rtrTimeout, }; } function clearRtrTimeout() { return { - type: 'CLEAR_RTR_TIMEOUT', + type: OKAPI_REDUCER_ACTIONS.CLEAR_RTR_TIMEOUT, + }; +} + +function setRtrFlsTimeout(rtrFlsTimeout) { + return { + type: OKAPI_REDUCER_ACTIONS.SET_RTR_FLS_TIMEOUT, + rtrFlsTimeout, }; } +function clearRtrFlsTimeout() { + return { + type: OKAPI_REDUCER_ACTIONS.CLEAR_RTR_FLS_TIMEOUT, + }; +} + + function toggleRtrModal(isVisible) { return { - type: 'TOGGLE_RTR_MODAL', + type: OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL, isVisible, }; } @@ -173,6 +189,7 @@ export { checkSSO, clearCurrentUser, clearOkapiToken, + clearRtrFlsTimeout, clearRtrTimeout, setAuthError, setBindings, @@ -185,6 +202,7 @@ export { setOkapiReady, setOkapiToken, setPlugins, + setRtrFlsTimeout, setRtrTimeout, setServerDown, setSessionData, diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 6c4f1f475..2fc52174b 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -1,78 +1,119 @@ +export const OKAPI_REDUCER_ACTIONS = { + CHECK_SSO: 'CHECK_SSO', + CLEAR_CURRENT_USER: 'CLEAR_CURRENT_USER', + CLEAR_OKAPI_TOKEN: 'CLEAR_OKAPI_TOKEN', + CLEAR_RTR_FLS_TIMEOUT: 'CLEAR_RTR_FLS_TIMEOUT', + CLEAR_RTR_TIMEOUT: 'CLEAR_RTR_TIMEOUT', + OKAPI_READY: 'OKAPI_READY', + SERVER_DOWN: 'SERVER_DOWN', + SET_AUTH_FAILURE: 'SET_AUTH_FAILURE', + SET_BINDINGS: 'SET_BINDINGS', + SET_CURRENCY: 'SET_CURRENCY', + SET_CURRENT_PERMS: 'SET_CURRENT_PERMS', + SET_CURRENT_USER: 'SET_CURRENT_USER', + SET_IS_AUTHENTICATED: 'SET_IS_AUTHENTICATED', + SET_LOCALE: 'SET_LOCALE', + SET_LOGIN_DATA: 'SET_LOGIN_DATA', + SET_OKAPI_TENANT: 'SET_OKAPI_TENANT', + SET_OKAPI_TOKEN: 'SET_OKAPI_TOKEN', + SET_PLUGINS: 'SET_PLUGINS', + SET_RTR_FLS_TIMEOUT: 'SET_RTR_FLS_TIMEOUT', + SET_RTR_TIMEOUT: 'SET_RTR_TIMEOUT', + SET_SESSION_DATA: 'SET_SESSION_DATA', + SET_SINGLE_PLUGIN: 'SET_SINGLE_PLUGIN', + SET_TIMEZONE: 'SET_TIMEZONE', + SET_TOKEN_EXPIRATION: 'SET_TOKEN_EXPIRATION', + SET_TRANSLATIONS: 'SET_TRANSLATIONS', + TOGGLE_RTR_MODAL: 'TOGGLE_RTR_MODAL', + UPDATE_CURRENT_USER: 'UPDATE_CURRENT_USER', +}; + export default function okapiReducer(state = {}, action) { switch (action.type) { - case 'SET_OKAPI_TENANT': { + case OKAPI_REDUCER_ACTIONS.SET_OKAPI_TENANT: { const { tenant, clientId } = action.payload; return Object.assign({}, state, { tenant, clientId }); } - case 'SET_OKAPI_TOKEN': + case OKAPI_REDUCER_ACTIONS.SET_OKAPI_TOKEN: return Object.assign({}, state, { token: action.token }); - case 'CLEAR_OKAPI_TOKEN': + case OKAPI_REDUCER_ACTIONS.CLEAR_OKAPI_TOKEN: return Object.assign({}, state, { token: null }); - case 'SET_CURRENT_USER': + case OKAPI_REDUCER_ACTIONS.SET_CURRENT_USER: return Object.assign({}, state, { currentUser: action.currentUser }); - case 'SET_IS_AUTHENTICATED': { + case OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED: { const newState = { isAuthenticated: action.isAuthenticated, }; - // if we're logging out, clear the RTR timeout - // and other rtr-related values + // if we're logging out, clear the RTR timeouts and related values if (!action.isAuthenticated) { clearTimeout(state.rtrTimeout); + clearTimeout(state.rtrFlsTimeout); newState.rtrModalIsVisible = false; newState.rtrTimeout = undefined; + newState.rtrFlsTimeout = undefined; } return { ...state, ...newState }; } - case 'SET_LOCALE': + case OKAPI_REDUCER_ACTIONS.SET_LOCALE: return Object.assign({}, state, { locale: action.locale }); - case 'SET_TIMEZONE': + case OKAPI_REDUCER_ACTIONS.SET_TIMEZONE: return Object.assign({}, state, { timezone: action.timezone }); - case 'SET_CURRENCY': + case OKAPI_REDUCER_ACTIONS.SET_CURRENCY: return Object.assign({}, state, { currency: action.currency }); - case 'SET_PLUGINS': + case OKAPI_REDUCER_ACTIONS.SET_PLUGINS: return Object.assign({}, state, { plugins: action.plugins }); - case 'SET_SINGLE_PLUGIN': + case OKAPI_REDUCER_ACTIONS.SET_SINGLE_PLUGIN: return Object.assign({}, state, { plugins: Object.assign({}, state.plugins, { [action.name]: action.value }) }); - case 'SET_BINDINGS': + case OKAPI_REDUCER_ACTIONS.SET_BINDINGS: return Object.assign({}, state, { bindings: action.bindings }); - case 'SET_CURRENT_PERMS': + case OKAPI_REDUCER_ACTIONS.SET_CURRENT_PERMS: return Object.assign({}, state, { currentPerms: action.currentPerms }); - case 'SET_LOGIN_DATA': + case OKAPI_REDUCER_ACTIONS.SET_LOGIN_DATA: return Object.assign({}, state, { loginData: action.loginData }); - case 'SET_TOKEN_EXPIRATION': + case OKAPI_REDUCER_ACTIONS.SET_TOKEN_EXPIRATION: return Object.assign({}, state, { loginData: { ...state.loginData, tokenExpiration: action.tokenExpiration } }); - case 'CLEAR_CURRENT_USER': + case OKAPI_REDUCER_ACTIONS.CLEAR_CURRENT_USER: return Object.assign({}, state, { currentUser: {}, currentPerms: {} }); - case 'SET_SESSION_DATA': { + case OKAPI_REDUCER_ACTIONS.SET_SESSION_DATA: { const { isAuthenticated, perms, tenant, token, user } = action.session; const sessionTenant = tenant || state.tenant; return { ...state, currentUser: user, currentPerms: perms, isAuthenticated, tenant: sessionTenant, token }; } - case 'SET_AUTH_FAILURE': + case OKAPI_REDUCER_ACTIONS.SET_AUTH_FAILURE: return Object.assign({}, state, { authFailure: action.message }); - case 'SET_TRANSLATIONS': + case OKAPI_REDUCER_ACTIONS.SET_TRANSLATIONS: return Object.assign({}, state, { translations: action.translations }); - case 'CHECK_SSO': + case OKAPI_REDUCER_ACTIONS.CHECK_SSO: return Object.assign({}, state, { ssoEnabled: action.ssoEnabled }); - case 'OKAPI_READY': + case OKAPI_REDUCER_ACTIONS.OKAPI_READY: return Object.assign({}, state, { okapiReady: true }); - case 'SERVER_DOWN': + case OKAPI_REDUCER_ACTIONS.SERVER_DOWN: return Object.assign({}, state, { serverDown: true }); - case 'UPDATE_CURRENT_USER': + case OKAPI_REDUCER_ACTIONS.UPDATE_CURRENT_USER: return { ...state, currentUser: { ...state.currentUser, ...action.data } }; - // clear existing timeout and set a new one - case 'SET_RTR_TIMEOUT': { + // clear existing AT rotation timeout and set a new one + case OKAPI_REDUCER_ACTIONS.SET_RTR_TIMEOUT: { clearTimeout(state.rtrTimeout); return { ...state, rtrTimeout: action.rtrTimeout }; } - case 'CLEAR_RTR_TIMEOUT': { + case OKAPI_REDUCER_ACTIONS.CLEAR_RTR_TIMEOUT: { clearTimeout(state.rtrTimeout); return { ...state, rtrTimeout: undefined }; } - case 'TOGGLE_RTR_MODAL': { + // clear existing FLS timeout and set a new one + case OKAPI_REDUCER_ACTIONS.SET_RTR_FLS_TIMEOUT: { + clearTimeout(state.rtrFlsTimeout); + return { ...state, rtrFlsTimeout: action.rtrFlsTimeout }; + } + case OKAPI_REDUCER_ACTIONS.CLEAR_RTR_FLS_TIMEOUT: { + clearTimeout(state.rtrFlsTimeout); + return { ...state, rtrFlsTimeout: undefined }; + } + + case OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL: { return { ...state, rtrModalIsVisible: action.isVisible }; } diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index d1c5ffe6d..ce366491e 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -1,10 +1,11 @@ -import okapiReducer from './okapiReducer'; +import okapiReducer, { OKAPI_REDUCER_ACTIONS } from './okapiReducer'; + describe('okapiReducer', () => { describe('SET_IS_AUTHENTICATED', () => { it('sets isAuthenticated to true', () => { const isAuthenticated = true; - const o = okapiReducer({}, { type: 'SET_IS_AUTHENTICATED', isAuthenticated: true }); + const o = okapiReducer({}, { type: OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED, isAuthenticated: true }); expect(o).toMatchObject({ isAuthenticated }); }); @@ -13,25 +14,26 @@ describe('okapiReducer', () => { rtrModalIsVisible: true, rtrTimeout: 123, }; - const ct = jest.spyOn(window, 'clearTimeout') - const o = okapiReducer(state, { type: 'SET_IS_AUTHENTICATED', isAuthenticated: false }); + const ct = jest.spyOn(window, 'clearTimeout'); + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.SET_IS_AUTHENTICATED, isAuthenticated: false }); expect(o.isAuthenticated).toBe(false); expect(o.rtrModalIsVisible).toBe(false); expect(o.rtrTimeout).toBe(undefined); + expect(o.rtrFlsTimeout).toBe(undefined); expect(ct).toHaveBeenCalled(); }); }); it('SET_LOGIN_DATA', () => { const loginData = 'loginData'; - const o = okapiReducer({}, { type: 'SET_LOGIN_DATA', loginData }); + const o = okapiReducer({}, { type: OKAPI_REDUCER_ACTIONS.SET_LOGIN_DATA, loginData }); expect(o).toMatchObject({ loginData }); }); it('UPDATE_CURRENT_USER', () => { const initialState = { funky: 'chicken' }; const data = { monkey: 'bagel' }; - const o = okapiReducer(initialState, { type: 'UPDATE_CURRENT_USER', data }); + const o = okapiReducer(initialState, { type: OKAPI_REDUCER_ACTIONS.UPDATE_CURRENT_USER, data }); expect(o).toMatchObject({ ...initialState, currentUser: { ...data } }); }); @@ -51,7 +53,7 @@ describe('okapiReducer', () => { }, tenant: 'institutional', }; - const o = okapiReducer(initialState, { type: 'SET_SESSION_DATA', session }); + const o = okapiReducer(initialState, { type: OKAPI_REDUCER_ACTIONS.SET_SESSION_DATA, session }); const { user, perms, ...rest } = session; expect(o).toMatchObject({ ...initialState, @@ -70,7 +72,7 @@ describe('okapiReducer', () => { const newState = { rtrTimeout: 997 }; - const o = okapiReducer(state, { type: 'SET_RTR_TIMEOUT', rtrTimeout: newState.rtrTimeout }); + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.SET_RTR_TIMEOUT, rtrTimeout: newState.rtrTimeout }); expect(o).toMatchObject(newState); expect(ct).toHaveBeenCalledWith(state.rtrTimeout); @@ -83,14 +85,41 @@ describe('okapiReducer', () => { rtrTimeout: 991, }; - const o = okapiReducer(state, { type: 'CLEAR_RTR_TIMEOUT' }); + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.CLEAR_RTR_TIMEOUT }); expect(o).toMatchObject({}); expect(ct).toHaveBeenCalledWith(state.rtrTimeout); }); it('TOGGLE_RTR_MODAL', () => { const rtrModalIsVisible = true; - const o = okapiReducer({}, { type: 'TOGGLE_RTR_MODAL', isVisible: true }); + const o = okapiReducer({}, { type: OKAPI_REDUCER_ACTIONS.TOGGLE_RTR_MODAL, isVisible: true }); expect(o).toMatchObject({ rtrModalIsVisible }); }); + + it('SET_RTR_FLS_TIMEOUT', () => { + const ct = jest.spyOn(window, 'clearTimeout'); + + const state = { + rtrFlsTimeout: 991, + }; + + const newState = { rtrFlsTimeout: 997 }; + + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.SET_RTR_FLS_TIMEOUT, rtrFlsTimeout: newState.rtrFlsTimeout }); + expect(o).toMatchObject(newState); + + expect(ct).toHaveBeenCalledWith(state.rtrFlsTimeout); + }); + + it('CLEAR_RTR_FLS_TIMEOUT', () => { + const ct = jest.spyOn(window, 'clearTimeout'); + + const state = { + rtrFlsTimeout: 991, + }; + + const o = okapiReducer(state, { type: OKAPI_REDUCER_ACTIONS.CLEAR_RTR_FLS_TIMEOUT }); + expect(o).toMatchObject({}); + expect(ct).toHaveBeenCalledWith(state.rtrFlsTimeout); + }); }); diff --git a/translations/stripes-core/en.json b/translations/stripes-core/en.json index b08b58fc5..27953d60d 100644 --- a/translations/stripes-core/en.json +++ b/translations/stripes-core/en.json @@ -13,7 +13,6 @@ "title.noPermission": "No permission", "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", "title.logout": "Log out", - "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", "front.welcome": "Welcome, the Future Of Libraries Is OPEN!", "front.home": "Home", "front.about": "Software versions", @@ -168,5 +167,7 @@ "rtr.idleSession.timeRemaining": "Time remaining", "rtr.idleSession.keepWorking": "Keep working", "rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.", - "rtr.idleSession.logInAgain": "Log in again" + "rtr.idleSession.logInAgain": "Log in again", + "rtr.fixedLengthSession.timeRemaining": "Your session will end soon! Time remaining:" + } diff --git a/translations/stripes-core/en_US.json b/translations/stripes-core/en_US.json index 4873ac4e4..a5bdc815d 100644 --- a/translations/stripes-core/en_US.json +++ b/translations/stripes-core/en_US.json @@ -152,11 +152,11 @@ "stale.reload": "Click here to reload.", "placeholder.forgotPassword": "Enter email or phone", "placeholder.forgotUsername": "Enter email or phone", - "title.cookieEnabled": "Cookies are required to login. Please enable cookies and try again.", "errors.sso.session.failed": "SSO Login failed. Please try again", "rtr.idleSession.modalHeader": "Your session will expire soon!", "rtr.idleSession.timeRemaining": "Time remaining", "rtr.idleSession.keepWorking": "Keep working", "rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.", - "rtr.idleSession.logInAgain": "Log in again" + "rtr.idleSession.logInAgain": "Log in again", + "rtr.fixedLengthSession.timeRemaining": "Your session will end soon! Time remaining:" }