diff --git a/src/App.js b/src/App.js
index 7f72ce32..4cd8ca07 100644
--- a/src/App.js
+++ b/src/App.js
@@ -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';
@@ -23,6 +24,39 @@ const StrictWrapper = ({ children }) => {
return {children};
};
+/**
+ * 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,
};
@@ -36,17 +70,23 @@ 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() {
@@ -54,6 +94,13 @@ export default class StripesCore extends Component {
}
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 ;
+ }
+
// no need to pass along `initialState`
// eslint-disable-next-line no-unused-vars
const { initialState, ...props } = this.props;
diff --git a/src/App.test.js b/src/App.test.js
new file mode 100644
index 00000000..eb805f6b
--- /dev/null
+++ b/src/App.test.js
@@ -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;
+ });
+ });
+});
diff --git a/src/components/AppConfigError/AppConfigError.css b/src/components/AppConfigError/AppConfigError.css
new file mode 100644
index 00000000..e8cc7eff
--- /dev/null
+++ b/src/components/AppConfigError/AppConfigError.css
@@ -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;
+ }
+}
diff --git a/src/components/AppConfigError/AppConfigError.js b/src/components/AppConfigError/AppConfigError.js
new file mode 100644
index 00000000..bf3623bc
--- /dev/null
+++ b/src/components/AppConfigError/AppConfigError.js
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+ FOLIO requires cookies, sessionStorage, and localStorage. Please enable these features and try again.
+
+
+
+
+
+
+ );
+};
+
+export default AppConfigError;
diff --git a/src/components/AppConfigError/AppConfigError.test.js b/src/components/AppConfigError/AppConfigError.test.js
new file mode 100644
index 00000000..95f88aa3
--- /dev/null
+++ b/src/components/AppConfigError/AppConfigError.test.js
@@ -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();
+
+ expect(screen.getByText(/cookies/i)).toBeInTheDocument();
+ expect(screen.getByText(/storage/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/AppConfigError/index.js b/src/components/AppConfigError/index.js
new file mode 100644
index 00000000..f6a6ec98
--- /dev/null
+++ b/src/components/AppConfigError/index.js
@@ -0,0 +1 @@
+export { default } from './AppConfigError';
diff --git a/src/components/OIDCRedirect.js b/src/components/OIDCRedirect.js
index 9d463fe9..8898ca6e 100644
--- a/src/components/OIDCRedirect.js
+++ b/src/components/OIDCRedirect.js
@@ -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 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.