diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3915e165..f7ade3cc0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -269,6 +269,8 @@ ### Internal +- Various build improvements and minor refactoring captured during the effort to unify + Volto, Plone classic, and ZMI authentication. @rpatterson - Change prop `name` -> `componentName` in component `Component` @sneridagh ## 15.0.0-alpha.11 (2022-03-02) diff --git a/Makefile b/Makefile index 56ffb585fe..d8f2c5f3af 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,18 @@ MAKEFLAGS+=--no-builtin-rules INSTANCE_PORT=8080 DOCKER_IMAGE=plone/plone-backend:5.2.7 KGS=plone.restapi==8.21.2 plone.volto==4.0.0a3 plone.rest==2.0.0a3 plone.app.iterate==4.0.2 plone.app.vocabularies==4.3.0 +# 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 +# Then run everything: +# $ make run-proxy-all +# Finally, visit the parts of the stack in a browser: +# - ZMI: http://localhost:49080/api/manage_main +# - Plone Classic: http://localhost:49080/api/Plone +# - Volto: http://localhost:49080/ # Sphinx variables # You can set these variables from the command line. @@ -78,7 +90,28 @@ dist: .PHONY: test test: $(MAKE) -C "./api/" test - +.PHONY: test-clean +test-clean: ## Test in a separate, clean worktree to expose clean build issues + mkdir -pv "$(CHECKOUT_TMP)/" + tmp_worktree="$$( + mktemp -d -p '$(CHECKOUT_TMP)/' \ + '$(CHECKOUT_BRANCH)-tmp-XXXXXXXXXX' + )" +# Disable VCS hooks which might be run when creating a worktree + if [ -e "./.git/hooks/post-checkout" ] + then + mv --backup=numbered -v \ + "./.git/hooks/post-checkout" "./.git/hooks/post-checkout~" + fi + git worktree add "$${tmp_worktree}" + if [ -e "./.git/hooks/post-checkout~" ] + then + mv --backup=numbered -v \ + "./.git/hooks/post-checkout~" "./.git/hooks/post-checkout" + fi + $(MAKE) -C "$${tmp_worktree}/" test +# Leave the temporary worktree around for the developer to inspect. +# Use the `$ make clean-tmp-worktrees` target to clean up all temporary worktrees .PHONY: storybook-build storybook-build: @@ -137,9 +170,9 @@ netlify: docs-test: docs-clean docs-linkcheck docs-spellcheck ## Clean docs build, then run linkcheck, spellcheck .PHONY: start -# Run both the back-end and the front end +# Run both the back-end and the front-end start: - $(MAKE) -j 2 start-backend start-frontend + $(MAKE) -e -j 2 start-backend start-frontend .PHONY: start-frontend start-frontend: @@ -161,6 +194,14 @@ start-frontend-docker: start-backend-docker-guillotina: docker-compose -f g-api/docker-compose.yml up -d +.PHONY: run-proxy +run-proxy: + docker-compose up traefik +.PHONY: run-proxy-all +# Run the back-end, front-end, and proxy testbed with all output combined +run-proxy-all: + $(MAKE) -e -j 3 start-backend start-frontend run-proxy + .PHONY: start-test start-test: ## Start Test @echo "$(GREEN)==> Start Test$(RESET)" @@ -212,5 +253,19 @@ test-acceptance-guillotina: .PHONY: clean clean: - $(MAKE) -C "./api/" clean + $(MAKE) -C "./api/" "$(@)" rm -rf node_modules +.PHONY: clean-data +clean-data: ## Remove all variable user data +# E.g., to run the proxy test bed against a fresh Plone + Volto site: +# $ make clean-data run-proxy-all + $(MAKE) -C "./api/" "$(@)" +.PHONY: clean-tmp-worktrees +clean-tmp-worktrees: ## Cleanup temporary worktrees managed by this `./Makefile` + git worktree list --porcelain | tail -n +5 | + sed -En 's|^worktree ($(CHECKOUT_TMP_ABS)/.+)$$|\1|p' | + while read + do + git worktree remove --force "$${REPLY}" + git branch -D "$$(basename "$${REPLY}")" + done diff --git a/api/Makefile b/api/Makefile index fbe15f133d..401fe3bd25 100644 --- a/api/Makefile +++ b/api/Makefile @@ -50,9 +50,18 @@ test-acceptance-server-old: build ZSERVER_PORT=55001 CONFIGURE_PACKAGES=plone.app.contenttypes,plone.restapi,plone.volto,plone.volto.cors APPLY_PROFILES=plone.app.contenttypes:plone-content,plone.restapi:default,plone.volto:default-homepage ./bin/robot-server plone.app.robotframework.testing.PLONE_ROBOT_TESTING .PHONY: clean -clean: +clean: ## Remove all build artifacts rm -rf bin eggs develop-eggs include lib parts .installed.cfg .mr.developer.cfg - +.PHONY: clean-data +clean-data: ## Remove all variable user data + mkdir -pv "./var/backups/" + for data_path in ./var/*storage ./var/instance ./parts/instance + do + test '!' -e "$${data_path}" || ( + mv --backup=numbered -f -v "$${data_path}" "./var/backups/" + ) + done + touch -c "./buildout.cfg" .PHONY: start start: build ## Start Plone Backend diff --git a/api/buildout.cfg b/api/buildout.cfg index ae7291d6ad..2d10f85e69 100644 --- a/api/buildout.cfg +++ b/api/buildout.cfg @@ -1,6 +1,7 @@ [buildout] index = https://pypi.org/simple/ extends = + https://raw.githubusercontent.com/plone/buildout.coredev/5.2.6/sources.cfg http://dist.plone.org/release/5.2.6/versions.cfg version-constraints.cfg versions.cfg @@ -14,11 +15,6 @@ auto-checkout = always-checkout = force show-picked-versions = true -[sources] -plone.volto = git https://github.com/plone/plone.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 - [instance] recipe = plone.recipe.zope2instance user = admin:admin @@ -83,4 +79,4 @@ site-replace = True # 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} +eggs = ${test:eggs} diff --git a/docker-compose.yml b/docker-compose.yml index e0a86fe47c..c14c411d57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,16 @@ services: - SITE=Plone - 'ADDONS=plone.restapi==8.21.0 plone.volto==4.0.0a3 plone.rest==2.0.0a2 plone.app.iterate==4.0.2 plone.app.vocabularies==4.3.0' - 'PROFILES=plone.volto:default-homepage' + 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: image: 'plone/plone-frontend:latest' @@ -25,3 +35,41 @@ services: RAZZLE_DEV_PROXY_API_PATH: http://backend:8080/Plone depends_on: - backend + 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=INFO" + # - "--log.level=DEBUG" + # https://doc.traefik.io/traefik/observability/access-logs/#configuration + - "--accesslog=true" + - "--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/docs/source/recipes/folder-structure.md b/docs/source/recipes/folder-structure.md index 01015ccb78..284759ce20 100644 --- a/docs/source/recipes/folder-structure.md +++ b/docs/source/recipes/folder-structure.md @@ -12,20 +12,26 @@ 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, also known as 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 +## Reducers -`components` contains all the views. This includes views for the management -interface and the theme. +`reducers` contains all the Redux reducers that manage the life-cycle for Redux actions +and make the according changes to state. -## Config +## Selectors -In this folder all configuration is stored. All configuration can be overridden -in your theme package. +`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 @@ -35,9 +41,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 diff --git a/src/actions/actions/actions.js b/src/actions/actions/actions.js index cf3fb1d695..170622e405 100644 --- a/src/actions/actions/actions.js +++ b/src/actions/actions/actions.js @@ -12,11 +12,13 @@ import { LIST_ACTIONS } from '@plone/volto/constants/ActionTypes'; * @returns {Object} List actions action. */ export function listActions(url) { - return { - type: LIST_ACTIONS, - request: { - op: 'get', - path: `${url}/@actions`, - }, + return async function (dispatch, getState) { + dispatch({ + type: LIST_ACTIONS, + request: { + op: 'get', + path: `${url}/@actions`, + }, + }); }; } diff --git a/src/actions/actions/actions.test.js b/src/actions/actions/actions.test.js index d9b8cc3948..721617c41c 100644 --- a/src/actions/actions/actions.test.js +++ b/src/actions/actions/actions.test.js @@ -5,11 +5,18 @@ describe('Actions action', () => { describe('listActions', () => { it('should create an action to list the actions', () => { const url = 'http://localhost'; - const action = listActions(url); + const getState = () => ({}); + const dispatch = jest.fn(); - expect(action.type).toEqual(LIST_ACTIONS); - expect(action.request.op).toEqual('get'); - expect(action.request.path).toEqual(`${url}/@actions`); + listActions(url)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + type: LIST_ACTIONS, + request: { + op: 'get', + path: `${url}/@actions`, + }, + }); }); }); }); 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 6ea8d24d01..05c592876a 100644 --- a/src/components/manage/Contents/Contents.jsx +++ b/src/components/manage/Contents/Contents.jsx @@ -49,6 +49,7 @@ import { updateColumnsContent, } from '@plone/volto/actions'; import Indexes, { defaultIndexes } from '@plone/volto/constants/Indexes'; +import { loggedIn } from '@plone/volto/selectors/userSession/userSession'; import { ContentsBreadcrumbs, ContentsIndexHeader, @@ -1099,9 +1100,7 @@ class Contents extends Component { const selected = this.state.selected.length > 0; const filteredItems = this.state.filteredItems || this.state.selected; 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 && @@ -1111,7 +1110,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 ? ( @@ -1796,7 +1795,8 @@ export const __test__ = compose( connect( (store, props) => { return { - token: store.userSession.token, + userLoggedIn: loggedIn(store), + actionsById: store.actions.actionsById, items: store.search.items, sort: store.content.update.sort, index: store.content.updatecolumns.idx, @@ -1836,7 +1836,8 @@ export default compose( connect( (store, props) => { return { - token: store.userSession.token, + 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 48f01c1562..51124e303c 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(); @@ -24,21 +25,22 @@ jest.mock('./ContentsUploadModal', () => jest.fn(() =>
), ); +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/Edit/Edit.jsx b/src/components/manage/Edit/Edit.jsx index 3c16a6bbb8..696e5cd546 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, @@ -354,7 +355,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, @@ -475,7 +476,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/Edit/Edit.test.jsx b/src/components/manage/Edit/Edit.test.jsx index 5e28911e95..330f5e05bf 100644 --- a/src/components/manage/Edit/Edit.test.jsx +++ b/src/components/manage/Edit/Edit.test.jsx @@ -4,6 +4,7 @@ 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 +14,25 @@ jest.mock('react-portal', () => ({ })); jest.mock('../Form/Form', () => jest.fn(() =>
)); +const actions = { + document_actions: [], + object: [ + { + icon: '', + id: 'edit', + title: 'Edit', + }, + ], +}; +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, }, @@ -64,18 +66,7 @@ describe('Edit', () => { 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/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 bd1d170452..7d9c731424 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 186ea3dc1c..d13438d177 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 { Pluggable } from '@plone/volto/components/manage/Pluggable'; import { userHasRoles } from '@plone/volto/helpers'; @@ -185,9 +185,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 02bd0544a4..2b77196b16 100644 --- a/src/components/manage/Toolbar/Toolbar.jsx +++ b/src/components/manage/Toolbar/Toolbar.jsx @@ -7,7 +7,6 @@ import React, { Component } from 'react'; import { defineMessages, injectIntl } from 'react-intl'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import jwtDecode from 'jwt-decode'; import { connect } from 'react-redux'; import { compose } from 'redux'; import { doesNodeContainClick } from 'semantic-ui-react/dist/commonjs/lib'; @@ -28,6 +27,10 @@ import { setExpandedToolbar, unlockContent, } from '@plone/volto/actions'; +import { + loggedIn, + userData, +} 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'; @@ -138,8 +141,7 @@ class Toolbar extends Component { object_buttons: PropTypes.arrayOf(PropTypes.object), user: PropTypes.arrayOf(PropTypes.object), }), - token: PropTypes.string, - userId: PropTypes.string, + userLoggedIn: PropTypes.bool, pathname: PropTypes.string.isRequired, content: PropTypes.shape({ '@type': PropTypes.string, @@ -168,8 +170,6 @@ class Toolbar extends Component { */ static defaultProps = { actions: null, - token: null, - userId: null, content: null, hideDefaultViewButtons: false, types: [], @@ -196,9 +196,10 @@ class Toolbar extends Component { * @returns {undefined} */ componentDidMount() { - this.props.listActions(getBaseUrl(this.props.pathname)); - this.props.getTypes(getBaseUrl(this.props.pathname)); - this.props.setExpandedToolbar(this.state.expanded); + this.props + .listActions(getBaseUrl(this.props.pathname)) + .then(() => this.props.getTypes(getBaseUrl(this.props.pathname))); + document.addEventListener('mousedown', this.handleClickOutside, false); } @@ -210,8 +211,9 @@ class Toolbar extends Component { */ UNSAFE_componentWillReceiveProps(nextProps) { if (nextProps.pathname !== this.props.pathname) { - this.props.listActions(getBaseUrl(nextProps.pathname)); - this.props.getTypes(getBaseUrl(nextProps.pathname)); + this.props + .listActions(getBaseUrl(nextProps.pathname)) + .then(() => this.props.getTypes(getBaseUrl(this.props.pathname))); } // Unlock @@ -313,7 +315,7 @@ class Toolbar extends Component { const { expanded } = this.state; return ( - this.props.token && ( + this.props.userLoggedIn && ( <> ({ actions: state.actions.actions, - token: state.userSession.token, - userId: state.userSession.token - ? jwtDecode(state.userSession.token).sub - : '', + userLoggedIn: loggedIn(state), + userId: userData(state).userId, content: state.content.data, pathname: props.pathname, types: filter(state.types.types, 'addable'), diff --git a/src/components/manage/Toolbar/Toolbar.test.jsx b/src/components/manage/Toolbar/Toolbar.test.jsx index 005f6fb9bc..4343432987 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'), }, @@ -144,105 +146,7 @@ describe('Toolbar', () => { it('renders the Toolbar component with lock', () => { 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/components/theme/Anontools/Anontools.jsx b/src/components/theme/Anontools/Anontools.jsx index 2b9e9fb5b6..2e357805ce 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. @@ -21,7 +23,7 @@ export class Anontools extends Component { * @static */ static propTypes = { - token: PropTypes.string, + userLoggedIn: PropTypes.bool, content: PropTypes.shape({ '@id': PropTypes.string, }), @@ -33,7 +35,6 @@ export class Anontools extends Component { * @static */ static defaultProps = { - token: null, content: { '@id': null, }, @@ -47,7 +48,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 483f6e2c68..76b5fd6c0e 100644 --- a/src/components/theme/App/App.jsx +++ b/src/components/theme/App/App.jsx @@ -4,7 +4,6 @@ */ import { Component } from 'react'; -import jwtDecode from 'jwt-decode'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { compose } from 'redux'; @@ -39,7 +38,12 @@ import { getNavigation, getTypes, getWorkflow, + listActions, } from '@plone/volto/actions'; +import { + loggedIn, + userData, +} from '@plone/volto/selectors/userSession/userSession'; import clearSVG from '@plone/volto/icons/clear.svg'; import MultilingualRedirector from '@plone/volto/components/theme/MultilingualRedirector/MultilingualRedirector'; @@ -137,8 +141,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, })} @@ -196,7 +200,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, @@ -242,6 +246,14 @@ export const fetchContent = async ({ store, location }) => { export default compose( asyncConnect([ + { + key: 'actions', + // Dispatch async/await to make the operation syncronous, otherwise it returns + // before the promise is resolved + promise: async ({ location, store: { dispatch } }) => + __SERVER__ && + (await dispatch(listActions(getBaseUrl(location.pathname)))), + }, { key: 'breadcrumbs', promise: ({ location, store: { dispatch } }) => @@ -278,10 +290,8 @@ export default compose( connect( (state, props) => ({ pathname: props.location.pathname, - token: state.userSession.token, - userId: state.userSession.token - ? jwtDecode(state.userSession.token).sub - : '', + userLoggedIn: loggedIn(state), + userId: userData(state).userId, 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 491f92d2ca..4ff00e8cc0 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, }; @@ -37,9 +38,7 @@ class Header extends Component { * @property {Object} defaultProps Default properties. * @static */ - static defaultProps = { - token: null, - }; + static defaultProps = {}; /** * Render method. @@ -59,7 +58,7 @@ class Header extends Component {
- {!this.props.token && ( + {!this.props.userLoggedIn && (
@@ -76,5 +75,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 1aa76b9004..06741f477c 100644 --- a/src/components/theme/View/LinkView.jsx +++ b/src/components/theme/View/LinkView.jsx @@ -28,7 +28,7 @@ class LinkView extends Component { description: PropTypes.string, remoteUrl: PropTypes.string, }), - token: PropTypes.string, + userLoggedIn: PropTypes.bool, }; /** @@ -38,11 +38,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 && @@ -270,7 +271,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/components/theme/View/View.test.jsx b/src/components/theme/View/View.test.jsx index b962b91fb5..2f467c03c4 100644 --- a/src/components/theme/View/View.test.jsx +++ b/src/components/theme/View/View.test.jsx @@ -2,9 +2,11 @@ import React from 'react'; import renderer from 'react-test-renderer'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-intl-redux'; +import thunk from 'redux-thunk'; import View from './View'; import config from '@plone/volto/registry'; +import { arrayWIdsToObject } from '@plone/volto/helpers/Utils/Utils'; beforeAll(() => { config.set('views', { @@ -23,7 +25,7 @@ beforeAll(() => { config.settings.publicURL = 'https://plone.org'; }); -const mockStore = configureStore(); +const mockStore = configureStore([thunk]); jest.mock('react-portal', () => ({ Portal: jest.fn(() =>
), @@ -127,6 +129,11 @@ const actions = { id: 'plone_setup', title: 'Site Setup', }, + { + icon: '', + id: 'login', + title: 'Log in', + }, { icon: '', id: 'logout', @@ -134,11 +141,12 @@ const actions = { }, ], }; +const actionsById = arrayWIdsToObject(actions); describe('View', () => { it('renders an empty view', () => { const store = mockStore({ - actions: { actions }, + actions: { actions, actionsById }, content: { get: { error: null } }, userSession: { token: null }, apierror: {}, @@ -158,7 +166,7 @@ describe('View', () => { it('renders a summary view', () => { const store = mockStore({ - actions: { actions }, + actions: { actions, actionsById }, content: { data: { layout: 'summary_view' }, get: { error: null } }, userSession: { token: null }, apierror: {}, @@ -178,7 +186,7 @@ describe('View', () => { it('renders a tabular view', () => { const store = mockStore({ - actions: { actions }, + actions: { actions, actionsById }, content: { data: { layout: 'tabular_view' }, get: { error: null } }, userSession: { token: null }, apierror: {}, @@ -198,7 +206,7 @@ describe('View', () => { it('renders a document view', () => { const store = mockStore({ - actions: { actions }, + actions: { actions, actionsById }, content: { data: {}, get: { error: null } }, userSession: { token: null }, apierror: {}, 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 ( { return language; }; +/** + * Given an array of objects where each object has a property whose key + * is `id` with a value of `value`, return an array of transformed objects + * where each object's `value` becomes the key of each object. + * + * Example: + * [{id: 'a', title: 'A'}, {id: 'b', title: 'B'}] + * ...transforms to: + * [{'a': {id: 'a', title: 'A'}, 'b': {id: 'b', title: 'B'}}] + * + * This makes lookups more convenient, such as `mapped_actions['a'].title`. + * + * @function arrayWIdsToObject + * @param {Array} An array of objects, each has an `id` property with a string value + * @returns {Object} Object 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, specifically 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])), + ]), + ); +} + /** * Lookup if a given expander is set in apiExpanders * @param {string} language Language to be normalized 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, }); diff --git a/src/selectors/userSession/userSession.js b/src/selectors/userSession/userSession.js new file mode 100644 index 0000000000..9a2f4c4095 --- /dev/null +++ b/src/selectors/userSession/userSession.js @@ -0,0 +1,30 @@ +/** + * Authenticated user state selectors. + * @module selectors/userSession/userSession + */ + +import jwtDecode from 'jwt-decode'; + +/** + * Is a user logged in selector + * @function loggedIn + * @param {Object} state Current full Redux store state. + * @returns {boolean} `true` if a user is currently authenticated, `false` otherwise. + */ +export function loggedIn(state) { + return !!state.userSession.token; +} + +/** + * Retreive user data for the currently authenticated user + * @function userData + * @param {Object} state Current full Redux store state. + * @returns {Object} An object with properties for each user data + */ +export function userData(state) { + return { + userId: state.userSession.token + ? jwtDecode(state.userSession.token).sub + : '', + }; +} diff --git a/src/selectors/userSession/userSession.test.js b/src/selectors/userSession/userSession.test.js new file mode 100644 index 0000000000..f3bd58889f --- /dev/null +++ b/src/selectors/userSession/userSession.test.js @@ -0,0 +1,30 @@ +import { loggedIn } from './userSession'; + +describe('loggedIn selector', () => { + it('if the initial state (no key), user is not logged in', () => { + const state = { + userSession: {}, + }; + expect(loggedIn(state)).toEqual(false); + }); + + it('No JWT token in the state, user is not logged in', () => { + const state = { + userSession: { + token: null, + }, + }; + + expect(loggedIn(state)).toEqual(false); + }); + + it('A JWT token is in the state, user is logged in', () => { + const state = { + userSession: { + token: 'thetoken', + }, + }; + + expect(loggedIn(state)).toEqual(true); + }); +}); 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/"