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..b3d6c0610
--- /dev/null
+++ b/includes/REST/Roles_Users_Controller.php
@@ -0,0 +1,94 @@
+ '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..ee63f2a24
--- /dev/null
+++ b/routes/ai-home/components/AccessControlSettings.tsx
@@ -0,0 +1,246 @@
+/**
+ * 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 { 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, fetchError } = useRoles();
+ const { suggestions, isSearching, search } = useUserSearch();
+ 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 every user returned from the API so that
+ // saved users always show their display name instead of a 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 0e89a3d4c..1ed770c09 100644
--- a/routes/ai-home/components/FeatureToggle.tsx
+++ b/routes/ai-home/components/FeatureToggle.tsx
@@ -8,13 +8,16 @@ import type { DataFormControlProps } from '@wordpress/dataviews';
* Internal dependencies
*/
import { useDeveloperModeContext } from '../hooks/use-developer-mode';
+import { useAccessControlModeContext } from '../hooks/use-access-control-mode';
import { DeveloperSettings } from './DeveloperSettings';
+import { AccessControlSettings } from './AccessControlSettings';
type AISettings = Record< string, boolean >;
type FeatureToggleProps = DataFormControlProps< AISettings > & {
featureId?: string;
capability?: string;
+ category?: string;
};
const FEATURE_SETTING_PATTERN = /^wpai_feature_(.+)_enabled$/;
@@ -36,9 +39,12 @@ export function FeatureToggle( {
onChange,
featureId,
capability = 'text_generation',
+ category,
}: FeatureToggleProps ): React.JSX.Element {
const checked = !! field.getValue( { item: data } );
const isDeveloperMode = useDeveloperModeContext();
+ const isAccessControlMode = useAccessControlModeContext();
+ const isEditorExperiment = category !== 'admin';
const resolvedFeatureId =
featureId ??
@@ -55,6 +61,9 @@ export function FeatureToggle( {
onChange( { [ field.id ]: value } );
} }
/>
+ { checked && isAccessControlMode && isEditorExperiment && (
+
+ ) }
{ 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..fdb7c7363
--- /dev/null
+++ b/routes/ai-home/hooks/use-roles-users.ts
@@ -0,0 +1,133 @@
+/**
+ * WordPress dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
+
+export interface Role {
+ id: string;
+ name: string;
+}
+
+export interface User {
+ id: number;
+ name: string;
+}
+
+interface RolesUsersResponse {
+ roles: Role[];
+ users: User[];
+}
+
+interface UseRolesReturn {
+ roles: Role[];
+ isLoading: boolean;
+ fetchError: string | null;
+}
+
+interface UseUserSearchReturn {
+ suggestions: User[];
+ isSearching: boolean;
+ search: ( query: string ) => void;
+}
+
+const DEBOUNCE_MS = 300;
+
+/**
+ * Fetches the complete list of roles once on mount.
+ *
+ * @return {UseRolesReturn} The roles and loading state.
+ */
+export function useRoles(): UseRolesReturn {
+ const [ roles, setRoles ] = useState< Role[] >( [] );
+ const [ isLoading, setIsLoading ] = useState( true );
+ const [ fetchError, setFetchError ] = useState< string | null >( null );
+
+ useEffect( () => {
+ let isMounted = 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;
+ };
+ }, [] );
+
+ return { roles, isLoading, fetchError };
+}
+
+/**
+ * Provides debounced async user search against the REST endpoint.
+ * Loads an initial set of users on mount and updates suggestions as the user types.
+ *
+ * @return {UseUserSearchReturn} The suggestions list, loading flag, and search trigger.
+ */
+export function useUserSearch(): 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 ) => {
+ 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 );
+ }
+ } );
+ }, [] );
+
+ useEffect( () => {
+ isMountedRef.current = true;
+ fetchUsers( '' );
+ return () => {
+ isMountedRef.current = false;
+ if ( debounceTimer.current ) {
+ clearTimeout( debounceTimer.current );
+ }
+ };
+ }, [ fetchUsers ] );
+
+ const search = useCallback(
+ ( query: string ) => {
+ if ( debounceTimer.current ) {
+ clearTimeout( debounceTimer.current );
+ }
+ debounceTimer.current = setTimeout( () => {
+ fetchUsers( query );
+ }, DEBOUNCE_MS );
+ },
+ [ fetchUsers ]
+ );
+
+ return { suggestions, isSearching, search };
+}
diff --git a/routes/ai-home/stage.tsx b/routes/ai-home/stage.tsx
index 29caeb378..b0b61fc18 100644
--- a/routes/ai-home/stage.tsx
+++ b/routes/ai-home/stage.tsx
@@ -37,12 +37,18 @@ 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 {
DeveloperModeContext,
useDeveloperMode,
useDeveloperModeContext,
} from './hooks/use-developer-mode';
+import {
+ AccessControlModeContext,
+ useAccessControlMode,
+ useAccessControlModeContext,
+} from './hooks/use-access-control-mode';
import './style.scss';
type AISettings = Record< string, boolean >;
@@ -595,6 +601,7 @@ function FeatureToggleWithSettings( {
const feature = FEATURES_BY_SETTING.get( field.id );
const checked = !! field.getValue( { item: data } );
const isDeveloperMode = useDeveloperModeContext();
+ const isAccessControlMode = useAccessControlModeContext();
return (
@@ -609,6 +616,12 @@ function FeatureToggleWithSettings( {
{ checked && feature && (
) }
+ { checked &&
+ isAccessControlMode &&
+ feature &&
+ feature.category !== 'admin' && (
+
+ ) }
{ checked && isDeveloperMode && feature && (
( () => {
// Return the stable module-level reference when page data is available so
@@ -796,6 +811,50 @@ function AISettingsPage() {
]
);
+ const handleToggleAccessControlMode = useCallback( async () => {
+ if ( isAccessControlMode ) {
+ const resets: Record< string, [] > = {};
+ const keysToSave: string[] = [];
+
+ featureDefinitions.forEach( ( feature ) => {
+ const rolesKey = `wpai_feature_${ feature.id }_roles`;
+ const usersKey = `wpai_feature_${ feature.id }_users`;
+ resets[ rolesKey ] = [];
+ resets[ usersKey ] = [];
+ keysToSave.push( rolesKey, usersKey );
+ } );
+
+ // @ts-expect-error -- core-data types don't expose editEntityRecord for 'root'/'site' args.
+ editEntityRecord( 'root', 'site', undefined, resets );
+
+ try {
+ await saveSpecifiedEdits(
+ 'root',
+ 'site',
+ undefined,
+ keysToSave,
+ {
+ throwOnError: true,
+ }
+ );
+ } catch {
+ createErrorNotice(
+ __( 'Failed to disable access controls', 'ai' ),
+ { type: 'snackbar' }
+ );
+ }
+ }
+
+ toggleAccessControlMode();
+ }, [
+ isAccessControlMode,
+ featureDefinitions,
+ editEntityRecord,
+ saveSpecifiedEdits,
+ createErrorNotice,
+ toggleAccessControlMode,
+ ] );
+
const fields = useMemo< Field< AISettings >[] >( () => {
const sectionActionsFields: Field< AISettings >[] = [];
const groupedFields = new Map< string, string[] >();
@@ -849,11 +908,13 @@ function AISettingsPage() {
} else {
const featureId = feature.id;
const featureCapability = feature.capability;
+ const featureCategory = feature.category;
baseField.Edit = ( props ) => (
);
}
@@ -959,113 +1020,139 @@ 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' ) }
-
-
- { () => (
-
-
-
- ) }
-
- >
- }
- >
-
- { ! 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' ) }
-
-
- ) }
-
+
+ }
+ title={ __( 'AI', 'ai' ) }
+ subTitle={ __(
+ 'Configure AI features and experiments for your WordPress site.',
+ 'ai'
) }
- { isLoading ? (
-
-
-
- ) : (
-
- data={ data }
- fields={ fields }
- form={ form }
- onChange={ handleChange }
- />
- ) }
-
-
+ actions={
+ <>
+
+ {
+ void handleChange( {
+ [ GLOBAL_FIELD_ID ]: checked,
+ } );
+ } }
+ disabled={ isLoading }
+ />
+
+
+
+ { __( 'Docs', 'ai' ) }
+
+
+ { __( 'Contribute', 'ai' ) }
+
+
+ { () => (
+
+
+
+
+ ) }
+
+ >
+ }
+ >
+
+ { ! 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 }
+ />
+ ) }
+
+
+
);
}