From 591792ea5049f4c43eee072f30433e8cd8ae37bc Mon Sep 17 00:00:00 2001 From: Ross Patterson Date: Wed, 26 May 2021 14:34:27 -0700 Subject: [PATCH 01/36] build(devel): Add a proxy hosting testbed A testbed that reproduces accessing all of the ZMI, the classic HTML plone UI, and the Volto UI under the same hostname. Originally needed to workout issues with different kinds of authentication working for all UIs. refs #131171 --- Makefile | 9 +++++++- docker-compose.yml | 45 ++++++++++++++++++++++++++++++++++++++++ traefik.d/local.yml | 50 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 traefik.d/local.yml diff --git a/Makefile b/Makefile index 440f76e24a..b12dfc37f4 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,12 @@ MAKEFLAGS+=--no-builtin-rules # Project settings INSTANCE_PORT=8080 +# The defaults from the UI configuration +# export RAZZLE_DEV_PROXY_API_PATH=http://localhost:$(INSTANCE_PORT)/Plone +# export RAZZLE_API_PATH=http://localhost:3000 +# Uncomment the following to run against the proxy hosting testbed +# export RAZZLE_DEV_PROXY_API_PATH= +# export RAZZLE_API_PATH=http://localhost:49080/api/Plone # Recipe snippets for reuse @@ -77,7 +83,8 @@ docs-build: .PHONY: start # Run both the back-end and the front end start: - $(MAKE) -j 2 start-backend start-frontend + docker-compose up -d traefik + $(MAKE) -e -j 2 start-backend start-frontend .PHONY: start-frontend start-frontend: dist diff --git a/docker-compose.yml b/docker-compose.yml index ec3f315e7d..0b9513f01c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,16 @@ services: - ADDONS=kitconcept.volto - 'PROFILES=kitconcept.volto:default-homepage' image: plone + labels: + traefik.enable: "true" + traefik.http.routers.plone.entrypoints: "web" + traefik.http.routers.plone.rule: "PathPrefix(`/api`)" + # https://doc.traefik.io/traefik/middlewares/stripprefix/#configuration-examples + traefik.http.middlewares.strip-api-prefix.stripprefix.prefixes: "/api" + traefik.http.middlewares.rewrite-api-vhost.replacepathregex.regex: "^(.*)$$" + traefik.http.middlewares.rewrite-api-vhost.replacepathregex.replacement: "/VirtualHostBase/http/localhost:49080/VirtualHostRoot/_vh_api$$1" + traefik.http.routers.plone.middlewares: "strip-api-prefix@docker,rewrite-api-vhost@docker" + traefik.http.services.plone.loadbalancer.server.port: "8080" frontend: ports: @@ -20,3 +30,38 @@ services: # environment: # - INTERNAL_API_PATH=plone:8080/Plone # - ADDONS="volto-slate:asDefault" + labels: + traefik.enable: "true" + traefik.http.routers.frontend.entrypoints: "web" + traefik.http.routers.frontend.rule: "PathPrefix(`/ui`)" + # https://doc.traefik.io/traefik/middlewares/stripprefix/#configuration-examples + traefik.http.middlewares.strip-ui-prefix.stripprefix.prefixes: "/ui" + traefik.http.routers.frontend.middlewares: "strip-ui-prefix@docker" + traefik.http.services.frontend.loadbalancer.server.port: "3000" + + traefik: + image: "traefik" + # Disabled so that the proxy can also be used to test deployment scenarios against + # a back-end and/or front-end running on the local host. + # depends_on: + # - plone + # - frontend + command: + # - "--log.level=DEBUG" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:49080" + - "--providers.file.directory=/etc/traefik.d/" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./traefik.d/:/etc/traefik.d/" + ports: + - "49080:49080" + +networks: + default: + ipam: + driver: "default" + config: + # Use the same random subnet each time + - subnet: "192.168.80.0/24" diff --git a/traefik.d/local.yml b/traefik.d/local.yml new file mode 100644 index 0000000000..8e3321a3da --- /dev/null +++ b/traefik.d/local.yml @@ -0,0 +1,50 @@ +# Proxy the back-end and front-end running on the local host +http: + routers: + + router0: + entryPoints: + - "web" + service: "plone" + rule: "PathPrefix(`/api`)" + middlewares: + - "strip-api-prefix" + - "rewrite-api-vhost" + + router1: + entryPoints: + - "web" + service: "frontend" + rule: "PathPrefix(`/`)" + # FIXME: It should be possible to host the UI at a path prefix but currently isn't + # because of how the UI app code assembles API URLs + # rule: "PathPrefix(`/ui`)" + # middlewares: + # - "strip-ui-prefix" + + middlewares: + + strip-api-prefix: + stripprefix: + prefixes: + - "/api" + rewrite-api-vhost: + replacepathregex: + regex: "^(.*)$" + replacement: "/VirtualHostBase/http/localhost:49080/VirtualHostRoot/_vh_api$1" + + # strip-ui-prefix: + # stripprefix: + # prefixes: + # - "/ui" + + services: + plone: + loadBalancer: + servers: + - url: "http://192.168.80.1:8080/" + + frontend: + loadBalancer: + servers: + - url: "http://192.168.80.1:3000/" From a54a42553b8a8b12ffc7bbede87d83ff47c9371a Mon Sep 17 00:00:00 2001 From: Ross Patterson Date: Tue, 15 Jun 2021 21:13:19 -0700 Subject: [PATCH 02/36] build(devel): Improve browsing API source code Use `collective.recipe.omelette` to reproduce a single directory tree of the Python packages installed in the `./api/` buildout's `instance` part. Useful for searching, browsing, or otherwise exploring all the source code involved in the application in a way that's more readable and avoids duplicates from older versions of eggs. --- api/buildout.cfg | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/api/buildout.cfg b/api/buildout.cfg index 112206db6d..3681659205 100644 --- a/api/buildout.cfg +++ b/api/buildout.cfg @@ -2,7 +2,7 @@ index = https://pypi.org/simple/ extends = http://dist.plone.org/release/5.2.4/versions.cfg find-links += http://dist.plone.org/thirdparty/ -parts = instance plonesite robot-server +parts = instance plonesite site-packages robot-server versions = versions extensions = mr.developer @@ -60,6 +60,14 @@ upgrade-portal = False upgrade-all-profiles = False site-replace = True +[site-packages] +# Reproduce a single directory tree of the Python packages installed in this buildout's +# `rel_client` part. Useful for searching, browsing, or otherwise exploring all the +# source code involved in the application in a way that's more readable and avoids +# duplicates from older versions of eggs. +recipe = collective.recipe.omelette +eggs = ${instance:eggs} + [versions] setuptools = zc.buildout = From fb65481e4209141d215749af8ca5b0f9382f3814 Mon Sep 17 00:00:00 2001 From: Ross Patterson Date: Tue, 15 Jun 2021 21:14:50 -0700 Subject: [PATCH 03/36] build(devel): Wrong checkout default branch name This repository has no `master` branch. At least according to GitHub, `main` is the default branch and it also has the most recent activity in `$ git log`. --- api/buildout.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/buildout.cfg b/api/buildout.cfg index 3681659205..a9ea059071 100644 --- a/api/buildout.cfg +++ b/api/buildout.cfg @@ -9,7 +9,7 @@ extensions = mr.developer # auto-checkout = [sources] -kitconcept.volto = git https://github.com/kitconcept/kitconcept.volto.git branch=master +kitconcept.volto = git https://github.com/kitconcept/kitconcept.volto.git branch=main plone.rest = git git@github.com:plone/plone.rest.git branch=master plone.restapi = git git://github.com/plone/plone.restapi.git pushurl=git@github.com:plone/plone.restapi.git branch=master From 27c65ca8cdf7e01556ebda83e1bec82d7378048a Mon Sep 17 00:00:00 2001 From: Ross Patterson Date: Tue, 22 Jun 2021 23:21:16 -0700 Subject: [PATCH 04/36] docs(redux): Order folder structure per frameworks More sensible ordering of the folder structure documentation so that the library, React, is first, the state framework, Redux, is next, followed by more project specific structure. --- docs/source/recipes/folder-structure.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/source/recipes/folder-structure.md b/docs/source/recipes/folder-structure.md index d4218d899c..ae7bcc0c31 100644 --- a/docs/source/recipes/folder-structure.md +++ b/docs/source/recipes/folder-structure.md @@ -4,20 +4,19 @@ Volto is based on React, Redux, and React-Router. All of the code is located in the `src` folder. The following convention for locating resources is used. +## Components + +`components` contains all the React components, AKA views. This includes views for the +management interface and the theme. + ## Actions `actions` contains all the redux actions for fetching all backend data like content, users and external resources that are pulled into our app in general. -## Components - -`components` contains all the views. This includes views for the management -interface and the theme. - -## Config +## Reducers -In this folder all configuration is stored. All configuration can be overridden -in your theme package. +All the reducers are located here. ## Constants @@ -27,9 +26,10 @@ The constants contain all constants including the action types. `helpers` contains helper methods like for example url helpers. -## Reducers +## Config -All the reducers are located here. +In this folder all configuration is stored. All configuration can be overridden +in your theme package. ## Theme From 5f5e7822c380db2efe2375eba6afd25f181d6f65 Mon Sep 17 00:00:00 2001 From: Ross Patterson Date: Thu, 17 Jun 2021 14:44:06 -0700 Subject: [PATCH 05/36] refactor(user): Factor user session state to selectors Refactor the logic around user session state out of the multiple components into Redux selectors. This is a step on the way to refactoring the UI user session logic to be agnostic of *how* the user is authenticated. Currently this includes 2 selectors: - detecting whether a user is currently authenticated and logged in - retrieving the authenticated users login or ID I note this project isn't using selectors prior to this. From [the Redux docs](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow#selectors): > Selectors are functions that know how to extract specific pieces of information from a > store state value. As an application grows bigger, this can help avoid repeating logic > as different parts of the app need to read the same data That's *exactly* what's needed here. This is particularly true for getting user data state, such as the `userId`. As such, I went ahead and introduced the usage of selectors here. Refs #134784 --- docs/source/recipes/folder-structure.md | 9 +++++- src/actions/types/types.js | 3 +- src/components/manage/Contents/Contents.jsx | 7 +++-- src/components/manage/Edit/Edit.jsx | 7 +++-- .../manage/Preferences/ChangePassword.jsx | 6 ++-- .../Preferences/PersonalInformation.jsx | 6 ++-- src/components/manage/Sharing/Sharing.jsx | 6 ++-- .../manage/Toolbar/PersonalTools.jsx | 6 ++-- src/components/manage/Toolbar/Toolbar.jsx | 8 ++--- src/components/theme/Anontools/Anontools.jsx | 9 +++--- src/components/theme/App/App.jsx | 9 +++--- src/components/theme/Header/Header.jsx | 8 ++--- src/components/theme/View/LinkView.jsx | 5 ++-- src/components/theme/View/LinkView.test.jsx | 2 +- src/components/theme/View/View.jsx | 5 ++-- src/config/RichTextEditor/ToHTML.jsx | 8 +++-- src/selectors/userSession/userSession.js | 30 +++++++++++++++++++ 17 files changed, 85 insertions(+), 49 deletions(-) create mode 100644 src/selectors/userSession/userSession.js diff --git a/docs/source/recipes/folder-structure.md b/docs/source/recipes/folder-structure.md index ae7bcc0c31..daa1555f23 100644 --- a/docs/source/recipes/folder-structure.md +++ b/docs/source/recipes/folder-structure.md @@ -16,7 +16,14 @@ content, users and external resources that are pulled into our app in general. ## Reducers -All the reducers are located here. +`reducers` contains all the Redux reducers that manage the life-cycle for Redux actions +and make the according changes to state. + +## Selectors + +`selectors` contains all the [Redux +selectors](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow#selectors) +that interpret state into the form used by UI components. ## Constants diff --git a/src/actions/types/types.js b/src/actions/types/types.js index 34f43ec791..a076777276 100644 --- a/src/actions/types/types.js +++ b/src/actions/types/types.js @@ -4,6 +4,7 @@ */ import { GET_TYPES } from '@plone/volto/constants/ActionTypes'; +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; /** * Get types function. @@ -13,7 +14,7 @@ import { GET_TYPES } from '@plone/volto/constants/ActionTypes'; */ export function getTypes(url) { return (dispatch, getState) => { - if (getState().userSession.token) { + if (loggedIn(getState())) { dispatch({ type: GET_TYPES, request: { diff --git a/src/components/manage/Contents/Contents.jsx b/src/components/manage/Contents/Contents.jsx index 2d0100676e..816d78d1a7 100644 --- a/src/components/manage/Contents/Contents.jsx +++ b/src/components/manage/Contents/Contents.jsx @@ -51,6 +51,7 @@ import { updateColumnsContent, } from '@plone/volto/actions'; import Indexes, { defaultIndexes } from '@plone/volto/constants/Indexes'; +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; import { ContentsIndexHeader, ContentsItem, @@ -1063,7 +1064,7 @@ class Contents extends Component { (this.props.orderRequest?.loading && !this.props.orderRequest?.error) || (this.props.searchRequest?.loading && !this.props.searchRequest?.error); - return this.props.token && this.props.objectActions.length > 0 ? ( + return this.props.userLoggedIn && this.props.objectActions.length > 0 ? ( <> {folderContentsAction ? ( @@ -1726,7 +1727,7 @@ export const __test__ = compose( connect( (store, props) => { return { - token: store.userSession.token, + userLoggedIn: loggedIn(store), items: store.search.items, sort: store.content.update.sort, index: store.content.updatecolumns.idx, @@ -1767,7 +1768,7 @@ export default compose( connect( (store, props) => { return { - token: store.userSession.token, + userLoggedIn: loggedIn(store), items: store.search.items, sort: store.content.update.sort, index: store.content.updatecolumns.idx, diff --git a/src/components/manage/Edit/Edit.jsx b/src/components/manage/Edit/Edit.jsx index 92d4027d6c..e5a03df676 100644 --- a/src/components/manage/Edit/Edit.jsx +++ b/src/components/manage/Edit/Edit.jsx @@ -16,6 +16,7 @@ import qs from 'query-string'; import { find } from 'lodash'; import { toast } from 'react-toastify'; +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; import { Forbidden, Form, @@ -328,7 +329,7 @@ class Edit extends Component { )} {!editPermission && ( <> - {this.props.token ? ( + {this.props.userLoggedIn ? ( ({ objectActions: state.actions.actions.object, - token: state.userSession.token, + userLoggedIn: loggedIn(state), content: state.content.data, compare_to: state.content.subrequests?.compare_to?.data, schema: state.schema.schema, @@ -441,7 +442,7 @@ export default compose( connect( (state, props) => ({ objectActions: state.actions.actions.object, - token: state.userSession.token, + userLoggedIn: loggedIn(state), content: state.content.data, compare_to: state.content.subrequests?.compare_to?.data, schema: state.schema.schema, diff --git a/src/components/manage/Preferences/ChangePassword.jsx b/src/components/manage/Preferences/ChangePassword.jsx index 308eb88e2e..d737c7a17a 100644 --- a/src/components/manage/Preferences/ChangePassword.jsx +++ b/src/components/manage/Preferences/ChangePassword.jsx @@ -12,11 +12,11 @@ import { Link, withRouter } from 'react-router-dom'; import { Portal } from 'react-portal'; import { defineMessages, injectIntl } from 'react-intl'; import { Container } from 'semantic-ui-react'; -import jwtDecode from 'jwt-decode'; import { toast } from 'react-toastify'; import { Form, Icon, Toast, Toolbar } from '@plone/volto/components'; import { updatePassword } from '@plone/volto/actions'; +import { userData } from '@plone/volto/selectors/userSession/userSession'; import { getBaseUrl } from '@plone/volto/helpers'; import backSVG from '@plone/volto/icons/back.svg'; @@ -226,9 +226,7 @@ export default compose( injectIntl, connect( (state, props) => ({ - userId: state.userSession.token - ? jwtDecode(state.userSession.token).sub - : '', + userId: userData(state).userId, loading: state.users.update_password.loading, pathname: props.location.pathname, }), diff --git a/src/components/manage/Preferences/PersonalInformation.jsx b/src/components/manage/Preferences/PersonalInformation.jsx index 774ef0646a..9e15931923 100644 --- a/src/components/manage/Preferences/PersonalInformation.jsx +++ b/src/components/manage/Preferences/PersonalInformation.jsx @@ -8,11 +8,11 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { compose } from 'redux'; import { defineMessages, injectIntl } from 'react-intl'; -import jwtDecode from 'jwt-decode'; import { toast } from 'react-toastify'; import { Form, Toast } from '@plone/volto/components'; import { getUser, updateUser } from '@plone/volto/actions'; +import { userData } from '@plone/volto/selectors/userSession/userSession'; const messages = defineMessages({ personalInformation: { @@ -230,9 +230,7 @@ export default compose( connect( (state, props) => ({ user: state.users.user, - userId: state.userSession.token - ? jwtDecode(state.userSession.token).sub - : '', + userId: userData(state).userId, loaded: state.users.get.loaded, loading: state.users.update.loading, }), diff --git a/src/components/manage/Sharing/Sharing.jsx b/src/components/manage/Sharing/Sharing.jsx index 91ceff5224..4dc1f43d0e 100644 --- a/src/components/manage/Sharing/Sharing.jsx +++ b/src/components/manage/Sharing/Sharing.jsx @@ -20,10 +20,10 @@ import { Segment, Table, } from 'semantic-ui-react'; -import jwtDecode from 'jwt-decode'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { updateSharing, getSharing } from '@plone/volto/actions'; +import { userData } from '@plone/volto/selectors/userSession/userSession'; import { getBaseUrl } from '@plone/volto/helpers'; import { Icon, Toolbar, Toast } from '@plone/volto/components'; import { toast } from 'react-toastify'; @@ -467,9 +467,7 @@ export default compose( updateRequest: state.sharing.update, pathname: props.location.pathname, title: state.content.data.title, - login: state.userSession.token - ? jwtDecode(state.userSession.token).sub - : '', + login: userData(state).userId, }), { updateSharing, getSharing }, ), diff --git a/src/components/manage/Toolbar/PersonalTools.jsx b/src/components/manage/Toolbar/PersonalTools.jsx index fd9a1a578a..e085fd662c 100644 --- a/src/components/manage/Toolbar/PersonalTools.jsx +++ b/src/components/manage/Toolbar/PersonalTools.jsx @@ -6,11 +6,11 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -import jwtDecode from 'jwt-decode'; import cx from 'classnames'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { Icon } from '@plone/volto/components'; import { getUser } from '@plone/volto/actions'; +import { userData } from '@plone/volto/selectors/userSession/userSession'; import { userHasRoles } from '@plone/volto/helpers'; import logoutSVG from '@plone/volto/icons/log-out.svg'; @@ -166,9 +166,7 @@ export default injectIntl( connect( (state) => ({ user: state.users.user, - userId: state.userSession.token - ? jwtDecode(state.userSession.token).sub - : '', + userId: userData(state).userId, }), { getUser }, )(PersonalTools), diff --git a/src/components/manage/Toolbar/Toolbar.jsx b/src/components/manage/Toolbar/Toolbar.jsx index 1e801e5699..51e306bb39 100644 --- a/src/components/manage/Toolbar/Toolbar.jsx +++ b/src/components/manage/Toolbar/Toolbar.jsx @@ -26,6 +26,7 @@ import { listActions, setExpandedToolbar, } from '@plone/volto/actions'; +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; import { Icon } from '@plone/volto/components'; import { BodyClass, getBaseUrl } from '@plone/volto/helpers'; import { Pluggable } from '@plone/volto/components/manage/Pluggable'; @@ -133,7 +134,7 @@ class Toolbar extends Component { object_buttons: PropTypes.arrayOf(PropTypes.object), user: PropTypes.arrayOf(PropTypes.object), }), - token: PropTypes.string, + userLoggedIn: PropTypes.bool, pathname: PropTypes.string.isRequired, content: PropTypes.shape({ '@type': PropTypes.string, @@ -160,7 +161,6 @@ class Toolbar extends Component { */ static defaultProps = { actions: null, - token: null, content: null, hideDefaultViewButtons: false, types: [], @@ -284,7 +284,7 @@ class Toolbar extends Component { const { expanded } = this.state; return ( - this.props.token && ( + this.props.userLoggedIn && ( <> ({ actions: state.actions.actions, - token: state.userSession.token, + userLoggedIn: loggedIn(state), content: state.content.data, pathname: props.pathname, types: filter(state.types.types, 'addable'), diff --git a/src/components/theme/Anontools/Anontools.jsx b/src/components/theme/Anontools/Anontools.jsx index 8b50fcb659..9ba2b4f1b5 100644 --- a/src/components/theme/Anontools/Anontools.jsx +++ b/src/components/theme/Anontools/Anontools.jsx @@ -9,7 +9,9 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { Menu } from 'semantic-ui-react'; import { FormattedMessage } from 'react-intl'; + import config from '@plone/volto/registry'; +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; /** * Anontools container class. @@ -23,7 +25,7 @@ export class Anontools extends Component { * @static */ static propTypes = { - token: PropTypes.string, + userLoggedIn: PropTypes.bool, content: PropTypes.shape({ '@id': PropTypes.string, }), @@ -35,7 +37,6 @@ export class Anontools extends Component { * @static */ static defaultProps = { - token: null, content: { '@id': null, }, @@ -49,7 +50,7 @@ export class Anontools extends Component { render() { const { settings } = config; return ( - !this.props.token && ( + !this.props.userLoggedIn && ( ({ - token: state.userSession.token, + userLoggedIn: loggedIn(state), content: state.content.data, }))(Anontools); diff --git a/src/components/theme/App/App.jsx b/src/components/theme/App/App.jsx index ca0b346df8..b5e60085d3 100644 --- a/src/components/theme/App/App.jsx +++ b/src/components/theme/App/App.jsx @@ -37,6 +37,7 @@ import { getTypes, getWorkflow, } from '@plone/volto/actions'; +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; import clearSVG from '@plone/volto/icons/clear.svg'; import MultilingualRedirector from '../MultilingualRedirector/MultilingualRedirector'; @@ -124,8 +125,8 @@ class App extends Component { [trim(join(split(this.props.pathname, '/'), ' section-'))]: this.props.pathname !== '/', siteroot: this.props.pathname === '/', - 'is-authenticated': !!this.props.token, - 'is-anonymous': !this.props.token, + 'is-authenticated': !!this.props.userLoggedIn, + 'is-anonymous': !this.props.userLoggedIn, 'cms-ui': isCmsUI, 'public-ui': !isCmsUI, })} @@ -175,7 +176,7 @@ class App extends Component { export const __test__ = connect( (state, props) => ({ pathname: props.location.pathname, - token: state.userSession.token, + userLoggedIn: loggedIn(state), content: state.content.data, apiError: state.apierror.error, connectionRefused: state.apierror.connectionRefused, @@ -220,7 +221,7 @@ export default compose( connect( (state, props) => ({ pathname: props.location.pathname, - token: state.userSession.token, + userLoggedIn: loggedIn(state), content: state.content.data, apiError: state.apierror.error, connectionRefused: state.apierror.connectionRefused, diff --git a/src/components/theme/Header/Header.jsx b/src/components/theme/Header/Header.jsx index 82aee39d09..cc57e0ec36 100644 --- a/src/components/theme/Header/Header.jsx +++ b/src/components/theme/Header/Header.jsx @@ -8,6 +8,7 @@ import { Container, Segment } from 'semantic-ui-react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; import { Anontools, LanguageSelector, @@ -28,7 +29,7 @@ class Header extends Component { * @static */ static propTypes = { - token: PropTypes.string, + userLoggedIn: PropTypes.bool, pathname: PropTypes.string.isRequired, }; @@ -38,7 +39,6 @@ class Header extends Component { * @static */ static defaultProps = { - token: null, }; /** @@ -59,7 +59,7 @@ class Header extends Component {
- {!this.props.token && ( + {!this.props.userLoggedIn && (
@@ -76,5 +76,5 @@ class Header extends Component { } export default connect((state) => ({ - token: state.userSession.token, + userLoggedIn: loggedIn(state), }))(Header); diff --git a/src/components/theme/View/LinkView.jsx b/src/components/theme/View/LinkView.jsx index 042f45b6ff..8e8c0fbdea 100644 --- a/src/components/theme/View/LinkView.jsx +++ b/src/components/theme/View/LinkView.jsx @@ -27,7 +27,7 @@ class LinkView extends Component { description: PropTypes.string, remoteUrl: PropTypes.string, }), - token: PropTypes.string, + userLoggedIn: PropTypes.bool, }; /** @@ -37,11 +37,10 @@ class LinkView extends Component { */ static defaultProps = { content: null, - token: null, }; componentDidMount() { - if (!this.props.token) { + if (!this.props.userLoggedIn) { const { remoteUrl } = this.props.content; if (isInternalURL(remoteUrl)) { this.props.history.replace(flattenToAppURL(remoteUrl)); diff --git a/src/components/theme/View/LinkView.test.jsx b/src/components/theme/View/LinkView.test.jsx index 00fc7e6f40..3b3905e382 100644 --- a/src/components/theme/View/LinkView.test.jsx +++ b/src/components/theme/View/LinkView.test.jsx @@ -7,7 +7,7 @@ test('renders a link view component', () => { const component = renderer.create( {config.settings.showTags && @@ -267,7 +268,7 @@ export default compose( connect( (state, props) => ({ actions: state.actions.actions, - token: state.userSession.token, + userLoggedIn: loggedIn(state), content: state.content.data, error: state.content.get.error, apiError: state.apierror.error, diff --git a/src/config/RichTextEditor/ToHTML.jsx b/src/config/RichTextEditor/ToHTML.jsx index 1a3a12446f..4d17c4554b 100644 --- a/src/config/RichTextEditor/ToHTML.jsx +++ b/src/config/RichTextEditor/ToHTML.jsx @@ -1,6 +1,8 @@ import React from 'react'; import { connect } from 'react-redux'; import { isEmpty } from 'lodash'; + +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink'; const styles = { @@ -219,9 +221,9 @@ const blocks = { }; const LinkEntity = connect((state) => ({ - token: state.userSession.token, -}))(({ token, key, url, target, targetUrl, download, children }) => { - const to = token ? url : targetUrl || url; + userLoggedIn: loggedIn(state), +}))(({ userLoggedIn, key, url, target, targetUrl, download, children }) => { + const to = userLoggedIn ? url : targetUrl || url; return ( Date: Sun, 20 Jun 2021 21:55:45 -0700 Subject: [PATCH 06/36] refactor(actions): Easier action lookup by id Seeking an action by id, for example to check for the presence of an action or to lookup the `url` property of a specific action, is a very common task. As such, transform the arrays of actions into objects of actions by action id and include that in Redux state as well. Next, I plan to use this to replace the JWT implementation-dependent check if a user is logged in. Refs #134784 --- src/components/manage/Contents/Contents.jsx | 6 +- .../manage/Contents/Contents.test.jsx | 26 +-- .../manage/Toolbar/Toolbar.test.jsx | 200 +++++++++--------- src/helpers/Utils/Utils.js | 18 ++ src/reducers/actions/actions.js | 5 + src/reducers/actions/actions.test.js | 68 ++++-- 6 files changed, 193 insertions(+), 130 deletions(-) diff --git a/src/components/manage/Contents/Contents.jsx b/src/components/manage/Contents/Contents.jsx index 816d78d1a7..b12ce1a9ba 100644 --- a/src/components/manage/Contents/Contents.jsx +++ b/src/components/manage/Contents/Contents.jsx @@ -1052,9 +1052,7 @@ class Contents extends Component { render() { const selected = this.state.selected.length > 0; const path = getBaseUrl(this.props.pathname); - const folderContentsAction = find(this.props.objectActions, { - id: 'folderContents', - }); + const folderContentsAction = this.props.actionsById.object.folderContents; const loading = (this.props.clipboardRequest?.loading && @@ -1728,6 +1726,7 @@ export const __test__ = compose( (store, props) => { return { userLoggedIn: loggedIn(store), + actionsById: store.actions.actionsById, items: store.search.items, sort: store.content.update.sort, index: store.content.updatecolumns.idx, @@ -1769,6 +1768,7 @@ export default compose( (store, props) => { return { userLoggedIn: loggedIn(store), + actionsById: store.actions.actionsById, items: store.search.items, sort: store.content.update.sort, index: store.content.updatecolumns.idx, diff --git a/src/components/manage/Contents/Contents.test.jsx b/src/components/manage/Contents/Contents.test.jsx index 5e8f9f9bf7..8963041329 100644 --- a/src/components/manage/Contents/Contents.test.jsx +++ b/src/components/manage/Contents/Contents.test.jsx @@ -5,6 +5,7 @@ import { Provider } from 'react-intl-redux'; import { MemoryRouter } from 'react-router-dom'; import { __test__ as Contents } from './Contents'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; const mockStore = configureStore(); @@ -31,21 +32,22 @@ jest.mock('moment', () => })), ); +const actions = { + document_actions: [], + object: [ + { + icon: '', + id: 'folderContents', + title: 'Contents', + }, + ], +}; +const actionsById = arrayWIdsToObject(actions); + describe('Contents', () => { it('renders a folder contents view component', () => { const store = mockStore({ - actions: { - actions: { - document_actions: [], - object: [ - { - icon: '', - id: 'folderContents', - title: 'Contents', - }, - ], - }, - }, + actions: { actions, actionsById }, userSession: { token: '14134234123qwdaf', }, diff --git a/src/components/manage/Toolbar/Toolbar.test.jsx b/src/components/manage/Toolbar/Toolbar.test.jsx index 078ae165f8..f1328eb7de 100644 --- a/src/components/manage/Toolbar/Toolbar.test.jsx +++ b/src/components/manage/Toolbar/Toolbar.test.jsx @@ -8,112 +8,114 @@ import thunk from 'redux-thunk'; import { PluggablesProvider } from '@plone/volto/components/manage/Pluggable'; import Toolbar from './Toolbar'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; const mockStore = configureStore([thunk]); +const actions = { + document_actions: [], + object: [ + { + icon: '', + id: 'view', + title: 'View', + }, + { + icon: '', + id: 'edit', + title: 'Edit', + }, + { + icon: '', + id: 'folderContents', + title: 'Contents', + }, + { + icon: '', + id: 'history', + title: 'History', + }, + { + icon: '', + id: 'local_roles', + title: 'Sharing', + }, + ], + object_buttons: [ + { + icon: '', + id: 'cut', + title: 'Cut', + }, + { + icon: '', + id: 'copy', + title: 'Copy', + }, + { + icon: '', + id: 'delete', + title: 'Delete', + }, + { + icon: '', + id: 'rename', + title: 'Rename', + }, + { + icon: '', + id: 'ical_import_enable', + title: 'Enable icalendar import', + }, + ], + portal_tabs: [], + site_actions: [ + { + icon: '', + id: 'sitemap', + title: 'Site Map', + }, + { + icon: '', + id: 'accessibility', + title: 'Accessibility', + }, + { + icon: '', + id: 'contact', + title: 'Contact', + }, + ], + user: [ + { + icon: '', + id: 'preferences', + title: 'Preferences', + }, + { + icon: '', + id: 'dashboard', + title: 'Dashboard', + }, + { + icon: '', + id: 'plone_setup', + title: 'Site Setup', + }, + { + icon: '', + id: 'logout', + title: 'Log out', + }, + ], +} +const actionsById = arrayWIdsToObject(actions); + describe('Toolbar', () => { it('renders the Toolbar component', () => { const store = mockStore({ types: { types: [{ title: 'Document', addable: true }] }, - actions: { - actions: { - document_actions: [], - object: [ - { - icon: '', - id: 'view', - title: 'View', - }, - { - icon: '', - id: 'edit', - title: 'Edit', - }, - { - icon: '', - id: 'folderContents', - title: 'Contents', - }, - { - icon: '', - id: 'history', - title: 'History', - }, - { - icon: '', - id: 'local_roles', - title: 'Sharing', - }, - ], - object_buttons: [ - { - icon: '', - id: 'cut', - title: 'Cut', - }, - { - icon: '', - id: 'copy', - title: 'Copy', - }, - { - icon: '', - id: 'delete', - title: 'Delete', - }, - { - icon: '', - id: 'rename', - title: 'Rename', - }, - { - icon: '', - id: 'ical_import_enable', - title: 'Enable icalendar import', - }, - ], - portal_tabs: [], - site_actions: [ - { - icon: '', - id: 'sitemap', - title: 'Site Map', - }, - { - icon: '', - id: 'accessibility', - title: 'Accessibility', - }, - { - icon: '', - id: 'contact', - title: 'Contact', - }, - ], - user: [ - { - icon: '', - id: 'preferences', - title: 'Preferences', - }, - { - icon: '', - id: 'dashboard', - title: 'Dashboard', - }, - { - icon: '', - id: 'plone_setup', - title: 'Site Setup', - }, - { - icon: '', - id: 'logout', - title: 'Log out', - }, - ], - }, - }, + actions: { actions, actionsById }, userSession: { token: jwt.sign({ fullname: 'John Doe' }, 'secret'), }, diff --git a/src/helpers/Utils/Utils.js b/src/helpers/Utils/Utils.js index 4cd4b02fc3..f493343ab3 100644 --- a/src/helpers/Utils/Utils.js +++ b/src/helpers/Utils/Utils.js @@ -184,3 +184,21 @@ export const normalizeLanguageName = (language) => { return language; }; + +/** + * Create an object with properties for each object with an `id` in an array + * @function arrayWIdsToObject + * @param {Array} An array of objects, each has an `id` property with a string value + * @returns {Object} Oobject with property for each array object keyed by the `id` + */ +export function arrayWIdsToObject(arrayOfObjs) { + /* The user may be authenticated by different means, including outside the UI. Defer + * to the response from Plone, sepcifically whether Plone presents an option to log + * in. */ + return Object.fromEntries( + Object.entries(arrayOfObjs).map((entry) => [ + entry[0], + Object.fromEntries(entry[1].map((action) => [action.id, action])), + ]), + ); +} diff --git a/src/reducers/actions/actions.js b/src/reducers/actions/actions.js index 4a95ef7ee4..8191d4f722 100644 --- a/src/reducers/actions/actions.js +++ b/src/reducers/actions/actions.js @@ -4,6 +4,7 @@ */ import { LIST_ACTIONS } from '@plone/volto/constants/ActionTypes'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; const initialState = { error: null, @@ -18,6 +19,7 @@ const initialState = { loaded: false, loading: false, }; +initialState.actionsById = arrayWIdsToObject(initialState.actions); /** * Actions reducer. @@ -38,8 +40,11 @@ export default function actions(state = initialState, action = {}) { case `${LIST_ACTIONS}_SUCCESS`: return { ...state, + /* Also transform the arrays of actions into objects of actions by action id. + * Makes it much easier to check for the presence of an action. */ error: null, actions: action.result, + actionsById: arrayWIdsToObject(action.result), loaded: true, loading: false, }; diff --git a/src/reducers/actions/actions.test.js b/src/reducers/actions/actions.test.js index d00d51ce7a..0a1d49febb 100644 --- a/src/reducers/actions/actions.test.js +++ b/src/reducers/actions/actions.test.js @@ -1,18 +1,23 @@ import actions from './actions'; import { LIST_ACTIONS } from '@plone/volto/constants/ActionTypes'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; + +const actionsFromApi = { + object: [], + object_buttons: [], + site_actions: [], + user: [], + document_actions: [], + portal_tabs: [], +}; +const actionsById = arrayWIdsToObject(actionsFromApi); describe('Actions reducer', () => { it('should return the initial state', () => { expect(actions()).toEqual({ error: null, - actions: { - object: [], - object_buttons: [], - site_actions: [], - user: [], - document_actions: [], - portal_tabs: [], - }, + actions: actionsFromApi, + actionsById, loaded: false, loading: false, }); @@ -25,14 +30,8 @@ describe('Actions reducer', () => { }), ).toEqual({ error: null, - actions: { - object: [], - object_buttons: [], - site_actions: [], - user: [], - document_actions: [], - portal_tabs: [], - }, + actions: actionsFromApi, + actionsById, loaded: false, loading: true, }); @@ -103,6 +102,35 @@ describe('Actions reducer', () => { document_actions: [], portal_tabs: [], }, + actionsById: { + document_actions: {}, + object: {}, + object_buttons: {}, + portal_tabs: {}, + site_actions: {}, + user: { + preferences: { + icon: '', + id: 'preferences', + title: 'Preferences', + }, + dashboard: { + icon: '', + id: 'dashboard', + title: 'Dashboard', + }, + logout: { + icon: '', + id: 'logout', + title: 'Log out', + }, + plone_setup: { + icon: '', + id: 'plone_setup', + title: 'Site Setup', + }, + }, + }, loaded: true, loading: false, }); @@ -117,6 +145,14 @@ describe('Actions reducer', () => { ).toEqual({ error: 'failed', actions: {}, + actionsById: { + document_actions: {}, + object: {}, + object_buttons: {}, + portal_tabs: {}, + site_actions: {}, + user: {}, + }, loaded: false, loading: false, }); From faec2aedc6a815849a7642c6fb3b09b521f8a594 Mon Sep 17 00:00:00 2001 From: Ross Patterson Date: Sun, 20 Jun 2021 22:03:30 -0700 Subject: [PATCH 07/36] fix(auth): UI logged-in detection for other auths Defer to the API, specifically the presence of the `login` action, to determine if a user is logged in instead of depending on the implementation detail of the UI JWT login process. Refs #134784 --- CHANGELOG.md | 3 ++ src/actions/types/types.test.js | 8 ++-- .../manage/Contents/Contents.test.jsx | 4 +- src/components/manage/Edit/Edit.test.jsx | 47 +++++++------------ .../manage/Toolbar/Toolbar.test.jsx | 2 +- .../theme/Anontools/Anontools.test.jsx | 13 +++-- .../__snapshots__/Anontools.test.jsx.snap | 4 +- src/components/theme/App/App.test.jsx | 8 ++-- src/components/theme/Header/Header.jsx | 3 +- src/components/theme/Header/Header.test.jsx | 6 ++- src/components/theme/Login/Login.test.jsx | 5 ++ src/components/theme/View/View.test.jsx | 18 ++++--- src/selectors/userSession/userSession.js | 5 +- 13 files changed, 65 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e93425699a..815f08336a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,10 @@ ### Breaking ### Feature + - Allowing user to paste url in search box in objectBrowser @iFlameing +- Detect when a user has logged in by means other than JWT, such as ZMI `Basic` + authentication or the classic HTML Plone `@login` view @rpatterson ### Bugfix diff --git a/src/actions/types/types.test.js b/src/actions/types/types.test.js index 885be2ec12..486b8b64f9 100644 --- a/src/actions/types/types.test.js +++ b/src/actions/types/types.test.js @@ -1,13 +1,15 @@ import { getTypes } from './types'; import { GET_TYPES } from '@plone/volto/constants/ActionTypes'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; + +const actions = { user: [{ id: 'logout' }] }; +const actionsById = arrayWIdsToObject(actions); describe('Types action', () => { describe('getTypes', () => { it('should create an action to get the types', () => { const getState = () => ({ - userSession: { - token: 'thetoken', - }, + actions: { actions, actionsById }, }); const url = '/blog'; const dispatch = jest.fn(); diff --git a/src/components/manage/Contents/Contents.test.jsx b/src/components/manage/Contents/Contents.test.jsx index 8963041329..8e021e3ec3 100644 --- a/src/components/manage/Contents/Contents.test.jsx +++ b/src/components/manage/Contents/Contents.test.jsx @@ -41,6 +41,7 @@ const actions = { title: 'Contents', }, ], + user: [{ id: 'logout' }], }; const actionsById = arrayWIdsToObject(actions); @@ -48,9 +49,6 @@ describe('Contents', () => { it('renders a folder contents view component', () => { const store = mockStore({ actions: { actions, actionsById }, - userSession: { - token: '14134234123qwdaf', - }, search: { items: [ { diff --git a/src/components/manage/Edit/Edit.test.jsx b/src/components/manage/Edit/Edit.test.jsx index 5e28911e95..ed0dc3f278 100644 --- a/src/components/manage/Edit/Edit.test.jsx +++ b/src/components/manage/Edit/Edit.test.jsx @@ -2,8 +2,8 @@ import React from 'react'; import renderer from 'react-test-renderer'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-intl-redux'; -import jwt from 'jsonwebtoken'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; import { __test__ as Edit } from './Edit'; const mockStore = configureStore(); @@ -13,24 +13,23 @@ jest.mock('react-portal', () => ({ })); jest.mock('../Form/Form', () => jest.fn(() =>
)); +const actions = { + document_actions: [], + object: [ + { + icon: '', + id: 'edit', + title: 'Edit', + }, + ], + user: [{ id: 'logout' }], +}; +const actionsById = arrayWIdsToObject(actions); + describe('Edit', () => { it('renders an empty edit component', () => { const store = mockStore({ - userSession: { - token: jwt.sign({ fullname: 'John Doe' }, 'secret'), - }, - actions: { - actions: { - document_actions: [], - object: [ - { - icon: '', - id: 'edit', - title: 'Edit', - }, - ], - }, - }, + actions: { actions, actionsById }, schema: { schema: null, }, @@ -61,21 +60,7 @@ describe('Edit', () => { it('renders an edit component', () => { const store = mockStore({ - userSession: { - token: jwt.sign({ fullname: 'John Doe' }, 'secret'), - }, - actions: { - actions: { - document_actions: [], - object: [ - { - icon: '', - id: 'edit', - title: 'Edit', - }, - ], - }, - }, + actions: { actions, actionsById }, schema: { schema: { some: 'field', diff --git a/src/components/manage/Toolbar/Toolbar.test.jsx b/src/components/manage/Toolbar/Toolbar.test.jsx index f1328eb7de..22135c2fe4 100644 --- a/src/components/manage/Toolbar/Toolbar.test.jsx +++ b/src/components/manage/Toolbar/Toolbar.test.jsx @@ -108,7 +108,7 @@ const actions = { title: 'Log out', }, ], -} +}; const actionsById = arrayWIdsToObject(actions); describe('Toolbar', () => { diff --git a/src/components/theme/Anontools/Anontools.test.jsx b/src/components/theme/Anontools/Anontools.test.jsx index 628e214925..3b64f27631 100644 --- a/src/components/theme/Anontools/Anontools.test.jsx +++ b/src/components/theme/Anontools/Anontools.test.jsx @@ -5,13 +5,17 @@ import { Provider } from 'react-intl-redux'; import { MemoryRouter } from 'react-router-dom'; import Anontools from './Anontools'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; const mockStore = configureStore(); +const actions = { user: [{ id: 'login' }] }; +const actionsById = arrayWIdsToObject(actions); + describe('Anontools', () => { - it('renders an anontools component when no token is specified', () => { + it('renders an anontools component when no use is logged in', () => { const store = mockStore({ - userSession: { token: null }, + actions: { actions, actionsById }, content: { data: { '@id': 'myid' } }, intl: { locale: 'en', @@ -29,9 +33,10 @@ describe('Anontools', () => { expect(json).toMatchSnapshot(); }); - it('should not render an anontools component when a token is specified', () => { + it('should not render an anontools component when a user is logged in', () => { + const actions = { user: [{ id: 'logout' }] }; const store = mockStore({ - userSession: { token: '1234' }, + actions: { actions, actionsById: arrayWIdsToObject(actions) }, content: { data: {} }, intl: { locale: 'en', diff --git a/src/components/theme/Anontools/__snapshots__/Anontools.test.jsx.snap b/src/components/theme/Anontools/__snapshots__/Anontools.test.jsx.snap index 70b76d32f0..68bfb1abfb 100644 --- a/src/components/theme/Anontools/__snapshots__/Anontools.test.jsx.snap +++ b/src/components/theme/Anontools/__snapshots__/Anontools.test.jsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Anontools renders an anontools component when no token is specified 1`] = ` +exports[`Anontools renders an anontools component when no use is logged in 1`] = `
@@ -19,4 +19,4 @@ exports[`Anontools renders an anontools component when no token is specified 1`]
`; -exports[`Anontools should not render an anontools component when a token is specified 1`] = `null`; +exports[`Anontools should not render an anontools component when a user is logged in 1`] = `null`; diff --git a/src/components/theme/App/App.test.jsx b/src/components/theme/App/App.test.jsx index 2a34423864..a6b2748998 100644 --- a/src/components/theme/App/App.test.jsx +++ b/src/components/theme/App/App.test.jsx @@ -6,6 +6,7 @@ import { MemoryRouter } from 'react-router-dom'; import config from '@plone/volto/registry'; import { __test__ as App } from './App'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; beforeAll(() => { config.settings.navDepth = 1; @@ -35,12 +36,13 @@ jest.mock('semantic-ui-react', () => ({ })); jest.mock('../Footer/Footer', () => jest.fn(() =>