diff --git a/includes/Abstracts/Abstract_Feature.php b/includes/Abstracts/Abstract_Feature.php
index fe52055c3..a2c4df81c 100644
--- a/includes/Abstracts/Abstract_Feature.php
+++ b/includes/Abstracts/Abstract_Feature.php
@@ -319,10 +319,81 @@ final public static function get_field_option_name( string $option_name ): strin
return 'wpai_feature_' . static::get_id() . '_field_' . $option_name;
}
+ /**
+ * Registers WordPress infrastructure that must run for all users.
+ *
+ * Example use cases:
+ * - Registering post/comment meta via register_meta() or register_post_meta()
+ * so the REST API schema is always available.
+ * - Attaching plugin-deactivation hooks to clear transient caches.
+ *
+ * @since x.x.x
+ *
+ * @return void
+ */
+ protected function register_infrastructure(): void {
+ // Default implementation is a no-op.
+ }
+
+ /**
+ * Checks whether the current user can access this feature.
+ *
+ * @since x.x.x
+ *
+ * @return bool True if current user has access, false otherwise.
+ */
+ public function current_user_can_access(): bool {
+ if ( ! $this->supports_access_control() ) {
+ return true;
+ }
+
+ return \WordPress\AI\current_user_can_access_feature( static::get_id() );
+ }
+
+ /**
+ * Checks whether access control applies to this feature.
+ *
+ * Admin-category features do not have access controls by default,
+ * except for features explicitly allowing it (e.g. comment-moderation, suggest-reply).
+ *
+ * @since x.x.x
+ *
+ * @return bool True if feature supports access control, false otherwise.
+ */
+ public function supports_access_control(): bool {
+ return 'admin' !== $this->category
+ || in_array( static::get_id(), array( 'comment-moderation', 'suggest-reply' ), true );
+ }
+
+ /**
+ * Registers user-facing feature hooks and functionality.
+ *
+ * Subclasses should override this method to register abilities,
+ * scripts, UI elements, and actions that require user access.
+ *
+ * @since x.x.x
+ *
+ * @return void
+ */
+ protected function register_feature(): void {
+ // Default implementation is a no-op.
+ }
+
/**
* {@inheritDoc}
*
- * Must be implemented by child classes to set up hooks and functionality.
+ * Runs infrastructure setup for all users, then registers feature
+ * hooks if the current user has access to this feature.
+ *
+ * @since 0.6.0
*/
- abstract public function register(): void;
+ public function register(): void {
+ $this->register_infrastructure();
+
+ if ( ! $this->current_user_can_access() ) {
+ return;
+ }
+
+ $this->register_feature();
+ }
}
diff --git a/includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php b/includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php
index cd2c4a8f9..2405bb36f 100644
--- a/includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php
+++ b/includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php
@@ -5,7 +5,7 @@
* @package WordPress\AI
*/
-declare( strict_types=1 );
+declare(strict_types=1);
namespace WordPress\AI\Experiments\Alt_Text_Generation;
@@ -78,7 +78,7 @@ protected function load_metadata(): array {
/**
* {@inheritDoc}
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
add_action( 'wp_enqueue_media', array( $this, 'enqueue_media_frame_assets' ) );
@@ -253,9 +253,9 @@ public function render_attachment_meta_box( \WP_Post $post ): void {
printf(
'
' .
- '
' .
- '
' .
- '
' .
+ '
' .
+ '
' .
+ '
' .
'
',
absint( $post->ID ),
esc_html( $button_text )
@@ -409,9 +409,9 @@ public function add_button_to_media_modal( array $fields, ?\WP_Post $post ): arr
'show_in_edit' => false,
'html' => sprintf(
'' .
- '
' .
- '
' .
- '
' .
+ '
' .
+ '
' .
+ '
' .
'
',
absint( $post->ID ),
esc_html( $button_text )
diff --git a/includes/Experiments/Comment_Moderation/Comment_Moderation.php b/includes/Experiments/Comment_Moderation/Comment_Moderation.php
index 70880f8ea..de798a4ba 100644
--- a/includes/Experiments/Comment_Moderation/Comment_Moderation.php
+++ b/includes/Experiments/Comment_Moderation/Comment_Moderation.php
@@ -307,18 +307,25 @@ protected function load_metadata(): array {
);
}
+ /**
+ * {@inheritDoc}
+ *
+ * @since x.x.x
+ */
+ protected function register_infrastructure(): void {
+ // Moderate new comments automatically in the background.
+ add_action( 'wp_insert_comment', array( $this, 'moderate_comment' ) );
+ }
+
/**
* {@inheritDoc}
*
* @since 0.9.0
*/
- public function register(): void {
+ protected function register_feature(): void {
// Register abilities.
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
- // Moderate new comments.
- add_action( 'wp_insert_comment', array( $this, 'moderate_comment' ) );
-
// Add columns to comments list table.
add_filter( 'manage_edit-comments_columns', array( $this, 'add_columns' ) );
add_action( 'manage_comments_custom_column', array( $this, 'render_column' ), 10, 2 );
diff --git a/includes/Experiments/Content_Classification/Content_Classification.php b/includes/Experiments/Content_Classification/Content_Classification.php
index 3f4ca30a9..708662a4e 100644
--- a/includes/Experiments/Content_Classification/Content_Classification.php
+++ b/includes/Experiments/Content_Classification/Content_Classification.php
@@ -5,7 +5,7 @@
* @package WordPress\AI
*/
-declare( strict_types=1 );
+declare(strict_types=1);
namespace WordPress\AI\Experiments\Content_Classification;
@@ -31,6 +31,7 @@
*/
class Content_Classification extends Abstract_Feature {
+
/**
* The default taxonomy strategy.
*
@@ -97,7 +98,7 @@ protected function load_metadata(): array {
/**
* {@inheritDoc}
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
diff --git a/includes/Experiments/Content_Resizing/Content_Resizing.php b/includes/Experiments/Content_Resizing/Content_Resizing.php
index a206005c5..d54ea725e 100644
--- a/includes/Experiments/Content_Resizing/Content_Resizing.php
+++ b/includes/Experiments/Content_Resizing/Content_Resizing.php
@@ -49,7 +49,7 @@ protected function load_metadata(): array {
/**
* {@inheritDoc}
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
diff --git a/includes/Experiments/Content_Translation/Content_Translation.php b/includes/Experiments/Content_Translation/Content_Translation.php
index b270cea9a..fe222e6bc 100644
--- a/includes/Experiments/Content_Translation/Content_Translation.php
+++ b/includes/Experiments/Content_Translation/Content_Translation.php
@@ -54,7 +54,7 @@ protected function load_metadata(): array {
*
* @since 1.3.0
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) );
diff --git a/includes/Experiments/Editorial_Notes/Editorial_Notes.php b/includes/Experiments/Editorial_Notes/Editorial_Notes.php
index 002c50ec9..0438c62b1 100644
--- a/includes/Experiments/Editorial_Notes/Editorial_Notes.php
+++ b/includes/Experiments/Editorial_Notes/Editorial_Notes.php
@@ -5,7 +5,7 @@
* @package WordPress\AI
*/
-declare( strict_types=1 );
+declare(strict_types=1);
namespace WordPress\AI\Experiments\Editorial_Notes;
@@ -31,6 +31,7 @@
*/
class Editorial_Notes extends Abstract_Feature {
+
/**
* {@inheritDoc}
*/
@@ -52,9 +53,19 @@ protected function load_metadata(): array {
/**
* {@inheritDoc}
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Registers comment meta and the REST insert filter.
+ *
+ * @since x.x.x
+ */
+ protected function register_infrastructure(): void {
add_filter( 'rest_pre_insert_comment', array( $this, 'maybe_set_ai_author' ), 10, 2 );
register_meta(
diff --git a/includes/Experiments/Editorial_Updates/Editorial_Updates.php b/includes/Experiments/Editorial_Updates/Editorial_Updates.php
index 78d9a71b2..e0803b1f0 100644
--- a/includes/Experiments/Editorial_Updates/Editorial_Updates.php
+++ b/includes/Experiments/Editorial_Updates/Editorial_Updates.php
@@ -5,7 +5,7 @@
* @package WordPress\AI
*/
-declare( strict_types=1 );
+declare(strict_types=1);
namespace WordPress\AI\Experiments\Editorial_Updates;
@@ -29,6 +29,7 @@
*/
class Editorial_Updates extends Abstract_Feature {
+
/**
* {@inheritDoc}
*/
@@ -54,7 +55,7 @@ protected function load_metadata(): array {
*
* @since 0.8.0
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) );
}
diff --git a/includes/Experiments/Excerpt_Generation/Excerpt_Generation.php b/includes/Experiments/Excerpt_Generation/Excerpt_Generation.php
index a5af2f77e..a6c8e1dc4 100644
--- a/includes/Experiments/Excerpt_Generation/Excerpt_Generation.php
+++ b/includes/Experiments/Excerpt_Generation/Excerpt_Generation.php
@@ -5,7 +5,7 @@
* @package WordPress\AI
*/
-declare( strict_types=1 );
+declare(strict_types=1);
namespace WordPress\AI\Experiments\Excerpt_Generation;
@@ -27,6 +27,7 @@
*/
class Excerpt_Generation extends Abstract_Feature {
+
/**
* {@inheritDoc}
*/
@@ -48,7 +49,7 @@ protected function load_metadata(): array {
/**
* {@inheritDoc}
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
diff --git a/includes/Experiments/Meta_Description/Meta_Description.php b/includes/Experiments/Meta_Description/Meta_Description.php
index 17d7f0326..821a1de35 100644
--- a/includes/Experiments/Meta_Description/Meta_Description.php
+++ b/includes/Experiments/Meta_Description/Meta_Description.php
@@ -5,7 +5,7 @@
* @package WordPress\AI
*/
-declare( strict_types=1 );
+declare(strict_types=1);
namespace WordPress\AI\Experiments\Meta_Description;
@@ -31,6 +31,7 @@
*/
class Meta_Description extends Abstract_Feature {
+
/**
* {@inheritDoc}
*/
@@ -54,13 +55,22 @@ protected function load_metadata(): array {
*
* @since 0.7.0
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
- add_action( 'deactivated_plugin', array( $this, 'clear_active_plugin_cache' ) );
+ }
- $this->maybe_output_meta_description();
+ /**
+ * {@inheritDoc}
+ *
+ * Registers post meta, the deactivated_plugin cache hook, and frontend meta description output.
+ *
+ * @since x.x.x
+ */
+ protected function register_infrastructure(): void {
$this->register_post_meta();
+ $this->maybe_output_meta_description();
+ add_action( 'deactivated_plugin', array( $this, 'clear_active_plugin_cache' ) );
}
/**
diff --git a/includes/Experiments/Slug_Generation/Slug_Generation.php b/includes/Experiments/Slug_Generation/Slug_Generation.php
index e0d612ab5..60bb2a5c0 100644
--- a/includes/Experiments/Slug_Generation/Slug_Generation.php
+++ b/includes/Experiments/Slug_Generation/Slug_Generation.php
@@ -54,7 +54,7 @@ protected function load_metadata(): array {
*
* @since 1.3.0
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
diff --git a/includes/Experiments/Suggest_Reply/Suggest_Reply.php b/includes/Experiments/Suggest_Reply/Suggest_Reply.php
index 9ce79de5e..1976703a7 100644
--- a/includes/Experiments/Suggest_Reply/Suggest_Reply.php
+++ b/includes/Experiments/Suggest_Reply/Suggest_Reply.php
@@ -53,7 +53,7 @@ protected function load_metadata(): array {
*
* @since 1.2.0
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_filter( 'comment_row_actions', array( $this, 'add_row_action' ), 10, 2 );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
diff --git a/includes/Experiments/Summarization/Summarization.php b/includes/Experiments/Summarization/Summarization.php
index 71473011c..fea8cec5e 100644
--- a/includes/Experiments/Summarization/Summarization.php
+++ b/includes/Experiments/Summarization/Summarization.php
@@ -5,7 +5,7 @@
* @package WordPress\AI
*/
-declare( strict_types=1 );
+declare(strict_types=1);
namespace WordPress\AI\Experiments\Summarization;
@@ -69,11 +69,9 @@ protected function load_metadata(): array {
/**
* {@inheritDoc}
*/
- public function register(): void {
- $this->register_post_meta();
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ), 5 );
- add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) );
add_action( 'load-edit.php', array( $this, 'register_bulk_action_hooks_for_screen' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_bulk_assets' ) );
@@ -121,6 +119,18 @@ public function register_bulk_action_hooks_for_screen(): void {
add_filter( "handle_bulk_actions-edit-{$post_type}", array( $this, 'handle_bulk_action' ), 10, 3 );
}
+ /**
+ * {@inheritDoc}
+ *
+ * Registers post meta.
+ *
+ * @since x.x.x
+ */
+ protected function register_infrastructure(): void {
+ add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) );
+ $this->register_post_meta();
+ }
+
/**
* Register any needed post meta.
*
diff --git a/includes/Experiments/Title_Generation/Title_Generation.php b/includes/Experiments/Title_Generation/Title_Generation.php
index a16d0a5f0..d05771df2 100644
--- a/includes/Experiments/Title_Generation/Title_Generation.php
+++ b/includes/Experiments/Title_Generation/Title_Generation.php
@@ -5,7 +5,7 @@
* @package WordPress\AI
*/
-declare( strict_types=1 );
+declare(strict_types=1);
namespace WordPress\AI\Experiments\Title_Generation;
@@ -27,6 +27,7 @@
*/
class Title_Generation extends Abstract_Feature {
+
/**
* {@inheritDoc}
*/
@@ -50,7 +51,7 @@ protected function load_metadata(): array {
*
* @since 0.1.0
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
diff --git a/includes/Experiments/Type_Ahead/Type_Ahead.php b/includes/Experiments/Type_Ahead/Type_Ahead.php
index 21cf67ea6..018d236c7 100644
--- a/includes/Experiments/Type_Ahead/Type_Ahead.php
+++ b/includes/Experiments/Type_Ahead/Type_Ahead.php
@@ -67,7 +67,7 @@ protected function load_metadata(): array {
*
* @since 1.1.0
*/
- public function register(): void {
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) );
add_action( 'enqueue_block_assets', array( $this, 'enqueue_block_assets' ) );
diff --git a/includes/Features/Image_Generation/Image_Generation.php b/includes/Features/Image_Generation/Image_Generation.php
index 84b4abada..c4ec5ac16 100644
--- a/includes/Features/Image_Generation/Image_Generation.php
+++ b/includes/Features/Image_Generation/Image_Generation.php
@@ -51,9 +51,17 @@ protected function load_metadata(): array {
/**
* {@inheritDoc}
+ *
+ * @since x.x.x
*/
- public function register(): void {
+ protected function register_infrastructure(): void {
$this->register_post_meta();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function register_feature(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_inline_assets' ) );
diff --git a/includes/REST/Roles_Users_Controller.php b/includes/REST/Roles_Users_Controller.php
new file mode 100644
index 000000000..d16b87443
--- /dev/null
+++ b/includes/REST/Roles_Users_Controller.php
@@ -0,0 +1,146 @@
+ 'GET',
+ 'callback' => array( $this, 'get_roles_users' ),
+ 'permission_callback' => array( $this, 'check_permission' ),
+ 'args' => array(
+ 'search' => array(
+ 'type' => 'string',
+ 'default' => '',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether the current user can access this endpoint.
+ *
+ * @since x.x.x
+ *
+ * @return bool True if the user has permission.
+ */
+ public function check_permission(): bool {
+ return current_user_can( 'manage_options' );
+ }
+
+ /**
+ * Returns roles and users for the access control endpoint.
+ *
+ * @since x.x.x
+ *
+ * @param \WP_REST_Request $request The REST request.
+ * @return \WP_REST_Response Response object containing roles and users.
+ */
+ public function get_roles_users( \WP_REST_Request $request ): \WP_REST_Response {
+ $roles = array();
+
+ foreach ( wp_roles()->roles as $role_id => $role ) {
+ if ( in_array( $role_id, array( 'subscriber', 'contributor' ), true ) ) {
+ continue;
+ }
+
+ $roles[] = array(
+ 'id' => $role_id,
+ 'name' => translate_user_role( $role['name'] ),
+ );
+ }
+
+ $search = (string) $request->get_param( 'search' );
+ $get_users_args = array(
+ 'fields' => array( 'ID', 'display_name' ),
+ 'number' => self::MAX_USERS,
+ 'role__not_in' => array( 'subscriber', 'contributor' ),
+ );
+
+ if ( '' !== $search ) {
+ $get_users_args['search'] = '*' . $search . '*';
+ $get_users_args['search_columns'] = array( 'user_login', 'display_name', 'user_email' );
+ }
+
+ $users = array();
+ $wp_users = get_users( $get_users_args );
+
+ foreach ( $wp_users as $user ) {
+ $users[] = array(
+ 'id' => (int) $user->ID,
+ 'name' => $user->display_name,
+ );
+ }
+
+ return new \WP_REST_Response(
+ array(
+ 'roles' => $roles,
+ 'users' => $users,
+ ),
+ 200
+ );
+ }
+}
diff --git a/includes/Settings/Settings_Registration.php b/includes/Settings/Settings_Registration.php
index 7478677c5..92459ff2b 100644
--- a/includes/Settings/Settings_Registration.php
+++ b/includes/Settings/Settings_Registration.php
@@ -13,6 +13,7 @@
use WordPress\AI\Features\Registry;
use WordPress\AI\REST\Models_Controller;
+use WordPress\AI\REST\Roles_Users_Controller;
use WordPress\AI\REST\Settings_IO_Controller;
/**
@@ -40,6 +41,18 @@ class Settings_Registration {
*/
public const OPTION_GROUP = 'ai_experiments';
+ /**
+ * The option group name for access control settings registration.
+ *
+ * Kept distinct from OPTION_GROUP so that site-specific access control policies
+ * (roles and user IDs) are excluded from settings export and import.
+ *
+ * @since x.x.x
+ *
+ * @var string
+ */
+ public const ACCESS_CONTROL_OPTION_GROUP = 'ai_experiments_access';
+
/**
* The option name for the global experiments toggle.
*
@@ -75,6 +88,7 @@ public function init(): void {
// Initialize the settings import/export REST endpoints.
( new Settings_IO_Controller() )->init();
+ ( new Roles_Users_Controller() )->init();
// Extend the HTTP timeout while core revalidates provider keys on save,
// then restore the default so it does not leak into later requests.
@@ -196,6 +210,71 @@ public function register_settings(): void {
)
);
+ register_setting(
+ self::ACCESS_CONTROL_OPTION_GROUP,
+ "wpai_feature_{$feature_id}_roles",
+ array(
+ 'type' => 'array',
+ 'default' => \WordPress\AI\get_default_feature_roles(),
+ 'sanitize_callback' => static function ( $roles ) {
+ if ( ! is_array( $roles ) ) {
+ return array();
+ }
+
+ $valid_roles = array_keys( wp_roles()->roles );
+
+ return array_values(
+ array_filter(
+ $roles,
+ static function ( $role ) use ( $valid_roles ) {
+ return is_string( $role ) && in_array( $role, $valid_roles, true );
+ }
+ )
+ );
+ },
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'array',
+ 'default' => \WordPress\AI\get_default_feature_roles(),
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ )
+ );
+
+ register_setting(
+ self::ACCESS_CONTROL_OPTION_GROUP,
+ "wpai_feature_{$feature_id}_users",
+ array(
+ 'type' => 'array',
+ 'default' => array(),
+ 'sanitize_callback' => static function ( $users ) {
+ if ( ! is_array( $users ) ) {
+ return array();
+ }
+
+ return array_values(
+ array_filter(
+ array_map( 'absint', $users ),
+ static function ( $user_id ) {
+ return $user_id > 0 && false !== get_userdata( $user_id );
+ }
+ )
+ );
+ },
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ ),
+ )
+ );
+
// Allow experiments to register their own custom settings.
if ( ! method_exists( $feature, 'register_settings' ) ) {
continue;
diff --git a/includes/helpers.php b/includes/helpers.php
index 1003268f5..29dcc3508 100644
--- a/includes/helpers.php
+++ b/includes/helpers.php
@@ -958,3 +958,80 @@ function generate_embeddings( $input, array $args = array() ) {
return new \WP_Error( 'ai_embeddings_failed', $e->getMessage() );
}
}
+
+/**
+ * Returns the default allowed roles for features when access control has not been customized.
+ *
+ * Excludes 'subscriber' and 'contributor' by default.
+ *
+ * @since x.x.x
+ *
+ * @return string[] Array of default role slugs.
+ */
+function get_default_feature_roles(): array {
+ if ( ! function_exists( 'wp_roles' ) ) {
+ return array( 'administrator', 'editor', 'author' );
+ }
+
+ $roles = array();
+ foreach ( array_keys( wp_roles()->roles ) as $role_id ) {
+ if ( in_array( $role_id, array( 'subscriber', 'contributor' ), true ) ) {
+ continue;
+ }
+
+ $roles[] = $role_id;
+ }
+
+ return $roles;
+}
+
+/**
+ * Checks whether the current user has access to a given feature based on access control settings.
+ *
+ * If the user is explicitly listed in the feature's allowed users, access is granted.
+ * Users with subscriber or contributor roles are denied access by default, but this can be overridden via filter.
+ * If no roles or users are explicitly configured for the feature, it defaults to allowing all non-subscriber/contributor roles.
+ * If access control is configured, the current user must match at least one allowed role or be explicitly listed as an allowed user.
+ * If all roles and users are unchecked/empty, access is denied.
+ * All access decisions pass through the `wpai_user_has_role_access` filter.
+ *
+ * @since x.x.x
+ *
+ * @param string $feature_id The ID of the feature/experiment.
+ * @return bool True if the user has access, false otherwise.
+ */
+function current_user_can_access_feature( string $feature_id ): bool {
+ $current_user = wp_get_current_user();
+
+ $roles = get_option( "wpai_feature_{$feature_id}_roles", null );
+ $users = get_option( "wpai_feature_{$feature_id}_users", null );
+
+ // If access control has not been configured in the database, default to all eligible roles.
+ if ( null === $roles && null === $users ) {
+ $roles = get_default_feature_roles();
+ $users = array();
+ } else {
+ $roles = is_array( $roles ) ? $roles : array();
+ $users = is_array( $users ) ? $users : array();
+ }
+
+ if ( array_intersect( $current_user->roles, array( 'subscriber', 'contributor' ) ) ) {
+ $has_access = false;
+ } elseif ( in_array( $current_user->ID, array_map( 'intval', $users ), true ) ) {
+ $has_access = true;
+ } else {
+ $has_access = (bool) array_intersect( $current_user->roles, $roles );
+ }
+
+ /**
+ * Filters whether the current user has access to a feature based on role.
+ *
+ * @since x.x.x
+ *
+ * @param bool $has_access Whether the user has access.
+ * @param string $feature_id The feature identifier.
+ * @param array $roles The allowed roles.
+ * @param \WP_User $current_user The current user object.
+ */
+ return apply_filters( 'wpai_user_has_role_access', $has_access, $feature_id, $roles, $current_user );
+}
diff --git a/routes/ai-home/components/AccessControlSettings.tsx b/routes/ai-home/components/AccessControlSettings.tsx
new file mode 100644
index 000000000..d12eb3921
--- /dev/null
+++ b/routes/ai-home/components/AccessControlSettings.tsx
@@ -0,0 +1,231 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ Button,
+ CheckboxControl,
+ Flex,
+ FlexItem,
+ FormTokenField,
+ Spinner,
+} from '@wordpress/components';
+import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { useAccessControlSettings } from '../hooks/use-access-control-settings';
+import { useRolesUsersContext } from '../hooks/use-roles-users';
+import type { Role, User } from '../hooks/use-roles-users';
+
+interface AccessControlSettingsProps {
+ featureId: string;
+ roles?: Role[];
+ isLoading?: boolean;
+ fetchError?: string | null;
+ suggestions?: User[];
+ isSearching?: boolean;
+ search?: ( query: string ) => void;
+}
+
+export function AccessControlSettings( {
+ featureId,
+ roles: propsRoles,
+ isLoading: propsIsLoading,
+ fetchError: propsFetchError,
+ suggestions: propsSuggestions,
+ isSearching: propsIsSearching,
+ search: propsSearch,
+}: AccessControlSettingsProps ): React.JSX.Element {
+ const contextData = useRolesUsersContext();
+ const roles = propsRoles ?? contextData.roles;
+ const isLoading = propsIsLoading ?? contextData.isLoading;
+ const fetchError = propsFetchError ?? contextData.fetchError;
+ const suggestions = propsSuggestions ?? contextData.suggestions;
+ const isSearching = propsIsSearching ?? contextData.isSearching;
+ const search = propsSearch ?? contextData.search;
+
+ const { settings, stage, save, isDirty, isSaving } =
+ useAccessControlSettings( featureId );
+
+ const [ localRoles, setLocalRoles ] = useState< string[] | null >( null );
+ const [ selectedUserMap, setSelectedUserMap ] = useState<
+ Map< number, string >
+ >( new Map() );
+ const [ localUsers, setLocalUsers ] = useState< number[] | null >( null );
+
+ const effectiveRoles = localRoles ?? settings.roles;
+ const effectiveUsers = localUsers ?? settings.users;
+ const suggestionNameToId = useMemo( () => {
+ const map = new Map< string, number >();
+ suggestions.forEach( ( u: User ) => map.set( u.name, u.id ) );
+ return map;
+ }, [ suggestions ] );
+
+ const selectedUsersTokens = useMemo( () => {
+ return effectiveUsers.map(
+ ( id ) => selectedUserMap.get( id ) ?? id.toString()
+ );
+ }, [ effectiveUsers, selectedUserMap ] );
+
+ // Seed selectedUserMap with users returned from the API (capped at
+ // MAX_USERS i.e. 10 at a time). If more than
+ // MAX_USERS users are saved, any beyond the cap won't be included in
+ // this response and will fall back to showing their raw ID.
+ useEffect( () => {
+ setSelectedUserMap( ( prev ) => {
+ const next = new Map( prev );
+ suggestions.forEach( ( u: User ) => next.set( u.id, u.name ) );
+ return next;
+ } );
+ }, [ suggestions ] );
+
+ // Exclude already-selected users from the suggestions dropdown.
+ const userSuggestionNames = useMemo(
+ () =>
+ suggestions
+ .filter( ( u: User ) => ! effectiveUsers.includes( u.id ) )
+ .map( ( u: User ) => u.name ),
+ [ suggestions, effectiveUsers ]
+ );
+
+ const handleRoleToggle = useCallback(
+ ( roleId: string, checked: boolean ) => {
+ const newRoles = checked
+ ? [ ...effectiveRoles, roleId ]
+ : effectiveRoles.filter( ( r ) => r !== roleId );
+ setLocalRoles( newRoles );
+ stage( { roles: newRoles, users: effectiveUsers } );
+ },
+ [ stage, effectiveRoles, effectiveUsers ]
+ );
+
+ const handleUsersChange = useCallback(
+ ( tokens: ( string | { value: string } )[] ) => {
+ const newUsers: number[] = [];
+ const newMap = new Map< number, string >( selectedUserMap );
+
+ tokens.forEach( ( token ) => {
+ const label = typeof token === 'string' ? token : token.value;
+ let id = suggestionNameToId.get( label );
+
+ if ( id === undefined ) {
+ for ( const [
+ mapId,
+ mapLabel,
+ ] of selectedUserMap.entries() ) {
+ if ( mapLabel === label ) {
+ id = mapId;
+ break;
+ }
+ }
+ }
+
+ if ( id !== undefined ) {
+ newUsers.push( id );
+ newMap.set( id, label );
+ }
+ } );
+
+ setLocalUsers( newUsers );
+ setSelectedUserMap( newMap );
+ stage( { roles: effectiveRoles, users: newUsers } );
+ search( '' );
+ },
+ [ stage, effectiveRoles, suggestionNameToId, selectedUserMap, search ]
+ );
+
+ const handleInputChange = useCallback(
+ ( input: string ) => {
+ search( input );
+ },
+ [ search ]
+ );
+
+ const handleSave = useCallback( async () => {
+ await save();
+ setLocalRoles( null );
+ setLocalUsers( null );
+ }, [ save ] );
+
+ return (
+
+ { isLoading &&
}
+ { ! isLoading && fetchError && (
+
+ { fetchError }
+
+ ) }
+ { ! isLoading && ! fetchError && (
+
+
+
+
+
+
+
+
+
+ { isSearching && (
+
+
+
+ ) }
+
+
+ { isDirty && (
+
+
+
+ ) }
+
+ ) }
+
+ );
+}
diff --git a/routes/ai-home/components/FeatureToggle.tsx b/routes/ai-home/components/FeatureToggle.tsx
index b87eaa688..35a1e9eb4 100644
--- a/routes/ai-home/components/FeatureToggle.tsx
+++ b/routes/ai-home/components/FeatureToggle.tsx
@@ -14,14 +14,17 @@ import { Stack } from '@wordpress/ui';
* Internal dependencies
*/
import { useDeveloperModeContext } from '../hooks/use-developer-mode';
+import { useAccessControlModeContext } from '../hooks/use-access-control-mode';
import { DeveloperSettings } from './DeveloperSettings';
import { ConnectorApprovalNotice } from './ConnectorApprovalNotice';
+import { AccessControlSettings } from './AccessControlSettings';
type AISettings = Record< string, boolean >;
type FeatureToggleProps = DataFormControlProps< AISettings > & {
featureId?: string;
capability?: string;
+ category?: string;
};
interface ConnectorApprovalState {
@@ -50,6 +53,7 @@ export function FeatureToggle( {
onChange,
featureId,
capability = 'text_generation',
+ category,
}: FeatureToggleProps ): React.JSX.Element {
const checked = !! field.getValue( { item: data } );
const isDeveloperMode = useDeveloperModeContext();
@@ -58,12 +62,18 @@ export function FeatureToggle( {
const [ approvalState, setApprovalState ] =
useState< ConnectorApprovalState | null >( null );
const [ isCheckingApprovals, setIsCheckingApprovals ] = useState( false );
+ const isAccessControlMode = useAccessControlModeContext();
const resolvedFeatureId =
featureId ??
FEATURE_SETTING_PATTERN.exec( field.id )?.[ 1 ] ??
field.id;
+ const canHaveAccessControl =
+ category !== 'admin' ||
+ resolvedFeatureId === 'comment-moderation' ||
+ resolvedFeatureId === 'suggest-reply';
+
const hasApprovedConnector =
approvalState?.approvals[ AI_PLUGIN ] &&
Object.entries( approvalState.approvals[ AI_PLUGIN ] ).some(
@@ -124,6 +134,9 @@ export function FeatureToggle( {
}
} }
/>
+ { checked && isAccessControlMode && canHaveAccessControl && (
+
+ ) }
{ checked && isDeveloperMode && (
( false );
+
+export function useAccessControlModeContext(): boolean {
+ return useContext( AccessControlModeContext );
+}
+
+interface UseAccessControlModeReturn {
+ isAccessControlMode: boolean;
+ toggleAccessControlMode: () => void;
+}
+
+/**
+ * useAccessControlMode hook.
+ *
+ * @return {UseAccessControlModeReturn} The access control mode return object.
+ */
+export function useAccessControlMode(): UseAccessControlModeReturn {
+ const [ isAccessControlMode, setIsAccessControlMode ] = useState< boolean >(
+ () => {
+ try {
+ return localStorage.getItem( STORAGE_KEY ) === 'true';
+ } catch {
+ return false;
+ }
+ }
+ );
+
+ useEffect( () => {
+ try {
+ if ( isAccessControlMode ) {
+ localStorage.setItem( STORAGE_KEY, 'true' );
+ } else {
+ localStorage.removeItem( STORAGE_KEY );
+ }
+ } catch {}
+ }, [ isAccessControlMode ] );
+
+ const toggleAccessControlMode = useCallback( () => {
+ setIsAccessControlMode( ( prev ) => ! prev );
+ }, [] );
+
+ return { isAccessControlMode, toggleAccessControlMode };
+}
diff --git a/routes/ai-home/hooks/use-access-control-settings.ts b/routes/ai-home/hooks/use-access-control-settings.ts
new file mode 100644
index 000000000..30d1eb7fc
--- /dev/null
+++ b/routes/ai-home/hooks/use-access-control-settings.ts
@@ -0,0 +1,119 @@
+/**
+ * WordPress dependencies
+ */
+import { store as coreStore } from '@wordpress/core-data';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { useCallback, useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+
+interface AccessControlSettings {
+ roles: string[];
+ users: number[];
+}
+
+interface UseAccessControlSettingsReturn {
+ settings: AccessControlSettings;
+ stage: ( next: AccessControlSettings ) => void;
+ save: () => Promise< void >;
+ clear: () => void;
+ isDirty: boolean;
+ isSaving: boolean;
+}
+
+const EMPTY_SETTINGS: AccessControlSettings = { roles: [], users: [] };
+
+/**
+ * Reads and writes the access control settings for a specific feature.
+ *
+ * @param {string} featureId The feature ID.
+ * @return {UseAccessControlSettingsReturn} The settings and update functions.
+ */
+export function useAccessControlSettings(
+ featureId: string
+): UseAccessControlSettingsReturn {
+ const rolesKey = `wpai_feature_${ featureId }_roles`;
+ const usersKey = `wpai_feature_${ featureId }_users`;
+
+ const { editedRecord, nonTransientEdits, isSaving } = useSelect(
+ ( select ) => {
+ const store: any = select( coreStore );
+ return {
+ editedRecord: store.getEditedEntityRecord( 'root', 'site' ) as
+ | Record< string, unknown >
+ | undefined,
+ nonTransientEdits: ( store.getEntityRecordNonTransientEdits(
+ 'root',
+ 'site'
+ ) ?? {} ) as Record< string, unknown >,
+ isSaving: store.isSavingEntityRecord(
+ 'root',
+ 'site'
+ ) as boolean,
+ };
+ },
+ []
+ );
+
+ const { editEntityRecord } = useDispatch( coreStore );
+ const { __experimentalSaveSpecifiedEntityEdits: saveSpecifiedEdits } =
+ useDispatch( coreStore ) as any;
+ const { createErrorNotice, createSuccessNotice } =
+ useDispatch( noticesStore );
+
+ const rawRoles = editedRecord?.[ rolesKey ];
+ const rawUsers = editedRecord?.[ usersKey ];
+
+ const settings: AccessControlSettings = {
+ roles: Array.isArray( rawRoles ) ? rawRoles.map( String ) : [],
+ users: Array.isArray( rawUsers ) ? rawUsers.map( Number ) : [],
+ };
+
+ const isDirty = useMemo(
+ () => rolesKey in nonTransientEdits || usersKey in nonTransientEdits,
+ [ rolesKey, usersKey, nonTransientEdits ]
+ );
+
+ const stage = useCallback(
+ ( next: AccessControlSettings ) => {
+ // @ts-expect-error -- core-data types don't expose editEntityRecord for 'root'/'site' args.
+ editEntityRecord( 'root', 'site', undefined, {
+ [ rolesKey ]: next.roles,
+ [ usersKey ]: next.users,
+ } );
+ },
+ [ rolesKey, usersKey, editEntityRecord ]
+ );
+
+ const save = useCallback( async () => {
+ try {
+ await saveSpecifiedEdits(
+ 'root',
+ 'site',
+ undefined,
+ [ rolesKey, usersKey ],
+ { throwOnError: true }
+ );
+ createSuccessNotice( __( 'Access control settings saved.', 'ai' ), {
+ type: 'snackbar',
+ } );
+ } catch {
+ createErrorNotice(
+ __( 'Failed to save access control settings.', 'ai' ),
+ { type: 'snackbar' }
+ );
+ }
+ }, [
+ rolesKey,
+ usersKey,
+ saveSpecifiedEdits,
+ createSuccessNotice,
+ createErrorNotice,
+ ] );
+
+ const clear = useCallback( () => {
+ stage( EMPTY_SETTINGS );
+ }, [ stage ] );
+
+ return { settings, stage, save, clear, isDirty, isSaving };
+}
diff --git a/routes/ai-home/hooks/use-roles-users.ts b/routes/ai-home/hooks/use-roles-users.ts
new file mode 100644
index 000000000..8b784d765
--- /dev/null
+++ b/routes/ai-home/hooks/use-roles-users.ts
@@ -0,0 +1,221 @@
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import {
+ createContext,
+ createElement,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { useAccessControlModeContext } from './use-access-control-mode';
+
+export interface Role {
+ id: string;
+ name: string;
+}
+
+export interface User {
+ id: number;
+ name: string;
+}
+
+interface RolesUsersResponse {
+ roles: Role[];
+ users: User[];
+}
+
+export interface UseRolesReturn {
+ roles: Role[];
+ isLoading: boolean;
+ fetchError: string | null;
+}
+
+export interface UseUserSearchReturn {
+ suggestions: User[];
+ isSearching: boolean;
+ search: ( query: string ) => void;
+}
+
+export interface RolesUsersContextValue
+ extends UseRolesReturn,
+ UseUserSearchReturn {}
+
+const DEFAULT_CONTEXT_VALUE: RolesUsersContextValue = {
+ roles: [],
+ isLoading: false,
+ fetchError: null,
+ suggestions: [],
+ isSearching: false,
+ search: () => {},
+};
+
+export const RolesUsersContext = createContext< RolesUsersContextValue | null >(
+ null
+);
+
+/**
+ * Provider component that fetches roles and users once at the top level
+ * when access control mode is active and shares the state with child components.
+ *
+ * @param {Object} props Component props.
+ * @param {React.ReactNode} props.children Child elements.
+ * @return {React.JSX.Element} The Provider component.
+ */
+export function RolesUsersProvider( {
+ children,
+}: {
+ children: React.ReactNode;
+} ): React.JSX.Element {
+ const isAccessControlMode = useAccessControlModeContext();
+ const rolesData = useRoles( isAccessControlMode );
+ const userSearchData = useUserSearch( isAccessControlMode );
+
+ const value = useMemo(
+ () => ( {
+ ...rolesData,
+ ...userSearchData,
+ } ),
+ [ rolesData, userSearchData ]
+ );
+
+ return createElement( RolesUsersContext.Provider, { value }, children );
+}
+
+/**
+ * Access the shared roles and user search context.
+ *
+ * @return {RolesUsersContextValue} The shared roles and user search data.
+ */
+export function useRolesUsersContext(): RolesUsersContextValue {
+ const context = useContext( RolesUsersContext );
+ return context ?? DEFAULT_CONTEXT_VALUE;
+}
+
+const DEBOUNCE_MS = 300;
+
+/**
+ * Fetches the complete list of roles once when enabled.
+ *
+ * @param {boolean} enabled Whether to fetch roles.
+ * @return {UseRolesReturn} The roles and loading state.
+ */
+export function useRoles( enabled = true ): UseRolesReturn {
+ const [ roles, setRoles ] = useState< Role[] >( [] );
+ const [ isLoading, setIsLoading ] = useState( false );
+ const [ fetchError, setFetchError ] = useState< string | null >( null );
+
+ useEffect( () => {
+ if ( ! enabled ) {
+ return;
+ }
+
+ let isMounted = true;
+ setIsLoading( true );
+
+ apiFetch< RolesUsersResponse >( { path: '/ai/v1/roles-users' } )
+ .then( ( data ) => {
+ if ( isMounted ) {
+ setRoles( data.roles || [] );
+ setIsLoading( false );
+ }
+ } )
+ .catch( ( error: unknown ) => {
+ if ( isMounted ) {
+ setFetchError(
+ error instanceof Error
+ ? error.message
+ : 'Failed to fetch roles'
+ );
+ setIsLoading( false );
+ }
+ } );
+
+ return () => {
+ isMounted = false;
+ };
+ }, [ enabled ] );
+
+ return { roles, isLoading, fetchError };
+}
+
+/**
+ * Provides debounced async user search against the REST endpoint.
+ * Loads an initial set of users when enabled and updates suggestions as the user types.
+ *
+ * @param {boolean} enabled Whether user search is enabled.
+ * @return {UseUserSearchReturn} The suggestions list, loading flag, and search trigger.
+ */
+export function useUserSearch( enabled = true ): UseUserSearchReturn {
+ const [ suggestions, setSuggestions ] = useState< User[] >( [] );
+ const [ isSearching, setIsSearching ] = useState( false );
+ const debounceTimer = useRef< ReturnType< typeof setTimeout > | null >(
+ null
+ );
+ const isMountedRef = useRef( true );
+
+ const fetchUsers = useCallback(
+ ( query: string ) => {
+ if ( ! enabled ) {
+ return;
+ }
+ setIsSearching( true );
+ const path = query
+ ? `/ai/v1/roles-users?search=${ encodeURIComponent( query ) }`
+ : '/ai/v1/roles-users';
+
+ apiFetch< RolesUsersResponse >( { path } )
+ .then( ( data ) => {
+ if ( isMountedRef.current ) {
+ setSuggestions( data.users || [] );
+ setIsSearching( false );
+ }
+ } )
+ .catch( () => {
+ if ( isMountedRef.current ) {
+ setIsSearching( false );
+ }
+ } );
+ },
+ [ enabled ]
+ );
+
+ useEffect( () => {
+ if ( ! enabled ) {
+ return;
+ }
+ isMountedRef.current = true;
+ fetchUsers( '' );
+ return () => {
+ isMountedRef.current = false;
+ if ( debounceTimer.current ) {
+ clearTimeout( debounceTimer.current );
+ }
+ };
+ }, [ enabled, fetchUsers ] );
+
+ const search = useCallback(
+ ( query: string ) => {
+ if ( ! enabled ) {
+ return;
+ }
+ if ( debounceTimer.current ) {
+ clearTimeout( debounceTimer.current );
+ }
+ debounceTimer.current = setTimeout( () => {
+ fetchUsers( query );
+ }, DEBOUNCE_MS );
+ },
+ [ enabled, fetchUsers ]
+ );
+
+ return { suggestions, isSearching, search };
+}
diff --git a/routes/ai-home/stage.tsx b/routes/ai-home/stage.tsx
index fb51ef486..f0d9017af 100644
--- a/routes/ai-home/stage.tsx
+++ b/routes/ai-home/stage.tsx
@@ -43,6 +43,7 @@ import { store as noticesStore } from '@wordpress/notices';
*/
import AIIcon from './ai-icon';
import { DeveloperSettings } from './components/DeveloperSettings';
+import { AccessControlSettings } from './components/AccessControlSettings';
import { FeatureToggle } from './components/FeatureToggle';
import { ImportConfirmModal } from './components/ImportConfirmModal';
import {
@@ -55,6 +56,12 @@ import {
useDeveloperMode,
useDeveloperModeContext,
} from './hooks/use-developer-mode';
+import {
+ AccessControlModeContext,
+ useAccessControlMode,
+ useAccessControlModeContext,
+} from './hooks/use-access-control-mode';
+import { RolesUsersProvider } from './hooks/use-roles-users';
import { useSettingsImportExport } from './hooks/use-settings-import-export';
import './style.scss';
@@ -611,11 +618,20 @@ function InlineFeatureSettings( { feature }: { feature: FeatureData } ) {
}
const FEATURES_BY_SETTING = new Map(
- STABLE_FEATURE_DEFINITIONS.filter(
- ( f ) => f.settingsFields.length > 0
- ).map( ( f ) => [ f.settingName, f ] as const )
+ STABLE_FEATURE_DEFINITIONS.map( ( f ) => [ f.settingName, f ] as const )
);
+function canHaveAccessControl( feature: {
+ id: string;
+ category: string;
+} ): boolean {
+ return (
+ feature.category !== 'admin' ||
+ feature.id === 'comment-moderation' ||
+ feature.id === 'suggest-reply'
+ );
+}
+
function FeatureToggleWithSettings( {
field,
data,
@@ -625,6 +641,7 @@ function FeatureToggleWithSettings( {
const checked = !! field.getValue( { item: data } );
const isDeveloperMode = useDeveloperModeContext();
const { isAdvancedSettingsEnabled } = useAdvancedSettingsContext();
+ const isAccessControlMode = useAccessControlModeContext();
return (
@@ -636,9 +653,18 @@ function FeatureToggleWithSettings( {
onChange( { [ field.id ]: value } );
} }
/>
- { checked && isAdvancedSettingsEnabled && feature && (
-
- ) }
+ { checked &&
+ isAdvancedSettingsEnabled &&
+ feature &&
+ feature.settingsFields.length > 0 && (
+
+ ) }
+ { checked &&
+ isAccessControlMode &&
+ feature &&
+ canHaveAccessControl( feature ) && (
+
+ ) }
{ checked && isDeveloperMode && feature && (
+ { globalEnabled &&
+ checked &&
+ isAccessControlMode &&
+ feature &&
+ canHaveAccessControl( feature ) && (
+
+ ) }
{ globalEnabled && checked && isDeveloperMode && feature && (
( () => {
// Return the stable module-level reference when page data is available so
@@ -899,11 +935,13 @@ function AISettingsPage() {
} else {
const featureId = feature.id;
const featureCapability = feature.capability;
+ const featureCategory = feature.category;
baseField.Edit = ( props ) => (
);
}
@@ -1010,194 +1048,236 @@ function AISettingsPage() {
return (
- }
- title={ __( 'AI', 'ai' ) }
- subTitle={ __(
- 'Configure AI features and experiments for your WordPress site.',
- 'ai'
- ) }
- actions={
- <>
-
- {
- void handleChange( {
- [ GLOBAL_FIELD_ID ]: checked,
- } );
- } }
- disabled={ isLoading }
- />
-
-
-
- { __( 'Docs', 'ai' ) }
-
-
- { __( 'Contribute', 'ai' ) }
-
-
- { () => (
- <>
-
-
-
-
-
-
-
-
- >
- ) }
-
- { /* Hidden file input for import */ }
-
- { pendingImport && (
- {
- void handleImportConfirm();
- } }
- onCancel={ handleImportCancel }
- isImporting={ isImporting }
- />
- ) }
- >
- }
+
-
- { ! PAGE_DATA.hasValidCredentials && (
-
-
- { ! PAGE_DATA.hasCredentials
- ? __(
- 'The AI plugin requires a valid AI Connector to function properly. Verify you have one or more AI Connectors configured.',
- 'ai'
- )
- : __(
- 'The AI plugin requires a valid AI Connector to function properly. Please review the AI Connectors you have configured to ensure they are valid.',
- 'ai'
- ) }
-
- { PAGE_DATA.connectorsUrl && (
-
-
- { __( 'Manage Connectors', 'ai' ) }
-
-
- ) }
-
- ) }
- { isLoading ? (
+
+ }
+ title={ __( 'AI', 'ai' ) }
+ subTitle={ __(
+ 'Configure AI features and experiments for your WordPress site.',
+ 'ai'
+ ) }
+ actions={
+ <>
+
+ {
+ void handleChange( {
+ [ GLOBAL_FIELD_ID ]:
+ checked,
+ } );
+ } }
+ disabled={ isLoading }
+ />
+
+
+
+ { __( 'Docs', 'ai' ) }
+
+
+ { __( 'Contribute', 'ai' ) }
+
+
+ { () => (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ ) }
+
+ { /* Hidden file input for import */ }
+
+ { pendingImport && (
+ {
+ void handleImportConfirm();
+ } }
+ onCancel={ handleImportCancel }
+ isImporting={ isImporting }
+ />
+ ) }
+ >
+ }
+ >
-
+ { ! PAGE_DATA.hasValidCredentials && (
+
+
+ { ! PAGE_DATA.hasCredentials
+ ? __(
+ 'The AI plugin requires a valid AI Connector to function properly. Verify you have one or more AI Connectors configured.',
+ 'ai'
+ )
+ : __(
+ 'The AI plugin requires a valid AI Connector to function properly. Please review the AI Connectors you have configured to ensure they are valid.',
+ 'ai'
+ ) }
+
+ { PAGE_DATA.connectorsUrl && (
+
+
+ { __(
+ 'Manage Connectors',
+ 'ai'
+ ) }
+
+
+ ) }
+
+ ) }
+ { isLoading ? (
+
+
+
+ ) : (
+
+ data={ data }
+ fields={ fields }
+ form={ form }
+ onChange={ handleChange }
+ />
+ ) }
- ) : (
-
- data={ data }
- fields={ fields }
- form={ form }
- onChange={ handleChange }
- />
- ) }
-
-
+
+
+
);
diff --git a/routes/ai-home/style.scss b/routes/ai-home/style.scss
index dbc7f1d5a..ce46280c1 100644
--- a/routes/ai-home/style.scss
+++ b/routes/ai-home/style.scss
@@ -90,6 +90,45 @@ body:has(.ai-settings-page) .components-popover {
}
}
+.ai-access-control-mode-fields {
+ margin-top: var(--wpds-dimension-gap-md, 12px);
+
+ &__fieldset {
+ border: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ &__legend {
+ font-size: 11px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+ }
+
+ &__roles-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ }
+
+ &__user-search-wrapper {
+ position: relative;
+ }
+
+ &__user-search-input {
+ flex: 1;
+ }
+
+ &__user-search-spinner {
+ margin-top: 4px;
+ position: absolute;
+ inset-inline-end: 0;
+ top: 20px;
+ }
+}
+
/**
* Snackbar notifications are rendered full-width by the route shell and, by
* default, centered over the (centered) settings content, where they obscure
diff --git a/tests/Integration/Includes/Admin/Site_HealthTest.php b/tests/Integration/Includes/Admin/Site_HealthTest.php
index 9a592524d..d74aab397 100644
--- a/tests/Integration/Includes/Admin/Site_HealthTest.php
+++ b/tests/Integration/Includes/Admin/Site_HealthTest.php
@@ -267,4 +267,3 @@ public function test_credentials_test_badge_label_is_ai(): void {
$this->assertSame( 'AI', $result['badge']['label'] );
}
}
-
diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php
index f6b2ea841..63aa13d4b 100644
--- a/tests/Integration/Includes/HelpersTest.php
+++ b/tests/Integration/Includes/HelpersTest.php
@@ -2218,4 +2218,67 @@ public function test_deprecated_get_post_details_ability_still_executes(): void
$this->assertSame( array( 'title' => 'Deprecated Title' ), $result, 'The deprecated ability should still return post details.' );
}
+
+ /**
+ * Tests current_user_can_access_feature() evaluates user restrictions.
+ *
+ * @since x.x.x
+ */
+ public function test_current_user_can_access_feature_evaluates_user_restrictions(): void {
+ $feature_id = 'test_user_access_feature';
+ $allowed_user = $this->factory->user->create( array( 'role' => 'editor' ) );
+ $unallowed_user = $this->factory->user->create( array( 'role' => 'editor' ) );
+
+ update_option( "wpai_feature_{$feature_id}_users", array( $allowed_user ) );
+
+ try {
+ wp_set_current_user( $allowed_user );
+ $this->assertTrue( \WordPress\AI\current_user_can_access_feature( $feature_id ) );
+
+ wp_set_current_user( $unallowed_user );
+ $this->assertFalse( \WordPress\AI\current_user_can_access_feature( $feature_id ) );
+ } finally {
+ delete_option( "wpai_feature_{$feature_id}_users" );
+ }
+ }
+
+ /**
+ * Tests current_user_can_access_feature() evaluates role restrictions.
+ *
+ * @since x.x.x
+ */
+ public function test_current_user_can_access_feature_evaluates_role_restrictions(): void {
+ $feature_id = 'test_role_access_feature';
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+
+ update_option( "wpai_feature_{$feature_id}_roles", array( 'administrator' ) );
+
+ try {
+ wp_set_current_user( $admin_id );
+ $this->assertTrue( \WordPress\AI\current_user_can_access_feature( $feature_id ) );
+
+ wp_set_current_user( $subscriber_id );
+ $this->assertFalse( \WordPress\AI\current_user_can_access_feature( $feature_id ) );
+ } finally {
+ delete_option( "wpai_feature_{$feature_id}_roles" );
+ }
+ }
+
+ /**
+ * Tests current_user_can_access_feature() directly denies subscribers and contributors.
+ *
+ * @since x.x.x
+ */
+ public function test_current_user_can_access_feature_denies_subscribers_and_contributors(): void {
+ $feature_id = 'test_subscriber_contributor_access';
+ $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ $contributor_id = $this->factory->user->create( array( 'role' => 'contributor' ) );
+
+ wp_set_current_user( $subscriber_id );
+ $this->assertFalse( \WordPress\AI\current_user_can_access_feature( $feature_id ) );
+
+ wp_set_current_user( $contributor_id );
+ $this->assertFalse( \WordPress\AI\current_user_can_access_feature( $feature_id ) );
+ }
}
diff --git a/tests/Integration/Includes/REST/Roles_Users_ControllerTest.php b/tests/Integration/Includes/REST/Roles_Users_ControllerTest.php
new file mode 100644
index 000000000..e68d5213c
--- /dev/null
+++ b/tests/Integration/Includes/REST/Roles_Users_ControllerTest.php
@@ -0,0 +1,172 @@
+controller = new Roles_Users_Controller();
+ $this->controller->init();
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Registering routes on core REST hook.
+ do_action( 'rest_api_init' );
+ }
+
+ /**
+ * Test that the route is registered correctly with GET method and search schema.
+ *
+ * @since x.x.x
+ */
+ public function test_route_registration_and_schema(): void {
+ $routes = rest_get_server()->get_routes();
+
+ $this->assertArrayHasKey( '/ai/v1/roles-users', $routes );
+ $route = $routes['/ai/v1/roles-users'][0];
+
+ $this->assertArrayHasKey( 'GET', $route['methods'] );
+ $this->assertArrayHasKey( 'search', $route['args'] );
+ $this->assertSame( 'string', $route['args']['search']['type'] );
+ $this->assertSame( 'sanitize_text_field', $route['args']['search']['sanitize_callback'] );
+ }
+
+ /**
+ * Test security permissions across unauthenticated and non-admin users.
+ *
+ * @since x.x.x
+ */
+ public function test_permission_checks(): void {
+ // 1. Unauthenticated request.
+ wp_set_current_user( 0 );
+ $request = new WP_REST_Request( 'GET', '/ai/v1/roles-users' );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertContains( $response->get_status(), array( 401, 403 ), 'Unauthenticated requests must be rejected.' );
+
+ // 2. Authenticated Subscriber.
+ $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber_id );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 403, $response->get_status(), 'Subscribers must be denied access.' );
+
+ // 3. Authenticated Editor.
+ $editor_id = $this->factory->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor_id );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 403, $response->get_status(), 'Editors without manage_options must be denied access.' );
+
+ // 4. Authenticated Administrator.
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin_id );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 200, $response->get_status(), 'Administrators must be granted access.' );
+ }
+
+ /**
+ * Test that response structure contains all registered WordPress roles with correct schema.
+ *
+ * @since x.x.x
+ */
+ public function test_get_roles_users_response_structure_and_roles_data(): void {
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin_id );
+
+ $request = new WP_REST_Request( 'GET', '/ai/v1/roles-users' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'roles', $data );
+ $this->assertArrayHasKey( 'users', $data );
+
+ $expected_roles = array_diff( array_keys( wp_roles()->roles ), array( 'subscriber', 'contributor' ) );
+ $returned_role_ids = array_column( $data['roles'], 'id' );
+
+ foreach ( $expected_roles as $role_id ) {
+ $this->assertContains( $role_id, $returned_role_ids, "Response should include registered role {$role_id}." );
+ }
+
+ $this->assertNotContains( 'subscriber', $returned_role_ids, 'Subscriber role should be excluded.' );
+ $this->assertNotContains( 'contributor', $returned_role_ids, 'Contributor role should be excluded.' );
+
+ foreach ( $data['roles'] as $role ) {
+ $this->assertArrayHasKey( 'id', $role );
+ $this->assertArrayHasKey( 'name', $role );
+ $this->assertIsString( $role['id'] );
+ $this->assertIsString( $role['name'] );
+ }
+ }
+
+ /**
+ * Test user listing, search filtering, and result capping (MAX_USERS = 10).
+ *
+ * @since x.x.x
+ */
+ public function test_get_roles_users_user_search_and_limits(): void {
+ $admin_id = $this->factory->user->create(
+ array(
+ 'role' => 'administrator',
+ 'display_name' => 'TargetAdminUser',
+ 'user_login' => 'targetadminuser',
+ )
+ );
+ wp_set_current_user( $admin_id );
+
+ // Create 11 extra users to verify the 10-user limit.
+ for ( $i = 1; $i <= 11; $i++ ) {
+ $this->factory->user->create(
+ array(
+ 'role' => 'subscriber',
+ 'display_name' => "BatchUser{$i}",
+ )
+ );
+ }
+
+ // 1. Unfiltered request should cap users at 10.
+ $request = new WP_REST_Request( 'GET', '/ai/v1/roles-users' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( 10, $data['users'], 'Unfiltered user results must be capped at MAX_USERS (10).' );
+
+ foreach ( $data['users'] as $user ) {
+ $this->assertIsInt( $user['id'] );
+ $this->assertIsString( $user['name'] );
+ }
+
+ // 2. Search filtered request.
+ $request->set_param( 'search', 'TargetAdminUser' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $user_names = array_column( $data['users'], 'name' );
+ $this->assertContains( 'TargetAdminUser', $user_names );
+ $this->assertNotContains( 'BatchUser1', $user_names );
+ }
+}
diff --git a/tests/Integration/Includes/REST/Settings_IO_ControllerTest.php b/tests/Integration/Includes/REST/Settings_IO_ControllerTest.php
index 5ee2cef62..0d7184ce2 100644
--- a/tests/Integration/Includes/REST/Settings_IO_ControllerTest.php
+++ b/tests/Integration/Includes/REST/Settings_IO_ControllerTest.php
@@ -699,4 +699,3 @@ public function test_import_of_exported_default_setting_restores_default_state()
$this->assertFalse( (bool) get_option( 'wpai_features_enabled' ) );
}
}
-
diff --git a/tests/Integration/Includes/Settings/Settings_RegistrationTest.php b/tests/Integration/Includes/Settings/Settings_RegistrationTest.php
index 20244222e..1e0a74c9a 100644
--- a/tests/Integration/Includes/Settings/Settings_RegistrationTest.php
+++ b/tests/Integration/Includes/Settings/Settings_RegistrationTest.php
@@ -54,7 +54,11 @@ public function tearDown(): void {
unregister_setting( Settings_Registration::OPTION_GROUP, Settings_Registration::GLOBAL_OPTION );
unregister_setting( Settings_Registration::OPTION_GROUP, 'wpai_feature_settings-registration-test_enabled' );
unregister_setting( Settings_Registration::OPTION_GROUP, 'wpai_feature_settings-registration-test_field_developer' );
+ unregister_setting( Settings_Registration::OPTION_GROUP, 'wpai_feature_settings-registration-test_roles' );
+ unregister_setting( Settings_Registration::OPTION_GROUP, 'wpai_feature_settings-registration-test_users' );
delete_option( 'wpai_feature_settings-registration-test_field_developer' );
+ delete_option( 'wpai_feature_settings-registration-test_roles' );
+ delete_option( 'wpai_feature_settings-registration-test_users' );
parent::tearDown();
}
@@ -83,6 +87,32 @@ public function test_register_settings_registers_developer_model_setting(): void
);
}
+ /**
+ * Test that register_settings() registers roles and users settings.
+ *
+ * @since x.x.x
+ */
+ public function test_register_settings_registers_roles_and_users_settings(): void {
+ global $wp_registered_settings;
+
+ $registry = new Registry();
+ $registry->register_feature( new Settings_Registration_Test_Feature() );
+
+ $registration = new Settings_Registration( $registry );
+ $registration->register_settings();
+
+ $roles_setting = 'wpai_feature_settings-registration-test_roles';
+ $users_setting = 'wpai_feature_settings-registration-test_users';
+
+ $this->assertArrayHasKey( $roles_setting, $wp_registered_settings );
+ $this->assertSame( 'array', $wp_registered_settings[ $roles_setting ]['type'] );
+ $this->assertSame( array(), $wp_registered_settings[ $roles_setting ]['default'] );
+
+ $this->assertArrayHasKey( $users_setting, $wp_registered_settings );
+ $this->assertSame( 'array', $wp_registered_settings[ $users_setting ]['type'] );
+ $this->assertSame( array(), $wp_registered_settings[ $users_setting ]['default'] );
+ }
+
/**
* Test that init() registers the provider discovery REST route hook.
*
diff --git a/tests/e2e/specs/admin/settings.spec.js b/tests/e2e/specs/admin/settings.spec.js
index 00a33ce63..e0d7c6988 100644
--- a/tests/e2e/specs/admin/settings.spec.js
+++ b/tests/e2e/specs/admin/settings.spec.js
@@ -1,7 +1,11 @@
/**
* WordPress dependencies
*/
-const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+const {
+ test,
+ expect,
+ RequestUtils: WPRequestUtils,
+} = require( '@wordpress/e2e-test-utils-playwright' );
/**
* Internal dependencies
@@ -24,6 +28,9 @@ const {
disableAdvancedSettings,
enableModelSelection,
disableModelSelection,
+ enableAccessControls,
+ disableAccessControls,
+ clearFeatureAccessSettings,
} = require( '../../utils/helpers' );
const EXPERIMENT_GROUPS = {
@@ -746,4 +753,504 @@ test.describe( 'Plugin settings', () => {
await disableModelSelection( page );
await disableExperiment( admin, page, 'Image Generation and Editing' );
} );
+
+ test( 'Access controls: Editor role is blocked then granted access to Content Summarization', async ( {
+ admin,
+ editor,
+ page,
+ requestUtils,
+ } ) => {
+ await requestUtils.activatePlugin( 'e2e-testing' );
+ await seedCredentials( requestUtils );
+
+ // Create an editor-role user to use throughout this test.
+ const editorUsername = `ai-editor-${ Date.now() }`;
+ const editorPassword = 'password';
+
+ const createdEditorUser = await requestUtils.createUser( {
+ username: editorUsername,
+ email: `${ editorUsername }@example.com`,
+ password: editorPassword,
+ roles: [ 'editor' ],
+ } );
+
+ // Enable AI + Content Summarization.
+ await enableExperiments( admin, page );
+ await enableExperiment( admin, page, 'Content Summarization' );
+
+ // Enable Access Controls via Developer Tools.
+ await visitSettingsPage( admin );
+ await enableAccessControls( page );
+
+ // Scope to the Content Summarization section so we don't accidentally
+ // target another feature's (e.g. Editorial Notes) access-control row.
+ const contentSummarizationSection = page
+ .locator( '.dataforms-layouts-regular__field', {
+ has: page.getByText( 'Content Summarization', { exact: true } ),
+ } )
+ .first();
+ await expect( contentSummarizationSection ).toBeVisible( {
+ timeout: 10000,
+ } );
+
+ const contentSummarizationAccessForm =
+ contentSummarizationSection.locator(
+ '.ai-access-control-mode-fields.ai-feature-settings-form'
+ );
+
+ // The Editor checkbox must NOT be checked at the start of this test.
+ // A previous run may have left it checked, so uncheck it if needed.
+ const editorCheckboxInitial = contentSummarizationAccessForm.getByRole(
+ 'checkbox',
+ { name: 'Editor' }
+ );
+ await expect( editorCheckboxInitial ).toBeVisible( { timeout: 10000 } );
+ if ( await editorCheckboxInitial.isChecked() ) {
+ await editorCheckboxInitial.uncheck();
+ // Save the unchecked state before proceeding.
+ const resetSaveButton = contentSummarizationAccessForm.getByRole(
+ 'button',
+ { name: 'Save' }
+ );
+ await expect( resetSaveButton ).toBeVisible( { timeout: 10000 } );
+ await resetSaveButton.click();
+ await expect( resetSaveButton ).not.toBeVisible( {
+ timeout: 10000,
+ } );
+ }
+ await expect( editorCheckboxInitial ).not.toBeChecked();
+
+ // Create a post.
+ await admin.createNewPost( {
+ postType: 'post',
+ title: 'Access Control Test Post',
+ content:
+ 'This is test content for the access control test. It needs to have enough characters to meet the minimum content length requirement for summarization to be enabled. The summarization feature requires a substantial amount of text before it will allow the user to generate a summary of the post content. Adding more text here.',
+ } );
+ await editor.saveDraft();
+
+ // Get the post URL.
+ const postUrl = page.url();
+
+ // Switch to editor session and verify button is NOT visible.
+ const editorRequestUtils = await WPRequestUtils.setup( {
+ user: { username: editorUsername, password: editorPassword },
+ } );
+ await editorRequestUtils.login();
+ await page
+ .context()
+ .addCookies(
+ ( await editorRequestUtils.request.storageState() ).cookies
+ );
+ await editorRequestUtils.request.dispose();
+
+ // Navigate to the post as the editor user.
+ await page.goto( postUrl );
+
+ // If the post-locked modal appears (another session still holds the lock),
+ // click "Take over" so the editor can access the post.
+ const takeOverLink = page.getByRole( 'link', { name: 'Take over' } );
+ if ( await takeOverLink.isVisible() ) {
+ await takeOverLink.click();
+ }
+
+ // Open the document sidebar.
+ const openSidebarButton = page.getByRole( 'button', {
+ name: 'Settings',
+ exact: true,
+ } );
+ if ( await openSidebarButton.isVisible() ) {
+ await openSidebarButton.click();
+ }
+
+ // The Generate Summary button must NOT be visible for the editor.
+ const generateSummaryButton = page.getByRole( 'button', {
+ name: 'Generate Summary',
+ exact: true,
+ } );
+ await expect( generateSummaryButton ).not.toBeVisible();
+
+ // Switch back to admin and grant the Editor role access.
+ const adminRequestUtils = await WPRequestUtils.setup( {
+ user: { username: 'admin', password: 'password' },
+ } );
+ await adminRequestUtils.login();
+ await page
+ .context()
+ .addCookies(
+ ( await adminRequestUtils.request.storageState() ).cookies
+ );
+ await adminRequestUtils.request.dispose();
+
+ // Navigate to the settings page as admin.
+ await visitSettingsPage( admin );
+
+ // Re-scope to Content Summarization's access-control form after page reload.
+ const contentSummarizationSectionAgain = page
+ .locator( '.dataforms-layouts-regular__field', {
+ has: page.getByText( 'Content Summarization', { exact: true } ),
+ } )
+ .first();
+ await expect( contentSummarizationSectionAgain ).toBeVisible( {
+ timeout: 10000,
+ } );
+
+ const contentSummarizationAccessFormAgain =
+ contentSummarizationSectionAgain.locator(
+ '.ai-access-control-mode-fields.ai-feature-settings-form'
+ );
+
+ // Check the Editor checkbox to grant access.
+ const editorCheckboxAgain =
+ contentSummarizationAccessFormAgain.getByRole( 'checkbox', {
+ name: 'Editor',
+ } );
+ await expect( editorCheckboxAgain ).toBeVisible( { timeout: 10000 } );
+ await editorCheckboxAgain.check();
+ await expect( editorCheckboxAgain ).toBeChecked();
+
+ // The access control form requires an explicit Save button click.
+ const saveButton = contentSummarizationAccessFormAgain.getByRole(
+ 'button',
+ { name: 'Save' }
+ );
+ await expect( saveButton ).toBeVisible( { timeout: 10000 } );
+ await saveButton.click();
+
+ // Wait for the success snackbar notice and for the Save button to disappear.
+ await expect(
+ page.getByTestId( 'snackbar' ).filter( {
+ hasText: 'Access control settings saved.',
+ } )
+ ).toBeVisible();
+ await expect( saveButton ).not.toBeVisible( { timeout: 10000 } );
+
+ const editorRequestUtils2 = await WPRequestUtils.setup( {
+ user: { username: editorUsername, password: editorPassword },
+ } );
+ await editorRequestUtils2.login();
+ await page
+ .context()
+ .addCookies(
+ ( await editorRequestUtils2.request.storageState() ).cookies
+ );
+ await editorRequestUtils2.request.dispose();
+
+ // Navigate back to the post as the editor user.
+ await page.goto( postUrl );
+
+ // Dismiss post-locked modal if present.
+ if ( await takeOverLink.isVisible() ) {
+ await takeOverLink.click();
+ }
+
+ // Open the document sidebar.
+ if ( await openSidebarButton.isVisible() ) {
+ await openSidebarButton.click();
+ }
+
+ // Dismiss the "Welcome to the editor" guide modal if it appears.
+ const welcomeModal = page.getByRole( 'dialog', {
+ name: 'Welcome to the editor',
+ } );
+ if ( await welcomeModal.isVisible() ) {
+ await page.keyboard.press( 'Escape' );
+ }
+
+ // The Generate Summary button must now be visible for the editor.
+ await expect(
+ page.getByRole( 'button', {
+ name: 'Generate Summary',
+ exact: true,
+ } )
+ ).toBeVisible( { timeout: 10000 } );
+
+ // Restore admin session.
+ const adminRequestUtils2 = await WPRequestUtils.setup( {
+ user: { username: 'admin', password: 'password' },
+ } );
+ await adminRequestUtils2.login();
+ await page
+ .context()
+ .addCookies(
+ ( await adminRequestUtils2.request.storageState() ).cookies
+ );
+ await adminRequestUtils2.request.dispose();
+
+ // Disable Access Controls.
+ await visitSettingsPage( admin );
+ // Clear the stored roles/users before disabling the UI so that stale
+ // access control options do not block the admin in subsequent tests.
+ await clearFeatureAccessSettings( requestUtils, 'summarization' );
+ await disableAccessControls( page );
+
+ // Disable the Content Summarization experiment.
+ await disableExperiment( admin, page, 'Content Summarization' );
+
+ // Delete the test user.
+ await requestUtils.rest( {
+ method: 'DELETE',
+ path: `/wp/v2/users/${ createdEditorUser.id }`,
+ params: { force: true, reassign: 1 },
+ } );
+ } );
+
+ test( 'Access controls: specific user is blocked then granted access to Content Summarization via Users field', async ( {
+ admin,
+ editor,
+ page,
+ requestUtils,
+ } ) => {
+ await requestUtils.activatePlugin( 'e2e-testing' );
+ await seedCredentials( requestUtils );
+
+ // Create a user with the editor role to use throughout this test.
+ const editorUsername = `ai-editor-${ Date.now() }`;
+ const editorPassword = 'password';
+
+ const createdEditorUser = await requestUtils.createUser( {
+ username: editorUsername,
+ email: `${ editorUsername }@example.com`,
+ password: editorPassword,
+ roles: [ 'editor' ],
+ } );
+
+ // Enable AI + Content Summarization.
+ await enableExperiments( admin, page );
+ await enableExperiment( admin, page, 'Content Summarization' );
+
+ // Enable Access Controls via Developer Tools.
+ await visitSettingsPage( admin );
+ await enableAccessControls( page );
+
+ // Scope to the Content Summarization section.
+ const contentSummarizationSection = page
+ .locator( '.dataforms-layouts-regular__field', {
+ has: page.getByText( 'Content Summarization', { exact: true } ),
+ } )
+ .first();
+ await expect( contentSummarizationSection ).toBeVisible( {
+ timeout: 10000,
+ } );
+
+ const contentSummarizationAccessForm =
+ contentSummarizationSection.locator(
+ '.ai-access-control-mode-fields.ai-feature-settings-form'
+ );
+
+ // Ensure ALL role checkboxes are unchecked so access is driven only by
+ // the Users token field (not by role membership).
+ const roleCheckboxes = contentSummarizationAccessForm.locator(
+ '.components-checkbox-control__input'
+ );
+ const roleCount = await roleCheckboxes.count();
+ for ( let i = 0; i < roleCount; i++ ) {
+ const checkbox = roleCheckboxes.nth( i );
+ if ( await checkbox.isChecked() ) {
+ await checkbox.uncheck();
+ }
+ }
+
+ // If any role was unchecked, a Save button will appear — click it.
+ const roleSaveButton = contentSummarizationAccessForm.getByRole(
+ 'button',
+ { name: 'Save' }
+ );
+ if ( await roleSaveButton.isVisible() ) {
+ await roleSaveButton.click();
+ await expect( roleSaveButton ).not.toBeVisible( {
+ timeout: 10000,
+ } );
+ }
+
+ // Create a post as admin so the editor can open it.
+ await admin.createNewPost( {
+ postType: 'post',
+ title: 'User Access Control Test Post',
+ content:
+ 'This is test content for the user access control test. It needs to have enough characters to meet the minimum content length requirement for summarization to be enabled. The summarization feature requires a substantial amount of text before it will allow the user to generate a summary of the post content. Adding more text here.',
+ } );
+ await editor.saveDraft();
+
+ // Get the post URL.
+ const postUrl = page.url();
+
+ // Switch to editor session and verify button is NOT visible.
+ const editorRequestUtils = await WPRequestUtils.setup( {
+ user: { username: editorUsername, password: editorPassword },
+ } );
+ await editorRequestUtils.login();
+ await page
+ .context()
+ .addCookies(
+ ( await editorRequestUtils.request.storageState() ).cookies
+ );
+ await editorRequestUtils.request.dispose();
+
+ // Navigate to the post as the editor user.
+ await page.goto( postUrl );
+
+ // If the post-locked modal appears, take over.
+ const takeOverLink = page.getByRole( 'link', { name: 'Take over' } );
+ if ( await takeOverLink.isVisible() ) {
+ await takeOverLink.click();
+ }
+
+ // Open the document sidebar.
+ const openSidebarButton = page.getByRole( 'button', {
+ name: 'Settings',
+ exact: true,
+ } );
+ if ( await openSidebarButton.isVisible() ) {
+ await openSidebarButton.click();
+ }
+
+ // The Generate Summary button must NOT be visible — no role or user access.
+ await expect(
+ page.getByRole( 'button', {
+ name: 'Generate Summary',
+ exact: true,
+ } )
+ ).not.toBeVisible();
+
+ // Switch back to admin and grant access via the Users token field.
+ const adminRequestUtils = await WPRequestUtils.setup( {
+ user: { username: 'admin', password: 'password' },
+ } );
+ await adminRequestUtils.login();
+ await page
+ .context()
+ .addCookies(
+ ( await adminRequestUtils.request.storageState() ).cookies
+ );
+ await adminRequestUtils.request.dispose();
+
+ await visitSettingsPage( admin );
+
+ // Re-scope to Content Summarization after page reload.
+ const contentSummarizationSectionAgain = page
+ .locator( '.dataforms-layouts-regular__field', {
+ has: page.getByText( 'Content Summarization', { exact: true } ),
+ } )
+ .first();
+ await expect( contentSummarizationSectionAgain ).toBeVisible( {
+ timeout: 10000,
+ } );
+
+ const contentSummarizationAccessFormAgain =
+ contentSummarizationSectionAgain.locator(
+ '.ai-access-control-mode-fields.ai-feature-settings-form'
+ );
+
+ // Type the editor's username into the Users token field and select it.
+ const usersTokenInput = contentSummarizationAccessFormAgain.getByRole(
+ 'combobox',
+ { name: 'Users' }
+ );
+ await expect( usersTokenInput ).toBeVisible( { timeout: 10000 } );
+ await usersTokenInput.click();
+ await usersTokenInput.fill( editorUsername );
+
+ // Wait for and select the matching suggestion.
+ const suggestion = page.locator(
+ '.components-form-token-field__suggestion',
+ { hasText: editorUsername }
+ );
+ await expect( suggestion ).toBeVisible( { timeout: 10000 } );
+ await suggestion.click();
+
+ // Wait for the token chip to appear in the field, confirming the
+ // selection was registered before proceeding to Save.
+ const addedToken = contentSummarizationAccessFormAgain.locator(
+ '.components-form-token-field__token-text',
+ { hasText: editorUsername }
+ );
+ await expect( addedToken ).toBeVisible( { timeout: 10000 } );
+ await usersTokenInput.evaluate( ( el ) => el.blur() );
+
+ // Save the user access settings.
+ const userSaveButton = contentSummarizationAccessFormAgain.getByRole(
+ 'button',
+ { name: 'Save' }
+ );
+ await expect( userSaveButton ).toBeVisible( { timeout: 10000 } );
+ await userSaveButton.click();
+
+ // Wait for the success snackbar notice and for the Save button to disappear.
+ await expect(
+ page.getByTestId( 'snackbar' ).filter( {
+ hasText: 'Access control settings saved.',
+ } )
+ ).toBeVisible();
+
+ // Switch back to editor session and verify button IS now visible.
+ const editorRequestUtils2 = await WPRequestUtils.setup( {
+ user: { username: editorUsername, password: editorPassword },
+ } );
+ await editorRequestUtils2.login();
+ await page
+ .context()
+ .addCookies(
+ ( await editorRequestUtils2.request.storageState() ).cookies
+ );
+ await editorRequestUtils2.request.dispose();
+
+ // Navigate back to the post as the editor user.
+ await page.goto( postUrl );
+
+ // Dismiss post-locked modal if present.
+ if ( await takeOverLink.isVisible() ) {
+ await takeOverLink.click();
+ }
+
+ // Open the document sidebar.
+ if ( await openSidebarButton.isVisible() ) {
+ await openSidebarButton.click();
+ }
+
+ // Dismiss the "Welcome to the editor" guide modal if it appears.
+ const welcomeModal = page.getByRole( 'dialog', {
+ name: 'Welcome to the editor',
+ } );
+ if ( await welcomeModal.isVisible() ) {
+ await page.keyboard.press( 'Escape' );
+ }
+
+ // The Generate Summary button must now be visible for the editor.
+ await expect(
+ page.getByRole( 'button', {
+ name: 'Generate Summary',
+ exact: true,
+ } )
+ ).toBeVisible( { timeout: 10000 } );
+
+ // Restore admin session.
+ const adminRequestUtils2 = await WPRequestUtils.setup( {
+ user: { username: 'admin', password: 'password' },
+ } );
+ await adminRequestUtils2.login();
+ await page
+ .context()
+ .addCookies(
+ ( await adminRequestUtils2.request.storageState() ).cookies
+ );
+ await adminRequestUtils2.request.dispose();
+
+ // Disable Access Controls.
+ await visitSettingsPage( admin );
+ // Clear the stored roles/users before disabling the UI so that stale
+ // access control options do not block the admin in subsequent tests.
+ await clearFeatureAccessSettings( requestUtils, 'summarization' );
+ await disableAccessControls( page );
+
+ // Disable the Content Summarization experiment.
+ await disableExperiment( admin, page, 'Content Summarization' );
+
+ // Delete the test user.
+ await requestUtils.rest( {
+ method: 'DELETE',
+ path: `/wp/v2/users/${ createdEditorUser.id }`,
+ params: { force: true, reassign: 1 },
+ } );
+ } );
} );
diff --git a/tests/e2e/specs/experiments/bulk-content-summarization.spec.js b/tests/e2e/specs/experiments/bulk-content-summarization.spec.js
index 4678aa17f..9bc995a89 100644
--- a/tests/e2e/specs/experiments/bulk-content-summarization.spec.js
+++ b/tests/e2e/specs/experiments/bulk-content-summarization.spec.js
@@ -8,6 +8,7 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
*/
const {
clearCredentials,
+ clearFeatureAccessSettings,
disableExperiment,
enableExperiment,
enableExperiments,
@@ -24,6 +25,13 @@ const LONG_CONTENT =
'Summaries are generated sequentially using the configured AI provider and stored as post meta.';
test.describe( 'Bulk Content Summarization', () => {
+ test.beforeAll( async ( { requestUtils } ) => {
+ // Clear any stale access control settings left by previous tests.
+ // Without this, the admin user can be blocked by role/user restrictions
+ // saved in a prior run of the access control tests in settings.spec.js.
+ await clearFeatureAccessSettings( requestUtils, 'summarization' );
+ } );
+
test( 'Bulk action appears in the posts list', async ( {
admin,
requestUtils,
diff --git a/tests/e2e/specs/experiments/content-summarization.spec.js b/tests/e2e/specs/experiments/content-summarization.spec.js
index 27e118fc3..8a5971352 100644
--- a/tests/e2e/specs/experiments/content-summarization.spec.js
+++ b/tests/e2e/specs/experiments/content-summarization.spec.js
@@ -11,9 +11,16 @@ const {
disableExperiments,
enableExperiment,
enableExperiments,
+ clearFeatureAccessSettings,
} = require( '../../utils/helpers' );
test.describe( 'Content Summarization Experiment', () => {
+ test.beforeAll( async ( { requestUtils } ) => {
+ // Clear any stale access control settings left by previous tests.
+ // Without this, the admin user can be blocked by role/user restrictions
+ // saved in a prior run of the access control tests in settings.spec.js.
+ await clearFeatureAccessSettings( requestUtils, 'summarization' );
+ } );
test( 'Can enable the content summarization experiment', async ( {
admin,
page,
diff --git a/tests/e2e/utils/helpers.ts b/tests/e2e/utils/helpers.ts
index 43899193f..c17497031 100644
--- a/tests/e2e/utils/helpers.ts
+++ b/tests/e2e/utils/helpers.ts
@@ -448,6 +448,35 @@ export const clearCredentials = async ( requestUtils: RequestUtils ) => {
} );
};
+/**
+ * Clears the access control settings (roles and users) for a specific feature.
+ *
+ * Resets `wpai_feature_{featureId}_roles` and `wpai_feature_{featureId}_users`
+ * back to empty arrays via the WordPress REST settings endpoint. This prevents
+ * stale access control options from leaking between tests and inadvertently
+ * blocking the admin user from accessing features like Content Summarization.
+ *
+ * @param requestUtils The requestUtils fixture from the test context.
+ * @param featureId The feature ID, e.g. 'summarization'.
+ */
+export const clearFeatureAccessSettings = async (
+ requestUtils: RequestUtils,
+ featureId: string
+) => {
+ await requestUtils.rest( {
+ path: '/wp/v2/settings',
+ method: 'POST',
+ data: {
+ [ `wpai_feature_${ featureId }_roles` ]: [
+ 'administrator',
+ 'editor',
+ 'author',
+ ],
+ [ `wpai_feature_${ featureId }_users` ]: [],
+ },
+ } );
+};
+
/**
* Enables the Model Selection feature via the Developer Tools menu.
*
@@ -590,3 +619,64 @@ export const disableAdvancedSettings = async ( page: Page ) => {
// Close the menu.
await page.keyboard.press( 'Escape' );
};
+
+/**
+ * Enables the Access Controls feature via the Developer Tools menu.
+ *
+ * Opens the Developer Tools menu, checks whether Access Controls is already
+ * enabled, and clicks it only when it is not. Closes the menu afterwards.
+ *
+ * @param page The page object.
+ */
+export const enableAccessControls = async ( page: Page ) => {
+ await page.getByRole( 'button', { name: 'Developer Tools' } ).click();
+
+ await expect( page.getByText( 'DEVELOPER TOOLS' ) ).toBeVisible();
+
+ const accessControls = page.getByRole( 'menuitemcheckbox', {
+ name: /Access controls/,
+ } );
+
+ await expect( accessControls ).toBeVisible();
+
+ if ( ( await accessControls.getAttribute( 'aria-checked' ) ) !== 'true' ) {
+ await accessControls.click();
+
+ // Verify the menu remains open after toggling the option.
+ await expect(
+ page.getByRole( 'menuitemcheckbox', { name: /Access controls/ } )
+ ).toBeVisible();
+ }
+
+ // Close the menu.
+ await page.keyboard.press( 'Escape' );
+};
+
+/**
+ * Disables the Access Controls feature via the Developer Tools menu.
+ *
+ * Opens the Developer Tools menu and clicks the Access Controls item to
+ * toggle it off, then closes the menu.
+ *
+ * @param page The page object.
+ */
+export const disableAccessControls = async ( page: Page ) => {
+ await page.getByRole( 'button', { name: 'Developer Tools' } ).click();
+
+ const accessControls = page.getByRole( 'menuitemcheckbox', {
+ name: /Access controls/,
+ } );
+
+ // Only click if it is currently enabled.
+ if ( ( await accessControls.getAttribute( 'aria-checked' ) ) === 'true' ) {
+ await accessControls.click();
+
+ // Verify the menu remains open after toggling the option.
+ await expect(
+ page.getByRole( 'menuitemcheckbox', { name: /Access controls/ } )
+ ).toBeVisible();
+ }
+
+ // Close the menu.
+ await page.keyboard.press( 'Escape' );
+};