diff --git a/packages/block-library/src/block/edit-panel/index.js b/packages/block-library/src/block/edit-panel/index.js index e91c76cc6bf52e..eb620a877b63a1 100644 --- a/packages/block-library/src/block/edit-panel/index.js +++ b/packages/block-library/src/block/edit-panel/index.js @@ -53,7 +53,7 @@ class ReusableBlockEditPanel extends Component { } render() { - const { isEditing, title, isSaving, onEdit, instanceId } = this.props; + const { isEditing, title, isSaving, isEditDisabled, onEdit, instanceId } = this.props; return ( @@ -66,6 +66,7 @@ class ReusableBlockEditPanel extends Component { ref={ this.editButton } isLarge className="reusable-block-edit-panel__button" + disabled={ isEditDisabled } onClick={ onEdit } > { __( 'Edit' ) } diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index d95b07aedb9150..c0bed4f8ae160a 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -97,7 +97,7 @@ class ReusableBlockEdit extends Component { } render() { - const { isSelected, reusableBlock, block, isFetching, isSaving } = this.props; + const { isSelected, reusableBlock, block, isFetching, isSaving, canUpdateBlock } = this.props; const { isEditing, title, changedAttributes } = this.state; if ( ! reusableBlock && isFetching ) { @@ -130,6 +130,7 @@ class ReusableBlockEdit extends Component { isEditing={ isEditing } title={ title !== null ? title : reusableBlock.title } isSaving={ isSaving && ! reusableBlock.isTemporary } + isEditDisabled={ ! canUpdateBlock } onEdit={ this.startEditing } onChangeTitle={ this.setTitle } onSave={ this.save } @@ -151,6 +152,8 @@ export default compose( [ __experimentalIsSavingReusableBlock: isSavingReusableBlock, getBlock, } = select( 'core/editor' ); + const { canUser } = select( 'core' ); + const { ref } = ownProps.attributes; const reusableBlock = getReusableBlock( ref ); @@ -159,6 +162,7 @@ export default compose( [ isFetching: isFetchingReusableBlock( ref ), isSaving: isSavingReusableBlock( ref ), block: reusableBlock ? getBlock( reusableBlock.clientId ) : null, + canUpdateBlock: canUser( 'update', 'blocks', ref ), }; } ), withDispatch( ( dispatch, ownProps ) => { diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index ebbcfb7e761500..bc6deae1c53eba 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -137,7 +137,25 @@ export function* saveEntityRecord( kind, name, record ) { */ export function receiveUploadPermissions( hasUploadPermissions ) { return { - type: 'RECEIVE_UPLOAD_PERMISSIONS', - hasUploadPermissions, + type: 'RECEIVE_USER_PERMISSIONS', + key: 'create/media', + isAllowed: hasUploadPermissions, + }; +} + +/** + * Returns an action object used in signalling that the current user has + * permission to perform an action on a REST resource. + * + * @param {string} key A key that represents the action and REST resource. + * @param {boolean} isAllowed Whether or not the user can perform the action. + * + * @return {Object} Action object. + */ +export function receiveUserPermissions( key, isAllowed ) { + return { + type: 'RECEIVE_USER_PERMISSIONS', + key, + isAllowed, }; } diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index d37969cbdae145..676edba2198587 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -218,17 +218,21 @@ export function embedPreviews( state = {}, action ) { } /** - * Reducer managing Upload permissions. + * State which tracks whether the user can perform an action on a REST + * resource. * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. + * @param {Object} state Current state. + * @param {Object} action Dispatched action. * * @return {Object} Updated state. */ -export function hasUploadPermissions( state = {}, action ) { +export function userPermissions( state = {}, action ) { switch ( action.type ) { - case 'RECEIVE_UPLOAD_PERMISSIONS': - return action.hasUploadPermissions; + case 'RECEIVE_USER_PERMISSIONS': + return { + ...state, + [ action.key ]: action.isAllowed, + }; } return state; @@ -241,5 +245,5 @@ export default combineReducers( { themeSupports, entities, embedPreviews, - hasUploadPermissions, + userPermissions, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index b8bd4ed2382854..f988e2f14f13fc 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { find, includes, get, hasIn } from 'lodash'; +import { find, includes, get, hasIn, compact } from 'lodash'; /** * WordPress dependencies @@ -16,7 +16,7 @@ import { receiveEntityRecords, receiveThemeSupports, receiveEmbedPreview, - receiveUploadPermissions, + receiveUserPermissions, } from './actions'; import { getKindEntities } from './entities'; import { apiFetch } from './controls'; @@ -103,7 +103,46 @@ export function* getEmbedPreview( url ) { * Requests Upload Permissions from the REST API. */ export function* hasUploadPermissions() { - const response = yield apiFetch( { path: '/wp/v2/media', method: 'OPTIONS', parse: false } ); + yield* canUser( 'create', 'media' ); +} + +/** + * Checks whether the current user can perform the given action on the given + * REST resource. + * + * @param {string} action Action to check. One of: 'create', 'read', 'update', + * 'delete'. + * @param {string} resource REST resource to check, e.g. 'media' or 'posts'. + * @param {?string} id ID of the rest resource to check. + */ +export function* canUser( action, resource, id ) { + const methods = { + create: 'POST', + read: 'GET', + update: 'PUT', + delete: 'DELETE', + }; + + const method = methods[ action ]; + if ( ! method ) { + throw new Error( `'${ action }' is not a valid action` ); + } + + const path = id ? `/wp/v2/${ resource }/${ id }` : `/wp/v2/${ resource }`; + + let response; + try { + response = yield apiFetch( { + path, + // Ideally this would always be an OPTIONS request, but unfortunately there's + // a bug in the REST API which causes the Allow header to not be sent on + // OPTIONS requests to /posts/:id routes. + method: id ? 'GET' : 'OPTIONS', + parse: false, + } ); + } catch ( error ) { + return; + } let allowHeader; if ( hasIn( response, [ 'headers', 'get' ] ) ) { @@ -116,5 +155,7 @@ export function* hasUploadPermissions() { allowHeader = get( response, [ 'headers', 'Allow' ], '' ); } - yield receiveUploadPermissions( includes( allowHeader, 'POST' ) ); + const key = compact( [ action, resource, id ] ).join( '/' ); + const isAllowed = includes( allowHeader, method ); + yield receiveUserPermissions( key, isAllowed ); } diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 95e9f867aa3c02..e5ba53ab5d385f 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -2,7 +2,7 @@ * External dependencies */ import createSelector from 'rememo'; -import { map, find, get, filter } from 'lodash'; +import { map, find, get, filter, compact } from 'lodash'; /** * WordPress dependencies @@ -178,5 +178,22 @@ export function isPreviewEmbedFallback( state, url ) { * @return {boolean} Upload Permissions. */ export function hasUploadPermissions( state ) { - return state.hasUploadPermissions; + return canUser( state, 'create', 'media' ); +} + +/** + * Returns whether the current user can perform the given action on the given + * REST resource. + * + * @param {Object} state Data state. + * @param {string} action Action to check. One of: 'create', 'read', 'update', + * 'delete'. + * @param {string} resource REST resource to check, e.g. 'media' or 'posts'. + * @param {?string} id ID of the rest resource to check. + * + * @return {boolean} Whether or not the user can perform the action. + */ +export function canUser( state, action, resource, id ) { + const key = compact( [ action, resource, id ] ).join( '/' ); + return get( state, [ 'userPermissions', key ], true ); } diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index b982ada4f3a9d2..bce4ae67b693e8 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -11,6 +11,7 @@ import { getEntityRecords, getEmbedPreview, isPreviewEmbedFallback, + canUser, } from '../selectors'; describe( 'getEntityRecord', () => { @@ -117,3 +118,30 @@ describe( 'isPreviewEmbedFallback()', () => { expect( isPreviewEmbedFallback( state, 'http://example.com/' ) ).toEqual( true ); } ); } ); + +describe( 'canUser', () => { + it( 'returns true by default', () => { + const state = deepFreeze( { + userPermissions: {}, + } ); + expect( canUser( state, 'create', 'media' ) ).toBe( true ); + } ); + + it( 'returns whether an action can be performed', () => { + const state = deepFreeze( { + userPermissions: { + 'create/media': false, + }, + } ); + expect( canUser( state, 'create', 'media' ) ).toBe( false ); + } ); + + it( 'returns whether an action can be performed for a given resource', () => { + const state = deepFreeze( { + userPermissions: { + 'create/media/123': false, + }, + } ); + expect( canUser( state, 'create', 'media', 123 ) ).toBe( false ); + } ); +} ); diff --git a/packages/editor/src/components/block-settings-menu/reusable-block-convert-button.js b/packages/editor/src/components/block-settings-menu/reusable-block-convert-button.js index 6d39538aa5e5ab..75b675e6c404de 100644 --- a/packages/editor/src/components/block-settings-menu/reusable-block-convert-button.js +++ b/packages/editor/src/components/block-settings-menu/reusable-block-convert-button.js @@ -16,6 +16,7 @@ import { compose } from '@wordpress/compose'; export function ReusableBlockConvertButton( { isVisible, isStaticBlock, + canCreateBlocks, onConvertToStatic, onConvertToReusable, } ) { @@ -29,6 +30,7 @@ export function ReusableBlockConvertButton( { { __( 'Add to Reusable Blocks' ) } @@ -54,6 +56,7 @@ export default compose( [ canInsertBlockType, __experimentalGetReusableBlock: getReusableBlock, } = select( 'core/editor' ); + const { canUser } = select( 'core' ); const blocks = getBlocksByClientId( clientIds ); @@ -80,6 +83,7 @@ export default compose( [ ! isReusableBlock( blocks[ 0 ] ) || ! getReusableBlock( blocks[ 0 ].attributes.ref ) ), + canCreateBlocks: canUser( 'create', 'blocks' ), }; } ), withDispatch( ( dispatch, { clientIds, onToggle = noop } ) => { diff --git a/packages/editor/src/components/block-settings-menu/reusable-block-delete-button.js b/packages/editor/src/components/block-settings-menu/reusable-block-delete-button.js index 52f2ad08108f93..b045a72e4c7ca8 100644 --- a/packages/editor/src/components/block-settings-menu/reusable-block-delete-button.js +++ b/packages/editor/src/components/block-settings-menu/reusable-block-delete-button.js @@ -12,8 +12,8 @@ import { __ } from '@wordpress/i18n'; import { isReusableBlock } from '@wordpress/blocks'; import { withSelect, withDispatch } from '@wordpress/data'; -export function ReusableBlockDeleteButton( { reusableBlock, onDelete } ) { - if ( ! reusableBlock ) { +export function ReusableBlockDeleteButton( { id, isDisabled, onDelete } ) { + if ( ! id ) { return null; } @@ -21,8 +21,8 @@ export function ReusableBlockDeleteButton( { reusableBlock, onDelete } ) { onDelete( reusableBlock.id ) } + disabled={ isDisabled } + onClick={ () => onDelete( id ) } > { __( 'Remove from Reusable Blocks' ) } @@ -35,9 +35,16 @@ export default compose( [ getBlock, __experimentalGetReusableBlock: getReusableBlock, } = select( 'core/editor' ); + const { canUser } = select( 'core' ); + const block = getBlock( clientId ); + + const id = isReusableBlock( block ) ? block.attributes.ref : null; return { - reusableBlock: block && isReusableBlock( block ) ? getReusableBlock( block.attributes.ref ) : null, + id, + isDisabled: !! id && ( + getReusableBlock( id ).isTemporary || ! canUser( 'delete', 'blocks', id ) + ), }; } ), withDispatch( ( dispatch, { onToggle = noop } ) => { diff --git a/packages/editor/src/components/block-settings-menu/test/__snapshots__/reusable-block-delete-button.js.snap b/packages/editor/src/components/block-settings-menu/test/__snapshots__/reusable-block-delete-button.js.snap index c31a0f8e9f9b46..23e876d36a9c6d 100644 --- a/packages/editor/src/components/block-settings-menu/test/__snapshots__/reusable-block-delete-button.js.snap +++ b/packages/editor/src/components/block-settings-menu/test/__snapshots__/reusable-block-delete-button.js.snap @@ -3,6 +3,7 @@ exports[`ReusableBlockDeleteButton matches the snapshot 1`] = ` diff --git a/packages/editor/src/components/block-settings-menu/test/reusable-block-convert-button.js b/packages/editor/src/components/block-settings-menu/test/reusable-block-convert-button.js index 1aabafc55ef925..13a000306d930a 100644 --- a/packages/editor/src/components/block-settings-menu/test/reusable-block-convert-button.js +++ b/packages/editor/src/components/block-settings-menu/test/reusable-block-convert-button.js @@ -28,12 +28,14 @@ describe( 'ReusableBlockConvertButton', () => { ); expect( wrapper.props.children[ 1 ] ).toBeFalsy(); const button = wrapper.props.children[ 0 ]; expect( button.props.children ).toBe( 'Add to Reusable Blocks' ); + expect( button.props.disabled ).toBe( false ); button.props.onClick(); expect( onConvert ).toHaveBeenCalled(); } ); diff --git a/packages/editor/src/components/block-settings-menu/test/reusable-block-delete-button.js b/packages/editor/src/components/block-settings-menu/test/reusable-block-delete-button.js index 9da36d7f24e601..ec1e18959d4e88 100644 --- a/packages/editor/src/components/block-settings-menu/test/reusable-block-delete-button.js +++ b/packages/editor/src/components/block-settings-menu/test/reusable-block-delete-button.js @@ -20,7 +20,8 @@ describe( 'ReusableBlockDeleteButton', () => { const wrapper = getShallowRenderOutput( ); @@ -32,7 +33,8 @@ describe( 'ReusableBlockDeleteButton', () => { const onDelete = jest.fn(); const wrapper = getShallowRenderOutput( );