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;
}
}