diff --git a/.gitignore b/.gitignore index 8248607ea..a1249c35e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ artifacts dist junit.xml .vscode/launch.json +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 86479e6a4..def64009b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Change history for stripes-core -## 10.1.0 IN PROGRESS +## [10.1.1](https://github.com/folio-org/stripes-core/tree/v10.1.1) (2024-03-25) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.0...v10.1.1) + +* Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. +* Idle-session timeout and "Keep working?" modal. Refs STCOR-776. +* Always retrieve `clientId` and `tenant` values from `config.tenantOptions` in stripes.config.js. Retires `okapi.tenant`, `okapi.clientId`, and `config.isSingleTenant`. Refs STCOR-787. +* Correctly evaluate `stripes.okapi` before rendering ``. Refs STCOR-864. +* `/users-keycloak/_self` is an authentication request. Refs STCOR-866. +* Terminate the session when the fixed-length session expires. Refs STCOR-862. + +## [10.1.0](https://github.com/folio-org/stripes-core/tree/v10.1.0) (2024-03-12) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.1.0) * Provide optional tenant argument to `useOkapiKy` hook. Refs STCOR-747. * Avoid private path when import `validateUser` function. Refs STCOR-749. @@ -24,6 +35,34 @@ * Add `idName` and `limit` as passable props to `useChunkedCQLFetch`. Refs STCOR-821. * Check for valid token before rotating during XHR send. Refs STCOR-817. * Remove `autoComplete` from ``, `` fields. Refs STCOR-742. +* Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. + +## [10.0.3](https://github.com/folio-org/stripes-core/tree/v10.0.3) (2023-11-10) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.2...v10.0.3) + +* Revert "Use cookies and RTR" until further notice. Refs FOLIO-3627. +* Ensure `` is not cut off when app name is long. Refs STCOR-752. + +## [10.0.2](https://github.com/folio-org/stripes-core/tree/v10.0.2) (2023-11-06) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.1...v10.0.2) + +* Use cookies and RTR instead of directly handling the JWT. Refs STCOR-671, STCOR-754, STCOR-756, FOLIO-3627. + +## [10.0.1](https://github.com/folio-org/stripes-core/tree/v10.0.1) (2023-10-25) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.0.0...v10.0.1) + +* Export `validateUser`. Refs STCOR-749. +* Opt-in: handle access-control via cookies. Refs STCOR-671. +* Opt-in: disable login when cookies are disabled. Refs STCOR-762. +* Convert `` tests to jest. STCOR-798. +* Parse response from `/authn/token` to immediately store AT/RT expiration values. Refs STCOR-811. +* Include `X-Okapi-Tenant` header in `/authn/logout` calls. Refs STCOR-812. +* Correctly parse `.../_self` permissions object. Refs STCOR-813. +* Export `getEventHandler` to be able to create events in other modules. Refs STCOR-770. +* Simplify logout workflow to bypass keycloak confirmation page. Refs STCOR-803. +* After login, only check SSO endpoints when `login-saml` interface is present. Refs STCOR-816. +* Add `idName` and `limit` as passable props to `useChunkedCQLFetch`. Refs STCOR-821. +* For the `/reset-password` route, allow token to be specified in the path or query arguments. Refs STCOR-820. ## [10.0.0](https://github.com/folio-org/stripes-core/tree/v10.0.0) (2023-10-11) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v9.0.0...v10.0.0) diff --git a/package.json b/package.json index 7f7bc4e68..9f46ae294 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@folio/stripes-core", - "version": "10.1.0", + "version": "10.1.1", "description": "The starting point for Stripes applications", "license": "Apache-2.0", "repository": "folio-org/stripes-core", @@ -75,11 +75,13 @@ "graphql": "^16.0.0", "history": "^4.6.3", "hoist-non-react-statics": "^3.3.0", + "inactivity-timer": "^1.0.0", "jwt-decode": "^3.1.2", "ky": "^0.23.0", "localforage": "^1.5.6", "lodash": "^4.17.21", "moment-timezone": "^0.5.14", + "ms": "^2.1.3", "prop-types": "^15.5.10", "query-string": "^7.1.2", "react-cookie": "^4.0.3", diff --git a/src/App.js b/src/App.js index 437037235..226750189 100644 --- a/src/App.js +++ b/src/App.js @@ -21,8 +21,11 @@ export default class StripesCore extends Component { constructor(props) { super(props); + const storedTenant = localStorage.getItem('tenant'); + const parsedTenant = storedTenant ? JSON.parse(storedTenant) : undefined; + const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0) - ? okapiConfig : { withoutOkapi: true }; + ? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId } : { withoutOkapi: true }; const initialState = merge({}, { okapi }, props.initialState); @@ -48,7 +51,7 @@ export default class StripesCore extends Component { logger={this.logger} config={config} actionNames={this.actionNames} - disableAuth={(config && config.disableAuth) || false} + disableAuth={(config?.disableAuth) || false} {...props} /> ); diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index a507fff5a..941d979ca 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -1,10 +1,10 @@ -import React from 'react'; +import { useState } from 'react'; import PropTypes from 'prop-types'; import { Router, Switch, + Redirect as InternalRedirect } from 'react-router-dom'; - import { Provider } from 'react-redux'; import { CookiesProvider } from 'react-cookie'; @@ -21,161 +21,176 @@ import { ModuleTranslator, TitledRoute, Front, + OIDCRedirect, + OIDCLanding, SSOLanding, SSORedirect, Settings, HandlerManager, TitleManager, - Login, + Logout, + LogoutTimeout, OverlayContainer, CreateResetPassword, CheckEmailStatusPage, ForgotPasswordCtrl, ForgotUserNameCtrl, AppCtxMenuProvider, + SessionEventContainer, } from './components'; import StaleBundleWarning from './components/StaleBundleWarning'; import { StripesContext } from './StripesContext'; import { CalloutContext } from './CalloutContext'; +import AuthnLogin from './components/AuthnLogin'; -class RootWithIntl extends React.Component { - static propTypes = { - stripes: PropTypes.shape({ - config: PropTypes.object, - epics: PropTypes.object, - logger: PropTypes.object.isRequired, - clone: PropTypes.func.isRequired, - }).isRequired, - token: PropTypes.string, - isAuthenticated: PropTypes.bool, - disableAuth: PropTypes.bool.isRequired, - history: PropTypes.shape({}), - }; +const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAuth, history = {} }) => { + const connect = connectFor('@folio/core', stripes.epics, stripes.logger); + const connectedStripes = stripes.clone({ connect }); - static defaultProps = { - token: '', - isAuthenticated: false, - history: {}, + const [callout, setCallout] = useState(null); + const setCalloutDomRef = (ref) => { + setCallout(ref); }; - state = { callout: null }; - - setCalloutRef = (ref) => { - this.setState({ - callout: ref, - }); - } - - render() { - const { - token, - isAuthenticated, - disableAuth, - history, - } = this.props; - - const connect = connectFor('@folio/core', this.props.stripes.epics, this.props.stripes.logger); - const stripes = this.props.stripes.clone({ connect }); + return ( + + + + + + + + { isAuthenticated || token || disableAuth ? + <> + + + + {typeof connectedStripes?.config?.staleBundleWarning === 'object' && } + + { (typeof connectedStripes.okapi !== 'object' || connectedStripes.discovery.isFinished) && ( + + + {connectedStripes.config.useSecureTokens && } + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + )} + + + + : + + {/* The ? after :token makes that part of the path optional, so that token may optionally + be passed in via URL parameter to avoid length restrictions */} + } + /> + } + key="sso-landing" + /> + } + key="oidc-landing" + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + + + + + + + + ); +}; - return ( - - - - - - - - { isAuthenticated || token || disableAuth ? - <> - - - - {typeof stripes?.config?.staleBundleWarning === 'object' && } - - { (stripes.okapi !== 'object' || stripes.discovery.isFinished) && ( - - - - } - /> - } - /> - } - /> - - - - )} - - - - : - - } - /> - } - key="sso-landing" - /> - } - /> - } - /> - } - /> - - } - /> - - } - - - - - - - - ); - } -} +RootWithIntl.propTypes = { + stripes: PropTypes.shape({ + clone: PropTypes.func.isRequired, + config: PropTypes.object, + epics: PropTypes.object, + logger: PropTypes.object.isRequired, + okapi: PropTypes.object.isRequired, + store: PropTypes.object.isRequired + }).isRequired, + token: PropTypes.string, + isAuthenticated: PropTypes.bool, + disableAuth: PropTypes.bool.isRequired, + history: PropTypes.shape({}), +}; export default RootWithIntl; diff --git a/src/RootWithIntl.test.js b/src/RootWithIntl.test.js new file mode 100644 index 000000000..6aa1babad --- /dev/null +++ b/src/RootWithIntl.test.js @@ -0,0 +1,95 @@ +/* shhhh, eslint, it's ok. we need "unused" imports for mocks */ +/* eslint-disable no-unused-vars */ + +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import { Router as DefaultRouter } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import AuthnLogin from './components/AuthnLogin'; +import MainNav from './components/MainNav'; +import MainContainer from './components/MainContainer'; +import ModuleContainer from './components/ModuleContainer'; +import RootWithIntl from './RootWithIntl'; +import Stripes from './Stripes'; + +jest.mock('./components/AuthnLogin', () => () => ''); +jest.mock('./components/MainNav', () => () => ''); +jest.mock('./components/ModuleContainer', () => () => ''); +jest.mock('./components/MainContainer', () => ({ children }) => children); + +const defaultHistory = createMemoryHistory(); + +const Harness = ({ + Router = DefaultRouter, + children, + history = defaultHistory, +}) => { + return ( + + {children} + + ); +}; + +const store = { + getState: () => ({ + okapi: { + token: '123', + }, + }), + dispatch: () => {}, + subscribe: () => {}, + replaceReducer: () => {}, +}; + +describe('RootWithIntl', () => { + it('renders login without one of (isAuthenticated, token, disableAuth)', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: false } }); + await render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + expect(screen.queryByText(//)).toBeNull(); + }); + + describe('renders MainNav', () => { + it('given isAuthenticated', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: false } }); + await render(); + + expect(screen.queryByText(//)).toBeNull(); + expect(screen.queryByText(//)).toBeInTheDocument(); + }); + + it('given token', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: false } }); + await render(); + + expect(screen.queryByText(//)).toBeNull(); + expect(screen.queryByText(//)).toBeInTheDocument(); + }); + + it('given disableAuth', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: false } }); + await render(); + + expect(screen.queryByText(//)).toBeNull(); + expect(screen.queryByText(//)).toBeInTheDocument(); + }); + }); + + describe('renders ModuleContainer', () => { + it('if config.okapi is not an object', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, discovery: { isFinished: true } }); + await render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + + it('if discovery is finished', async () => { + const stripes = new Stripes({ epics: {}, logger: {}, bindings: {}, config: {}, store, okapi: {}, discovery: { isFinished: true } }); + await render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Stripes.js b/src/Stripes.js index 3d7413354..5f91fe238 100644 --- a/src/Stripes.js +++ b/src/Stripes.js @@ -23,6 +23,7 @@ export const stripesShape = PropTypes.shape({ logTimestamp: PropTypes.bool, showHomeLink: PropTypes.bool, showPerms: PropTypes.bool, + tenantOptions: PropTypes.object, }).isRequired, connect: PropTypes.func.isRequired, currency: PropTypes.string, diff --git a/src/components/About/About.css b/src/components/About/About.css index e940a85d7..782bde274 100644 --- a/src/components/About/About.css +++ b/src/components/About/About.css @@ -23,3 +23,7 @@ .incompatible { color: orange; } + +.paddingLeftOfListItems { + padding-left: 14px; +} diff --git a/src/components/About/About.js b/src/components/About/About.js index 58de039f2..607282945 100644 --- a/src/components/About/About.js +++ b/src/components/About/About.js @@ -1,27 +1,30 @@ -import _ from 'lodash'; import React, { useRef, useEffect } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; -import stripesConnect from '@folio/stripes-connect/package'; -import stripesComponents from '@folio/stripes-components/package'; -import stripesLogger from '@folio/stripes-logger/package'; - +import { config } from 'stripes-config'; import { - Pane, Headline, - List, - Loading + Loading, + Pane, } from '@folio/stripes-components'; -import AboutEnabledModules from './AboutEnabledModules'; + import AboutInstallMessages from './AboutInstallMessages'; import WarningBanner from './WarningBanner'; import { withModules } from '../Modules'; -import stripesCore from '../../../package'; import css from './About.css'; +import { useStripes } from '../../StripesContext'; +import AboutOkapi from './AboutOkapi'; +import AboutApplicationVersions from './AboutApplicationVersions'; +import AboutStripes from './AboutStripes'; +import AboutAPIGateway from './AboutAPIGateway'; +import AboutUIDependencies from './AboutUIDependencies'; +import AboutUIModuleDetails from './AboutUIModuleDetails'; +import stripesCore from '../../../package'; const About = (props) => { const titleRef = useRef(null); const bannerRef = useRef(null); + const stripes = useStripes(); useEffect(() => { if (bannerRef.current) { @@ -31,85 +34,17 @@ const About = (props) => { } }, []); - function renderDependencies(m, interfaces) { - const base = `${m.module} ${m.version}`; - if (!interfaces) { - return base; - } - - const okapiInterfaces = m.okapiInterfaces; - if (!okapiInterfaces) { - return ; - } - - const itemFormatter = (key) => { - const text = okapiInterfaces[key]; + const applications = stripes.discovery?.applications || {}; + const interfaces = stripes.discovery?.interfaces || {}; + const isLoadingFinished = stripes.discovery?.isFinished; + const na = Object.keys(applications).length; - return ( -
  • - {key} - {' '} - {text} -
  • - ); - }; - - return ( - - - - - - - ); - } - - function listModules(caption, list, interfaces) { - const itemFormatter = m => (
  • {renderDependencies(m, interfaces)}
  • ); - let headlineMsg; - switch (caption) { - case 'app': - headlineMsg = ; - break; - case 'settings': - headlineMsg = ; - break; - case 'plugin': - headlineMsg = ; - break; - default: - headlineMsg = ; - } - - list.sort(); - - return ( -
    - {headlineMsg} -
    - -
    -
    - ); - } - - const modules = _.get(props.stripes, ['discovery', 'modules']) || {}; - const interfaces = _.get(props.stripes, ['discovery', 'interfaces']) || {}; - const isLoadingFinished = _.get(props.stripes, ['discovery', 'isFinished']); - const nm = Object.keys(modules).length; - const ni = Object.keys(interfaces).length; - const ConnectedAboutEnabledModules = props.stripes.connect(AboutEnabledModules); - const unknownMsg = ; - const numModulesMsg = ; - const numInterfacesMsg = ; + const numApplicationsMsg = ( + + ); return ( { bannerRef={bannerRef} /> )} - +
    -
    - - - - - - - - (
  • {item.value}
  • )} - /> -
    -
    - {Object.keys(props.modules).map(key => listModules(key, props.modules[key]))} -
    -
    -
    - - - - Okapi - (
  • {item}
  • )} - items={[ - , - , - - ]} - /> -
    - {numModulesMsg} - - - - - {chunks} - }} - /> -
    - {numInterfacesMsg} - ( -
  • - {`${key} ${interfaces[key]}`} -
  • - )} - /> -
    -
    - - - - - - - {renderDependencies(Object.assign({}, stripesCore.stripes || {}, { module: 'stripes-core' }), interfaces)} -
    - {Object.keys(props.modules).map(key => listModules(key, props.modules[key], interfaces))} -
    + {config.tenantOptions ? ( + <> +
    + +
    +
    + +
    + +
    + + + + + + + + +
    + + ) : ( + + )}
    ); @@ -227,14 +100,6 @@ const About = (props) => { About.propTypes = { modules: PropTypes.object, - stripes: PropTypes.shape({ - discovery: PropTypes.shape({ - modules: PropTypes.object, - interfaces: PropTypes.object, - }), - connect: PropTypes.func, - hasPerm: PropTypes.func.isRequired, - }).isRequired, }; export default withModules(About); diff --git a/src/components/About/About.test.js b/src/components/About/About.test.js new file mode 100644 index 000000000..6306e9e31 --- /dev/null +++ b/src/components/About/About.test.js @@ -0,0 +1,117 @@ +/* shhhh, eslint, it's ok. we need "unused" imports for mocks */ +/* eslint-disable no-unused-vars */ + +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import { okapi as okapiConfig } from 'stripes-config'; + +import AboutAPIGateway from './AboutAPIGateway'; +import AboutApplicationVersions from './AboutApplicationVersions'; +import AboutEnabledModules from './AboutEnabledModules'; +import AboutInstallMessages from './AboutInstallMessages'; +import AboutOkapi from './AboutOkapi'; +import AboutStripes from './AboutStripes'; +import AboutUIDependencies from './AboutUIDependencies'; +import AboutUIModuleDetails from './AboutUIModuleDetails'; +import WarningBanner from './WarningBanner'; + +import { useStripes } from '../../StripesContext'; +import About from './About'; + +jest.mock('./AboutAPIGateway', () => () => 'AboutAPIGateway'); +jest.mock('./AboutApplicationVersions', () => () => 'AboutApplicationVersions'); +jest.mock('./AboutEnabledModules', () => () => 'AboutEnabledModules'); +jest.mock('./AboutInstallMessages', () => () => 'AboutInstallMessages'); +jest.mock('./AboutOkapi', () => () => 'AboutOkapi'); +jest.mock('./AboutStripes', () => () => 'AboutStripes'); +jest.mock('./AboutUIDependencies', () => () => 'AboutUIDependencies'); +jest.mock('./AboutUIModuleDetails', () => () => 'AboutUIModuleDetails'); +jest.mock('./WarningBanner', () => () => 'WarningBanner'); + +jest.mock('stripes-config', () => ({ + config: { tenantOptions: true }, +})); + +// set query retries to false. otherwise, react-query will thoughtfully +// (but unhelpfully, in the context of testing) retry a failed query +// several times causing the test to timeout when what we really want +// is for it to throw so we can catch and test the exception. +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false + } + } +}); + +jest.mock('../../StripesContext'); + +describe('About', () => { + it('displays application discovery details', async () => { + const modules = { + app: [ + { + module: 'app-alpha', + version: '1.2.3', + okapiInterfaces: { + iAlpha: '1.0', + } + }, + { module: 'app-beta', version: '2.3.4' } + ], + settings: [ + { module: 'settings-alpha', version: '3.4.5' }, + { module: 'settings-beta', version: '4.5.6' } + ], + plugin: [ + { module: 'plugin-alpha', version: '5.6.7' }, + { module: 'plugin-beta', version: '6.7.8' } + ], + typeThatHasNotBeenInventedYet: [ + { module: 'typeThatHasNotBeenInventedYet-alpha', version: '7.8.9' }, + { module: 'typeThatHasNotBeenInventedYet-beta', version: '8.9.10' } + ], + }; + + const stripes = { + okapi: { + tenant: 'barbie', + url: 'https://oppie.edu', + }, + discovery: { + modules, + interfaces: { + bar: '1.0', + bat: '2.0', + }, + isFinished: true, + } + }; + + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue(stripes); + + render( + + + + ); + + expect(screen.getByText(/WarningBanner/)).toBeInTheDocument(); + expect(screen.getByText(/AboutInstallMessages/)).toBeInTheDocument(); + expect(screen.getByText(/AboutApplicationVersions/)).toBeInTheDocument(); + expect(screen.getByText(/AboutStripes/)).toBeInTheDocument(); + expect(screen.getByText(/AboutAPIGateway/)).toBeInTheDocument(); + expect(screen.getByText(/about.uiOrServiceDependencies/)).toBeInTheDocument(); + expect(screen.getByText(/AboutUIModuleDetails/)).toBeInTheDocument(); + expect(screen.getByText(/AboutUIDependencies/)).toBeInTheDocument(); + expect(screen.queryByText(/AboutOkapi/)).toBe(null); + }); +}); diff --git a/src/components/About/AboutAPIGateway.js b/src/components/About/AboutAPIGateway.js new file mode 100644 index 000000000..676280f40 --- /dev/null +++ b/src/components/About/AboutAPIGateway.js @@ -0,0 +1,39 @@ +import _ from 'lodash'; +import { FormattedMessage } from 'react-intl'; + +import { + Headline, + List, +} from '@folio/stripes-components'; +import { useStripes } from '../../StripesContext'; + +/** + * AboutAPIGateway + * Display API gateway details including version, tenant, gateway URL + * @returns + */ +const AboutAPIGateway = () => { + const stripes = useStripes(); + const unknownMsg = ; + return ( + <> + + + + + + + (
  • {item}
  • )} + items={[ + , + , + + ]} + /> + + ); +}; + +export default AboutAPIGateway; diff --git a/src/components/About/AboutAPIGateway.test.js b/src/components/About/AboutAPIGateway.test.js new file mode 100644 index 000000000..f621b2b0c --- /dev/null +++ b/src/components/About/AboutAPIGateway.test.js @@ -0,0 +1,27 @@ +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import { useStripes } from '../../StripesContext'; +import AboutAPIGateway from './AboutAPIGateway'; + +jest.mock('../../StripesContext'); + +describe('AboutAPIGateway', () => { + it('displays API gateway details', async () => { + const okapi = { + tenant: 'barbie', + url: 'https://oppie.com' + }; + + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue({ okapi }); + + render(); + + expect(screen.getByText(/about.version/)).toBeInTheDocument(); + expect(screen.getByText(/about.forTenant/)).toBeInTheDocument(); + expect(screen.getByText(/about.onUrl/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/About/AboutApplicationVersions.js b/src/components/About/AboutApplicationVersions.js new file mode 100644 index 000000000..7de809407 --- /dev/null +++ b/src/components/About/AboutApplicationVersions.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { + Headline, +} from '@folio/stripes-components'; + +import css from './About.css'; +import AboutModules from './AboutModules'; + +/** + * AboutApplicationVersions + * Applications listed by discovery + * @param {*} param0 + * @returns + */ +const AboutApplicationVersions = ({ message, applications }) => { + return ( +
    + + + + {message} + {Object.values(applications) + .map((app) => { + return ( +
      +
    • + {app.name} + +
    • +
    + ); + })} +
    + ); +}; + +AboutApplicationVersions.propTypes = { + applications: PropTypes.object, + message: PropTypes.object, +}; + +export default AboutApplicationVersions; diff --git a/src/components/About/AboutApplicationVersions.test.js b/src/components/About/AboutApplicationVersions.test.js new file mode 100644 index 000000000..f30dd0d0b --- /dev/null +++ b/src/components/About/AboutApplicationVersions.test.js @@ -0,0 +1,36 @@ +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; +import { FormattedMessage } from 'react-intl'; + +import AboutApplicationVersions from './AboutApplicationVersions'; + +describe('AboutApplicationVersions', () => { + it('displays application version details', async () => { + const applications = { + a: { + name: 'Albus', + modules: [{ name: 'apple' }, { name: 'banana' }, { name: 'cherry' }] + }, + b: { + name: 'Beetlejuice', + modules: [{ name: 'alpha' }, { name: 'barvo' }, { name: 'charlie' }] + } + + }; + const id = 'All passing tests are alike; each failing test fails in its own way.'; + const message = ; + + render(); + + expect(screen.getByText(/about.applicationsVersionsTitle/)).toBeInTheDocument(); + expect(screen.getByText(id)).toBeInTheDocument(); + Object.keys(applications).forEach((i) => { + expect(screen.getByText(applications[i].name)).toBeInTheDocument(); + applications[i].modules.forEach((j) => { + expect(screen.getByText(j.name)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/About/AboutEnabledModules.js b/src/components/About/AboutEnabledModules.js index 3df0bf0ae..e2d8cf3e3 100644 --- a/src/components/About/AboutEnabledModules.js +++ b/src/components/About/AboutEnabledModules.js @@ -3,6 +3,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { List } from '@folio/stripes-components'; +import stripesConnect from '../../stripesConnect'; + class AboutEnabledModules extends React.Component { static manifest = Object.freeze({ enabledModules: { @@ -50,4 +52,4 @@ class AboutEnabledModules extends React.Component { } } -export default AboutEnabledModules; +export default stripesConnect(AboutEnabledModules); diff --git a/src/components/About/AboutEnabledModules.test.js b/src/components/About/AboutEnabledModules.test.js new file mode 100644 index 000000000..4c4392960 --- /dev/null +++ b/src/components/About/AboutEnabledModules.test.js @@ -0,0 +1,41 @@ +/* shhhh, eslint, it's ok. we need "unused" imports for mocks */ +/* eslint-disable no-unused-vars */ + +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import stripesConnect from '../../stripesConnect'; +import AboutEnabledModules from './AboutEnabledModules'; + +jest.mock('../../stripesConnect', () => (Component) => Component); + +describe('AboutEnabledModules', () => { + it('displays application version details', async () => { + const availableModules = { + amy: 'angelo', + beth: 'baldwin', + camilla: 'claude' + }; + const resources = { + enabledModules: { + records: [ + { id: 'amy' }, + { id: 'beth' }, + ] + } + }; + const tenantid = 'monkey'; + + render(); + + Object.keys(availableModules).forEach((i) => { + expect(screen.getByText(availableModules[i])).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/About/AboutInstallMessages.js b/src/components/About/AboutInstallMessages.js index d808f8e53..cce8395f2 100644 --- a/src/components/About/AboutInstallMessages.js +++ b/src/components/About/AboutInstallMessages.js @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { FormattedDate } from 'react-intl'; import { @@ -9,6 +8,7 @@ import { useConfigurations, useOkapiEnv, } from '../../queries'; +import { useStripes } from '../../StripesContext'; export function entryFor(config, code) { @@ -75,20 +75,21 @@ export function installMessage(env, conf, stripesConf) { * @param {} props * @returns */ -const AboutInstallMessages = (props) => { +const AboutInstallMessages = () => { + const stripes = useStripes(); const aboutEnv = useOkapiEnv(); const aboutConfig = useConfigurations({ module: '@folio/stripes-core', configName: 'aboutInstall', }); - const version = installVersion(aboutEnv.data, aboutConfig.data, props.stripes.config); - const date = installDate(aboutEnv.data, aboutConfig.data, props.stripes.config); - const message = installMessage(aboutEnv.data, aboutConfig.data, props.stripes.config); + const version = installVersion(aboutEnv.data, aboutConfig.data, stripes.config); + const date = installDate(aboutEnv.data, aboutConfig.data, stripes.config); + const message = installMessage(aboutEnv.data, aboutConfig.data, stripes.config); let formattedDate = ''; if (date) { - formattedDate = <>(); + formattedDate = ; } return ( @@ -99,11 +100,4 @@ const AboutInstallMessages = (props) => { ); }; -AboutInstallMessages.propTypes = { - stripes: PropTypes.shape({ - config: PropTypes.object, - hasPerm: PropTypes.func.isRequired, - }).isRequired, -}; - export default AboutInstallMessages; diff --git a/src/components/About/AboutModules.js b/src/components/About/AboutModules.js new file mode 100644 index 000000000..5fc9ca7d5 --- /dev/null +++ b/src/components/About/AboutModules.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import { + Headline, + List, +} from '@folio/stripes-components'; + +import css from './About.css'; + + +const AboutInterfaces = ({ list }) => { + return ( +
  • {item.name}
  • } + /> + ); +}; + +AboutInterfaces.propTypes = { + list: PropTypes.arrayOf(PropTypes.object), +}; + +const AboutModules = ({ list }) => { + return ( + { + return ( +
  • + + {item.name} + + +
  • + ); + }} + /> + ); +}; + +AboutModules.propTypes = { + list: PropTypes.arrayOf(PropTypes.object), +}; + +export default AboutModules; diff --git a/src/components/About/AboutModules.test.js b/src/components/About/AboutModules.test.js new file mode 100644 index 000000000..cc070d640 --- /dev/null +++ b/src/components/About/AboutModules.test.js @@ -0,0 +1,24 @@ +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import AboutModules from './AboutModules'; + +describe('AboutModules', () => { + it('displays application version details', async () => { + const list = [ + { name: 'Abigail', interfaces: [{ name: 'iAnnabeth' }, { name: 'iAlice' }] }, + { name: 'Betsy', interfaces: [{ name: 'iBelle' }, { name: 'iBrea' }] }, + ]; + + render(); + + Object.keys(list).forEach((i) => { + expect(screen.getByText(list[i].name)).toBeInTheDocument(); + list[i].interfaces.forEach((j) => { + expect(screen.getByText(j.name)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/About/AboutOkapi.js b/src/components/About/AboutOkapi.js new file mode 100644 index 000000000..fd38a8657 --- /dev/null +++ b/src/components/About/AboutOkapi.js @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Headline, List } from '@folio/stripes-components'; + +import AboutAPIGateway from './AboutAPIGateway'; +import AboutStripes from './AboutStripes'; +import AboutUIModuleDetails from './AboutUIModuleDetails'; +import AboutUIDependencies from './AboutUIDependencies'; + +import { withModules } from '../Modules'; +import css from './About.css'; +import stripesCore from '../../../package'; +import { useStripes } from '../../StripesContext'; +import AboutEnabledModules from './AboutEnabledModules'; + +const AboutOkapi = ({ modules }) => { + const stripes = useStripes(); + + const dmodules = stripes.discovery.modules || {}; + const dinterfaces = stripes.discovery.interfaces || {}; + + const nm = Object.keys(dmodules).length; + const ni = Object.keys(dinterfaces).length; + + const unknownMsg = ; + const numModulesMsg = ; + const numInterfacesMsg = ; + + + return ( + <> +
    + + +
    + +
    + + + {numModulesMsg} + + + + + {chunks} + }} + /> +
    + {numInterfacesMsg} + a.localeCompare(b))} + itemFormatter={key => ( +
  • + {`${key} ${dinterfaces[key]}`} +
  • + )} + /> +
    + +
    + + + + + + + + +
    + + ); +}; + +AboutOkapi.propTypes = { + modules: PropTypes.shape({ + app: PropTypes.arrayOf(PropTypes.object), + plugin: PropTypes.arrayOf(PropTypes.object), + settings: PropTypes.arrayOf(PropTypes.object), + handler: PropTypes.arrayOf(PropTypes.object), + }), +}; + +export default withModules(AboutOkapi); diff --git a/src/components/About/AboutOkapi.test.js b/src/components/About/AboutOkapi.test.js new file mode 100644 index 000000000..2165b8ee5 --- /dev/null +++ b/src/components/About/AboutOkapi.test.js @@ -0,0 +1,79 @@ +/* shhhh, eslint, it's ok. we need "unused" imports for mocks */ +/* eslint-disable no-unused-vars */ + +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import AboutOkapi from './AboutOkapi'; +import { useStripes } from '../../StripesContext'; +import stripesConnect from '../../stripesConnect'; +import AboutEnabledModules from './AboutEnabledModules'; + +jest.mock('../../StripesContext'); +jest.mock('../../stripesConnect'); +jest.mock('./AboutEnabledModules', () => () => <>); + +describe('AboutOkapi', () => { + const modules = { + app: [ + { + module: 'app-alpha', + version: '1.2.3', + okapiInterfaces: { + iAlpha: '1.0', + } + }, + { module: 'app-beta', version: '2.3.4' } + ], + settings: [ + { module: 'settings-alpha', version: '3.4.5' }, + { module: 'settings-beta', version: '4.5.6' } + ], + plugin: [ + { module: 'plugin-alpha', version: '5.6.7' }, + { module: 'plugin-beta', version: '6.7.8' } + ], + typeThatHasNotBeenInventedYet: [ + { module: 'typeThatHasNotBeenInventedYet-alpha', version: '7.8.9' }, + { module: 'typeThatHasNotBeenInventedYet-beta', version: '8.9.10' } + ], + }; + + const stripes = { + okapi: { + tenant: 'barbie', + url: 'https://oppie.edu', + }, + discovery: { + modules, + interfaces: { + bar: '1.0', + bat: '2.0', + } + } + }; + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue(stripes); + + it('displays application version details', async () => { + render(); + + expect(screen.getByText(/about.userInterface/)).toBeInTheDocument(); + expect(screen.getByText(/stripes-connect/)).toBeInTheDocument(); + expect(screen.getByText(/stripes-components/)).toBeInTheDocument(); + expect(screen.getByText(/stripes-logger/)).toBeInTheDocument(); + + expect(screen.getByText(/about.okapiServices/)).toBeInTheDocument(); + expect(screen.getByText(/about.version/)).toBeInTheDocument(); + expect(screen.getByText(/about.forTenant/)).toBeInTheDocument(); + expect(screen.getByText(/about.onUrl/)).toBeInTheDocument(); + + expect(screen.getByText(/about.moduleCount/)).toBeInTheDocument(); + expect(screen.getByText(/about.interfaceCount/)).toBeInTheDocument(); + + expect(screen.getByText(/about.uiOrServiceDependencies/)).toBeInTheDocument(); + // expect(screen.getByText(/about.moduleDependsOn/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/About/AboutStripes.js b/src/components/About/AboutStripes.js new file mode 100644 index 000000000..b86a36443 --- /dev/null +++ b/src/components/About/AboutStripes.js @@ -0,0 +1,62 @@ +import _ from 'lodash'; +import { FormattedMessage } from 'react-intl'; +import stripesConnect from '@folio/stripes-connect/package'; +import stripesComponents from '@folio/stripes-components/package'; +import stripesLogger from '@folio/stripes-logger/package'; + +import { + Headline, + List, +} from '@folio/stripes-components'; +import stripesCore from '../../../package'; +import { useStripes } from '../../StripesContext'; + + +const AboutStripes = () => { + const stripes = useStripes(); + const unknownMsg = ; + const stripesModules = [ + { + key: 'stripes-core', + value: `stripes-core ${stripesCore.version}`, + }, + { + key: 'stripes-connect', + value: `stripes-connect ${stripesConnect.version}`, + }, + { + key: 'stripes-components', + value: `stripes-components ${stripesComponents.version}`, + }, + { + key: 'stripes-logger', + value: `stripes-logger ${stripesLogger.version}`, + }, + ]; + + return ( + <> + + + + + + + + (
  • {item.value}
  • )} + /> + + ); +}; + +export default AboutStripes; diff --git a/src/components/About/AboutStripes.test.js b/src/components/About/AboutStripes.test.js new file mode 100644 index 000000000..ebb614859 --- /dev/null +++ b/src/components/About/AboutStripes.test.js @@ -0,0 +1,26 @@ +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import AboutStripes from './AboutStripes'; + +jest.mock('@folio/stripes-connect/package', () => ({ version: '1.2.3' })); +jest.mock('@folio/stripes-components/package', () => ({ version: '4.5.6' })); +jest.mock('@folio/stripes-logger/package', () => ({ version: '7.8.9' })); +jest.mock('../../../package', () => ({ version: '10.11.12' })); + +describe('AboutStripes', () => { + it('displays stripes-* version details', async () => { + render(); + + expect(screen.getByText(/about.userInterface/)).toBeInTheDocument(); + expect(screen.getByText(/about.foundation/)).toBeInTheDocument(); + + expect(screen.getByText(/stripes-core 10.11.12/)).toBeInTheDocument(); + expect(screen.getByText(/stripes-connect 1.2.3/)).toBeInTheDocument(); + expect(screen.getByText(/stripes-components 4.5.6/)).toBeInTheDocument(); + expect(screen.getByText(/stripes-logger 7.8.9/)).toBeInTheDocument(); + }); +}); + diff --git a/src/components/About/AboutUIDependencies.js b/src/components/About/AboutUIDependencies.js new file mode 100644 index 000000000..2a7bb51d9 --- /dev/null +++ b/src/components/About/AboutUIDependencies.js @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { + Headline, + List, +} from '@folio/stripes-components'; + +import AboutUIModuleDetails from './AboutUIModuleDetails'; + +/** + * AboutUIDependencies + * Display + * + * @param {object} modules { app[], settings[], plugin[], handler[] } + * array entries contain module details from discovery + * @param {bool} showDependencies true to display interface dependencies + * @returns + */ +const AboutUIDependencies = ({ modules, showDependencies }) => { + const itemFormatter = (m) => ( +
  • + +
  • + ); + + const headlineFor = (key, count) => { + let headlineMsg; + switch (key) { + case 'app': + headlineMsg = ; + break; + case 'settings': + headlineMsg = ; + break; + case 'plugin': + headlineMsg = ; + break; + default: + headlineMsg = ; + } + return headlineMsg; + }; + + return Object.keys(modules).map((key) => { + const items = modules[key].sort((a, b) => a.module.localeCompare(b.module)); + return ( +
    + {headlineFor(key, items.length)} +
    + +
    +
    + ); + }); +}; + +AboutUIDependencies.propTypes = { + modules: PropTypes.object, + showDependencies: PropTypes.bool, +}; + +export default AboutUIDependencies; diff --git a/src/components/About/AboutUIDependencies.test.js b/src/components/About/AboutUIDependencies.test.js new file mode 100644 index 000000000..90a8343f5 --- /dev/null +++ b/src/components/About/AboutUIDependencies.test.js @@ -0,0 +1,54 @@ +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import AboutUIDependencies from './AboutUIDependencies'; + +describe('AboutUIDependencies', () => { + const modules = { + app: [ + { + module: 'app-alpha', + version: '1.2.3', + okapiInterfaces: { + iAlpha: '1.0', + } + }, + { module: 'app-beta', version: '2.3.4' } + ], + settings: [ + { module: 'settings-alpha', version: '3.4.5' }, + { module: 'settings-beta', version: '4.5.6' } + ], + plugin: [ + { module: 'plugin-alpha', version: '5.6.7' }, + { module: 'plugin-beta', version: '6.7.8' } + ], + typeThatHasNotBeenInventedYet: [ + { module: 'typeThatHasNotBeenInventedYet-alpha', version: '7.8.9' }, + { module: 'typeThatHasNotBeenInventedYet-beta', version: '8.9.10' } + ], + }; + + it('displays UI module details', async () => { + render(); + + expect(screen.queryByText(/about.noDependencies/)).toBe(null); + expect(screen.queryByText(/iAlpha/)).toBe(null); + + Object.keys(modules).forEach((i) => { + modules[i].forEach((j) => { + expect(screen.getByText(j.module, { exact: false })).toBeInTheDocument(); + expect(screen.getByText(j.version, { exact: false })).toBeInTheDocument(); + }); + }); + }); + + it('displays required interfaces', async () => { + render(); + + expect(screen.getAllByText(/about.noDependencies/).length).toBeGreaterThan(1); + expect(screen.getByText(/iAlpha/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/About/AboutUIModuleDetails.js b/src/components/About/AboutUIModuleDetails.js new file mode 100644 index 000000000..cff2965fc --- /dev/null +++ b/src/components/About/AboutUIModuleDetails.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +import { + List, +} from '@folio/stripes-components'; + +/** + * AboutUIModuleDetails + * Given a UI module, display its name, version; optionally show the interfaces + * it module depends on. + * + * @param {object} module + * @param {array} showDependencies + * @returns + */ +const AboutUIModuleDetails = ({ module, showDependencies }) => { + const base = `${module.module} ${module.version}`; + + if (!showDependencies) { + return base; + } + + if (!module.okapiInterfaces) { + return ; + } + + const items = Object + .keys(module.okapiInterfaces) + .sort((a, b) => a.localeCompare(b)) + .map((item) => `${item} ${module.okapiInterfaces[item]}`); + + return ( + <> + + + + ); +}; + +AboutUIModuleDetails.propTypes = { + module: PropTypes.object, + showDependencies: PropTypes.bool, +}; + +export default AboutUIModuleDetails; diff --git a/src/components/About/WarningBanner.test.js b/src/components/About/WarningBanner.test.js new file mode 100644 index 000000000..3ae1e270e --- /dev/null +++ b/src/components/About/WarningBanner.test.js @@ -0,0 +1,47 @@ +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import WarningBanner from './WarningBanner'; + +describe('WarningBanner', () => { + const modules = { + app: [ + { + module: 'app-alpha', + version: '1.2.3', + okapiInterfaces: { + alpha: '1.0', + beta: '2.0', + gamma: '3.1', + } + }, + { module: 'app-beta', version: '2.3.4' } + ], + }; + + it('displays missing interfaces', async () => { + const interfaces = { + alpha: '1.0', + beta: '2.0', + }; + + render(); + + expect(screen.getByText(/about.missingModuleCount/)).toBeInTheDocument(); + expect(screen.getByText(/gamma/)).toBeInTheDocument(); + }); + + it('displays incompatible interfaces', async () => { + const interfaces = { + alpha: '1.0', + beta: '2.0', + gamma: '3.0', + }; + render(); + + expect(screen.getByText(/about.incompatibleModuleCount/)).toBeInTheDocument(); + expect(screen.getByText(/gamma/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/AuthnLogin/AuthnLogin.js b/src/components/AuthnLogin/AuthnLogin.js new file mode 100644 index 000000000..093c3db68 --- /dev/null +++ b/src/components/AuthnLogin/AuthnLogin.js @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import Redirect from '../Redirect'; +import PreLoginLanding from '../PreLoginLanding'; +import Login from '../Login'; + +import { setOkapiTenant } from '../../okapiActions'; +import { setUnauthorizedPathToSession } from '../../loginServices'; + +const AuthnLogin = ({ stripes }) => { + const { config, okapi } = stripes; + // If config.tenantOptions is not defined, default to classic okapi.tenant and okapi.clientId + const { tenantOptions = [{ name: okapi.tenant, clientId: okapi.clientId }] } = config; + const tenants = Object.values(tenantOptions); + + const setTenant = (tenant, clientId) => { + localStorage.setItem('tenant', JSON.stringify({ tenantName: tenant, clientId })); + stripes.store.dispatch(setOkapiTenant({ tenant, clientId })); + }; + + useEffect(() => { + /** + * Cache the current path so we can return to it after authenticating. + * In RootWithIntl, unauthenticated visits to protected paths will be + * handled by this component, i.e. + * /some-interesting-path + * but if the user was de-authenticated due to a session timeout, they + * will have a history something like + * /some-interesting-path + * /logout + * / + * but we still want to return to /some-interesting-path, which will + * have been cached by the logout-timeout handler, and must not be + * overwritten here. + * + * @see OIDCRedirect + */ + if (okapi.authnUrl && window.location.pathname !== '/') { + setUnauthorizedPathToSession(window.location.pathname); + } + + // If only 1 tenant is defined in config (in either okapi or config.tenantOptions) set to okapi to be accessed there + // in the rest of the application for compatibity across existing modules. + if (tenants.length === 1) { + const loginTenant = tenants[0]; + setTenant(loginTenant.name, loginTenant.clientId); + } + // we only want to run this effect once, on load. + // okapi.authnUrl tenant values are defined in stripes.config.js + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (okapi.authnUrl) { + // If only 1 tenant is defined in config, skip the tenant selection screen. + if (tenants.length === 1) { + const loginTenant = tenants[0]; + const redirectUri = `${window.location.protocol}//${window.location.host}/oidc-landing`; + const authnUri = `${okapi.authnUrl}/realms/${loginTenant.name}/protocol/openid-connect/auth?client_id=${loginTenant.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid`; + return ; + } + + return ; + } + + return ; +}; + +AuthnLogin.propTypes = { + stripes: PropTypes.object +}; + +export default AuthnLogin; diff --git a/src/components/AuthnLogin/AuthnLogin.test.js b/src/components/AuthnLogin/AuthnLogin.test.js new file mode 100644 index 000000000..e8aa7ffdb --- /dev/null +++ b/src/components/AuthnLogin/AuthnLogin.test.js @@ -0,0 +1,76 @@ +/* shhhh, eslint, it's ok. we need "unused" imports for mocks */ +/* eslint-disable no-unused-vars */ + +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; + +import { Redirect as InternalRedirect } from 'react-router-dom'; +import Redirect from '../Redirect'; +import Login from '../Login'; +import PreLoginLanding from '../PreLoginLanding'; + +import AuthnLogin from './AuthnLogin'; + +jest.mock('react-router-dom', () => ({ + Redirect: () => '', + withRouter: (Component) => Component, +})); +jest.mock('../Redirect', () => () => ''); +jest.mock('../Login', () => () => ''); +jest.mock('../PreLoginLanding', () => () => ''); + +const store = { + getState: () => ({ + okapi: { + token: '123', + }, + }), + dispatch: () => {}, + subscribe: () => {}, + replaceReducer: () => {}, +}; + +describe('RootWithIntl', () => { + describe('AuthnLogin', () => { + it('handles legacy login', () => { + const stripes = { okapi: {}, config: {}, store }; + render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + + describe('handles third-party login', () => { + it('handles single-tenant', () => { + const stripes = { + okapi: { authnUrl: 'https://barbie.com' }, + config: { + isSingleTenant: true, + tenantOptions: { + diku: { name: 'diku', clientId: 'diku-application' } + } + }, + store + }; + render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + + it('handles multi-tenant', () => { + const stripes = { + okapi: { authnUrl: 'https://oppie.com' }, + config: { + isSingleTenant: false, + tenantOptions: { + diku: { name: 'diku', clientId: 'diku-application' }, + diku2: { name: 'diku2', clientId: 'diku2-application' } + } + }, + store + }; + render(); + + expect(screen.getByText(//)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/AuthnLogin/index.js b/src/components/AuthnLogin/index.js new file mode 100644 index 000000000..cf8b8b128 --- /dev/null +++ b/src/components/AuthnLogin/index.js @@ -0,0 +1 @@ +export { default } from './AuthnLogin'; diff --git a/src/components/CreateResetPassword/CreateResetPasswordControl.js b/src/components/CreateResetPassword/CreateResetPasswordControl.js index d096f5629..e912a0fdf 100644 --- a/src/components/CreateResetPassword/CreateResetPasswordControl.js +++ b/src/components/CreateResetPassword/CreateResetPasswordControl.js @@ -8,6 +8,7 @@ import { stripesShape } from '../../Stripes'; import { setAuthError } from '../../okapiActions'; import { defaultErrors } from '../../constants'; import OrganizationLogo from '../OrganizationLogo'; +import { getLocationQuery } from '../../locationService'; import CreateResetPassword from './CreateResetPassword'; import PasswordHasNotChanged from './components/PasswordHasNotChanged'; @@ -18,13 +19,14 @@ class CreateResetPasswordControl extends Component { static propTypes = { authFailure: PropTypes.arrayOf(PropTypes.object), location: PropTypes.shape({ + query: PropTypes.string, search: PropTypes.string.isRequired, }), match: PropTypes.shape({ params: PropTypes.shape({ - token: PropTypes.string.isRequired, - }).isRequired, - }).isRequired, + token: PropTypes.string, + }), + }), stripes: stripesShape.isRequired, handleBadResponse: PropTypes.func.isRequired, clearAuthErrors: PropTypes.func.isRequired, @@ -107,13 +109,15 @@ class CreateResetPasswordControl extends Component { }, } = stripes; - const path = `${url}/bl-users/password-reset/${isValidToken ? 'reset' : 'validate'}`; + const resetToken = token ?? getLocationQuery(location)?.resetToken; + const interfacePath = stripes.hasInterface('users-keycloak') ? 'users-keycloak' : 'bl-users'; + const path = `${url}/${interfacePath}/password-reset/${isValidToken ? 'reset' : 'validate'}`; fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-okapi-token': token, + 'x-okapi-token': resetToken, 'x-okapi-tenant': getTenant(stripes, location), }, ...(body && { body: JSON.stringify(body) }), @@ -144,11 +148,6 @@ class CreateResetPasswordControl extends Component { render() { const { authFailure, - match: { - params: { - token, - }, - }, clearAuthErrors, } = this.props; @@ -177,7 +176,6 @@ class CreateResetPasswordControl extends Component { return ( ({ + getLocationQuery: jest.fn() +})); + +describe('CreateResetPasswordControl', () => { + const mockFetch = jest.fn(() => Promise.resolve({ + json: () => Promise.resolve({ test: 100 }), + })); + + const stripes = { + clone: jest.fn(), + config: {}, + okapi: { + url: 'http://test' + }, + hasInterface: jest.fn().mockReturnValue(true), + store: { + getState: () => ({ + okapi: { + token: '123', + }, + }), + dispatch: () => {}, + subscribe: () => {}, + replaceReducer: () => {}, + } + }; + + beforeEach(() => { + global.fetch = mockFetch; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('uses reset token in URL param', async () => { + getLocationQuery.mockReturnValue({ resetToken: '123' }); + + const history = createMemoryHistory(); + + render( + + + + + + ); + + expect(mockFetch.mock.lastCall[1].headers['x-okapi-token'] === '123').toBeTruthy(); + }); +}); diff --git a/src/components/ForgotPassword/ForgotPasswordCtrl.js b/src/components/ForgotPassword/ForgotPasswordCtrl.js index fbe46e7e0..7223a007f 100644 --- a/src/components/ForgotPassword/ForgotPasswordCtrl.js +++ b/src/components/ForgotPassword/ForgotPasswordCtrl.js @@ -33,7 +33,12 @@ class ForgotPasswordCtrl extends Component { static manifest = Object.freeze({ searchUsername: { type: 'okapi', - path: 'bl-users/forgotten/password', + path: (queryParams, pathComponents, resourceData, config, props) => { + if (props.stripes.okapi.authnUrl) { + return 'users-keycloak/forgotten/password'; + } + return 'bl-users/forgotten/password'; + }, headers: { 'accept': '*/*', }, diff --git a/src/components/ForgotUserName/ForgotUserNameCtrl.js b/src/components/ForgotUserName/ForgotUserNameCtrl.js index 44ddc5717..007b09246 100644 --- a/src/components/ForgotUserName/ForgotUserNameCtrl.js +++ b/src/components/ForgotUserName/ForgotUserNameCtrl.js @@ -34,7 +34,12 @@ class ForgotUserNameCtrl extends Component { static manifest = Object.freeze({ searchUsername: { type: 'okapi', - path: 'bl-users/forgotten/username', + path: (queryParams, pathComponents, resourceData, config, props) => { + if (props.stripes.okapi.authnUrl) { + return 'users-keycloak/forgotten/username'; + } + return 'bl-users/forgotten/username'; + }, headers: { 'accept': '*/*', }, diff --git a/src/components/Login/Login.js b/src/components/Login/Login.js index 48bad369a..eda2c87e3 100644 --- a/src/components/Login/Login.js +++ b/src/components/Login/Login.js @@ -66,7 +66,7 @@ class Login extends Component { const buttonLabel = submissionStatus ? 'loggingIn' : 'login'; return (
    -
    +
    @@ -160,6 +160,7 @@ class Login extends Component { validationEnabled={false} hasClearIcon={false} autoComplete="current-password" + required /> diff --git a/src/components/Login/LoginForm.js b/src/components/Login/LoginForm.js new file mode 100644 index 000000000..2dcab4c46 --- /dev/null +++ b/src/components/Login/LoginForm.js @@ -0,0 +1,228 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Field, Form } from 'react-final-form'; + +import { branding } from 'stripes-config'; + +import { + TextField, + Button, + Row, + Col, + Headline, +} from '@folio/stripes-components'; + +import SSOLogin from '../SSOLogin'; +import OrganizationLogo from '../OrganizationLogo'; +import AuthErrorsContainer from '../AuthErrorsContainer'; +import FieldLabel from '../CreateResetPassword/components/FieldLabel'; + +import styles from './Login.css'; + +class LoginForm extends Component { + static propTypes = { + ssoActive: PropTypes.bool, + authErrors: PropTypes.arrayOf(PropTypes.object), + onSubmit: PropTypes.func.isRequired, + handleSSOLogin: PropTypes.func.isRequired, + }; + + static defaultProps = { + authErrors: [], + ssoActive: false, + }; + + render() { + const { + authErrors, + handleSSOLogin, + ssoActive, + onSubmit, + } = this.props; + + return ( +
    { + const { username } = values; + const submissionStatus = submitting || submitSucceeded; + const buttonDisabled = submissionStatus || !(username); + const buttonLabel = submissionStatus ? 'loggingIn' : 'login'; + return ( +
    +
    +
    + + + + + + + handleSubmit(data).then(() => form.change('password', undefined))} + > + + +
    + {ssoActive && } +
    + +
    + + + + + + + +
    + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + + + + + + + + + +
    + + +
    + +
    + +
    + + + + + + + + + + + + + + +
    + +
    + +
    + + {ssoActive &&
    } + +
    +
    +
    + ); + }} + /> + ); + } +} + +export default LoginForm; diff --git a/src/components/Login/index.js b/src/components/Login/index.js index 44e798405..2a741cdbd 100644 --- a/src/components/Login/index.js +++ b/src/components/Login/index.js @@ -1 +1 @@ -export { default } from './LoginCtrl'; +export { default } from './Login'; diff --git a/src/components/Logout/Logout.js b/src/components/Logout/Logout.js new file mode 100644 index 000000000..c8361a16f --- /dev/null +++ b/src/components/Logout/Logout.js @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Redirect } from 'react-router'; +import { FormattedMessage } from 'react-intl'; + +import { useStripes } from '../../StripesContext'; +import { getLocale, logout } from '../../loginServices'; + +/** + * Logout + * Call logout, then redirect to root. + * + * This corresponds to the '/logout' route, allowing that route to be directly + * accessible rather than only accessible through the menu action. + * + * @param {object} history + */ +const Logout = ({ history }) => { + const stripes = useStripes(); + const [didLogout, setDidLogout] = useState(false); + + useEffect( + () => { + getLocale(stripes.okapi.url, stripes.store, stripes.okapi.tenant) + .then(logout(stripes.okapi.url, stripes.store, history)) + .then(setDidLogout(true)); + }, + // no dependencies because we only want to start the logout process once. + // we don't care about changes to history or stripes; certainly those + // could be updated as part of the logout process + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return didLogout ? : ; +}; + +Logout.propTypes = { + history: PropTypes.object, +}; + +export default Logout; diff --git a/src/components/Logout/Logout.test.js b/src/components/Logout/Logout.test.js new file mode 100644 index 000000000..383c89d0e --- /dev/null +++ b/src/components/Logout/Logout.test.js @@ -0,0 +1,46 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import Harness from '../../../test/jest/helpers/harness'; +import Logout from './Logout'; +import { logout } from '../../loginServices'; + +jest.mock('../../loginServices', () => ({ + ...(jest.requireActual('../../loginServices')), + getLocale: () => Promise.resolve(), + logout: jest.fn() +})); + +jest.mock('react-router', () => ({ + ...(jest.requireActual('react-router')), + Redirect: () =>
    Redirect
    +})); + +const stripes = { + config: { + rtr: { + idleModalTTL: '3s', + idleSessionTTL: '3s', + } + }, + okapi: { + url: 'https://blah', + }, + logger: { log: jest.fn() }, + store: { + getState: jest.fn(), + }, +}; + +describe('Logout', () => { + it('calls logout and redirects', async () => { + logout.mockReturnValue(Promise.resolve()); + + render(); + + await waitFor(() => { + screen.getByText('Redirect'); + }); + + expect(logout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Logout/index.js b/src/components/Logout/index.js new file mode 100644 index 000000000..5e6c69022 --- /dev/null +++ b/src/components/Logout/index.js @@ -0,0 +1 @@ +export { default } from './Logout'; diff --git a/src/components/LogoutTimeout/LogoutTimeout.css b/src/components/LogoutTimeout/LogoutTimeout.css new file mode 100644 index 000000000..198315603 --- /dev/null +++ b/src/components/LogoutTimeout/LogoutTimeout.css @@ -0,0 +1,53 @@ +@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; +} + +.linksWrapper, +.authErrorsWrapper { + margin-top: 1rem; +} + +.link { + display: block; + width: 100%; + font-size: var(--font-size-large); + font-weight: var(--text-weight-headline-basis); + margin: 0; +} + +@media (--medium-up) { + .container { + min-height: initial; + } +} + +@media (--large-up) { + .header { + font-size: var(--font-size-xx-large); + } + + .toggleButtonWrapper { + justify-content: left; + + & > button { + margin: 0 0 1rem 1rem; + } + } +} + +@media (height <= 440px) { + .container { + min-height: 330px; + } +} diff --git a/src/components/LogoutTimeout/LogoutTimeout.js b/src/components/LogoutTimeout/LogoutTimeout.js new file mode 100644 index 000000000..e0af76f17 --- /dev/null +++ b/src/components/LogoutTimeout/LogoutTimeout.js @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { branding } from 'stripes-config'; + +import { + Button, + Col, + Headline, + LoadingView, + Row, +} from '@folio/stripes-components'; + +import OrganizationLogo from '../OrganizationLogo'; +import { useStripes } from '../../StripesContext'; +import { logout } from '../../loginServices'; + +import styles from './LogoutTimeout.css'; + +/** + * LogoutTimeout + * Show a "sorry, your session timed out message"; if the session is still + * active, call logout() to end it. + * + * Having a static route to this page allows the logout handler to choose + * between redirecting straight to the login page (if the user chose to + * logout) or to this page (if the session timed out). + * + * This corresponds to the '/logout-timeout' route. + */ +const LogoutTimeout = () => { + const stripes = useStripes(); + const [didLogout, setDidLogout] = useState(false); + + useEffect( + () => { + if (stripes.okapi.isAuthenticated) { + // returns a promise, which we ignore + logout(stripes.okapi.url, stripes.store) + .then(setDidLogout(true)); + } else { + setDidLogout(true); + } + }, + // no dependencies because we only want to start the logout process once. + // we don't care about changes to stripes; certainly it'll be updated as + // part of the logout process + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + if (!didLogout) { + return ; + } + + return ( +
    +
    +
    + + + + + + + + + + + + + + + +
    +
    +
    + ); +}; + +export default LogoutTimeout; diff --git a/src/components/LogoutTimeout/LogoutTimeout.test.js b/src/components/LogoutTimeout/LogoutTimeout.test.js new file mode 100644 index 000000000..6efff5e0c --- /dev/null +++ b/src/components/LogoutTimeout/LogoutTimeout.test.js @@ -0,0 +1,36 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; + +import LogoutTimeout from './LogoutTimeout'; +import { useStripes } from '../../StripesContext'; +import { logout } from '../../loginServices'; + + + +jest.mock('../OrganizationLogo'); +jest.mock('../../StripesContext'); +jest.mock('react-router', () => ({ + Redirect: () =>
    Redirect
    , +})); + +jest.mock('../../loginServices', () => ({ + logout: jest.fn(() => Promise.resolve()), +})); + +describe('LogoutTimeout', () => { + it('if not authenticated, renders a timeout message', async () => { + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: false } }); + + render(); + screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); + }); + + it('if authenticated, calls logout then renders a timeout message', async () => { + const mockUseStripes = useStripes; + mockUseStripes.mockReturnValue({ okapi: { isAuthenticated: true } }); + + render(); + expect(logout).toHaveBeenCalled(); + screen.getByText('stripes-core.rtr.idleSession.sessionExpiredSoSad'); + }); +}); diff --git a/src/components/LogoutTimeout/index.js b/src/components/LogoutTimeout/index.js new file mode 100644 index 000000000..a355b4149 --- /dev/null +++ b/src/components/LogoutTimeout/index.js @@ -0,0 +1 @@ +export { default } from './LogoutTimeout'; diff --git a/src/components/MainNav/MainNav.js b/src/components/MainNav/MainNav.js index d1b7a6e07..85f1dfb57 100644 --- a/src/components/MainNav/MainNav.js +++ b/src/components/MainNav/MainNav.js @@ -5,13 +5,12 @@ import { compose } from 'redux'; import { injectIntl } from 'react-intl'; import { withRouter } from 'react-router'; -import { branding, config } from 'stripes-config'; +import { branding } from 'stripes-config'; import { Icon } from '@folio/stripes-components'; import { withModules } from '../Modules'; import { LastVisitedContext } from '../LastVisited'; -import { getLocale, logout as sessionLogout } from '../../loginServices'; import { updateQueryResource, getLocationQuery, @@ -65,7 +64,6 @@ class MainNav extends Component { userMenuOpen: false, }; this.store = props.stripes.store; - this.logout = this.logout.bind(this); this.getAppList = this.getAppList.bind(this); } @@ -116,24 +114,6 @@ class MainNav extends Component { }); } - // Return the user to the login screen, but after logging in they will return to their previous activity. - returnToLogin() { - const { okapi } = this.store.getState(); - - return getLocale(okapi.url, this.store, okapi.tenant) - .then(sessionLogout(okapi.url, this.store)); - } - - // return the user to the login screen, but after logging in they will be brought to the default screen. - logout() { - if (!config.preserveConsole) { - console.clear(); // eslint-disable-line no-console - } - this.returnToLogin().then(() => { - this.props.history.push('/'); - }); - } - getAppList(lastVisited) { const { stripes, location: { pathname }, modules, intl: { formatMessage } } = this.props; @@ -223,10 +203,7 @@ class MainNav extends Component { target="_blank" /> - + ); diff --git a/src/components/MainNav/ProfileDropdown/ProfileDropdown.js b/src/components/MainNav/ProfileDropdown/ProfileDropdown.js index 6cc58ba8a..1ecf70da0 100644 --- a/src/components/MainNav/ProfileDropdown/ProfileDropdown.js +++ b/src/components/MainNav/ProfileDropdown/ProfileDropdown.js @@ -28,7 +28,6 @@ class ProfileDropdown extends Component { modules: PropTypes.shape({ app: PropTypes.arrayOf(PropTypes.object), }), - onLogout: PropTypes.func.isRequired, stripes: PropTypes.shape({ config: PropTypes.shape({ showPerms: PropTypes.bool, @@ -172,7 +171,7 @@ class ProfileDropdown extends Component { }; getDropdownContent() { - const { stripes, onLogout } = this.props; + const { stripes } = this.props; const user = this.getUserData(); const currentPerms = stripes.user ? stripes.user.perms : undefined; const messageId = stripes.okapi.ssoEnabled ? 'stripes-core.logoutKeepSso' : 'stripes-core.logout'; @@ -230,7 +229,7 @@ class ProfileDropdown extends Component { } {this.userLinks} - + diff --git a/src/components/OIDCLanding.js b/src/components/OIDCLanding.js new file mode 100644 index 000000000..d72e0805a --- /dev/null +++ b/src/components/OIDCLanding.js @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from 'react'; +import { useLocation, Redirect } from 'react-router-dom'; +import queryString from 'query-string'; +import { useStore } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; + +import { Loading } from '@folio/stripes-components'; + +import { requestUserWithPerms, setTokenExpiry } from '../loginServices'; + +import css from './Front.css'; +import { useStripes } from '../StripesContext'; + +/** + * OIDCLanding: un-authenticated route handler for /sso-landing. + * + * Reads one-time-code from URL params, exchanging it for an access_token + * and then leveraging that to retrieve a user via requestUserWithPerms, + * eventually dispatching session and Okapi-ready, resulting in a + * re-render of RoothWithIntl with prop isAuthenticated: true. + * + * @see RootWithIntl + */ +const OIDCLanding = () => { + const location = useLocation(); + const store = useStore(); + // const samlError = useRef(); + const { okapi } = useStripes(); + const [potp, setPotp] = useState(); + const [samlError, setSamlError] = useState(); + + + /** + * Exchange the otp for AT/RT cookies, then retrieve the user. + * + * See https://ebscoinddev.atlassian.net/wiki/spaces/TEUR/pages/12419306/mod-login-keycloak#mod-login-keycloak-APIs + * for additional details. May not be necessary for SAML-specific pages + * to exist since the workflow is the same for SSO. We can just inspect + * the response for SSO-y values or SAML-y values and act accordingly. + */ + useEffect(() => { + const getParams = () => { + const search = location.search; + if (!search) return undefined; + return queryString.parse(search) || {}; + }; + + /** + * retrieve the OTP + * @returns {string} + */ + const getOtp = () => { + return getParams()?.code; + }; + + const otp = getOtp(); + + if (otp) { + setPotp(otp); + fetch(`${okapi.url}/authn/token?code=${otp}&redirect-uri=${window.location.protocol}//${window.location.host}/oidc-landing`, { + credentials: 'include', + headers: { 'X-Okapi-tenant': okapi.tenant, 'Content-Type': 'application/json' }, + mode: 'cors', + }) + .then((resp) => { + if (resp.ok) { + return resp.json() + .then((json) => { + return setTokenExpiry({ + atExpires: json.accessTokenExpiration ? new Date(json.accessTokenExpiration) : Date.now() + (60 * 1000), + rtExpires: json.refreshTokenExpiration ? new Date(json.refreshTokenExpiration) : Date.now() + (2 * 60 * 1000), + }); + }) + .then(() => { + return requestUserWithPerms(okapi.url, store, okapi.tenant); + }); + } else { + return resp.json().then((error) => { + throw error; + }); + } + }) + .catch(e => { + // eslint-disable-next-line no-console + console.error('@@ Oh, snap, OTP exchange failed!', e); + setSamlError(e); + }); + } + // we only want to run this effect once, on load. + // keycloak authentication will redirect here and the other deps will be constant: + // location.search: the query string; this will never change + // okapi.tenant, okapi.url: these are defined in stripes.config.js + // store: the redux store + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (samlError) { + return ( +
    +
    + +
    +
    + + {JSON.stringify(samlError.current, null, 2)} + +
    + +
    + ); + } + + return ( +
    +
    + +
    +
    +
    +          {JSON.stringify(samlError, null, 2)}
    +        
    +
    +
    + ); +}; + +export default OIDCLanding; diff --git a/src/components/OIDCLanding.test.js b/src/components/OIDCLanding.test.js new file mode 100644 index 000000000..f7e199c50 --- /dev/null +++ b/src/components/OIDCLanding.test.js @@ -0,0 +1,85 @@ +import { render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import OIDCLanding from './OIDCLanding'; + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + search: 'session_state=dead-beef&code=c0ffee' + }), + Redirect: () => <>Redirect, +})); + +jest.mock('react-redux', () => ({ + useStore: () => { }, +})); + +jest.mock('../StripesContext', () => ({ + useStripes: () => ({ + okapi: { url: 'https://whaterver' }, + config: { tenantOptions: { diku: { name: 'diku', clientId: 'diku-application' } } }, + }), +})); + +// jest.mock('../loginServices'); + + +const mockSetTokenExpiry = jest.fn(); +const mockRequestUserWithPerms = jest.fn(); +const mockFoo = jest.fn(); +jest.mock('../loginServices', () => ({ + setTokenExpiry: () => mockSetTokenExpiry(), + requestUserWithPerms: () => mockRequestUserWithPerms(), + foo: () => mockFoo(), +})); + + +// fetch success: resolve promise with ok == true and $data in json() +const mockFetchSuccess = (data) => { + global.fetch = jest.fn().mockImplementation(() => ( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(data), + headers: new Map(), + }) + )); +}; + +// fetch failure: resolve promise with ok == false and $error in json() +const mockFetchError = (error) => { + global.fetch = jest.fn().mockImplementation(() => ( + Promise.resolve({ + ok: false, + json: () => Promise.resolve(error), + headers: new Map(), + }) + )); +}; + +// restore default fetch impl +const mockFetchCleanUp = () => { + global.fetch.mockClear(); + delete global.fetch; +}; + +describe('OIDCLanding', () => { + it('calls requestUserWithPerms, setTokenExpiry on success', async () => { + mockFetchSuccess({ + accessTokenExpiration: '2024-05-23T09:47:17.000-04:00', + refreshTokenExpiration: '2024-05-23T10:07:17.000-04:00', + }); + + await render(); + screen.getByText('Loading'); + await waitFor(() => expect(mockSetTokenExpiry).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockRequestUserWithPerms).toHaveBeenCalledTimes(1)); + mockFetchCleanUp(); + }); + + it('displays an error on failure', async () => { + mockFetchError('barf'); + + await render(); + await screen.findByText('errors.saml.missingToken'); + mockFetchCleanUp(); + }); +}); diff --git a/src/components/OIDCRedirect.js b/src/components/OIDCRedirect.js new file mode 100644 index 000000000..c224b3dad --- /dev/null +++ b/src/components/OIDCRedirect.js @@ -0,0 +1,45 @@ +import { withRouter, Redirect, useLocation } from 'react-router'; +import queryString from 'query-string'; +import { useStripes } from '../StripesContext'; +import { getUnauthorizedPathFromSession } from '../loginServices'; + +/** + * OIDCRedirect authenticated route handler for /oidc-landing. + * + * Read unauthorized_path from session storage if keycloak authn provided + * if `fwd` provided into redirect url, it causes strange behavior - infinite login requests + * decided to use session storage for that case. Refs STCOR-789 + * + * Reads `fwd` from URL params and redirects. + * + * @see RootWithIntl + * @see AuthnLogin + * + * @returns {Redirect} + */ +const OIDCRedirect = () => { + const location = useLocation(); + const stripes = useStripes(); + + const getParams = () => { + const search = location.search; + if (!search) return undefined; + return queryString.parse(search) || {}; + }; + + const getUrl = () => { + if (stripes.okapi.authnUrl) { + const unauthorizedPath = getUnauthorizedPathFromSession(); + if (unauthorizedPath) return unauthorizedPath; + } + + const params = getParams(); + return params?.fwd ?? ''; + }; + + return ( + + ); +}; + +export default withRouter(OIDCRedirect); diff --git a/src/components/OIDCRedirect.test.js b/src/components/OIDCRedirect.test.js new file mode 100644 index 000000000..48c8a0c4d --- /dev/null +++ b/src/components/OIDCRedirect.test.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import OIDCRedirect from './OIDCRedirect'; +import { useStripes } from '../StripesContext'; + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + Redirect: () =>
    internalredirect
    , + withRouter: Component => Component, + useLocation: () => ({ + search: '?fwd=/dashboard', + }), +})); + +jest.mock('../StripesContext'); + +describe('OIDCRedirect', () => { + beforeAll(() => { + sessionStorage.setItem( + 'unauthorized_path', + '/example' + ); + }); + + afterAll(() => sessionStorage.removeItem('unauthorized_path')); + + it('redirects to value from session storage under unauthorized_path key', () => { + useStripes.mockReturnValue({ okapi: { authnUrl: 'http://example.com/authn' } }); + render(); + + expect(screen.getByText(/internalredirect/)).toBeInTheDocument(); + }); + + it('redirects fwd if no authn provided to stripes okapi config', () => { + useStripes.mockReturnValue({ okapi: { } }); + render(); + + expect(screen.getByText(/internalredirect/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/PreLoginLanding/PreLoginLanding.js b/src/components/PreLoginLanding/PreLoginLanding.js new file mode 100644 index 000000000..acc11e642 --- /dev/null +++ b/src/components/PreLoginLanding/PreLoginLanding.js @@ -0,0 +1,76 @@ +import React, { useRef } from 'react'; +import { Button, Select, Col, Row } from '@folio/stripes-components'; +import { useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import { OrganizationLogo } from '../index'; +import styles from './index.css'; +import { useStripes } from '../../StripesContext'; + +function PreLoginLanding({ onSelectTenant }) { + const intl = useIntl(); + const { okapi, config: { tenantOptions = {} } } = useStripes(); + + const redirectUri = `${window.location.protocol}//${window.location.host}/oidc-landing`; + const options = Object.keys(tenantOptions).map(tenantName => ({ value: tenantName, label: tenantName })); + + const getLoginUrl = () => { + if (!okapi.tenant) return ''; + if (okapi.authnUrl) { + return `${okapi.authnUrl}/realms/${okapi.tenant}/protocol/openid-connect/auth?client_id=${okapi.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid&isConsortium=true`; + } + return ''; + }; + + const submitButtonRef = useRef({ disabled: true }); + + const handleChangeTenant = (e) => { + const tenantName = e.target.value; + submitButtonRef.current.disabled = !tenantName; + if (tenantName === '') { + onSelectTenant('', ''); + return; + } + const clientId = tenantOptions[tenantName].clientId; + onSelectTenant(tenantName, clientId); + }; + + return ( +
    +
    +
    + + + + + + + +