From 613527a8698f394ca3823b343a2633e96bdedae4 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Wed, 17 Jun 2026 15:27:19 +0530 Subject: [PATCH 01/12] feat: add access control settings for features with REST API and UI integration --- .../Title_Generation/Title_Generation.php | 4 + includes/REST/Roles_Users_Controller.php | 64 +++++++++ includes/Settings/Settings_Registration.php | 36 +++++ includes/helpers.php | 36 +++++ .../components/AccessControlSettings.tsx | 127 ++++++++++++++++++ routes/ai-home/components/FeatureToggle.tsx | 6 + .../ai-home/hooks/use-access-control-mode.ts | 54 ++++++++ .../hooks/use-access-control-settings.ts | 95 +++++++++++++ routes/ai-home/hooks/use-roles-users.ts | 68 ++++++++++ routes/ai-home/stage.tsx | 33 +++++ 10 files changed, 523 insertions(+) create mode 100644 includes/REST/Roles_Users_Controller.php create mode 100644 routes/ai-home/components/AccessControlSettings.tsx create mode 100644 routes/ai-home/hooks/use-access-control-mode.ts create mode 100644 routes/ai-home/hooks/use-access-control-settings.ts create mode 100644 routes/ai-home/hooks/use-roles-users.ts diff --git a/includes/Experiments/Title_Generation/Title_Generation.php b/includes/Experiments/Title_Generation/Title_Generation.php index 2edc35e50..da089fa4a 100644 --- a/includes/Experiments/Title_Generation/Title_Generation.php +++ b/includes/Experiments/Title_Generation/Title_Generation.php @@ -49,6 +49,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..59971bf8e --- /dev/null +++ b/includes/REST/Roles_Users_Controller.php @@ -0,0 +1,64 @@ + 'GET', + 'callback' => array( $this, 'get_roles_users' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + } + + public function check_permission(): bool { + return current_user_can( 'manage_options' ); + } + + public function get_roles_users( \WP_REST_Request $request ) { + $roles = array(); + $editable_roles = wp_roles()->roles; + + foreach ( $editable_roles as $role_id => $role ) { + $roles[] = array( + 'id' => $role_id, + 'name' => translate_user_role( $role['name'] ), + ); + } + + $users = array(); + $wp_users = get_users( array( 'fields' => array( 'ID', 'display_name' ) ) ); + + 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 a6bcef5e8..935e85163 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -671,3 +671,39 @@ function is_connector_plugin_active( array $connector_data ): bool { return is_multisite() && function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $plugin_file ); } + +/** + * 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 { + if ( ! is_user_logged_in() ) { + return false; + } + + $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; + } + + if ( ! empty( array_intersect( $current_user->roles, $roles ) ) ) { + return true; + } + + return false; +} diff --git a/routes/ai-home/components/AccessControlSettings.tsx b/routes/ai-home/components/AccessControlSettings.tsx new file mode 100644 index 000000000..b5851a811 --- /dev/null +++ b/routes/ai-home/components/AccessControlSettings.tsx @@ -0,0 +1,127 @@ +/** + * WordPress dependencies + */ +import { FormTokenField, Spinner } from '@wordpress/components'; +import { useCallback, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useAccessControlSettings } from '../hooks/use-access-control-settings'; +import { useRolesUsers } from '../hooks/use-roles-users'; + +interface AccessControlSettingsProps { + featureId: string; +} + +export function AccessControlSettings( { + featureId, +}: AccessControlSettingsProps ): React.JSX.Element { + const { roles, users, isLoading, fetchError } = useRolesUsers(); + const { settings, update } = useAccessControlSettings( featureId ); + + const roleSuggestions = useMemo( + () => roles.map( ( r ) => r.name ), + [ roles ] + ); + + const userSuggestions = useMemo( + () => users.map( ( u ) => `${ u.name } (#${ u.id })` ), + [ users ] + ); + + const roleMap = useMemo( () => { + const map = new Map< string, string >(); + roles.forEach( ( r ) => map.set( r.name, r.id ) ); + return map; + }, [ roles ] ); + + const reverseRoleMap = useMemo( () => { + const map = new Map< string, string >(); + roles.forEach( ( r ) => map.set( r.id, r.name ) ); + return map; + }, [ roles ] ); + + const userMap = useMemo( () => { + const map = new Map< string, number >(); + users.forEach( ( u ) => map.set( `${ u.name } (#${ u.id })`, u.id ) ); + return map; + }, [ users ] ); + + const reverseUserMap = useMemo( () => { + const map = new Map< number, string >(); + users.forEach( ( u ) => map.set( u.id, `${ u.name } (#${ u.id })` ) ); + return map; + }, [ users ] ); + + const selectedRolesTokens = useMemo( () => { + return settings.roles.map( ( r ) => reverseRoleMap.get( r ) || r ); + }, [ settings.roles, reverseRoleMap ] ); + + const selectedUsersTokens = useMemo( () => { + return settings.users.map( + ( u ) => reverseUserMap.get( u ) || u.toString() + ); + }, [ settings.users, reverseUserMap ] ); + + const handleRolesChange = useCallback( + ( tokens: ( string | { value: string } )[] ) => { + const newRoles: string[] = []; + tokens.forEach( ( token ) => { + const label = typeof token === 'string' ? token : token.value; + const id = roleMap.get( label ) || label; + newRoles.push( id ); + } ); + void update( { ...settings, roles: newRoles } ); + }, + [ update, settings, roleMap ] + ); + + const handleUsersChange = useCallback( + ( tokens: ( string | { value: string } )[] ) => { + const newUsers: number[] = []; + tokens.forEach( ( token ) => { + const label = typeof token === 'string' ? token : token.value; + const id = userMap.get( label ); + if ( id !== undefined ) { + newUsers.push( id ); + } + } ); + void update( { ...settings, users: newUsers } ); + }, + [ update, settings, userMap ] + ); + + return ( +
+ { isLoading && } + { ! isLoading && fetchError && ( +

+ { fetchError } +

+ ) } + { ! isLoading && ! fetchError && ( + <> + + + + ) } +
+ ); +} diff --git a/routes/ai-home/components/FeatureToggle.tsx b/routes/ai-home/components/FeatureToggle.tsx index 0e89a3d4c..a6020918d 100644 --- a/routes/ai-home/components/FeatureToggle.tsx +++ b/routes/ai-home/components/FeatureToggle.tsx @@ -8,7 +8,9 @@ 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 >; @@ -39,6 +41,7 @@ export function FeatureToggle( { }: FeatureToggleProps ): React.JSX.Element { const checked = !! field.getValue( { item: data } ); const isDeveloperMode = useDeveloperModeContext(); + const isAccessControlMode = useAccessControlModeContext(); const resolvedFeatureId = featureId ?? @@ -55,6 +58,9 @@ export function FeatureToggle( { onChange( { [ field.id ]: value } ); } } /> + { checked && isAccessControlMode && ( + + ) } { 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..d643ad551 --- /dev/null +++ b/routes/ai-home/hooks/use-access-control-settings.ts @@ -0,0 +1,95 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +interface AccessControlSettings { + roles: string[]; + users: number[]; +} + +interface UseAccessControlSettingsReturn { + settings: AccessControlSettings; + update: ( next: AccessControlSettings ) => Promise< void >; + clear: () => Promise< void >; + 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, isSaving } = useSelect( ( select ) => { + const store: any = select( coreStore ); + return { + editedRecord: store.getEditedEntityRecord( 'root', 'site' ) as + | Record< string, unknown > + | undefined, + isSaving: store.isSavingEntityRecord( 'root', 'site' ) as boolean, + }; + }, [] ); + + const { editEntityRecord } = useDispatch( coreStore ); + const { __experimentalSaveSpecifiedEntityEdits: saveSpecifiedEdits } = + useDispatch( coreStore ) as any; + const { createErrorNotice } = 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 save = useCallback( + async ( value: AccessControlSettings ) => { + // @ts-expect-error -- core-data types don't expose editEntityRecord for 'root'/'site' args. + editEntityRecord( 'root', 'site', undefined, { + [ rolesKey ]: value.roles, + [ usersKey ]: value.users, + } ); + try { + await saveSpecifiedEdits( + 'root', + 'site', + undefined, + [ rolesKey, usersKey ], + { throwOnError: true } + ); + } catch { + createErrorNotice( + __( 'Failed to save access control settings.', 'ai' ), + { type: 'snackbar' } + ); + } + }, + [ rolesKey, usersKey, editEntityRecord, saveSpecifiedEdits, createErrorNotice ] + ); + + const update = useCallback( + ( next: AccessControlSettings ) => save( next ), + [ save ] + ); + + const clear = useCallback( + () => save( EMPTY_SETTINGS ), + [ save ] + ); + + return { settings, update, clear, 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..0e9574ead --- /dev/null +++ b/routes/ai-home/hooks/use-roles-users.ts @@ -0,0 +1,68 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { useEffect, 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 UseRolesUsersReturn { + roles: Role[]; + users: User[]; + isLoading: boolean; + fetchError: string | null; +} + +/** + * Fetches roles and users from the REST API. + * + * @return {UseRolesUsersReturn} The roles, users, and loading state. + */ +export function useRolesUsers(): UseRolesUsersReturn { + const [ roles, setRoles ] = useState< Role[] >( [] ); + const [ users, setUsers ] = useState< User[] >( [] ); + 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 || [] ); + setUsers( data.users || [] ); + setIsLoading( false ); + } + } ) + .catch( ( error: unknown ) => { + if ( isMounted ) { + setFetchError( + error instanceof Error + ? error.message + : 'Failed to fetch roles and users' + ); + setIsLoading( false ); + } + } ); + + return () => { + isMounted = false; + }; + }, [] ); + + return { roles, users, isLoading, fetchError }; +} diff --git a/routes/ai-home/stage.tsx b/routes/ai-home/stage.tsx index c300a3673..51ed7e85f 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,9 @@ function FeatureToggleWithSettings( { { checked && feature && ( ) } + { checked && isAccessControlMode && feature && ( + + ) } { checked && isDeveloperMode && feature && ( + { checked && isAccessControlMode && feature && ( + + ) } { checked && isDeveloperMode && feature && ( ( () => { // Return the stable module-level reference when page data is available so @@ -959,6 +974,7 @@ function AISettingsPage() { return ( + } title={ __( 'AI', 'ai' ) } @@ -1001,6 +1017,22 @@ function AISettingsPage() { + { + toggleAccessControlMode(); + } } + > + { __( 'Access controls', 'ai' ) } + + ); } From 1ca49352de6cee9d48ab970c2f50e47337b49b75 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Thu, 18 Jun 2026 13:32:01 +0530 Subject: [PATCH 02/12] refactor: implement local state for role and user tokens in AccessControlSettings to enable explicit saving --- .../components/AccessControlSettings.tsx | 69 +++-- .../ai-home/hooks/use-access-control-mode.ts | 14 +- .../hooks/use-access-control-settings.ts | 104 ++++--- routes/ai-home/stage.tsx | 255 +++++++++--------- 4 files changed, 255 insertions(+), 187 deletions(-) diff --git a/routes/ai-home/components/AccessControlSettings.tsx b/routes/ai-home/components/AccessControlSettings.tsx index b5851a811..1e9fdb29c 100644 --- a/routes/ai-home/components/AccessControlSettings.tsx +++ b/routes/ai-home/components/AccessControlSettings.tsx @@ -1,9 +1,10 @@ /** * WordPress dependencies */ -import { FormTokenField, Spinner } from '@wordpress/components'; -import { useCallback, useMemo } from '@wordpress/element'; +import { Button, FormTokenField, Spinner } from '@wordpress/components'; +import { useCallback, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { Stack } from '@wordpress/ui'; /** * Internal dependencies @@ -19,7 +20,14 @@ export function AccessControlSettings( { featureId, }: AccessControlSettingsProps ): React.JSX.Element { const { roles, users, isLoading, fetchError } = useRolesUsers(); - const { settings, update } = useAccessControlSettings( featureId ); + const { settings, stage, save, isDirty, isSaving } = + useAccessControlSettings( featureId ); + + const [ localRoles, setLocalRoles ] = useState< string[] | null >( null ); + const [ localUsers, setLocalUsers ] = useState< number[] | null >( null ); + + const effectiveRoles = localRoles ?? settings.roles; + const effectiveUsers = localUsers ?? settings.users; const roleSuggestions = useMemo( () => roles.map( ( r ) => r.name ), @@ -27,7 +35,7 @@ export function AccessControlSettings( { ); const userSuggestions = useMemo( - () => users.map( ( u ) => `${ u.name } (#${ u.id })` ), + () => users.map( ( u ) => u.name ), [ users ] ); @@ -45,25 +53,28 @@ export function AccessControlSettings( { const userMap = useMemo( () => { const map = new Map< string, number >(); - users.forEach( ( u ) => map.set( `${ u.name } (#${ u.id })`, u.id ) ); + users.forEach( ( u ) => map.set( u.name, u.id ) ); return map; }, [ users ] ); const reverseUserMap = useMemo( () => { const map = new Map< number, string >(); - users.forEach( ( u ) => map.set( u.id, `${ u.name } (#${ u.id })` ) ); + users.forEach( ( u ) => map.set( u.id, u.name ) ); return map; }, [ users ] ); - const selectedRolesTokens = useMemo( () => { - return settings.roles.map( ( r ) => reverseRoleMap.get( r ) || r ); - }, [ settings.roles, reverseRoleMap ] ); + const selectedRolesTokens = useMemo( + () => effectiveRoles.map( ( r ) => reverseRoleMap.get( r ) || r ), + [ effectiveRoles, reverseRoleMap ] + ); - const selectedUsersTokens = useMemo( () => { - return settings.users.map( - ( u ) => reverseUserMap.get( u ) || u.toString() - ); - }, [ settings.users, reverseUserMap ] ); + const selectedUsersTokens = useMemo( + () => + effectiveUsers.map( + ( u ) => reverseUserMap.get( u ) || u.toString() + ), + [ effectiveUsers, reverseUserMap ] + ); const handleRolesChange = useCallback( ( tokens: ( string | { value: string } )[] ) => { @@ -73,9 +84,10 @@ export function AccessControlSettings( { const id = roleMap.get( label ) || label; newRoles.push( id ); } ); - void update( { ...settings, roles: newRoles } ); + setLocalRoles( newRoles ); + stage( { roles: newRoles, users: effectiveUsers } ); }, - [ update, settings, roleMap ] + [ stage, effectiveUsers, roleMap ] ); const handleUsersChange = useCallback( @@ -88,11 +100,18 @@ export function AccessControlSettings( { newUsers.push( id ); } } ); - void update( { ...settings, users: newUsers } ); + setLocalUsers( newUsers ); + stage( { roles: effectiveRoles, users: newUsers } ); }, - [ update, settings, userMap ] + [ stage, effectiveRoles, userMap ] ); + const handleSave = useCallback( async () => { + await save(); + setLocalRoles( null ); + setLocalUsers( null ); + }, [ save ] ); + return (
+ { isDirty && ( + + + + ) } ) }
diff --git a/routes/ai-home/hooks/use-access-control-mode.ts b/routes/ai-home/hooks/use-access-control-mode.ts index 48e2342f4..6062c9d18 100644 --- a/routes/ai-home/hooks/use-access-control-mode.ts +++ b/routes/ai-home/hooks/use-access-control-mode.ts @@ -28,13 +28,15 @@ interface UseAccessControlModeReturn { * @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; + const [ isAccessControlMode, setIsAccessControlMode ] = useState< boolean >( + () => { + try { + return localStorage.getItem( STORAGE_KEY ) === 'true'; + } catch { + return false; + } } - } ); + ); useEffect( () => { try { diff --git a/routes/ai-home/hooks/use-access-control-settings.ts b/routes/ai-home/hooks/use-access-control-settings.ts index d643ad551..57d57ebbd 100644 --- a/routes/ai-home/hooks/use-access-control-settings.ts +++ b/routes/ai-home/hooks/use-access-control-settings.ts @@ -3,7 +3,7 @@ */ import { store as coreStore } from '@wordpress/core-data'; import { useDispatch, useSelect } from '@wordpress/data'; -import { useCallback } from '@wordpress/element'; +import { useCallback, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; @@ -14,8 +14,10 @@ interface AccessControlSettings { interface UseAccessControlSettingsReturn { settings: AccessControlSettings; - update: ( next: AccessControlSettings ) => Promise< void >; + stage: ( next: AccessControlSettings ) => void; + save: () => Promise< void >; clear: () => Promise< void >; + isDirty: boolean; isSaving: boolean; } @@ -33,20 +35,31 @@ export function useAccessControlSettings( const rolesKey = `wpai_feature_${ featureId }_roles`; const usersKey = `wpai_feature_${ featureId }_users`; - const { editedRecord, isSaving } = useSelect( ( select ) => { - const store: any = select( coreStore ); - return { - editedRecord: store.getEditedEntityRecord( 'root', 'site' ) as - | Record< string, unknown > - | undefined, - isSaving: store.isSavingEntityRecord( 'root', 'site' ) as boolean, - }; - }, [] ); + 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 } = useDispatch( noticesStore ); + const { createErrorNotice, createSuccessNotice } = + useDispatch( noticesStore ); const rawRoles = editedRecord?.[ rolesKey ]; const rawUsers = editedRecord?.[ usersKey ]; @@ -56,40 +69,51 @@ export function useAccessControlSettings( users: Array.isArray( rawUsers ) ? rawUsers.map( Number ) : [], }; - const save = useCallback( - async ( value: AccessControlSettings ) => { + 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 ]: value.roles, - [ usersKey ]: value.users, + [ rolesKey ]: next.roles, + [ usersKey ]: next.users, } ); - try { - await saveSpecifiedEdits( - 'root', - 'site', - undefined, - [ rolesKey, usersKey ], - { throwOnError: true } - ); - } catch { - createErrorNotice( - __( 'Failed to save access control settings.', 'ai' ), - { type: 'snackbar' } - ); - } }, - [ rolesKey, usersKey, editEntityRecord, saveSpecifiedEdits, createErrorNotice ] + [ rolesKey, usersKey, editEntityRecord ] ); - const update = useCallback( - ( next: AccessControlSettings ) => save( next ), - [ save ] - ); + 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( - () => save( EMPTY_SETTINGS ), - [ save ] - ); + const clear = useCallback( () => { + stage( EMPTY_SETTINGS ); + }, [ stage ] ); - return { settings, update, clear, isSaving }; + return { settings, stage, save, clear, isDirty, isSaving }; } diff --git a/routes/ai-home/stage.tsx b/routes/ai-home/stage.tsx index 51ed7e85f..ffc2ec7b1 100644 --- a/routes/ai-home/stage.tsx +++ b/routes/ai-home/stage.tsx @@ -700,7 +700,8 @@ function AISettingsPage() { useDispatch( noticesStore ); const registry = useRegistry(); const { isDeveloperMode, toggleDeveloperMode } = useDeveloperMode(); - const { isAccessControlMode, toggleAccessControlMode } = useAccessControlMode(); + const { isAccessControlMode, toggleAccessControlMode } = + useAccessControlMode(); const featureDefinitions = useMemo< FeatureData[] >( () => { // Return the stable module-level reference when page data is available so @@ -975,129 +976,137 @@ 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' ) } - - - { () => ( - - { - toggleAccessControlMode(); - } } - > - { __( 'Access controls', 'ai' ) } - - { - toggleDeveloperMode(); - } } - > - { __( 'Model selection', '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' ) } + + + { () => ( + + { + toggleAccessControlMode(); + } } + > + { __( 'Access controls', 'ai' ) } + + { + toggleDeveloperMode(); + } } + > + { __( 'Model selection', '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 } + /> + ) } + + ); From 013f1571fcd08e9ce827b6f0a1a8526687734735 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Thu, 18 Jun 2026 13:45:14 +0530 Subject: [PATCH 03/12] refactor: replace Stack with Flex layout in AccessControlSettings and update dependencies --- .../components/AccessControlSettings.tsx | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/routes/ai-home/components/AccessControlSettings.tsx b/routes/ai-home/components/AccessControlSettings.tsx index 1e9fdb29c..75d573b7f 100644 --- a/routes/ai-home/components/AccessControlSettings.tsx +++ b/routes/ai-home/components/AccessControlSettings.tsx @@ -1,10 +1,15 @@ /** * WordPress dependencies */ -import { Button, FormTokenField, Spinner } from '@wordpress/components'; +import { + Button, + Flex, + FlexItem, + FormTokenField, + Spinner, +} from '@wordpress/components'; import { useCallback, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Stack } from '@wordpress/ui'; /** * Internal dependencies @@ -125,34 +130,44 @@ export function AccessControlSettings( { ) } { ! isLoading && ! fetchError && ( <> - - - { isDirty && ( - - - - ) } + + + + + + + + { isDirty && ( + + + + ) } + ) }
From c1f6160bfd1f9424426487717e049cb0718de19a Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Thu, 18 Jun 2026 16:07:11 +0530 Subject: [PATCH 04/12] refactor: replace FormTokenField with CheckboxControl for role selection in AccessControlSettings --- .../components/AccessControlSettings.tsx | 73 +++++++++---------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/routes/ai-home/components/AccessControlSettings.tsx b/routes/ai-home/components/AccessControlSettings.tsx index 75d573b7f..ec5cf9336 100644 --- a/routes/ai-home/components/AccessControlSettings.tsx +++ b/routes/ai-home/components/AccessControlSettings.tsx @@ -3,6 +3,7 @@ */ import { Button, + CheckboxControl, Flex, FlexItem, FormTokenField, @@ -34,28 +35,11 @@ export function AccessControlSettings( { const effectiveRoles = localRoles ?? settings.roles; const effectiveUsers = localUsers ?? settings.users; - const roleSuggestions = useMemo( - () => roles.map( ( r ) => r.name ), - [ roles ] - ); - const userSuggestions = useMemo( () => users.map( ( u ) => u.name ), [ users ] ); - const roleMap = useMemo( () => { - const map = new Map< string, string >(); - roles.forEach( ( r ) => map.set( r.name, r.id ) ); - return map; - }, [ roles ] ); - - const reverseRoleMap = useMemo( () => { - const map = new Map< string, string >(); - roles.forEach( ( r ) => map.set( r.id, r.name ) ); - return map; - }, [ roles ] ); - const userMap = useMemo( () => { const map = new Map< string, number >(); users.forEach( ( u ) => map.set( u.name, u.id ) ); @@ -68,11 +52,6 @@ export function AccessControlSettings( { return map; }, [ users ] ); - const selectedRolesTokens = useMemo( - () => effectiveRoles.map( ( r ) => reverseRoleMap.get( r ) || r ), - [ effectiveRoles, reverseRoleMap ] - ); - const selectedUsersTokens = useMemo( () => effectiveUsers.map( @@ -81,18 +60,15 @@ export function AccessControlSettings( { [ effectiveUsers, reverseUserMap ] ); - const handleRolesChange = useCallback( - ( tokens: ( string | { value: string } )[] ) => { - const newRoles: string[] = []; - tokens.forEach( ( token ) => { - const label = typeof token === 'string' ? token : token.value; - const id = roleMap.get( label ) || label; - newRoles.push( id ); - } ); + 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, effectiveUsers, roleMap ] + [ stage, effectiveRoles, effectiveUsers ] ); const handleUsersChange = useCallback( @@ -130,15 +106,34 @@ export function AccessControlSettings( { ) } { ! isLoading && ! fetchError && ( <> - + - +
+ + { __( 'Roles', 'ai' ) } + + + { roles.map( ( role ) => ( + + + handleRoleToggle( role.id, checked ) + } + /> + + ) ) } + +
Date: Thu, 18 Jun 2026 16:58:19 +0530 Subject: [PATCH 05/12] feat: implement debounced server-side user search and persistent selection tracking in AccessControlSettings --- includes/REST/Roles_Users_Controller.php | 27 +++- .../components/AccessControlSettings.tsx | 126 +++++++++++++----- routes/ai-home/hooks/use-roles-users.ts | 85 ++++++++++-- 3 files changed, 191 insertions(+), 47 deletions(-) diff --git a/includes/REST/Roles_Users_Controller.php b/includes/REST/Roles_Users_Controller.php index 59971bf8e..6f458f328 100644 --- a/includes/REST/Roles_Users_Controller.php +++ b/includes/REST/Roles_Users_Controller.php @@ -12,6 +12,8 @@ class Roles_Users_Controller { private const ROUTE = '/roles-users'; + private const MAX_USERS = 10; + public function init(): void { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } @@ -24,6 +26,13 @@ public function register_routes(): void { 'methods' => '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', + ), + ), ) ); } @@ -34,17 +43,27 @@ public function check_permission(): bool { public function get_roles_users( \WP_REST_Request $request ) { $roles = array(); - $editable_roles = wp_roles()->roles; - foreach ( $editable_roles as $role_id => $role ) { + foreach ( wp_roles()->roles as $role_id => $role ) { $roles[] = array( 'id' => $role_id, 'name' => translate_user_role( $role['name'] ), ); } - $users = array(); - $wp_users = get_users( array( 'fields' => array( 'ID', 'display_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( diff --git a/routes/ai-home/components/AccessControlSettings.tsx b/routes/ai-home/components/AccessControlSettings.tsx index ec5cf9336..8c9a83747 100644 --- a/routes/ai-home/components/AccessControlSettings.tsx +++ b/routes/ai-home/components/AccessControlSettings.tsx @@ -16,7 +16,8 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { useAccessControlSettings } from '../hooks/use-access-control-settings'; -import { useRolesUsers } from '../hooks/use-roles-users'; +import { useRoles, useUserSearch } from '../hooks/use-roles-users'; +import type { User } from '../hooks/use-roles-users'; interface AccessControlSettingsProps { featureId: string; @@ -25,39 +26,44 @@ interface AccessControlSettingsProps { export function AccessControlSettings( { featureId, }: AccessControlSettingsProps ): React.JSX.Element { - const { roles, users, isLoading, fetchError } = useRolesUsers(); + 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; - const userSuggestions = useMemo( - () => users.map( ( u ) => u.name ), - [ users ] - ); - - const userMap = useMemo( () => { + // Build a name→id map from the current suggestions page. + const suggestionNameToId = useMemo( () => { const map = new Map< string, number >(); - users.forEach( ( u ) => map.set( u.name, u.id ) ); + suggestions.forEach( ( u: User ) => map.set( u.name, u.id ) ); return map; - }, [ users ] ); + }, [ suggestions ] ); - const reverseUserMap = useMemo( () => { - const map = new Map< number, string >(); - users.forEach( ( u ) => map.set( u.id, u.name ) ); - return map; - }, [ users ] ); - - const selectedUsersTokens = useMemo( - () => - effectiveUsers.map( - ( u ) => reverseUserMap.get( u ) || u.toString() - ), - [ effectiveUsers, reverseUserMap ] + // 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( @@ -74,17 +80,29 @@ export function AccessControlSettings( { 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 = userMap.get( label ); + 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, userMap ] + [ stage, effectiveRoles, suggestionNameToId, selectedUserMap ] + ); + + const handleInputChange = useCallback( + ( input: string ) => { + search( input ); + }, + [ search ] ); const handleSave = useCallback( async () => { @@ -93,6 +111,9 @@ export function AccessControlSettings( { setLocalUsers( null ); }, [ save ] ); + const isLoading = rolesLoading; + const fetchError = rolesFetchError; + return (
- handleRoleToggle( role.id, checked ) + handleRoleToggle( + role.id, + checked + ) } /> @@ -136,13 +162,47 @@ export function AccessControlSettings( { - + +
+ +
+ { isSearching && ( +
+ +
+ ) } +
{ isDirty && ( diff --git a/routes/ai-home/hooks/use-roles-users.ts b/routes/ai-home/hooks/use-roles-users.ts index 0e9574ead..fdb7c7363 100644 --- a/routes/ai-home/hooks/use-roles-users.ts +++ b/routes/ai-home/hooks/use-roles-users.ts @@ -2,7 +2,7 @@ * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; -import { useEffect, useState } from '@wordpress/element'; +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; export interface Role { id: string; @@ -19,21 +19,27 @@ interface RolesUsersResponse { users: User[]; } -interface UseRolesUsersReturn { +interface UseRolesReturn { roles: Role[]; - users: User[]; isLoading: boolean; fetchError: string | null; } +interface UseUserSearchReturn { + suggestions: User[]; + isSearching: boolean; + search: ( query: string ) => void; +} + +const DEBOUNCE_MS = 300; + /** - * Fetches roles and users from the REST API. + * Fetches the complete list of roles once on mount. * - * @return {UseRolesUsersReturn} The roles, users, and loading state. + * @return {UseRolesReturn} The roles and loading state. */ -export function useRolesUsers(): UseRolesUsersReturn { +export function useRoles(): UseRolesReturn { const [ roles, setRoles ] = useState< Role[] >( [] ); - const [ users, setUsers ] = useState< User[] >( [] ); const [ isLoading, setIsLoading ] = useState( true ); const [ fetchError, setFetchError ] = useState< string | null >( null ); @@ -44,7 +50,6 @@ export function useRolesUsers(): UseRolesUsersReturn { .then( ( data ) => { if ( isMounted ) { setRoles( data.roles || [] ); - setUsers( data.users || [] ); setIsLoading( false ); } } ) @@ -53,7 +58,7 @@ export function useRolesUsers(): UseRolesUsersReturn { setFetchError( error instanceof Error ? error.message - : 'Failed to fetch roles and users' + : 'Failed to fetch roles' ); setIsLoading( false ); } @@ -64,5 +69,65 @@ export function useRolesUsers(): UseRolesUsersReturn { }; }, [] ); - return { roles, users, isLoading, fetchError }; + 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 }; } From 1e6c0aea1985517e86576ebb3ad9f406fe62702c Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Thu, 18 Jun 2026 17:43:51 +0530 Subject: [PATCH 06/12] feat: update access control settings input to use 40px default sizing and refresh lockfile --- routes/ai-home/components/AccessControlSettings.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/routes/ai-home/components/AccessControlSettings.tsx b/routes/ai-home/components/AccessControlSettings.tsx index 8c9a83747..2715a0dbf 100644 --- a/routes/ai-home/components/AccessControlSettings.tsx +++ b/routes/ai-home/components/AccessControlSettings.tsx @@ -176,6 +176,7 @@ export function AccessControlSettings( { onInputChange={ handleInputChange } __experimentalExpandOnFocus __experimentalShowHowTo={ false } + __next40pxDefaultSize messages={ { added: __( 'User added.', 'ai' ), removed: __( From 4832b1224df4397c1fbb1fea06c33c1306a9616d Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Fri, 19 Jun 2026 17:52:44 +0530 Subject: [PATCH 07/12] feat: enforce access control checks for experiment registration and update clear hook type definition --- .../Experiments/Alt_Text_Generation/Alt_Text_Generation.php | 4 ++++ .../Content_Classification/Content_Classification.php | 4 ++++ includes/Experiments/Content_Resizing/Content_Resizing.php | 4 ++++ includes/Experiments/Editorial_Notes/Editorial_Notes.php | 4 ++++ includes/Experiments/Editorial_Updates/Editorial_Updates.php | 4 ++++ .../Experiments/Excerpt_Generation/Excerpt_Generation.php | 4 ++++ includes/Experiments/Meta_Description/Meta_Description.php | 4 ++++ includes/Experiments/Summarization/Summarization.php | 4 ++++ routes/ai-home/hooks/use-access-control-settings.ts | 2 +- 9 files changed, 33 insertions(+), 1 deletion(-) 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/routes/ai-home/hooks/use-access-control-settings.ts b/routes/ai-home/hooks/use-access-control-settings.ts index 57d57ebbd..30d1eb7fc 100644 --- a/routes/ai-home/hooks/use-access-control-settings.ts +++ b/routes/ai-home/hooks/use-access-control-settings.ts @@ -16,7 +16,7 @@ interface UseAccessControlSettingsReturn { settings: AccessControlSettings; stage: ( next: AccessControlSettings ) => void; save: () => Promise< void >; - clear: () => Promise< void >; + clear: () => void; isDirty: boolean; isSaving: boolean; } From bd2ba2a06db9803b4d848a0718840a8d78377daf Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Fri, 19 Jun 2026 18:33:10 +0530 Subject: [PATCH 08/12] feat: restrict access control settings to non-admin features --- routes/ai-home/components/FeatureToggle.tsx | 5 ++++- routes/ai-home/stage.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/routes/ai-home/components/FeatureToggle.tsx b/routes/ai-home/components/FeatureToggle.tsx index a6020918d..1ed770c09 100644 --- a/routes/ai-home/components/FeatureToggle.tsx +++ b/routes/ai-home/components/FeatureToggle.tsx @@ -17,6 +17,7 @@ type AISettings = Record< string, boolean >; type FeatureToggleProps = DataFormControlProps< AISettings > & { featureId?: string; capability?: string; + category?: string; }; const FEATURE_SETTING_PATTERN = /^wpai_feature_(.+)_enabled$/; @@ -38,10 +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 ?? @@ -58,7 +61,7 @@ export function FeatureToggle( { onChange( { [ field.id ]: value } ); } } /> - { checked && isAccessControlMode && ( + { checked && isAccessControlMode && isEditorExperiment && ( ) } { checked && isDeveloperMode && ( diff --git a/routes/ai-home/stage.tsx b/routes/ai-home/stage.tsx index a601442dc..a5989f44c 100644 --- a/routes/ai-home/stage.tsx +++ b/routes/ai-home/stage.tsx @@ -616,7 +616,7 @@ function FeatureToggleWithSettings( { { checked && feature && ( ) } - { checked && isAccessControlMode && feature && ( + { checked && isAccessControlMode && feature && feature.category !== 'admin' && ( ) } { checked && isDeveloperMode && feature && ( @@ -665,7 +665,7 @@ function VisualCardToggle( { disabled={ ! globalEnabled } help={ field.description } /> - { checked && isAccessControlMode && feature && ( + { checked && isAccessControlMode && feature && feature.category !== 'admin' && ( ) } { globalEnabled && checked && isDeveloperMode && feature && ( @@ -865,11 +865,13 @@ function AISettingsPage() { } else { const featureId = feature.id; const featureCapability = feature.capability; + const featureCategory = feature.category; baseField.Edit = ( props ) => ( ); } From 615d44b6c0e3eccdda279a1dcdcee14524c3f4d9 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Fri, 19 Jun 2026 19:09:59 +0530 Subject: [PATCH 09/12] refactor: add type hinting to REST controller and simplify role verification logic in helper function --- includes/REST/Roles_Users_Controller.php | 10 ++++++++-- includes/helpers.php | 6 +----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/includes/REST/Roles_Users_Controller.php b/includes/REST/Roles_Users_Controller.php index 6f458f328..94aaed4e9 100644 --- a/includes/REST/Roles_Users_Controller.php +++ b/includes/REST/Roles_Users_Controller.php @@ -41,7 +41,13 @@ public function check_permission(): bool { return current_user_can( 'manage_options' ); } - public function get_roles_users( \WP_REST_Request $request ) { + /** + * 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 ) { @@ -51,7 +57,7 @@ public function get_roles_users( \WP_REST_Request $request ) { ); } - $search = (string) $request->get_param( 'search' ); + $search = (string) $request->get_param( 'search' ); $get_users_args = array( 'fields' => array( 'ID', 'display_name' ), 'number' => self::MAX_USERS, diff --git a/includes/helpers.php b/includes/helpers.php index 77edef26f..66c5e3e11 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -722,9 +722,5 @@ function ai_current_user_can_access_feature( string $feature_id ): bool { return true; } - if ( ! empty( array_intersect( $current_user->roles, $roles ) ) ) { - return true; - } - - return false; + return (bool) array_intersect( $current_user->roles, $roles ); } From 25ba58362a365e0710fdf49aa359ca01d8cd72f8 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Fri, 19 Jun 2026 19:11:31 +0530 Subject: [PATCH 10/12] refactor: format AccessControlSettings conditional rendering for better readability --- routes/ai-home/stage.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/routes/ai-home/stage.tsx b/routes/ai-home/stage.tsx index a5989f44c..3c2fb5682 100644 --- a/routes/ai-home/stage.tsx +++ b/routes/ai-home/stage.tsx @@ -616,9 +616,12 @@ function FeatureToggleWithSettings( { { checked && feature && ( ) } - { checked && isAccessControlMode && feature && feature.category !== 'admin' && ( - - ) } + { checked && + isAccessControlMode && + feature && + feature.category !== 'admin' && ( + + ) } { checked && isDeveloperMode && feature && ( - { checked && isAccessControlMode && feature && feature.category !== 'admin' && ( - - ) } + { checked && + isAccessControlMode && + feature && + feature.category !== 'admin' && ( + + ) } { globalEnabled && checked && isDeveloperMode && feature && ( Date: Fri, 19 Jun 2026 19:13:13 +0530 Subject: [PATCH 11/12] refactor: improve Yoda condition readability in user search query logic --- includes/REST/Roles_Users_Controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/REST/Roles_Users_Controller.php b/includes/REST/Roles_Users_Controller.php index 94aaed4e9..bfb003d1d 100644 --- a/includes/REST/Roles_Users_Controller.php +++ b/includes/REST/Roles_Users_Controller.php @@ -63,7 +63,7 @@ public function get_roles_users( \WP_REST_Request $request ): \WP_REST_Response 'number' => self::MAX_USERS, ); - if ( $search !== '' ) { + if ( '' !== $search ) { $get_users_args['search'] = '*' . $search . '*'; $get_users_args['search_columns'] = array( 'user_login', 'display_name', 'user_email' ); } From 3f112392d86b87e201ae3bdbd7efb33aa0679c0d Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Fri, 19 Jun 2026 19:26:26 +0530 Subject: [PATCH 12/12] refactor: remove redundant user login check from access verification helper --- includes/helpers.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/includes/helpers.php b/includes/helpers.php index 66c5e3e11..7d677d525 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -705,10 +705,6 @@ function get_min_content_length( string $feature_id, int $content_length = 100 ) * @return bool True if the user has access, false otherwise. */ function ai_current_user_can_access_feature( string $feature_id ): bool { - if ( ! is_user_logged_in() ) { - return false; - } - $roles = get_option( "wpai_feature_{$feature_id}_roles", array() ); $users = get_option( "wpai_feature_{$feature_id}_users", array() );