From b923f34a9c68111a14e176ae1fcebd9897c4ec50 Mon Sep 17 00:00:00 2001
From: Sukhendu Sekhar Guria
Date: Wed, 17 Jun 2026 13:13:24 +0530
Subject: [PATCH] Add manage_nav_menus capability
---
src/wp-admin/includes/ajax-actions.php | 8 +-
src/wp-admin/menu.php | 21 +++-
src/wp-admin/nav-menus.php | 4 +-
src/wp-includes/admin-bar.php | 12 +-
src/wp-includes/capabilities.php | 18 +++
.../class-wp-customize-control.php | 2 +-
.../class-wp-customize-nav-menus.php | 53 +++++---
.../class-wp-rest-menu-items-controller.php | 40 ++++++-
...lass-wp-rest-menu-locations-controller.php | 2 +-
.../class-wp-rest-menus-controller.php | 4 +-
src/wp-includes/taxonomy.php | 8 +-
.../widgets/class-wp-nav-menu-widget.php | 16 ++-
tests/phpunit/tests/adminbar.php | 46 +++++++
tests/phpunit/tests/ajax/wpAjaxNavMenus.php | 113 ++++++++++++++++++
tests/phpunit/tests/customize/nav-menus.php | 26 +++-
.../rest-api/wpRestMenuItemsController.php | 65 ++++++++++
.../wpRestMenuLocationsController.php | 54 ++++++++-
.../tests/rest-api/wpRestMenusController.php | 61 ++++++++++
tests/phpunit/tests/user/capabilities.php | 2 +
19 files changed, 495 insertions(+), 60 deletions(-)
create mode 100644 tests/phpunit/tests/ajax/wpAjaxNavMenus.php
diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php
index 2af08fba70af9..ee6acda7ea976 100644
--- a/src/wp-admin/includes/ajax-actions.php
+++ b/src/wp-admin/includes/ajax-actions.php
@@ -1527,7 +1527,7 @@ function wp_ajax_edit_comment() {
function wp_ajax_add_menu_item() {
check_ajax_referer( 'add-menu_item', 'menu-settings-column-nonce' );
- if ( ! current_user_can( 'edit_theme_options' ) ) {
+ if ( ! current_user_can( 'manage_nav_menus' ) ) {
wp_die( -1 );
}
@@ -1879,7 +1879,7 @@ function wp_ajax_update_welcome_panel() {
* @since 3.1.0
*/
function wp_ajax_menu_get_metabox() {
- if ( ! current_user_can( 'edit_theme_options' ) ) {
+ if ( ! current_user_can( 'manage_nav_menus' ) ) {
wp_die( -1 );
}
@@ -1966,7 +1966,7 @@ function wp_ajax_wp_link_ajax() {
* @since 3.1.0
*/
function wp_ajax_menu_locations_save() {
- if ( ! current_user_can( 'edit_theme_options' ) ) {
+ if ( ! current_user_can( 'manage_nav_menus' ) ) {
wp_die( -1 );
}
@@ -2022,7 +2022,7 @@ function wp_ajax_meta_box_order() {
* @since 3.1.0
*/
function wp_ajax_menu_quick_search() {
- if ( ! current_user_can( 'edit_theme_options' ) ) {
+ if ( ! current_user_can( 'manage_nav_menus' ) ) {
wp_die( -1 );
}
diff --git a/src/wp-admin/menu.php b/src/wp-admin/menu.php
index 57d94c75e26f2..dd922d9a85f85 100644
--- a/src/wp-admin/menu.php
+++ b/src/wp-admin/menu.php
@@ -204,7 +204,16 @@
$menu[59] = array( '', 'read', 'separator2', '', 'wp-menu-separator' );
-$appearance_capability = current_user_can( 'switch_themes' ) ? 'switch_themes' : 'edit_theme_options';
+$themes_capability = current_user_can( 'switch_themes' ) ? 'switch_themes' : 'edit_theme_options';
+$appearance_capability = $themes_capability;
+
+if (
+ ! current_user_can( $appearance_capability ) &&
+ current_user_can( 'manage_nav_menus' ) &&
+ ( current_theme_supports( 'menus' ) || current_theme_supports( 'widgets' ) )
+) {
+ $appearance_capability = 'manage_nav_menus';
+}
$menu[60] = array( __( 'Appearance' ), $appearance_capability, 'themes.php', '', 'menu-top menu-icon-appearance', 'menu-appearance', 'dashicons-admin-appearance' );
@@ -222,7 +231,7 @@
}
/* translators: %s: Number of available theme updates. */
- $submenu['themes.php'][5] = array( sprintf( __( 'Themes %s' ), $count ), $appearance_capability, 'themes.php' );
+ $submenu['themes.php'][5] = array( sprintf( __( 'Themes %s' ), $count ), $themes_capability, 'themes.php' );
if ( wp_is_block_theme() ) {
$submenu['themes.php'][6] = array( _x( 'Editor', 'site editor menu item' ), 'edit_theme_options', 'site-editor.php' );
@@ -248,20 +257,20 @@
}
if ( current_theme_supports( 'menus' ) || current_theme_supports( 'widgets' ) ) {
- $submenu['themes.php'][10] = array( __( 'Menus' ), 'edit_theme_options', 'nav-menus.php' );
+ $submenu['themes.php'][10] = array( __( 'Menus' ), 'manage_nav_menus', 'nav-menus.php' );
}
if ( current_theme_supports( 'custom-header' ) && current_user_can( 'customize' ) ) {
$customize_header_url = add_query_arg( array( 'autofocus' => array( 'control' => 'header_image' ) ), $customize_url );
- $submenu['themes.php'][15] = array( _x( 'Header', 'custom image header' ), $appearance_capability, esc_url( $customize_header_url ), '', 'hide-if-no-customize' );
+ $submenu['themes.php'][15] = array( _x( 'Header', 'custom image header' ), $themes_capability, esc_url( $customize_header_url ), '', 'hide-if-no-customize' );
}
if ( current_theme_supports( 'custom-background' ) && current_user_can( 'customize' ) ) {
$customize_background_url = add_query_arg( array( 'autofocus' => array( 'control' => 'background_image' ) ), $customize_url );
- $submenu['themes.php'][20] = array( _x( 'Background', 'custom background' ), $appearance_capability, esc_url( $customize_background_url ), '', 'hide-if-no-customize' );
+ $submenu['themes.php'][20] = array( _x( 'Background', 'custom background' ), $themes_capability, esc_url( $customize_background_url ), '', 'hide-if-no-customize' );
}
-unset( $customize_url, $appearance_capability );
+unset( $customize_url, $appearance_capability, $themes_capability );
// Add 'Theme File Editor' to the bottom of the Appearance (non-block themes) or Tools (block themes) menu.
if ( ! is_multisite() ) {
diff --git a/src/wp-admin/nav-menus.php b/src/wp-admin/nav-menus.php
index 808574f1250d6..66e2397738ed9 100644
--- a/src/wp-admin/nav-menus.php
+++ b/src/wp-admin/nav-menus.php
@@ -20,10 +20,10 @@
}
// Permissions check.
-if ( ! current_user_can( 'edit_theme_options' ) ) {
+if ( ! current_user_can( 'manage_nav_menus' ) ) {
wp_die(
'' . __( 'You need a higher level of permission.' ) . '
' .
- '' . __( 'Sorry, you are not allowed to edit theme options on this site.' ) . '
',
+ '' . __( 'Sorry, you are not allowed to manage navigation menus on this site.' ) . '
',
403
);
}
diff --git a/src/wp-includes/admin-bar.php b/src/wp-includes/admin-bar.php
index 50868b11a2870..00d25b473b2c1 100644
--- a/src/wp-includes/admin-bar.php
+++ b/src/wp-includes/admin-bar.php
@@ -1151,11 +1151,7 @@ function wp_admin_bar_appearance_menu( $wp_admin_bar ) {
);
}
- if ( ! current_user_can( 'edit_theme_options' ) ) {
- return;
- }
-
- if ( current_theme_supports( 'widgets' ) ) {
+ if ( current_user_can( 'edit_theme_options' ) && current_theme_supports( 'widgets' ) ) {
$wp_admin_bar->add_node(
array(
'parent' => 'appearance',
@@ -1166,7 +1162,7 @@ function wp_admin_bar_appearance_menu( $wp_admin_bar ) {
);
}
- if ( current_theme_supports( 'menus' ) || current_theme_supports( 'widgets' ) ) {
+ if ( current_user_can( 'manage_nav_menus' ) && ( current_theme_supports( 'menus' ) || current_theme_supports( 'widgets' ) ) ) {
$wp_admin_bar->add_node(
array(
'parent' => 'appearance',
@@ -1177,7 +1173,7 @@ function wp_admin_bar_appearance_menu( $wp_admin_bar ) {
);
}
- if ( current_theme_supports( 'custom-background' ) ) {
+ if ( current_user_can( 'edit_theme_options' ) && current_theme_supports( 'custom-background' ) ) {
$wp_admin_bar->add_node(
array(
'parent' => 'appearance',
@@ -1191,7 +1187,7 @@ function wp_admin_bar_appearance_menu( $wp_admin_bar ) {
);
}
- if ( current_theme_supports( 'custom-header' ) ) {
+ if ( current_user_can( 'edit_theme_options' ) && current_theme_supports( 'custom-header' ) ) {
$wp_admin_bar->add_node(
array(
'parent' => 'appearance',
diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php
index 028e61ec414a8..61b762d27dcc4 100644
--- a/src/wp-includes/capabilities.php
+++ b/src/wp-includes/capabilities.php
@@ -34,6 +34,7 @@
* `edit_app_password`, `delete_app_passwords`, `delete_app_password`,
* and `update_https` capabilities.
* @since 6.7.0 Added the `edit_block_binding` capability.
+ * @since 7.1.0 Added the `manage_nav_menus` capability.
*
* @global array $post_type_meta_caps Used to get post type meta capabilities.
*
@@ -136,6 +137,13 @@ function map_meta_cap( $cap, $user_id, ...$args ) {
break;
}
+ // Route nav_menu_item edit/delete through manage_nav_menus
+ // instead of the post type's primitive edit_theme_options caps.
+ if ( 'nav_menu_item' === $post->post_type ) {
+ $caps = map_meta_cap( 'manage_nav_menus', $user_id );
+ break;
+ }
+
if ( ! $post_type->map_meta_cap ) {
$caps[] = $post_type->cap->$cap;
// Prior to 3.1 we would re-call map_meta_cap here.
@@ -239,6 +247,13 @@ function map_meta_cap( $cap, $user_id, ...$args ) {
break;
}
+ // Route nav_menu_item edit/delete through manage_nav_menus
+ // instead of the post type's primitive edit_theme_options caps.
+ if ( 'nav_menu_item' === $post->post_type ) {
+ $caps = map_meta_cap( 'manage_nav_menus', $user_id );
+ break;
+ }
+
if ( ! $post_type->map_meta_cap ) {
$caps[] = $post_type->cap->$cap;
// Prior to 3.1 we would re-call map_meta_cap here.
@@ -698,6 +713,9 @@ function map_meta_cap( $cap, $user_id, ...$args ) {
case 'customize':
$caps[] = 'edit_theme_options';
break;
+ case 'manage_nav_menus':
+ $caps[] = 'edit_theme_options';
+ break;
case 'delete_site':
if ( is_multisite() ) {
$caps[] = 'manage_options';
diff --git a/src/wp-includes/class-wp-customize-control.php b/src/wp-includes/class-wp-customize-control.php
index 43f0ac6d4ca64..cb6e340c28699 100644
--- a/src/wp-includes/class-wp-customize-control.php
+++ b/src/wp-includes/class-wp-customize-control.php
@@ -636,7 +636,7 @@ protected function render_content() {
echo $dropdown;
?>
- allow_addition && current_user_can( 'publish_pages' ) && current_user_can( 'edit_theme_options' ) ) : // Currently tied to menus functionality. ?>
+ allow_addition && current_user_can( 'publish_pages' ) && current_user_can( 'manage_nav_menus' ) ) : // Currently tied to menus functionality. ?>