options = $options; add_action( 'wp_ajax_aofb_user_access', [ $this, 'handle_user_access' ] ); add_action( 'admin_init', [ $this, 'init' ] ); } /** * Start the engine running * * @return void */ public function init() { $recaptcha_admin_url = 'https://www.google.com/recaptcha/admin/create'; $recaptcha_docs_url = 'https://developers.google.com/recaptcha/docs/settings'; $turnstile_docs_url = 'https://developers.cloudflare.com/turnstile/get-started/widget-management/dashboard/'; // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Documentation link shown to the admin, not an offloaded asset. $this->settings_options = [ [ 'option_group' => Form_Builder::OPTION_NAME, 'option_name' => Form_Builder::OPTION_NAME, 'args' => [ 'sanitize_callback' => [ $this, 'sanitize_settings' ] ], ], ]; $this->settings_sections = [ [ 'id' => 'adminoptimizer-recaptcha', 'title' => __( 'reCaptcha', 'admin-optimizer' ), 'description' => __( 'reCAPTCHA protects you against spam and other types of automated abuse. ', 'admin-optimizer' ), 'menu_slug' => Form_Builder::OPTION_NAME, 'option_name' => Form_Builder::OPTION_NAME, 'fields' => [ [ 'type' => 'checkbox', 'title' => __( 'Enable Recaptcha', 'admin-optimizer' ), 'id' => 'enable_recaptcha', 'name' => 'enable_recaptcha', 'desc' => __( 'Enable reCAPTCHA', 'admin-optimizer' ), ], [ 'type' => 'text', 'title' => __( 'Recaptcha Site Key', 'admin-optimizer' ), 'id' => 'recaptcha_site_key', 'name' => 'recaptcha_site_key', 'desc' => sprintf( /* translators: 1: reCAPTCHA Admin Console URL, 2: reCAPTCHA settings documentation URL. */ __( 'Enter your reCAPTCHA site key. Create or view keys in the reCAPTCHA Admin Console. Google also documents where to find keys in reCAPTCHA settings.', 'admin-optimizer' ), esc_url( $recaptcha_admin_url ), esc_url( $recaptcha_docs_url ) ), ], [ 'type' => 'callback', 'title' => __( 'Recaptcha Secret Key', 'admin-optimizer' ), 'id' => 'recaptcha_secret_key', 'name' => 'recaptcha_secret_key', 'callback' => [ $this, 'render_masked_secret_field' ], 'desc' => sprintf( /* translators: 1: reCAPTCHA Admin Console URL, 2: reCAPTCHA settings documentation URL. */ __( 'Enter your reCAPTCHA secret key. Create or view keys in the reCAPTCHA Admin Console. Google also documents where to find keys in reCAPTCHA settings.', 'admin-optimizer' ), esc_url( $recaptcha_admin_url ), esc_url( $recaptcha_docs_url ) ), ], ], ], [ 'id' => 'adminoptimizer-turnstile', 'title' => __( 'Turnstile', 'admin-optimizer' ), 'description' => __( 'Turnstile is Cloudflare\'s smart CAPTCHA alternative, which confirms web visitors are real and blocks unwanted bots without slowing down web experiences for real users. ', 'admin-optimizer' ), 'menu_slug' => Form_Builder::OPTION_NAME, 'option_name' => Form_Builder::OPTION_NAME, 'fields' => [ [ 'type' => 'checkbox', 'title' => __( 'Enable Turnstile', 'admin-optimizer' ), 'id' => 'enable_turnstile', 'name' => 'enable_turnstile', 'desc' => __( 'Enable Turnstile', 'admin-optimizer' ), ], [ 'type' => 'text', 'title' => __( 'Turnstile Site Key', 'admin-optimizer' ), 'id' => 'turnstile_site_key', 'name' => 'turnstile_site_key', 'desc' => sprintf( /* translators: %s: Cloudflare Turnstile widget management documentation URL. */ __( 'Enter your Turnstile site key. Create or manage widgets and copy the sitekey from the Cloudflare Turnstile dashboard.', 'admin-optimizer' ), esc_url( $turnstile_docs_url ) ), ], [ 'type' => 'callback', 'title' => __( 'Turnstile Secret Key', 'admin-optimizer' ), 'id' => 'turnstile_secret_key', 'name' => 'turnstile_secret_key', 'callback' => [ $this, 'render_masked_secret_field' ], 'desc' => sprintf( /* translators: %s: Cloudflare Turnstile widget management documentation URL. */ __( 'Enter your Turnstile secret key. Create or manage widgets and copy the secret key from the Cloudflare Turnstile dashboard.', 'admin-optimizer' ), esc_url( $turnstile_docs_url ) ), ], [ 'type' => 'text', 'title' => __( 'Invisible Turnstile Site Key', 'admin-optimizer' ), 'id' => 'turnstile_invisible_site_key', 'name' => 'turnstile_invisible_site_key', 'desc' => __( 'Use the site key from an Invisible widget in the Cloudflare dashboard.', 'admin-optimizer' ), ], [ 'type' => 'callback', 'title' => __( 'Invisible Turnstile Secret Key', 'admin-optimizer' ), 'id' => 'turnstile_invisible_secret_key', 'name' => 'turnstile_invisible_secret_key', 'callback' => [ $this, 'render_masked_secret_field' ], ], ], ], ]; $this->setup(); } /** * Render Settings fields * * @return void */ public function render_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'admin-optimizer' ) ); } ?>


render_settings_on_page( Form_Builder::OPTION_NAME ); ?>
admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aofb_user_access' ), 'levels' => [ 'own' => __( 'Their own forms', 'admin-optimizer' ), 'all' => __( 'All forms', 'admin-optimizer' ), 'custom' => __( 'Custom access', 'admin-optimizer' ) ], 'grant' => __( 'Grant access', 'admin-optimizer' ), 'grantCounts' => $grant_counts, 'updatedCounts' => $updated_counts, 'hintNone' => __( 'Pick at least one person first.', 'admin-optimizer' ), 'hintOwn' => __( 'They will manage only forms they create.', 'admin-optimizer' ), 'hintAll' => __( 'They will be able to manage every form.', 'admin-optimizer' ), /* translators: %s: person's display name. */ 'changedOwn' => __( '%s can now manage their own forms.', 'admin-optimizer' ), /* translators: %s: person's display name. */ 'changedAll' => __( '%s can now manage all forms.', 'admin-optimizer' ), /* translators: %s: person's display name. */ 'removed' => __( 'Removed form access for %s.', 'admin-optimizer' ), 'error' => __( 'Something went wrong. Nothing was changed.', 'admin-optimizer' ), 'searching' => __( 'Searching…', 'admin-optimizer' ), 'noMatches' => __( "No matching people. Administrators aren't shown.", 'admin-optimizer' ), 'moreResults' => __( 'Showing the first 20. Type more to narrow the search.', 'admin-optimizer' ), 'hasAccess' => __( 'Has access', 'admin-optimizer' ), /* translators: %s: person's display name. */ 'removeChip' => _x( 'Remove %s', 'person chip', 'admin-optimizer' ), 'limit' => __( 'You can pick up to 20 people at once.', 'admin-optimizer' ), /* translators: %s: person's display name. */ 'levelFor' => __( 'Access level for %s', 'admin-optimizer' ), 'remove' => __( 'Remove access', 'admin-optimizer' ), /* translators: %s: person's display name. */ 'removeFor' => __( 'Remove access for %s', 'admin-optimizer' ), 'roleSettings' => __( "Change in the role's settings", 'admin-optimizer' ), 'fromRole' => __( 'Comes from their role', 'admin-optimizer' ), 'noFilterMatch' => __( 'Nobody matches that filter.', 'admin-optimizer' ), 'toggleRow' => __( 'Show more details', 'admin-optimizer' ), 'colName' => __( 'Name', 'admin-optimizer' ), 'colRole' => __( 'Role', 'admin-optimizer' ), 'colLevel' => __( 'Access level', 'admin-optimizer' ), 'firstPage' => __( 'First page', 'admin-optimizer' ), 'prevPage' => __( 'Previous page', 'admin-optimizer' ), 'nextPage' => __( 'Next page', 'admin-optimizer' ), 'lastPage' => __( 'Last page', 'admin-optimizer' ), 'currentPage' => __( 'Current Page', 'admin-optimizer' ), /* translators: 1: current page, 2: total pages. */ 'pagingText' => _x( '%1$s of %2$s', 'paging', 'admin-optimizer' ), 'dismiss' => __( 'Dismiss this notice.', 'admin-optimizer' ), ] ); } wp_enqueue_style( 'adminoptim-sections' , ADMINOPTIMIZER_ADMIN_URI . 'assets/css/sections.min.css', [], ADMINOPTIMIZER_VERSION ); } /** Administrator-only, nonce-protected user search and individual grants. */ public function handle_user_access() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Access denied.', 'admin-optimizer' ) ], 403 ); return; } check_ajax_referer( 'aofb_user_access', 'nonce' ); $operation = isset( $_POST['operation'] ) && is_string( $_POST['operation'] ) ? sanitize_key( wp_unslash( $_POST['operation'] ) ) : ''; if ( in_array( $operation, [ 'grant', 'revoke' ], true ) ) { $ids = isset( $_POST['users'] ) && is_array( $_POST['users'] ) ? $_POST['users'] : []; if ( ! $ids || count( $ids ) > 20 ) { wp_send_json_error( [ 'message' => __( 'Select between 1 and 20 users.', 'admin-optimizer' ) ], 400 ); return; } $users = []; foreach ( $ids as $id ) { $user = is_scalar( $id ) && ctype_digit( (string) $id ) ? get_userdata( (int) $id ) : false; if ( ! $user || ! is_user_member_of_blog( $user->ID ) || user_can( $user, 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Invalid user selection.', 'admin-optimizer' ) ], 400 ); return; } $users[] = $user; } $preset = 'revoke' === $operation ? 'none' : ( isset( $_POST['preset'] ) && is_string( $_POST['preset'] ) ? sanitize_key( wp_unslash( $_POST['preset'] ) ) : '' ); if ( ! in_array( $preset, [ 'none', 'own', 'all' ], true ) ) { wp_send_json_error( [ 'message' => __( 'Invalid permission level.', 'admin-optimizer' ) ], 400 ); return; } foreach ( $users as $user ) { foreach ( array_merge( Form_Builder::preset_capabilities(), [ 'ao_manage_forms' ] ) as $capability ) { $user->remove_cap( $capability ); } foreach ( Form_Builder::preset_capabilities( $preset ) as $capability ) { $user->add_cap( $capability ); } } wp_send_json_success(); return; } if ( ! in_array( $operation, [ 'search', 'list' ], true ) ) { wp_send_json_error( [], 400 ); return; } // --- 1. Bounded query: pages of 20, administrators excluded --- $page = isset( $_POST['page_number'] ) && is_scalar( $_POST['page_number'] ) ? max( 1, absint( $_POST['page_number'] ) ) : 1; $args = [ 'number' => 20, 'paged' => $page, 'orderby' => 'display_name', 'order' => 'ASC', 'capability__not_in' => [ 'manage_options' ] ]; $search = isset( $_POST['search'] ) && is_string( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : ''; if ( 'list' === $operation ) { $args['capability__in'] = Form_Builder::preset_capabilities(); // Optional name/username filter for the access list (1-100 characters). if ( '' !== $search ) { if ( mb_strlen( $search ) > 100 ) { wp_send_json_error( [ 'message' => __( 'The filter is too long.', 'admin-optimizer' ) ], 400 ); return; } $args['search'] = '*' . $search . '*'; $args['search_columns'] = [ 'user_login', 'display_name' ]; } } else { if ( strlen( $search ) < 2 ) { wp_send_json_success( [ 'users' => [], 'more' => false ] ); return; } $args['search'] = '*' . $search . '*'; $args['search_columns'] = [ 'user_login', 'display_name' ]; } $query = new \WP_User_Query( $args ); // --- 2. Describe each user's direct and role-inherited access --- $preset_caps = Form_Builder::preset_capabilities(); // Roles User Access Manager can edit: its default roles, extended by Pro through its own filter. $uam_running = Module_Registry::can_run( 'user_access_manager' ); $rows = []; foreach ( $query->get_results() as $user ) { if ( user_can( $user, 'manage_options' ) ) { continue; } $roles = []; $role_sources = []; $role_caps = []; $inherited = false; foreach ( $user->roles as $role_name ) { $role = get_role( $role_name ); $label = translate_user_role( wp_roles()->role_names[ $role_name ] ?? $role_name ); $granted = $role ? array_intersect( $preset_caps, array_keys( array_filter( $role->capabilities ) ) ) : []; $inherited = $inherited || $granted; $roles[] = $label; if ( $granted ) { $role_caps = array_merge( $role_caps, $granted ); $editable = $uam_running && apply_filters( 'adminoptim_uam_is_manageable_role_key', in_array( $role_name, [ 'administrator', 'editor', 'author', 'contributor', 'subscriber' ], true ), $role_name ); $role_sources[] = [ 'name' => $label, 'url' => $editable ? Module_Registry::get_module_url( 'user_access_manager', [ 'action' => 'edit', 'role' => $role_name ] ) : '', ]; } } $direct = array_intersect_key( $user->caps, array_flip( $preset_caps ) ); $effective = array_values( array_filter( $preset_caps, static function ( $cap ) use ( $user ) { return user_can( $user, $cap ); } ) ); $role_level = $this->access_level( array_values( array_unique( $role_caps ) ) ); // Sentences shown under the access-level select; built here for _n() and localized list joining. $role_note = ''; $role_also_note = ''; if ( $role_sources ) { $role_count = count( $role_sources ); /* translators: %s: one role name, or a localized list such as "Editor and Shop Manager". */ $role_note = sprintf( _n( 'From the %s role', 'From the %s roles', $role_count, 'admin-optimizer' ), wp_sprintf_l( '%l', array_column( $role_sources, 'name' ) ) ); if ( 'all' === $role_level ) { $role_also_note = _n( 'Their role also gives access to all forms.', 'Their roles also give access to all forms.', $role_count, 'admin-optimizer' ); } elseif ( 'own' === $role_level ) { $role_also_note = _n( 'Their role also gives access to their own forms.', 'Their roles also give access to their own forms.', $role_count, 'admin-optimizer' ); } else { $role_also_note = _n( 'Their role also gives some form permissions.', 'Their roles also give some form permissions.', $role_count, 'admin-optimizer' ); } } $rows[] = [ 'id' => $user->ID, 'label' => $user->display_name . ' (' . $user->user_login . ') — ' . implode( ', ', $roles ), 'direct' => ! empty( $direct ), 'inherited' => (bool) $inherited, 'level' => $this->access_level( $effective ), 'directLevel' => $this->access_level( array_keys( array_filter( $direct ) ) ), 'capabilities' => $effective, 'name' => $user->display_name, 'login' => $user->user_login, 'roles' => $roles, 'roleSources' => $role_sources, 'roleLevel' => $role_level, 'roleNote' => $role_note, 'roleAlsoNote' => $role_also_note, ]; } // --- 3. Respond --- $response = [ 'users' => $rows, 'more' => $query->get_total() > $page * 20 ]; if ( 'list' === $operation ) { $response['total'] = (int) $query->get_total(); /* translators: %s: number of people with Form Builder access. */ $response['totalText'] = sprintf( _n( '%s person', '%s people', $response['total'], 'admin-optimizer' ), number_format_i18n( $response['total'] ) ); } wp_send_json_success( $response ); } /** Describe custom role combinations without overstating their permissions. */ private function access_level( array $capabilities ): string { if ( ! $capabilities ) { return 'none'; } foreach ( [ 'all', 'own' ] as $preset ) { $expected = Form_Builder::preset_capabilities( $preset ); if ( ! array_diff( $expected, $capabilities ) && ! array_diff( $capabilities, $expected ) ) { return $preset; } } return 'custom'; } /** * Render a secret-key field whose stored value is never echoed into the * page: the input is always left blank and only shows an obfuscated hint * (first character + last 4, same masking as the Pro license key field) * as its placeholder, so the real secret never reaches the DOM/page * source. Saving with the field left blank keeps the previously stored * secret (see sanitize_settings()); typing a new value replaces it. * * @param array $field Field config, including the current * 'option' values injected by the * settings helper. * * @return void */ public function render_masked_secret_field( array $field ) { $name = isset( $field['name'] ) ? sanitize_key( $field['name'] ) : ''; $id = isset( $field['id'] ) ? sanitize_key( $field['id'] ) : $name; $option = isset( $field['option'] ) && is_array( $field['option'] ) ? $field['option'] : []; $stored = isset( $option[ $name ] ) ? (string) $option[ $name ] : ''; $masked = '' !== $stored ? self::obfuscate_secret( $stored ) : ''; printf( '', esc_attr( Form_Builder::OPTION_NAME ), esc_attr( $name ), esc_attr( $id ), esc_attr( '' !== $masked ? $masked : __( 'Not set', 'admin-optimizer' ) ) ); if ( '' !== $masked ) { echo '

' . esc_html__( 'A key is already saved. Leave this blank to keep it, or enter a new key to replace it.', 'admin-optimizer' ) . '

'; } if ( ! empty( $field['desc'] ) ) { echo '

' . wp_kses_post( $field['desc'] ) . '

'; } } /** * Mask a secret for display: first character, then asterisks, then the * last 4 characters (same masking pattern Pro's license key field uses). * * @param string $value Secret value. * * @return string */ private static function obfuscate_secret( string $value ) { $length = strlen( $value ); if ( $length <= 4 ) { return str_repeat( '*', $length ); } $first = substr( $value, 0, 1 ); $last4 = substr( $value, -4 ); $stars = str_repeat( '*', max( 0, $length - 5 ) ); return $first . $stars . $last4; } /** * Callback function to sanitize user options * * @param array $options User options. * * @return array */ public function sanitize_settings( $options ) { $sanitized_options = []; if ( ! is_array( $options ) ) { return $sanitized_options; } $old_options = get_option( Form_Builder::OPTION_NAME, [] ); $old_options = is_array( $old_options ) ? $old_options : []; $sanitized_options['enable_recaptcha'] = ! empty( $options['enable_recaptcha'] ) ? 1 : 0; $sanitized_options['recaptcha_site_key'] = isset( $options['recaptcha_site_key'] ) ? sanitize_text_field( $options['recaptcha_site_key'] ) : ''; $sanitized_options['recaptcha_secret_key'] = $this->sanitize_secret_setting( $options['recaptcha_secret_key'] ?? '', $old_options['recaptcha_secret_key'] ?? '' ); $sanitized_options['enable_turnstile'] = ! empty( $options['enable_turnstile'] ) ? 1 : 0; $sanitized_options['turnstile_site_key'] = isset( $options['turnstile_site_key'] ) ? sanitize_text_field( $options['turnstile_site_key'] ) : ''; $sanitized_options['turnstile_secret_key'] = $this->sanitize_secret_setting( $options['turnstile_secret_key'] ?? '', $old_options['turnstile_secret_key'] ?? '' ); $sanitized_options['turnstile_invisible_site_key'] = isset( $options['turnstile_invisible_site_key'] ) ? sanitize_text_field( $options['turnstile_invisible_site_key'] ) : ''; $sanitized_options['turnstile_invisible_secret_key'] = $this->sanitize_secret_setting( $options['turnstile_invisible_secret_key'] ?? '', $old_options['turnstile_invisible_secret_key'] ?? '' ); return $sanitized_options; } /** * Sanitize a submitted masked-secret field: a blank submission means the * field was left untouched (its displayed value is always blank — see * render_masked_secret_field()), so the previously stored secret is kept * rather than wiped. A non-blank submission replaces it. * * @param mixed $submitted Raw submitted value for this field. * @param string $old_value Previously stored secret. * * @return string */ private function sanitize_secret_setting( $submitted, $old_value ) { $submitted = is_scalar( $submitted ) ? sanitize_text_field( (string) $submitted ) : ''; return '' !== $submitted ? $submitted : (string) $old_value; } }