Skip to content

Commit

Permalink
STCOR-849 return to previous location after logout-timeout (#1523)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
zburke committed Aug 22, 2024
1 parent e723082 commit 5eb571d
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 8 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion src/components/LogoutTimeout/LogoutTimeout.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import OrganizationLogo from '../OrganizationLogo';
import { useStripes } from '../../StripesContext';

import styles from './LogoutTimeout.css';
import {
getUnauthorizedPathFromSession,
removeUnauthorizedPathFromSession,
} from '../../loginServices';

/**
* LogoutTimeout
Expand All @@ -33,6 +37,13 @@ const LogoutTimeout = () => {
return <Redirect to="/" />;
}

const handleClick = (_e) => {
removeUnauthorizedPathFromSession();
};

const previousPath = getUnauthorizedPathFromSession();
const redirectTo = previousPath ?? '/';

return (
<main>
<div className={styles.wrapper} style={branding.style?.login ?? {}}>
Expand All @@ -49,7 +60,7 @@ const LogoutTimeout = () => {
</Row>
<Row center="xs">
<Col xs={12}>
<Button to="/"><FormattedMessage id="stripes-core.rtr.idleSession.logInAgain" /></Button>
<Button to={redirectTo} onClick={handleClick}><FormattedMessage id="stripes-core.rtr.idleSession.logInAgain" /></Button>
</Col>
</Row>
</div>
Expand Down
29 changes: 24 additions & 5 deletions src/components/LogoutTimeout/LogoutTimeout.test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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(<LogoutTimeout />);
screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad');
render(<LogoutTimeout />);
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(<LogoutTimeout />);

await user.click(screen.getByRole('button'));

expect(getUnauthorizedPathFromSession()).toBe(null);
});
});

it('if authenticated, renders a redirect', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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('/');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});

Expand All @@ -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');
});

Expand All @@ -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');
});

Expand Down
18 changes: 18 additions & 0 deletions src/loginServices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions src/loginServices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import {
getPlugins,
getOkapiSession,
getTokenExpiry,
getUnauthorizedPathFromSession,
getUserLocale,
handleLoginError,
loadTranslations,
logout,
processOkapiSession,
removeUnauthorizedPathFromSession,
setTokenExpiry,
setUnauthorizedPathToSession,
spreadUserWithPerms,
supportedLocales,
supportedNumberingSystems,
Expand Down Expand Up @@ -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);
});
});
});

0 comments on commit 5eb571d

Please sign in to comment.