Step-by-Step Guide to building a custom role-based access control editor block for Gutenberg using REST API custom routes
Defining Custom REST API Endpoints for Role Management
To enable a dynamic, client-side Gutenberg block for managing custom roles and their capabilities, we first need to expose this functionality via WordPress’s REST API. This involves creating custom routes that our block can interact with. We’ll define endpoints for fetching existing roles, creating new roles, updating role capabilities, and deleting roles. This approach ensures a clean separation of concerns and allows for future expansion of our role management system.
We’ll leverage the `register_rest_route` function within a custom plugin or theme’s `functions.php` file. This function allows us to hook into the REST API and define our own endpoints, specifying the callback functions that will handle requests.
Fetching All Roles and Capabilities
The first endpoint will be responsible for retrieving a list of all available roles and their associated capabilities. This data will populate our Gutenberg block’s initial state.
Endpoint: GET /myplugin/v1/roles
add_action( 'rest_api_init', function () {
register_rest_route( 'myplugin/v1', '/roles', array(
'methods' => 'GET',
'callback' => 'myplugin_get_all_roles',
'permission_callback' => function () {
// Ensure only users with 'manage_options' capability can access this.
return current_user_can( 'manage_options' );
}
) );
} );
function myplugin_get_all_roles() {
$roles = wp_roles()->roles;
$formatted_roles = array();
foreach ( $roles as $role_slug => $role_data ) {
$formatted_roles[] = array(
'slug' => $role_slug,
'name' => translate_user_role( $role_data['name'] ),
'capabilities' => array_keys( $role_data['capabilities'] ),
);
}
return new WP_REST_Response( $formatted_roles, 200 );
}
Creating a New Role
This endpoint will handle the creation of new custom roles. It will accept a role slug and a display name in the request body.
Endpoint: POST /myplugin/v1/roles
add_action( 'rest_api_init', function () {
register_rest_route( 'myplugin/v1', '/roles', array(
'methods' => 'POST',
'callback' => 'myplugin_create_role',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'args' => array(
'slug' => array(
'required' => true,
'validate_callback' => function( $param, $request, $key ) {
// Basic validation: alphanumeric, no spaces, not a reserved slug
return preg_match( '/^[a-z0-9_]+$/', $param ) && ! in_array( $param, array_keys( wp_roles()->roles ) );
}
),
'name' => array(
'required' => true,
'validate_callback' => 'rest_validate_request_arg',
),
),
) );
} );
function myplugin_create_role( WP_REST_Request $request ) {
$slug = sanitize_key( $request->get_param( 'slug' ) );
$name = sanitize_text_field( $request->get_param( 'name' ) );
if ( empty( $slug ) || empty( $name ) ) {
return new WP_Error( 'invalid_data', __( 'Role slug and name are required.', 'myplugin' ), array( 'status' => 400 ) );
}
// Check if role already exists (redundant due to validation, but good practice)
if ( wp_roles()->get_role( $slug ) ) {
return new WP_Error( 'role_exists', __( 'A role with this slug already exists.', 'myplugin' ), array( 'status' => 409 ) );
}
// Add the role with default capabilities (empty for now, will be managed separately)
add_role( $slug, $name, array() );
return new WP_REST_Response( array( 'message' => sprintf( __( 'Role "%s" created successfully.', 'myplugin' ), $name ) ), 201 );
}
Updating Role Capabilities
This endpoint will allow us to add or remove capabilities for a specific role. It will accept the role slug and an array of capabilities to set.
Endpoint: PUT /myplugin/v1/roles/(?P<slug>[\w-]+)/capabilities
add_action( 'rest_api_init', function () {
register_rest_route( 'myplugin/v1', '/roles/(?P<slug>[\w-]+)/capabilities', array(
'methods' => 'PUT',
'callback' => 'myplugin_update_role_capabilities',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'args' => array(
'capabilities' => array(
'required' => true,
'type' => 'array',
'items' => array(
'type' => 'string',
),
'validate_callback' => function( $param, $request, $key ) {
// Further validation could be added here to check against a known list of capabilities
return is_array( $param );
}
),
),
) );
} );
function myplugin_update_role_capabilities( WP_REST_Request $request ) {
$slug = sanitize_key( $request->get_param( 'slug' ) );
$capabilities = $request->get_param( 'capabilities' );
$role = wp_roles()->get_role( $slug );
if ( ! $role ) {
return new WP_Error( 'role_not_found', __( 'Role not found.', 'myplugin' ), array( 'status' => 404 ) );
}
// Ensure we don't modify core roles or administrator role capabilities directly via this endpoint
if ( in_array( $slug, array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ) ) ) {
return new WP_Error( 'cannot_modify_core_role', __( 'Core roles cannot be modified via this endpoint.', 'myplugin' ), array( 'status' => 403 ) );
}
// Prepare capabilities for update
$new_capabilities = array();
foreach ( $capabilities as $cap ) {
$new_capabilities[ sanitize_key( $cap ) ] = true;
}
// Update the role
$role->add_cap( $new_capabilities ); // This adds new capabilities. For a full replacement, we'd need to remove old ones.
// A more robust solution would involve fetching existing caps, diffing, and applying.
// For simplicity here, we assume the client sends the *desired* final state of capabilities.
// A better approach for replacement:
$role_object = get_role( $slug );
if ( $role_object ) {
// Remove all existing capabilities
foreach ( $role_object->capabilities as $cap => $granted ) {
$role_object->remove_cap( $cap );
}
// Add the new capabilities
foreach ( $new_capabilities as $cap => $granted ) {
$role_object->add_cap( $cap );
}
remove_role( $slug ); // Remove and re-add to ensure WP_Roles object is refreshed
add_role( $slug, $role->name, $new_capabilities );
}
return new WP_REST_Response( array( 'message' => sprintf( __( 'Capabilities for role "%s" updated successfully.', 'myplugin' ), $slug ) ), 200 );
}
Deleting a Role
This endpoint will handle the deletion of custom roles. It will accept the role slug as a parameter.
Endpoint: DELETE /myplugin/v1/roles/(?P<slug>[\w-]+)
add_action( 'rest_api_init', function () {
register_rest_route( 'myplugin/v1', '/roles/(?P<slug>[\w-]+)', array(
'methods' => 'DELETE',
'callback' => 'myplugin_delete_role',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
) );
} );
function myplugin_delete_role( WP_REST_Request $request ) {
$slug = sanitize_key( $request->get_param( 'slug' ) );
// Prevent deletion of core roles
if ( in_array( $slug, array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ) ) ) {
return new WP_Error( 'cannot_delete_core_role', __( 'Core roles cannot be deleted.', 'myplugin' ), array( 'status' => 403 ) );
}
$role = wp_roles()->get_role( $slug );
if ( ! $role ) {
return new WP_Error( 'role_not_found', __( 'Role not found.', 'myplugin' ), array( 'status' => 404 ) );
}
remove_role( $slug );
return new WP_REST_Response( array( 'message' => sprintf( __( 'Role "%s" deleted successfully.', 'myplugin' ), $slug ) ), 200 );
}
Developing the Gutenberg Block for Role Editing
Now that our REST API endpoints are defined, we can proceed to build the Gutenberg block. This block will provide a user-friendly interface for administrators to manage roles directly within the WordPress editor. We’ll use React for the block’s frontend and JavaScript to interact with our custom REST API routes.
Block Registration and Dependencies
First, we need to register our block using the `block.json` file and enqueue the necessary JavaScript and CSS files. The `block.json` file defines the block’s metadata, including its name, title, icon, and attributes. We’ll also specify the JavaScript file that contains our block’s React component.
{
"apiVersion": 2,
"name": "myplugin/role-editor",
"title": "Role Editor",
"category": "widgets",
"icon": "admin-users",
"description": "A custom block to manage user roles and capabilities.",
"keywords": ["roles", "permissions", "access control", "rbac"],
"attributes": {
"roles": {
"type": "array",
"default": []
}
},
"editorScript": "file:./build/index.js",
"editorStyle": "file:./build/index.css",
"style": "file:./build/style-index.css",
"supports": {
"html": false
}
}
In your plugin’s main PHP file (e.g., `myplugin.php`), you’ll need to enqueue the block assets:
function myplugin_register_role_editor_block() {
register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'myplugin_register_role_editor_block' );
React Component for the Role Editor Block
The core of our block will be a React component. This component will fetch roles from our REST API, display them, and provide controls for adding, editing, and deleting roles. We’ll use the `wp.apiFetch` utility for making requests to the WordPress REST API.
src/index.js (or similar entry point)
import { registerBlockType } from '@wordpress/blocks';
import { useState, useEffect } from '@wordpress/element';
import {
PanelBody,
TextControl,
Button,
Table,
TableBody,
TableCell,
TableRow,
Spinner,
Modal,
Placeholder,
SelectControl,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
const ROLES_API_NAMESPACE = 'myplugin/v1';
function RoleEditorBlock( { attributes, setAttributes } ) {
const [ roles, setRoles ] = useState( [] );
const [ isLoading, setIsLoading ] = useState( true );
const [ isModalOpen, setIsModalOpen ] = useState( false );
const [ currentRole, setCurrentRole ] = useState( null );
const [ newRoleSlug, setNewRoleSlug ] = useState( '' );
const [ newRoleName, setNewRoleName ] = useState( '' );
const [ availableCapabilities, setAvailableCapabilities ] = useState( [] );
const [ selectedCapabilities, setSelectedCapabilities ] = useState( [] );
const [ isAddingNewRole, setIsAddingNewRole ] = useState( false );
// Fetch roles on component mount
useEffect( () => {
fetchRoles();
fetchAvailableCapabilities();
}, [] );
const fetchRoles = async () => {
setIsLoading( true );
try {
const fetchedRoles = await apiFetch( { path: `/${ ROLES_API_NAMESPACE }/roles` } );
setRoles( fetchedRoles );
setAttributes( { roles: fetchedRoles } ); // Update block attributes
} catch ( error ) {
console.error( 'Error fetching roles:', error );
} finally {
setIsLoading( false );
}
};
const fetchAvailableCapabilities = async () => {
try {
// This endpoint is not defined in our PHP, but we can simulate it or fetch from WP core if needed.
// For now, let's assume a static list or fetch from a hypothetical endpoint.
// A more robust solution would involve fetching all registered capabilities from WordPress.
// For demonstration, let's use a placeholder list.
const response = await apiFetch( { path: '/wp/v2/types' } ); // Example: Fetching post types to get some capabilities
const coreCapabilities = [
'read', 'edit_posts', 'publish_posts', 'upload_files', 'manage_options', 'edit_others_posts', 'delete_posts', 'edit_pages', 'publish_pages', 'delete_pages', 'edit_others_pages', 'delete_others_pages', 'read_private_pages', 'edit_published_posts', 'delete_published_posts', 'edit_private_posts', 'delete_private_posts', 'edit_private_pages', 'delete_private_pages', 'read_private_posts', 'moderate_comments', 'unfiltered_html', 'manage_categories', 'manage_links', 'edit_users', 'list_users', 'remove_users', 'promote_users', 'edit_others_tasks', 'delete_others_tasks', 'edit_tasks', 'delete_tasks', 'edit_others_work', 'delete_others_work', 'edit_work', 'delete_work'
];
setAvailableCapabilities( coreCapabilities );
} catch ( error ) {
console.error( 'Error fetching available capabilities:', error );
// Fallback to a hardcoded list if API fetch fails
setAvailableCapabilities( [
'read', 'edit_posts', 'publish_posts', 'upload_files', 'manage_options', 'edit_others_posts', 'delete_posts', 'edit_pages', 'publish_pages', 'delete_pages', 'edit_others_pages', 'delete_others_pages', 'read_private_pages', 'edit_published_posts', 'delete_published_posts', 'edit_private_posts', 'delete_private_posts', 'edit_private_pages', 'delete_private_pages', 'read_private_posts', 'moderate_comments', 'unfiltered_html', 'manage_categories', 'manage_links', 'edit_users', 'list_users', 'remove_users', 'promote_users'
] );
}
};
const handleAddRole = async () => {
if ( ! newRoleSlug || ! newRoleName ) {
alert( __( 'Please enter both a slug and a name for the new role.', 'myplugin' ) );
return;
}
setIsAddingNewRole( true );
try {
await apiFetch( {
path: `/${ ROLES_API_NAMESPACE }/roles`,
method: 'POST',
data: { slug: newRoleSlug, name: newRoleName },
} );
setNewRoleSlug( '' );
setNewRoleName( '' );
fetchRoles(); // Refresh the list
} catch ( error ) {
console.error( 'Error adding role:', error );
alert( __( 'Failed to add role. Please check the console for details.', 'myplugin' ) );
} finally {
setIsAddingNewRole( false );
}
};
const handleEditRole = ( role ) => {
setCurrentRole( role );
setSelectedCapabilities( role.capabilities || [] );
setIsModalOpen( true );
};
const handleSaveRoleCapabilities = async () => {
if ( ! currentRole ) return;
try {
await apiFetch( {
path: `/${ ROLES_API_NAMESPACE }/roles/${ currentRole.slug }/capabilities`,
method: 'PUT',
data: { capabilities: selectedCapabilities },
} );
fetchRoles(); // Refresh the list
setIsModalOpen( false );
setCurrentRole( null );
} catch ( error ) {
console.error( 'Error updating role capabilities:', error );
alert( __( 'Failed to update capabilities. Please check the console for details.', 'myplugin' ) );
}
};
const handleDeleteRole = async ( slug ) => {
if ( ! confirm( __( 'Are you sure you want to delete this role? This action cannot be undone.', 'myplugin' ) ) ) {
return;
}
try {
await apiFetch( {
path: `/${ ROLES_API_NAMESPACE }/roles/${ slug }`,
method: 'DELETE',
} );
fetchRoles(); // Refresh the list
} catch ( error ) {
console.error( 'Error deleting role:', error );
alert( __( 'Failed to delete role. Please check the console for details.', 'myplugin' ) );
}
};
const handleCapabilityChange = ( capability, isChecked ) => {
if ( isChecked ) {
setSelectedCapabilities( [ ...selectedCapabilities, capability ] );
} else {
setSelectedCapabilities( selectedCapabilities.filter( cap => cap !== capability ) );
}
};
const handleSelectAllCapabilities = () => {
setSelectedCapabilities( availableCapabilities );
};
const handleDeselectAllCapabilities = () => {
setSelectedCapabilities( [] );
};
if ( isLoading ) {
return (
<Placeholder icon="admin-users" label={ __( 'Role Editor', 'myplugin' ) }>
<Spinner />
</Placeholder>
);
}
return (
<div className="role-editor-block">
<h3>{ __( 'Manage Roles', 'myplugin' ) }</h3>
<div className="add-role-section">
<h4>{ __( 'Add New Role', 'myplugin' ) }</h4>
<TextControl
label={ __( 'Role Slug (e.g., custom_role)', 'myplugin' ) }
value={ newRoleSlug }
onChange={ ( value ) => setNewRoleSlug( value ) }
help={ __( 'Use lowercase letters, numbers, and underscores only. Cannot be changed later.', 'myplugin' ) }
/>
<TextControl
label={ __( 'Display Name', 'myplugin' ) }
value={ newRoleName }
onChange={ ( value ) => setNewRoleName( value ) }
/>
<Button
isPrimary
onClick={ handleAddRole }
disabled={ ! newRoleSlug || ! newRoleName || isAddingNewRole }
isBusy={ isAddingNewRole }
>
{ __( 'Add Role', 'myplugin' ) }
</Button>
</div>
<hr />
<h3>{ __( 'Existing Roles', 'myplugin' ) }</h3>
{ roles.length === 0 && ! isLoading && (
<p>{ __( 'No custom roles found. Add one above.', 'myplugin' ) }</p>
) }
{ roles.length > 0 && (
<Table>
<TableBody>
<TableRow>
<TableCell><strong>{ __( 'Role', 'myplugin' ) }</strong></TableCell>
<TableCell><strong>{ __( 'Capabilities', 'myplugin' ) }</strong></TableCell>
<TableCell><strong>{ __( 'Actions', 'myplugin' ) }</strong></TableCell>
</TableRow>
{ roles.map( ( role ) => (
<TableRow key={ role.slug }>
<TableCell>{ role.name } ({ role.slug })</TableCell>
<TableCell>
{ role.capabilities.length > 0
? role.capabilities.slice( 0, 5 ).join( ', ' ) + ( role.capabilities.length > 5 ? '...' : '' )
: __( 'None', 'myplugin' ) }
</TableCell>
<TableCell>
<Button isSmall onClick={ () => handleEditRole( role ) }>{ __( 'Edit', 'myplugin' ) }</Button>
<Button isSmall isDestructive onClick={ () => handleDeleteRole( role.slug ) }>{ __( 'Delete', 'myplugin' ) }</Button>
</TableCell>
</TableRow>
) ) }
</TableBody>
</Table>
) }
{ isModalOpen && currentRole && (
<Modal
title={ sprintf( __( 'Edit Capabilities for %s', 'myplugin' ), currentRole.name ) }
onRequestClose={ () => setIsModalOpen( false ) }
className="role-editor-modal"
>
<div>
<h4>{ __( 'Available Capabilities', 'myplugin' ) }</h4>
<div className="capabilities-actions">
<Button isSecondary onClick={ handleSelectAllCapabilities }>{ __( 'Select All', 'myplugin' ) }</Button>
<Button isSecondary onClick={ handleDeselectAllCapabilities }>{ __( 'Deselect All', 'myplugin' ) }</Button>
</div>
<div className="capabilities-list">
{ availableCapabilities.map( ( cap ) => (
<label key={ cap }>
<input
type="checkbox"
checked={ selectedCapabilities.includes( cap ) }
onChange={ ( e ) => handleCapabilityChange( cap, e.target.checked ) }
/>
{ cap }
</label>
) ) }
</div>
</div>
<div className="modal-actions">
<Button isPrimary onClick={ handleSaveRoleCapabilities }>{ __( 'Save Changes', 'myplugin' ) }</Button>
<Button isSecondary onClick={ () => setIsModalOpen( false ) }>{ __( 'Cancel', 'myplugin' ) }</Button>
</div>
</Modal>
) }
</div>
);
}
registerBlockType( 'myplugin/role-editor', {
edit: RoleEditorBlock,
save: () => null, // This block is dynamic and renders server-side or via JS, so save returns null.
} );
Note on Capabilities: The `fetchAvailableCapabilities` function in the example above is a placeholder. In a production environment, you would want to fetch a comprehensive list of all registered capabilities in WordPress. This can be achieved by inspecting the `$wp_roles` object or by making a request to a dedicated REST API endpoint that lists all capabilities.
Styling the Block
You’ll also need to provide CSS for both the editor and the frontend. Create a CSS file (e.g., `src/style.scss` or `src/index.scss`) and compile it. The `block.json` file points to `build/index.css` and `build/style-index.css` for editor and frontend styles, respectively.
/* src/style.scss (or index.scss) */
.role-editor-block {
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
h3, h4 {
margin-top: 0;
margin-bottom: 15px;
}
.add-role-section {
margin-bottom: 20px;
padding: 15px;
background-color: #fff;
border: 1px solid #eee;
border-radius: 4px;
}
hr {
margin: 25px 0;
border: 0;
border-top: 1px solid #eee;
}
.components-table-control__table {
width: 100%;
}
.components-table-control__table th,
.components-table-control__table td {
padding: 10px;
border: 1px solid #eee;
}
.components-button.is-small {
margin-right: 5px;
}
.role-editor-modal {
.components-modal__content {
padding: 20px;
}
.capabilities-actions {
margin-bottom: 15px;
button {
margin-right: 10px;
}
}
.capabilities-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #eee;
padding: 10px;
background-color: #fff;
label {
display: block;
margin-bottom: 8px;
input[type="checkbox"] {
margin-right: 8px;
}
}
}
.modal-actions {
margin-top: 20px;
text-align: right;
button {
margin-left: 10px;
}
}
}
}
Build Process and Deployment
To compile the React/JavaScript code and SCSS into the `build` directory, you’ll need a build process. WordPress development typically uses tools like `@wordpress/scripts` which provides a pre-configured Webpack setup. Ensure you have Node.js and npm/yarn installed.
1. **Install Dependencies:** Navigate to your plugin’s directory in the terminal and run:
npm install --save-dev @wordpress/scripts
2. **Add Scripts to package.json:** Add the following scripts to your `package.json` file:
{
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
}
}
3. **Build the Assets:** Run the build command:
npm run build
This will compile your JavaScript and SCSS files into the `build` directory, creating `index.js`, `index.asset.php` (for dependency management), `index.css`, and `style-index.css`. The `index.asset.php` file is crucial for WordPress to correctly enqueue your script with its dependencies.
Security Considerations and Best Practices
When building custom REST API endpoints and Gutenberg blocks, security must be a top priority. The following points are critical for a production-ready solution:
- Capability Checks: Always implement robust `permission_callback` functions for your REST API routes. Ensure that only users with the necessary privileges (e.g., `manage_options`) can access or modify sensitive data like roles.
- Input Sanitization and Validation: Sanitize all data received from the client-side before using it in database operations or role management functions. Use functions like `sanitize_key()`, `sanitize_text_field()`, and `wp_kses_post()`. Validate input parameters rigorously, as demonstrated in the `register_rest_route` arguments.
- Prevent Modification of Core Roles: Explicitly prevent the modification or deletion of WordPress’s built-in roles (`administrator`, `editor`, `author`, etc.) through your custom endpoints.
- Nonce Verification (for non-REST API interactions): While REST API requests typically handle authentication and authorization, if you were to implement direct AJAX handlers, nonce verification would be essential to protect against CSRF attacks.
- Error Handling: Provide meaningful error messages to the client and log detailed errors on the server-side for debugging