diff --git a/includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php b/includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php index cfa048dbb..5dea327c5 100644 --- a/includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php +++ b/includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php @@ -59,6 +59,10 @@ protected function load_metadata(): array { * {@inheritDoc} */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + 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' ) ); diff --git a/includes/Experiments/Content_Classification/Content_Classification.php b/includes/Experiments/Content_Classification/Content_Classification.php index c7686b4a2..72ebd82cd 100644 --- a/includes/Experiments/Content_Classification/Content_Classification.php +++ b/includes/Experiments/Content_Classification/Content_Classification.php @@ -98,6 +98,10 @@ protected function load_metadata(): array { * {@inheritDoc} */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + 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 e204bfbdf..2fd95a7dc 100644 --- a/includes/Experiments/Content_Resizing/Content_Resizing.php +++ b/includes/Experiments/Content_Resizing/Content_Resizing.php @@ -50,6 +50,10 @@ protected function load_metadata(): array { * {@inheritDoc} */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } diff --git a/includes/Experiments/Editorial_Notes/Editorial_Notes.php b/includes/Experiments/Editorial_Notes/Editorial_Notes.php index ec45766be..0f7174c2f 100644 --- a/includes/Experiments/Editorial_Notes/Editorial_Notes.php +++ b/includes/Experiments/Editorial_Notes/Editorial_Notes.php @@ -53,6 +53,10 @@ protected function load_metadata(): array { * {@inheritDoc} */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) ); add_filter( 'rest_pre_insert_comment', array( $this, 'maybe_set_ai_author' ), 10, 2 ); diff --git a/includes/Experiments/Editorial_Updates/Editorial_Updates.php b/includes/Experiments/Editorial_Updates/Editorial_Updates.php index 78d9a71b2..d385b27b3 100644 --- a/includes/Experiments/Editorial_Updates/Editorial_Updates.php +++ b/includes/Experiments/Editorial_Updates/Editorial_Updates.php @@ -55,6 +55,10 @@ protected function load_metadata(): array { * @since 0.8.0 */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + 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 5f6e48c45..856b23af2 100644 --- a/includes/Experiments/Excerpt_Generation/Excerpt_Generation.php +++ b/includes/Experiments/Excerpt_Generation/Excerpt_Generation.php @@ -49,6 +49,10 @@ protected function load_metadata(): array { * {@inheritDoc} */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + 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 801c4d3bd..e7d97ffc8 100644 --- a/includes/Experiments/Meta_Description/Meta_Description.php +++ b/includes/Experiments/Meta_Description/Meta_Description.php @@ -55,6 +55,10 @@ protected function load_metadata(): array { * @since 0.7.0 */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + 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' ) ); diff --git a/includes/Experiments/Summarization/Summarization.php b/includes/Experiments/Summarization/Summarization.php index 2ec4b59c0..942fbe60d 100644 --- a/includes/Experiments/Summarization/Summarization.php +++ b/includes/Experiments/Summarization/Summarization.php @@ -50,6 +50,10 @@ protected function load_metadata(): array { * {@inheritDoc} */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + $this->register_post_meta(); add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ), 5 ); diff --git a/includes/Experiments/Title_Generation/Title_Generation.php b/includes/Experiments/Title_Generation/Title_Generation.php index 4180b7cfb..dc744a8d3 100644 --- a/includes/Experiments/Title_Generation/Title_Generation.php +++ b/includes/Experiments/Title_Generation/Title_Generation.php @@ -51,6 +51,10 @@ protected function load_metadata(): array { * @since 0.1.0 */ public function register(): void { + if ( ! \WordPress\AI\ai_current_user_can_access_feature( $this->get_id() ) ) { + return; + } + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } diff --git a/includes/REST/Roles_Users_Controller.php b/includes/REST/Roles_Users_Controller.php new file mode 100644 index 000000000..bfb003d1d --- /dev/null +++ b/includes/REST/Roles_Users_Controller.php @@ -0,0 +1,89 @@ + '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', + ), + ), + ) + ); + } + + public function check_permission(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Returns roles and users for the access control endpoint. + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response + */ + public function get_roles_users( \WP_REST_Request $request ): \WP_REST_Response { + $roles = array(); + + foreach ( wp_roles()->roles as $role_id => $role ) { + $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, + ); + + 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 e0608f1ba..12880e5b8 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; /** * Handles registration of settings for the AI plugin. @@ -71,6 +72,7 @@ public function init(): void { // Initialize the provider/model discovery REST endpoint. ( new Models_Controller() )->init(); + ( new Roles_Users_Controller() )->init(); } /** @@ -132,6 +134,40 @@ public function register_settings(): void { ) ); + register_setting( + self::OPTION_GROUP, + "wpai_feature_{$feature_id}_roles", + array( + 'type' => 'array', + 'default' => array(), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + ) + ); + + register_setting( + self::OPTION_GROUP, + "wpai_feature_{$feature_id}_users", + array( + 'type' => 'array', + 'default' => array(), + '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 0f8314dac..7d677d525 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -692,3 +692,31 @@ function get_min_content_length( string $feature_id, int $content_length = 100 ) */ return (int) apply_filters( 'wpai_min_content_length', $content_length, $feature_id ); } + +/** + * Checks whether the current user has access to a given feature based on access control settings. + * + * If no roles or users are explicitly configured for the feature, it allows access by default. + * If there are configured roles/users, the current user must match at least one role or be explicitly listed. + * + * @since 0.1.0 + * + * @param string $feature_id The ID of the feature/experiment. + * @return bool True if the user has access, false otherwise. + */ +function ai_current_user_can_access_feature( string $feature_id ): bool { + $roles = get_option( "wpai_feature_{$feature_id}_roles", array() ); + $users = get_option( "wpai_feature_{$feature_id}_users", array() ); + + if ( empty( $roles ) && empty( $users ) ) { + return true; + } + + $current_user = wp_get_current_user(); + + if ( in_array( $current_user->ID, $users, true ) ) { + return true; + } + + return (bool) array_intersect( $current_user->roles, $roles ); +} diff --git a/routes/ai-home/components/AccessControlSettings.tsx b/routes/ai-home/components/AccessControlSettings.tsx new file mode 100644 index 000000000..2715a0dbf --- /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, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useAccessControlSettings } from '../hooks/use-access-control-settings'; +import { useRoles, useUserSearch } from '../hooks/use-roles-users'; +import type { User } from '../hooks/use-roles-users'; + +interface AccessControlSettingsProps { + featureId: string; +} + +export function AccessControlSettings( { + featureId, +}: AccessControlSettingsProps ): React.JSX.Element { + const { + roles, + isLoading: rolesLoading, + fetchError: rolesFetchError, + } = useRoles(); + const { suggestions, isSearching, search } = useUserSearch(); + const { settings, stage, save, isDirty, isSaving } = + useAccessControlSettings( featureId ); + + const [ localRoles, setLocalRoles ] = useState< string[] | null >( null ); + // Preserve selected users by ID so they survive suggestion list changes. + 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; + + // Build a nameāid map from the current suggestions page. + const suggestionNameToId = useMemo( () => { + const map = new Map< string, number >(); + suggestions.forEach( ( u: User ) => map.set( u.name, u.id ) ); + return map; + }, [ suggestions ] ); + + // Derive display labels for currently selected users. + // Falls back to a merged map of suggestions + previously seen names. + const selectedUsersTokens = useMemo( () => { + return effectiveUsers.map( + ( id ) => selectedUserMap.get( id ) ?? id.toString() + ); + }, [ effectiveUsers, selectedUserMap ] ); + + // Suggestion labels for the token field. + const userSuggestionNames = useMemo( + () => suggestions.map( ( u: User ) => u.name ), + [ suggestions ] + ); + + 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; + const id = suggestionNameToId.get( label ); + if ( id !== undefined ) { + newUsers.push( id ); + newMap.set( id, label ); + } + } ); + + setLocalUsers( newUsers ); + setSelectedUserMap( newMap ); + stage( { roles: effectiveRoles, users: newUsers } ); + }, + [ stage, effectiveRoles, suggestionNameToId, selectedUserMap ] + ); + + const handleInputChange = useCallback( + ( input: string ) => { + search( input ); + }, + [ search ] + ); + + const handleSave = useCallback( async () => { + await save(); + setLocalRoles( null ); + setLocalUsers( null ); + }, [ save ] ); + + const isLoading = rolesLoading; + const fetchError = rolesFetchError; + + return ( +
+ { fetchError } +
+ ) } + { ! isLoading && ! fetchError && ( + <> +