Skip to content

Commit

Permalink
STCOR-926 validate that cookies and storage are available (#1581)
Browse files Browse the repository at this point in the history
Detect the presence of localStorage, sessionStorage, and cookies early
early early in the stripes-init process and show an error message if any
are unavailable.

This prevents a white screen of death if, say, session storage is
unavailable but we call it anyway, resulting in an untrapped exception
(Here's looking at you, OIDCRedirect).

Refs STCOR-926
  • Loading branch information
zburke authored Jan 9, 2025
1 parent 2f75d43 commit 983ce86
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 9 deletions.
63 changes: 55 additions & 8 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { okapi as okapiConfig, config } from 'stripes-config';
import merge from 'lodash/merge';

import AppConfigError from './components/AppConfigError';
import connectErrorEpic from './connectErrorEpic';
import configureEpics from './configureEpics';
import configureLogger from './configureLogger';
Expand All @@ -23,6 +24,39 @@ const StrictWrapper = ({ children }) => {
return <StrictMode>{children}</StrictMode>;
};

/**
* isStorageEnabled
* Return true if local-storage, session-storage, and cookies are all enabled.
* Return false otherwise.
* @returns boolean true if storages are enabled; false otherwise.
*/
export const isStorageEnabled = () => {
let isEnabled = true;
// local storage
try {
localStorage.getItem('test-key');
} catch (e) {
console.warn('local storage is disabled'); // eslint-disable-line no-console
isEnabled = false;
}

// session storage
try {
sessionStorage.getItem('test-key');
} catch (e) {
console.warn('session storage is disabled'); // eslint-disable-line no-console
isEnabled = false;
}

// cookies
if (!navigator.cookieEnabled) {
console.warn('cookies are disabled'); // eslint-disable-line no-console
isEnabled = false;
}

return isEnabled;
};

StrictWrapper.propTypes = {
children: PropTypes.node.isRequired,
};
Expand All @@ -36,24 +70,37 @@ export default class StripesCore extends Component {
constructor(props) {
super(props);

const parsedTenant = getStoredTenant();
if (isStorageEnabled()) {
const parsedTenant = getStoredTenant();

const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0)
? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId || okapiConfig.clientId } : { withoutOkapi: true };
const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0)
? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId || okapiConfig.clientId } : { withoutOkapi: true };

const initialState = merge({}, { okapi }, props.initialState);
const initialState = merge({}, { okapi }, props.initialState);

this.logger = configureLogger(config);
this.epics = configureEpics(connectErrorEpic);
this.store = configureStore(initialState, this.logger, this.epics);
this.actionNames = gatherActions();
this.logger = configureLogger(config);
this.epics = configureEpics(connectErrorEpic);
this.store = configureStore(initialState, this.logger, this.epics);
this.actionNames = gatherActions();

this.state = { isStorageEnabled: true };
} else {
this.state = { isStorageEnabled: false };
}
}

componentWillUnmount() {
this.store.dispatch(destroyStore());
}

render() {
// Stripes requires cookies (for login) and session and local storage
// (for session state and all manner of things). If these are not enabled,
// stop and show an error message.
if (!this.state.isStorageEnabled) {
return <AppConfigError />;
}

// no need to pass along `initialState`
// eslint-disable-next-line no-unused-vars
const { initialState, ...props } = this.props;
Expand Down
36 changes: 36 additions & 0 deletions src/App.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { isStorageEnabled } from './App';

const storageMock = () => ({
getItem: () => {
throw new Error();
},
});

describe('isStorageEnabled', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('returns true when all storage options are enabled', () => {
expect(isStorageEnabled()).toBeTrue;
});

describe('returns false when any storage option is disabled', () => {
it('handles local storage', () => {
Object.defineProperty(window, 'localStorage', { value: storageMock });
const isEnabled = isStorageEnabled();
expect(isEnabled).toBeFalse;
});
it('handles session storage', () => {
Object.defineProperty(window, 'sessionStorage', { value: storageMock });
const isEnabled = isStorageEnabled();
expect(isEnabled).toBeFalse;
});

it('handles cookies', () => {
jest.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(false);
const isEnabled = isStorageEnabled();
expect(isEnabled).toBeFalse;
});
});
});
32 changes: 32 additions & 0 deletions src/components/AppConfigError/AppConfigError.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@import "@folio/stripes-components/lib/variables.css";

.wrapper {
display: flex;
justify-content: center;
min-height: 100vh;
}

.container {
width: 100%;
max-width: 940px;
min-height: 330px;
margin: 12vh 2rem 0;
}

@media (--medium-up) {
.container {
min-height: initial;
}
}

@media (--large-up) {
.header {
font-size: var(--font-size-xx-large);
}
}

@media (height <= 440px) {
.container {
min-height: 330px;
}
}
47 changes: 47 additions & 0 deletions src/components/AppConfigError/AppConfigError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { branding } from 'stripes-config';

import {
Row,
Col,
Headline,
} from '@folio/stripes-components';

import OrganizationLogo from '../OrganizationLogo';
import styles from './AppConfigError.css';

/**
* AppConfigError
* Show an error message. This component is rendered by App, before anything
* else, when it detects that local storage, session storage, or cookies are
* unavailable. This happens _before_ Root has been initialized, i.e. before
* an IntlProvider is available, hence the hard-coded, English-only message.
*
* @returns English-only error message
*/
const AppConfigError = () => {
return (
<main>
<div className={styles.wrapper} style={branding?.style?.login ?? {}}>
<div className={styles.container}>
<Row center="xs">
<Col xs={6}>
<OrganizationLogo />
</Col>
</Row>
<Row center="xs">
<Col xs={6}>
<Headline
size="xx-large"
tag="h1"
>
FOLIO requires cookies, sessionStorage, and localStorage. Please enable these features and try again.
</Headline>
</Col>
</Row>
</div>
</div>
</main>
);
};

export default AppConfigError;
12 changes: 12 additions & 0 deletions src/components/AppConfigError/AppConfigError.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { render, screen } from '@folio/jest-config-stripes/testing-library/react';
import AppConfigError from './AppConfigError';

jest.mock('../OrganizationLogo', () => () => 'OrganizationLogo');
describe('AppConfigError', () => {
it('displays a warning message', async () => {
render(<AppConfigError />);

expect(screen.getByText(/cookies/i)).toBeInTheDocument();
expect(screen.getByText(/storage/i)).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/components/AppConfigError/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './AppConfigError';
10 changes: 9 additions & 1 deletion src/components/OIDCRedirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ import { getUnauthorizedPathFromSession, removeUnauthorizedPathFromSession } fro

// Setting at top of component since value should be retained during re-renders
// but will be correctly re-fetched when redirected from Keycloak login page.
const unauthorizedPath = getUnauthorizedPathFromSession();
// The empty try/catch is necessary because, by setting this at the top of
// the component, it is automatically executed even before <App /> renders.
// IOW, even though we check for session-storage in App, we still have to
// protect the call here.
let unauthorizedPath = null;
try {
unauthorizedPath = getUnauthorizedPathFromSession();
} catch (e) { // eslint-disable-line no-empty
}

/**
* OIDCRedirect authenticated route handler for /oidc-landing.
Expand Down

0 comments on commit 983ce86

Please sign in to comment.