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 ( +
+ { isLoading && } + { ! isLoading && fetchError && ( +

+ { fetchError } +

+ ) } + { ! isLoading && ! fetchError && ( + <> + + +
+ + { __( 'Roles', 'ai' ) } + + + { roles.map( ( role ) => ( + + + handleRoleToggle( + role.id, + checked + ) + } + /> + + ) ) } + +
+
+ + +
+ +
+ { 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..3c2fb5682 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 && ( + { checked && + isAccessControlMode && + feature && + feature.category !== 'admin' && ( + + ) } { globalEnabled && checked && isDeveloperMode && feature && ( ( () => { // Return the stable module-level reference when page data is available so @@ -849,11 +871,13 @@ function AISettingsPage() { } else { const featureId = feature.id; const featureCapability = feature.capability; + const featureCategory = feature.category; baseField.Edit = ( props ) => ( ); } @@ -959,113 +983,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' ) } - - - { () => ( - - { - 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 } - /> + + } + 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' ) } + + + ) } + + ) } + { isLoading ? ( + + + + ) : ( + + data={ data } + fields={ fields } + form={ form } + onChange={ handleChange } + /> + ) } + + + ); }