Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

STCOR-926 validate that cookies and storage are available #1581

Merged
merged 4 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading