Skip to content

Commit

Permalink
Merge branch 'master' into STCOR-932
Browse files Browse the repository at this point in the history
  • Loading branch information
zburke authored Jan 16, 2025
2 parents 4bb8660 + 792d3b2 commit af2bd81
Show file tree
Hide file tree
Showing 12 changed files with 232 additions and 40 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* Don't override initial discovery and okapi data in test mocks. Refs STCOR-913.
* `<Logout>` must consume `QueryClient` in order to supply it to `loginServices::logout()`. Refs STCOR-907.
* On resuming session, spread session and `_self` together to preserve session values. Refs STCOR-912.
* Provide `useModuleFor(path)` hook that maps paths to their implementing modules. Refs STCOR-932.
* Fetch `/saml/check` when starting a new session, i.e. before discovery. Refs STCOR-933, STCOR-816.
* Provide `useModuleInfo(path)` hook that maps paths to their implementing modules. Refs STCOR-932.

## [10.2.0](https://github.com/folio-org/stripes-core/tree/v10.2.0) (2024-10-11)
[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.1...v10.2.0)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"ms": "^2.1.3",
"prop-types": "^15.5.10",
"query-string": "^7.1.2",
"react-cookie": "^4.0.3",
"react-cookie": "^7.2.2",
"react-final-form": "^6.3.0",
"react-query": "^3.6.0",
"react-titled": "^2.0.0",
Expand Down
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
7 changes: 5 additions & 2 deletions src/loginServices.js
Original file line number Diff line number Diff line change
Expand Up @@ -851,8 +851,11 @@ export function checkOkapiSession(okapiUrl, store, tenant) {
.then((sess) => {
return sess?.user?.id ? validateUser(okapiUrl, store, tenant, sess) : null;
})
.then(() => {
if (store.getState().discovery?.interfaces?.['login-saml']) {
.then((res) => {
// check whether SSO is enabled if either
// 1. res is null (when we are starting a new session)
// 2. login-saml interface is present (when we are resuming an existing session)
if (!res || store.getState().discovery?.interfaces?.['login-saml']) {
return getSSOEnabled(okapiUrl, store, tenant);
}
return Promise.resolve();
Expand Down
12 changes: 6 additions & 6 deletions translations/stripes-core/zh_TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"settingPassword": "正在設定密碼...",
"mainnav.showAllApplicationsButtonLabel": "應用程式",
"mainnav.showAllApplicationsButtonAriaLabel": "顯示所有應用程式",
"mainnav.currentAppAriaLabel": "目前開啟的應用程式{appName} 點擊返回",
"mainnav.currentAppAriaLabel": "目前開啟的應用程式 : {appName} ( 點擊返回 )",
"mainnav.topLevelLabel": "主要",
"mainnav.applicationListLabel": "應用程式清單",
"errors.default.error": "抱歉,輸入的資訊與我們的紀錄不符。",
Expand Down Expand Up @@ -122,24 +122,24 @@
"errors.password.whiteSpace.invalid": "密碼不能含有空格。",
"mainnav.myProfileAriaLabel": "{tenantName} {servicePointName}設定檔",
"mainnav.skipMainNavigation": "跳過主導覽",
"errors.user.timeout": "連線已過期。請再次登入以恢復連線",
"errors.user.timeout": "工作階段已過期。請再次登入以恢復工作階段",
"errors.password.consecutiveWhitespaces.invalid": "密碼不得包含連續的空格字元。",
"about.missingModuleCount": "{count, number}缺少{count, plural, one {介面} other {介面}}} }}",
"about.incompatibleModuleCount": "{count, number}不相容於介面{count, plural, one { version } other { versions }}",
"routeErrorBoundary.sub": "此頁面發生錯誤,無法載入。",
"routeErrorBoundary.goToAppHomeLabel": "返回首頁",
"routeErrorBoundary.goToModuleHomeLabel": "返回{name}登入頁",
"routeErrorBoundary.goToModuleSettingsHomeLabel": "返回{name}設定",
"logoutKeepSso": "從 FOLIO 登出,保持 SSO 連線",
"logoutKeepSso": "從 FOLIO 登出,保持 SSO 連線工作階段",
"mainnav.profileDropdown.locale": "語言環境",
"mainnav.profileDropdown.permissions": "權限",
"front.error.header": "錯誤 404",
"errors.password.compromised.invalid": "密碼不得為常用、被預測或被外洩的密碼",
"createResetPassword.ruleTemplate": "必須{description}",
"front.error.general.message": "在此伺服器上找不到請求的 URL {br}{url}{br} 。",
"front.error.setPassword.message": "請登出目前的 FOLIO 連線以設定密碼。登出後,請嘗試使用此連結再次設定您的密碼。",
"front.error.setPassword.message": "請登出目前的 FOLIO 工作階段以設定密碼。登出後,請嘗試使用此連結再次設定您的密碼。",
"title.noPermission": "沒有權限",
"front.error.noPermission": "您無權查看此應用程式/記錄",
"front.error.noPermission": "您無權查看此應用程式 / 紀錄",
"button.duplicate": "複製",
"stale.warning": "伺服器上的應用程式已更改,需要重新整理。",
"stale.reload": "點擊此處重新載入。",
Expand All @@ -151,7 +151,7 @@
"rtr.idleSession.timeRemaining": "Time remaining",
"rtr.idleSession.keepWorking": "Keep working",
"rtr.idleSession.sessionExpiredSoSad": "Your session expired due to inactivity.",
"rtr.idleSession.logInAgain": "Log in again",
"rtr.idleSession.logInAgain": "再次登入",
"title.logout": "Log out",
"about.applicationCount": "{count} applications",
"about.applicationsVersionsTitle": "Applications/modules/interfaces",
Expand Down
Loading

0 comments on commit af2bd81

Please sign in to comment.