diff --git a/CHANGELOG.md b/CHANGELOG.md index 916675fa..043f06d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ * Don't override initial discovery and okapi data in test mocks. Refs STCOR-913. * `` 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) diff --git a/package.json b/package.json index 05dc18b3..62ea54f2 100644 --- a/package.json +++ b/package.json @@ -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", 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. diff --git a/src/loginServices.js b/src/loginServices.js index 5a38dc46..71670c70 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -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(); diff --git a/translations/stripes-core/zh_TW.json b/translations/stripes-core/zh_TW.json index c53dddb7..45dddb49 100644 --- a/translations/stripes-core/zh_TW.json +++ b/translations/stripes-core/zh_TW.json @@ -84,7 +84,7 @@ "settingPassword": "正在設定密碼...", "mainnav.showAllApplicationsButtonLabel": "應用程式", "mainnav.showAllApplicationsButtonAriaLabel": "顯示所有應用程式", - "mainnav.currentAppAriaLabel": "目前開啟的應用程式: {appName} (點擊返回)", + "mainnav.currentAppAriaLabel": "目前開啟的應用程式 : {appName} ( 點擊返回 )", "mainnav.topLevelLabel": "主要", "mainnav.applicationListLabel": "應用程式清單", "errors.default.error": "抱歉,輸入的資訊與我們的紀錄不符。", @@ -122,7 +122,7 @@ "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 }}", @@ -130,16 +130,16 @@ "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": "點擊此處重新載入。", @@ -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", diff --git a/yarn.lock b/yarn.lock index 781fb316..a270f02e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,16 +2841,16 @@ "@types/node" "*" "@types/responselike" "^1.0.0" -"@types/cookie@^0.3.3": - version "0.3.3" - resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" - integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== - "@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== +"@types/cookie@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" + integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== + "@types/cors@^2.8.12": version "2.8.17" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" @@ -2893,7 +2893,7 @@ dependencies: "@types/node" "*" -"@types/hoist-non-react-statics@^3.0.1", "@types/hoist-non-react-statics@^3.3.1": +"@types/hoist-non-react-statics@^3.3.1", "@types/hoist-non-react-statics@^3.3.5": version "3.3.5" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== @@ -4942,7 +4942,12 @@ cookie@0.6.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== -cookie@^0.4.0, cookie@~0.4.1: +cookie@^0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cookie@~0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== @@ -7817,7 +7822,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -11377,14 +11382,14 @@ rc@1.2.8, rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-cookie@^4.0.3: - version "4.1.1" - resolved "https://registry.yarnpkg.com/react-cookie/-/react-cookie-4.1.1.tgz#832e134ad720e0de3e03deaceaab179c4061a19d" - integrity sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A== +react-cookie@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/react-cookie/-/react-cookie-7.2.2.tgz#a7559e552ea9cca39a4b3686723a5acf504b8f84" + integrity sha512-e+hi6axHcw9VODoeVu8WyMWyoosa1pzpyjfvrLdF7CexfU+WSGZdDuRfHa4RJgTpfv3ZjdIpHE14HpYBieHFhg== dependencies: - "@types/hoist-non-react-statics" "^3.0.1" - hoist-non-react-statics "^3.0.0" - universal-cookie "^4.0.0" + "@types/hoist-non-react-statics" "^3.3.5" + hoist-non-react-statics "^3.3.2" + universal-cookie "^7.0.0" react-dom@^18.2.0: version "18.2.0" @@ -13361,13 +13366,13 @@ unique-string@^3.0.0: dependencies: crypto-random-string "^4.0.0" -universal-cookie@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-4.0.4.tgz#06e8b3625bf9af049569ef97109b4bb226ad798d" - integrity sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw== +universal-cookie@^7.0.0: + version "7.2.2" + resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-7.2.2.tgz#93ae9ec55baab89b24300473543170bb8112773c" + integrity sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ== dependencies: - "@types/cookie" "^0.3.3" - cookie "^0.4.0" + "@types/cookie" "^0.6.0" + cookie "^0.7.2" universal-user-agent@^6.0.0: version "6.0.1"