From 5eb571d1a3d2c2a1675cc0e056dc5c0c5fd42e6d Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 22 Aug 2024 11:08:22 -0400 Subject: [PATCH] STCOR-849 return to previous location after logout-timeout (#1523) When re-authenticating after getting kicked out due to an inactivity-timeout, return to the previous location, allowing uninterrupted work. This was a particular problem in certain application such as bulk-edit and data-import that have long-running processes and transient URLs, i.e. starting a process might direct you to a URL like `/some-process/123-abc`, and this is a stable URL you can return to _if you know the URL_, but when starting a new session there is no way to discover this URL by browsing or searching through the UI. This is handled by caching the current location in session-storage in the inactivity event handlers and retrieving at on the logout-timeout page, allowing you to return to such transient URLs. Refs STCOR-849 --- CHANGELOG.md | 1 + src/components/LogoutTimeout/LogoutTimeout.js | 13 ++++++- .../LogoutTimeout/LogoutTimeout.test.js | 29 ++++++++++++--- .../SessionEventContainer.js | 6 ++- .../SessionEventContainer.test.js | 17 ++++++++- src/loginServices.js | 18 +++++++++ src/loginServices.test.js | 37 +++++++++++++++++++ 7 files changed, 113 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b92a4e..d9714752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Change main navigation's skip link label to "Skip to main content". Refs STCOR-863. * Invalidate `QueryClient` cache on login/logout. Refs STCOR-832. * Ensure support for the passed `tenantId` value by `useChunkedCQLFetch` for manipulations in the context of a specific tenant. Refs STCOR-873. +* When re-authenticating after logout timeout, return to previous location. Refs STCOR-849. ## [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/LogoutTimeout/LogoutTimeout.js b/src/components/LogoutTimeout/LogoutTimeout.js index 2eda1142..34f0fc44 100644 --- a/src/components/LogoutTimeout/LogoutTimeout.js +++ b/src/components/LogoutTimeout/LogoutTimeout.js @@ -13,6 +13,10 @@ import OrganizationLogo from '../OrganizationLogo'; import { useStripes } from '../../StripesContext'; import styles from './LogoutTimeout.css'; +import { + getUnauthorizedPathFromSession, + removeUnauthorizedPathFromSession, +} from '../../loginServices'; /** * LogoutTimeout @@ -33,6 +37,13 @@ const LogoutTimeout = () => { return ; } + const handleClick = (_e) => { + removeUnauthorizedPathFromSession(); + }; + + const previousPath = getUnauthorizedPathFromSession(); + const redirectTo = previousPath ?? '/'; + return (
@@ -49,7 +60,7 @@ const LogoutTimeout = () => { - +
diff --git a/src/components/LogoutTimeout/LogoutTimeout.test.js b/src/components/LogoutTimeout/LogoutTimeout.test.js index 55c9c9b5..d7477f9b 100644 --- a/src/components/LogoutTimeout/LogoutTimeout.test.js +++ b/src/components/LogoutTimeout/LogoutTimeout.test.js @@ -1,7 +1,10 @@ import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import { userEvent } from '@folio/jest-config-stripes/testing-library/user-event'; + import LogoutTimeout from './LogoutTimeout'; import { useStripes } from '../../StripesContext'; +import { getUnauthorizedPathFromSession, setUnauthorizedPathToSession } from '../../loginServices'; jest.mock('../OrganizationLogo'); @@ -11,12 +14,28 @@ jest.mock('react-router', () => ({ })); describe('LogoutTimeout', () => { - it('if not authenticated, renders a timeout message', async () => { - const mockUseStripes = useStripes; - mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: false } }); + describe('if not authenticated', () => { + it('renders a timeout message', async () => { + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: false } }); - render(); - screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); + render(); + screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); + }); + + it('clears previous path from storage after clicking', async () => { + const previousPath = '/monkey?bagel'; + setUnauthorizedPathToSession(previousPath); + const user = userEvent.setup(); + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: false } }); + + render(); + + await user.click(screen.getByRole('button')); + + expect(getUnauthorizedPathFromSession()).toBe(null); + }); }); it('if authenticated, renders a redirect', async () => { diff --git a/src/components/SessionEventContainer/SessionEventContainer.js b/src/components/SessionEventContainer/SessionEventContainer.js index 1316a5d2..b82f3400 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.js +++ b/src/components/SessionEventContainer/SessionEventContainer.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import createInactivityTimer from 'inactivity-timer'; import ms from 'ms'; -import { logout, SESSION_NAME } from '../../loginServices'; +import { logout, SESSION_NAME, setUnauthorizedPathToSession } from '../../loginServices'; import KeepWorkingModal from './KeepWorkingModal'; import { useStripes } from '../../StripesContext'; import { @@ -21,6 +21,7 @@ import { toggleRtrModal } from '../../okapiActions'; // RTR error in this window: logout export const thisWindowRtrError = (_e, stripes, history, queryClient) => { console.warn('rtr error; logging out'); // eslint-disable-line no-console + setUnauthorizedPathToSession(); return logout(stripes.okapi.url, stripes.store, queryClient) .then(() => { history.push('/logout-timeout'); @@ -30,6 +31,7 @@ export const thisWindowRtrError = (_e, stripes, history, queryClient) => { // idle session timeout in this window: logout export const thisWindowRtrTimeout = (_e, stripes, history, queryClient) => { stripes.logger.log('rtr', 'idle session timeout; logging out'); + setUnauthorizedPathToSession(); return logout(stripes.okapi.url, stripes.store, queryClient) .then(() => { history.push('/logout-timeout'); @@ -43,12 +45,14 @@ export const thisWindowRtrTimeout = (_e, stripes, history, queryClient) => { export const otherWindowStorage = (e, stripes, history, queryClient) => { if (e.key === RTR_TIMEOUT_EVENT) { stripes.logger.log('rtr', 'idle session timeout; logging out'); + setUnauthorizedPathToSession(); return logout(stripes.okapi.url, stripes.store, queryClient) .then(() => { history.push('/logout-timeout'); }); } else if (!localStorage.getItem(SESSION_NAME)) { stripes.logger.log('rtr', 'external localstorage change; logging out'); + setUnauthorizedPathToSession(); return logout(stripes.okapi.url, stripes.store, queryClient) .then(() => { history.push('/'); diff --git a/src/components/SessionEventContainer/SessionEventContainer.test.js b/src/components/SessionEventContainer/SessionEventContainer.test.js index 8c7594c9..fbb561e4 100644 --- a/src/components/SessionEventContainer/SessionEventContainer.test.js +++ b/src/components/SessionEventContainer/SessionEventContainer.test.js @@ -9,7 +9,11 @@ import SessionEventContainer, { thisWindowRtrError, thisWindowRtrTimeout, } from './SessionEventContainer'; -import { logout, SESSION_NAME } from '../../loginServices'; +import { + logout, + setUnauthorizedPathToSession, + SESSION_NAME, +} from '../../loginServices'; import { RTR_TIMEOUT_EVENT } from '../Root/constants'; import { toggleRtrModal } from '../../okapiActions'; @@ -72,8 +76,12 @@ describe('SessionEventContainer event listeners', () => { const logoutMock = logout; logoutMock.mockReturnValue(Promise.resolve()); + const setUnauthorizedPathToSessionMock = setUnauthorizedPathToSession; + setUnauthorizedPathToSessionMock.mockReturnValue(null); + await thisWindowRtrError(null, { okapi: { url: 'http' } }, history); expect(logout).toHaveBeenCalled(); + expect(setUnauthorizedPathToSession).toHaveBeenCalled(); expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); @@ -92,8 +100,12 @@ describe('SessionEventContainer event listeners', () => { const logoutMock = logout; await logoutMock.mockReturnValue(Promise.resolve()); + const setUnauthorizedPathToSessionMock = setUnauthorizedPathToSession; + setUnauthorizedPathToSessionMock.mockReturnValue(null); + await thisWindowRtrTimeout(null, s, history); expect(logout).toHaveBeenCalled(); + expect(setUnauthorizedPathToSession).toHaveBeenCalled(); expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); @@ -115,9 +127,12 @@ describe('SessionEventContainer event listeners', () => { }; const history = { push: jest.fn() }; const qc = {}; + const setUnauthorizedPathToSessionMock = setUnauthorizedPathToSession; + setUnauthorizedPathToSessionMock.mockReturnValue(null); await otherWindowStorage(e, s, history, qc); expect(logout).toHaveBeenCalledWith(s.okapi.url, s.store, qc); + expect(setUnauthorizedPathToSession).toHaveBeenCalled(); expect(history.push).toHaveBeenCalledWith('/logout-timeout'); }); diff --git a/src/loginServices.js b/src/loginServices.js index 2f90fab2..506de3ca 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -111,6 +111,24 @@ export const setTokenExpiry = async (te) => { return localforage.setItem(SESSION_NAME, val); }; +/** + * 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, 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) => { + const path = pathname ?? `${window.location.pathname}${window.location.search}`; + if (!path.startsWith('/logout')) { + 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 export const userLocaleConfig = { diff --git a/src/loginServices.test.js b/src/loginServices.test.js index f5ec3246..86c80ed8 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -7,12 +7,15 @@ import { getPlugins, getOkapiSession, getTokenExpiry, + getUnauthorizedPathFromSession, getUserLocale, handleLoginError, loadTranslations, logout, processOkapiSession, + removeUnauthorizedPathFromSession, setTokenExpiry, + setUnauthorizedPathToSession, spreadUserWithPerms, supportedLocales, supportedNumberingSystems, @@ -612,3 +615,37 @@ describe('getBindings', () => { mockFetchCleanUp(); }); }); + +describe('unauthorizedPath functions', () => { + describe('removeUnauthorizedPathFromSession', () => { + it('clears the value', () => { + setUnauthorizedPathToSession('monkey'); + removeUnauthorizedPathFromSession(); + expect(getUnauthorizedPathFromSession()).toBe(null); + }); + }); + + describe('setUnauthorizedPathToSession', () => { + it('stores the given value', () => { + const value = 'monkey'; + setUnauthorizedPathToSession(value); + expect(getUnauthorizedPathFromSession()).toBe(value); + }); + + it('stores the current location given no value', () => { + window.location.pathname = '/some-path'; + window.location.search = '?monkey=bagel'; + setUnauthorizedPathToSession(); + expect(getUnauthorizedPathFromSession()).toBe(`${window.location.pathname}${window.location.search}`); + }); + }); + + describe('getUnauthorizedPathFromSession', () => { + it('retrieves the value', () => { + const value = 'monkey'; + setUnauthorizedPathToSession(value); + expect(getUnauthorizedPathFromSession()).toBe(value); + }); + }); +}); +