options = is_array( $options ) ? $this->normalize_options( $options ) : $this->get_default_options(); add_action( 'admin_post_' . self::ACTION_SAVE_CAPS, [ $this, 'handle_save_role_caps' ] ); add_action( 'admin_post_' . self::ACTION_ADD_CAP, [ $this, 'handle_add_custom_cap' ] ); add_action( 'admin_post_' . self::ACTION_DELETE_CAP, [ $this, 'handle_delete_custom_cap' ] ); add_action( 'admin_post_' . self::ACTION_RESET_ROLE, [ $this, 'handle_reset_role' ] ); add_action( 'admin_post_' . self::ACTION_SAVE_ADMIN_ACCESS, [ $this, 'handle_save_admin_access' ] ); } /** * Enqueue settings assets. * * @param string|null $hook_suffix Admin page hook suffix. * * @return void */ public function enqueue_settings_assets( string $hook_suffix ): void { wp_enqueue_style( 'adminoptim-user-access-manager', User_Access_Manager::MODULE_URL . 'assets/css/user-access-manager.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptim-user-access-manager', User_Access_Manager::MODULE_URL . 'assets/js/user-access-manager.min.js', [], ADMINOPTIMIZER_VERSION, true ); } /** * Render settings page. * * @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' ) ); } $action = isset( $_GET['action'] ) && is_scalar( $_GET['action'] ) ? sanitize_key( (string) wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'edit' === $action ) { $this->render_edit_role_page(); return; } if ( 'add' === $action ) { $this->render_add_role_page(); return; } if ( 'admin-access' === $this->get_active_tab() ) { $this->render_admin_access_page(); return; } if ( 'unused-caps' === $this->get_active_tab() ) { $this->render_unused_capabilities_page(); return; } $this->render_roles_page(); } /** * Render the roles table screen. * * @return void */ protected function render_roles_page() { $roles = $this->get_default_roles(); $admin_access_options = $this->get_block_admin_access_options(); $hide_admin_bar_globally = ! empty( $admin_access_options['hide_admin_bar_globally'] ); $is_pro_active = Module_Registry::is_pro_active(); ?>
render_notices(); ?> render_tabs( 'roles' ); ?>

', '' ) ); ?>

$details ) : ?> get_edit_role_url( $role_key ); ?>
|
get_role_type_label( $role_key ) ); ?> count_role_users( $role_key ) ) ); ?> count_role_capabilities( $role_key ) ) ); ?> role_has_admin_access( $role_key ) ? esc_html__( 'Yes', 'admin-optimizer' ) : esc_html__( 'No', 'admin-optimizer' ); ?> get_role_setting( $role_key, 'hide_admin_bar', 0 ) ) ? esc_html__( 'Yes', 'admin-optimizer' ) : esc_html__( 'No', 'admin-optimizer' ); ?>

render_notices(); ?> get_available_capabilities( '' ); $grouped_caps = array_merge( [ __( 'All', 'admin-optimizer' ) => $capabilities ], $this->group_capabilities( $capabilities ) ); $capability_labels = []; foreach ( $capabilities as $capability ) { $capability_labels[ $capability ] = $this->get_capability_label( $capability ); } /** * Fires the add-role form into the page content area. * * Free has no notion of creating a role at all; this exists * solely for Pro to hook. Free precomputes the capability * data from its own (protected) helpers so Pro's callback * doesn't need access to Free's internals. * * @param array $capabilities Flat list of available capability keys. * @param array $grouped_caps Capabilities grouped by tab, `[ 'All' => [...], 'Posts' => [...], ... ]`. * @param array $capability_labels Map of capability key => display label. */ do_action( 'adminoptim_uam_render_add_role_page', $capabilities, $grouped_caps, $capability_labels ); ?>

', '' ) ); ?>

render_notices(); ?> render_tabs( 'unused-caps' ); ?>

get_custom_capabilities() ) ); /** * Fires the unused-capabilities list and cleanup form into the * page content area. * * Free has no notion of removing a role's capabilities at all; * this exists solely for Pro to hook. * * @param array $unused_capabilities Map of capability => role keys it is granted on. */ do_action( 'adminoptim_uam_render_unused_capabilities_page', $unused_capabilities ); ?>

', '' ) ); ?>

get_block_admin_access_options(); $blocked_roles = isset( $options['block_user_roles'] ) && is_array( $options['block_user_roles'] ) ? $options['block_user_roles'] : []; $redirect_url = isset( $options['block_redirect'] ) && is_scalar( $options['block_redirect'] ) ? (string) $options['block_redirect'] : ''; $hide_admin_bar_globally = ! empty( $options['hide_admin_bar_globally'] ); $roles = get_editable_roles(); ?>
render_notices(); ?> render_tabs( 'admin-access' ); ?>

get_selected_role_key(); $roles = $this->get_default_roles(); $role = get_role( $selected_role ); $role_caps = $this->get_role_capabilities( $selected_role ); $capabilities = $this->get_available_capabilities( $selected_role ); $grouped_caps = array_merge( [ __( 'All', 'admin-optimizer' ) => $capabilities ], $this->group_capabilities( $capabilities ) ); $custom_caps = $this->get_custom_capabilities(); $hide_admin_bar = $this->get_role_setting( $selected_role, 'hide_admin_bar', 0 ); ?>

render_notices(); ?>

    $caps ) : ?>
$caps ) : ?>
>
get_capability_label( $capability ) ); ?>

    $details ) : ?>
  • get_capability_label( $capability ) ); ?>

authorize_role_action( self::ACTION_SAVE_CAPS ); $role = get_role( $role_key ); if ( ! $role ) { $this->redirect_with_message( $role_key, 'invalid_role' ); } $available_caps = $this->get_available_capabilities( $role_key ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified in authorize_role_action() above; each value sanitized with sanitize_key() in the loop below. $submitted_caps = isset( $_POST['capabilities'] ) && is_array( $_POST['capabilities'] ) ? wp_unslash( $_POST['capabilities'] ) : []; $selected_caps = []; foreach ( $submitted_caps as $capability ) { if ( ! is_scalar( $capability ) ) { continue; } $capability = sanitize_key( (string) $capability ); if ( in_array( $capability, $available_caps, true ) ) { $selected_caps[ $capability ] = true; } } if ( $this->current_user_loses_manage_options( $role_key, $selected_caps ) ) { $this->redirect_with_message( $role_key, 'role_self_lockout' ); } if ( $this->role_change_causes_site_lockout( $role_key, $selected_caps ) ) { $this->redirect_with_message( $role_key, 'role_site_lockout' ); } $current_caps = is_array( $role->capabilities ) ? $role->capabilities : []; foreach ( $available_caps as $capability ) { if ( ! empty( $selected_caps[ $capability ] ) ) { $role->add_cap( $capability ); } elseif ( array_key_exists( $capability, $current_caps ) ) { $role->remove_cap( $capability ); } } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified in authorize_role_action() above; only used as an isset() presence check below, never as a value. $raw_role_settings = isset( $_POST['role_settings'] ) && is_array( $_POST['role_settings'] ) ? wp_unslash( $_POST['role_settings'] ) : []; $hide_admin_bar = isset( $raw_role_settings['hide_admin_bar'] ) ? 1 : 0; $this->set_role_setting( $role_key, 'hide_admin_bar', $hide_admin_bar ); /** * Fires after this role's capabilities and settings are saved, so Pro * can persist the extra per-role fields (like admin menu visibility) * its own settings UI renders into this same form. The nonce for * this submission has already been verified above. * * @param string $role_key Role key just saved. */ do_action( 'adminoptim_uam_after_save_role_caps', $role_key ); $this->redirect_with_message( $role_key, 'role_saved' ); } /** * Add a custom capability. * * @return void */ public function handle_add_custom_cap() { $role_key = $this->authorize_role_action( self::ACTION_ADD_CAP, false ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified in authorize_role_action() above; sanitized with sanitize_key() on the next line. $raw_key = isset( $_POST['capability_key'] ) && is_scalar( $_POST['capability_key'] ) ? wp_unslash( $_POST['capability_key'] ) : ''; $cap_key = sanitize_key( (string) $raw_key ); if ( empty( $cap_key ) || ! preg_match( '/^[a-z0-9_]+$/', $cap_key ) ) { $this->redirect_with_message( $role_key, 'invalid_capability' ); } $custom_caps = $this->get_custom_capabilities(); if ( isset( $custom_caps[ $cap_key ] ) ) { $this->redirect_with_message( $role_key, 'duplicate_capability' ); } if ( self::is_core_capability( $cap_key ) ) { $this->redirect_with_message( $role_key, 'core_capability' ); } $custom_caps[ $cap_key ] = [ 'label' => $this->get_capability_label( $cap_key ), 'created_at' => time(), ]; $this->options = $this->get_stored_options(); $this->options['custom_capabilities'] = $custom_caps; update_option( User_Access_Manager::OPTION_NAME, $this->normalize_options( $this->options ) ); $this->redirect_with_message( $role_key, 'capability_added' ); } /** * Delete a custom capability. * * @return void */ public function handle_delete_custom_cap() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'admin-optimizer' ) ); } $role_key = isset( $_POST['role'] ) && is_scalar( $_POST['role'] ) ? sanitize_key( (string) wp_unslash( $_POST['role'] ) ) : 'administrator'; if ( ! $this->is_manageable_role_key( $role_key ) ) { wp_die( esc_html__( 'Invalid role selected.', 'admin-optimizer' ) ); } $cap_key = isset( $_POST['capability_key'] ) && is_scalar( $_POST['capability_key'] ) ? sanitize_key( (string) wp_unslash( $_POST['capability_key'] ) ) : ''; check_admin_referer( self::ACTION_DELETE_CAP . '_' . $cap_key ); $custom_caps = $this->get_custom_capabilities(); if ( empty( $cap_key ) || ! isset( $custom_caps[ $cap_key ] ) ) { $this->redirect_with_message( $role_key, 'invalid_capability' ); } unset( $custom_caps[ $cap_key ] ); $this->options = $this->get_stored_options(); $this->options['custom_capabilities'] = $custom_caps; update_option( User_Access_Manager::OPTION_NAME, $this->normalize_options( $this->options ) ); foreach ( $this->default_role_keys as $default_role_key ) { $role = get_role( $default_role_key ); if ( $role && isset( $role->capabilities[ $cap_key ] ) ) { $role->remove_cap( $cap_key ); } } $this->redirect_with_message( $role_key, 'capability_deleted' ); } /** * Reset a default role. * * @return void */ public function handle_reset_role() { $role_key = $this->authorize_role_action( self::ACTION_RESET_ROLE ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in authorize_role_action() above. if ( empty( $_POST['confirm_reset'] ) ) { $this->redirect_with_message( $role_key, 'reset_not_confirmed' ); } $role = get_role( $role_key ); if ( ! $role ) { $this->redirect_with_message( $role_key, 'invalid_role' ); } $current_caps = is_array( $role->capabilities ) ? array_keys( $role->capabilities ) : []; foreach ( $current_caps as $capability ) { $role->remove_cap( $capability ); } $default_caps = self::get_default_role_capability_map(); if ( ! empty( $default_caps[ $role_key ] ) && is_array( $default_caps[ $role_key ] ) ) { foreach ( $default_caps[ $role_key ] as $capability => $grant ) { if ( $grant ) { $role->add_cap( $capability ); } } } $this->redirect_with_message( $role_key, 'role_reset' ); } /** * Save integrated Block Admin Access settings. * * @return void */ public function handle_save_admin_access() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'admin-optimizer' ) ); } check_admin_referer( self::ACTION_SAVE_ADMIN_ACCESS ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized in sanitize_block_admin_access_options() below. $raw_options = isset( $_POST[ Block_Admin_Access::OPTION_NAME ] ) && is_array( $_POST[ Block_Admin_Access::OPTION_NAME ] ) ? wp_unslash( $_POST[ Block_Admin_Access::OPTION_NAME ] ) : []; $options = $this->sanitize_block_admin_access_options( $raw_options ); $blocked_roles = isset( $options['block_user_roles'] ) && is_array( $options['block_user_roles'] ) ? $options['block_user_roles'] : []; if ( ! $this->has_allowed_admin_access_role( $blocked_roles ) ) { $this->redirect_admin_access_with_message( 'admin_access_needs_allowed_admin' ); } update_option( Block_Admin_Access::OPTION_NAME, $options ); $this->redirect_admin_access_with_message( 'admin_access_saved' ); } /** * Verify permissions, nonce, and requested role. * * @param string $action Action name. * @param bool $role_nonce Whether nonce includes the role key. * * @return string */ protected function authorize_role_action( string $action, bool $role_nonce = true ): string { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'admin-optimizer' ) ); } $role_key = isset( $_POST['role'] ) && is_scalar( $_POST['role'] ) ? sanitize_key( (string) wp_unslash( $_POST['role'] ) ) : 'administrator'; if ( ! $this->is_manageable_role_key( $role_key ) ) { wp_die( esc_html__( 'Invalid role selected.', 'admin-optimizer' ) ); } check_admin_referer( $role_nonce ? $action . '_' . $role_key : $action ); return $role_key; } /** * Render admin notices. * * @return void */ protected function render_notices() { $message = isset( $_GET['uam_message'] ) && is_scalar( $_GET['uam_message'] ) ? sanitize_key( (string) wp_unslash( $_GET['uam_message'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $message ) ) { return; } $messages = [ 'role_saved' => [ 'success', __( 'Role capabilities saved.', 'admin-optimizer' ) ], 'capability_added' => [ 'success', __( 'Custom capability added.', 'admin-optimizer' ) ], 'capability_deleted' => [ 'success', __( 'Custom capability deleted and removed from default roles.', 'admin-optimizer' ) ], 'role_reset' => [ 'success', __( 'Role reset to WordPress default capabilities.', 'admin-optimizer' ) ], 'invalid_role' => [ 'error', __( 'The selected role is not supported in this version.', 'admin-optimizer' ) ], 'role_self_lockout' => [ 'error', __( 'Not saved: this would remove your own administrator access, with no other role covering it.', 'admin-optimizer' ) ], 'role_site_lockout' => [ 'error', __( 'Not saved: this would leave the site with no role that can manage settings.', 'admin-optimizer' ) ], 'invalid_capability' => [ 'error', __( 'Capability keys must contain only lowercase letters, numbers, and underscores.', 'admin-optimizer' ) ], 'duplicate_capability' => [ 'error', __( 'That custom capability already exists.', 'admin-optimizer' ) ], 'core_capability' => [ 'error', __( 'That capability conflicts with an existing WordPress capability.', 'admin-optimizer' ) ], 'reset_not_confirmed' => [ 'error', __( 'Confirm the reset before continuing.', 'admin-optimizer' ) ], 'admin_access_saved' => [ 'success', __( 'Admin access settings saved.', 'admin-optimizer' ) ], 'admin_access_needs_allowed_admin' => [ 'error', __( 'Keep at least one role with administrator permissions allowed before saving admin access restrictions.', 'admin-optimizer' ) ], ]; /** * Filters the notice message lookup table. * * Free only knows the message keys its own admin_post handlers * redirect with; Pro hooks this to add the keys its own handlers * (e.g. role creation) use, without Free needing to know them. * * @param array $messages Map of message key => [ type, text ]. */ $messages = apply_filters( 'adminoptim_uam_notice_messages', $messages ); if ( empty( $messages[ $message ] ) ) { return; } [ $type, $text ] = $messages[ $message ]; ?>

'edit', 'role' => sanitize_key( $role_key ), 'uam_message' => sanitize_key( $message ), ] ) ); exit; } /** * Redirect back to the Admin Access tab. * * @param string $message Message key. * * @return void */ protected function redirect_admin_access_with_message( string $message ) { wp_safe_redirect( User_Access_Manager::get_module_admin_url( [ 'tab' => 'admin-access', 'uam_message' => sanitize_key( $message ), ] ) ); exit; } /** * Render top-level User Access Manager tabs. * * @param string $active_tab Active tab key. * * @return void */ protected function render_tabs( string $active_tab ) { $tabs = [ 'roles' => __( 'Roles & Capabilities', 'admin-optimizer' ), 'admin-access' => __( 'Admin Access', 'admin-optimizer' ), 'unused-caps' => __( 'Unused Capabilities', 'admin-optimizer' ), ]; ?> sanitize_block_admin_access_options( is_array( $options ) ? $options : [] ); } /** * Sanitize legacy Block Admin Access settings. * * @param array $options Raw options. * * @return array */ protected function sanitize_block_admin_access_options( array $options ): array { return Block_Admin_Access::sanitize_options( $options ); } /** * Whether saving $new_caps to $role_key would strip the current user's * own `manage_options` access, with no other role of theirs covering it. * * Mirrors the "self lockout" half of * `User_Access_Manager::validate_lockout_protection()` (role assignment), * applied here to a capability edit instead of a role reassignment. * * @param string $role_key Role being saved. * @param array $new_caps Capability key => true map about to be persisted. * * @return bool */ protected function current_user_loses_manage_options( string $role_key, array $new_caps ): bool { if ( ! empty( $new_caps['manage_options'] ) ) { return false; } if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'is_super_admin' ) && is_super_admin() ) { return false; // Super admin capability doesn't depend on this role. } $user = wp_get_current_user(); if ( ! $user instanceof \WP_User || ! is_array( $user->roles ) || ! in_array( $role_key, $user->roles, true ) ) { return false; } foreach ( $user->roles as $role ) { if ( $role === $role_key ) { continue; } $other_role = get_role( $role ); if ( $other_role && $other_role->has_cap( 'manage_options' ) ) { return false; } } return true; } /** * Whether saving $new_caps to $role_key would leave no editable role on * the site granting `manage_options` at all. * * Generalizes `has_allowed_admin_access_role()` below (which guards the * Block Admin Access blocked-roles list) to a capability edit instead. * * @param string $role_key Role being saved. * @param array $new_caps Capability key => true map about to be persisted. * * @return bool */ protected function role_change_causes_site_lockout( string $role_key, array $new_caps ): bool { if ( ! empty( $new_caps['manage_options'] ) ) { return false; } $roles = get_editable_roles(); if ( ! is_array( $roles ) ) { return false; } foreach ( $roles as $role => $details ) { if ( $role === $role_key ) { continue; } $capabilities = isset( $details['capabilities'] ) && is_array( $details['capabilities'] ) ? $details['capabilities'] : []; if ( ! empty( $capabilities['manage_options'] ) ) { return false; } } return true; } /** * Ensure at least one administrator-capable role stays allowed. * * @param array $blocked_roles Blocked roles. * * @return bool */ protected function has_allowed_admin_access_role( array $blocked_roles ): bool { $roles = get_editable_roles(); if ( ! is_array( $roles ) ) { return true; } foreach ( $roles as $role => $details ) { if ( in_array( $role, $blocked_roles, true ) ) { continue; } $capabilities = isset( $details['capabilities'] ) && is_array( $details['capabilities'] ) ? $details['capabilities'] : []; if ( ! empty( $capabilities['manage_options'] ) ) { return true; } } return false; } /** * Render a disabled preview of the Pro Admin Menu editor. * * @return void */ protected function render_pro_admin_menu_showcase() { $menu_items = [ [ __( 'Dashboard', 'admin-optimizer' ), 'index.php', [] ], [ __( 'Posts', 'admin-optimizer' ), 'edit.php', [ [ __( 'All Posts', 'admin-optimizer' ), 'edit.php' ], [ __( 'Add New Post', 'admin-optimizer' ), 'post-new.php' ], [ __( 'Categories', 'admin-optimizer' ), 'edit-tags.php?taxonomy=category' ] ] ], [ __( 'Media', 'admin-optimizer' ), 'upload.php', [] ], [ __( 'Pages', 'admin-optimizer' ), 'edit.php?post_type=page', [] ], [ __( 'Comments', 'admin-optimizer' ), 'edit-comments.php', [] ], [ __( 'Appearance', 'admin-optimizer' ), 'themes.php', [] ], [ __( 'Plugins', 'admin-optimizer' ), 'plugins.php', [] ], [ __( 'Users', 'admin-optimizer' ), 'users.php', [] ], [ __( 'Tools', 'admin-optimizer' ), 'tools.php', [] ], [ __( 'Settings', 'admin-optimizer' ), 'options-general.php', [] ], ]; ?>

', '' ) ); ?>

'edit', 'role' => sanitize_key( $role_key ), ] ); } /** * Count users assigned to a role. * * @param string $role_key Role key. * * @return int */ protected function count_role_users( string $role_key ): int { $user_count = count_users(); if ( ! is_array( $user_count ) || empty( $user_count['avail_roles'] ) || ! is_array( $user_count['avail_roles'] ) ) { return 0; } return isset( $user_count['avail_roles'][ $role_key ] ) ? absint( $user_count['avail_roles'][ $role_key ] ) : 0; } /** * Get selected default role key. * * @return string */ protected function get_selected_role_key(): string { $role_key = isset( $_GET['role'] ) && is_scalar( $_GET['role'] ) ? sanitize_key( (string) wp_unslash( $_GET['role'] ) ) : 'administrator'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return $this->is_manageable_role_key( $role_key ) ? $role_key : 'administrator'; } /** * Check whether role is supported. * * @param string $role_key Role key. * * @return bool */ protected function is_default_role_key( string $role_key ): bool { return in_array( $role_key, $this->default_role_keys, true ); } /** * Check whether a role key may be opened, edited, and saved through the * role editor — a broader question than {@see is_default_role_key()}, * which only asks whether a role is one of Free's hardcoded five. * * Free itself never manages anything outside that hardcoded five; Pro * hooks this to additionally allow any role it creates or otherwise * manages. * * @param string $role_key Role key. * * @return bool */ protected function is_manageable_role_key( string $role_key ): bool { return apply_filters( 'adminoptim_uam_is_manageable_role_key', $this->is_default_role_key( $role_key ), $role_key ); } /** * Get default roles. * * @return array */ protected function get_default_roles(): array { $wp_roles = wp_roles(); $roles = []; foreach ( $this->default_role_keys as $role_key ) { if ( ! isset( $wp_roles->roles[ $role_key ] ) || ! is_array( $wp_roles->roles[ $role_key ] ) ) { continue; } $name = $wp_roles->roles[ $role_key ]['name'] ?? $role_key; $roles[ $role_key ] = [ 'name' => translate_user_role( $name ), ]; } /** * Filters the roles listed on the Roles & Capabilities screen. * * Free only ever lists its own hardcoded five; Pro hooks this to * additionally list the custom roles it has created. * * @param array $roles Roles keyed by role key, each `[ 'name' => ... ]`. * @param \WP_Roles $wp_roles The live WP_Roles registry. */ return apply_filters( 'adminoptim_uam_roles_list', $roles, $wp_roles ); } /** * Get a role's display type for the roles table. * * @param string $role_key Role key. * * @return string */ protected function get_role_type_label( string $role_key ): string { return $this->is_default_role_key( $role_key ) ? __( 'Default', 'admin-optimizer' ) : __( 'Custom', 'admin-optimizer' ); } /** * Count active role capabilities. * * @param string $role_key Role key. * * @return int */ protected function count_role_capabilities( string $role_key ): int { $caps = $this->get_role_capabilities( $role_key ); return count( array_filter( $caps ) ); } /** * Whether a role is allowed to access the WordPress admin area, per the * Admin Access settings. Administrators are always allowed. * * @param string $role_key Role key. * * @return bool */ protected function role_has_admin_access( string $role_key ): bool { $role_key = sanitize_key( $role_key ); if ( 'administrator' === $role_key ) { return true; } $options = $this->get_block_admin_access_options(); $blocked_roles = isset( $options['block_user_roles'] ) && is_array( $options['block_user_roles'] ) ? $options['block_user_roles'] : []; return ! in_array( $role_key, $blocked_roles, true ); } /** * Get active role capabilities. * * @param string $role_key Role key. * * @return array */ protected function get_role_capabilities( string $role_key ): array { $role = get_role( $role_key ); if ( ! $role || ! is_array( $role->capabilities ) ) { return []; } return array_filter( $role->capabilities ); } /** * Get capabilities available for editing. * * @param string $selected_role Selected role key. * * @return array */ protected function get_available_capabilities( string $selected_role ): array { $capabilities = []; foreach ( $this->get_role_capabilities( $selected_role ) as $capability => $grant ) { if ( $grant ) { $capabilities[ $capability ] = true; } } foreach ( $this->default_role_keys as $role_key ) { foreach ( $this->get_role_capabilities( $role_key ) as $capability => $grant ) { if ( $grant ) { $capabilities[ $capability ] = true; } } } foreach ( self::get_core_capabilities() as $capability ) { $capabilities[ $capability ] = true; } foreach ( array_keys( $this->get_custom_capabilities() ) as $capability ) { $capabilities[ $capability ] = true; } $capabilities = array_keys( $capabilities ); sort( $capabilities, SORT_NATURAL | SORT_FLAG_CASE ); return $capabilities; } /** * Group capabilities for display. * * @param array $capabilities Capabilities. * * @return array */ protected function group_capabilities( array $capabilities ): array { $groups = [ __( 'Posts', 'admin-optimizer' ) => [], __( 'Pages', 'admin-optimizer' ) => [], __( 'Media', 'admin-optimizer' ) => [], __( 'Comments', 'admin-optimizer' ) => [], __( 'Users', 'admin-optimizer' ) => [], __( 'Themes', 'admin-optimizer' ) => [], __( 'Plugins', 'admin-optimizer' ) => [], __( 'Settings', 'admin-optimizer' ) => [], __( 'Custom', 'admin-optimizer' ) => [], __( 'Other', 'admin-optimizer' ) => [], ]; $custom_caps = array_keys( $this->get_custom_capabilities() ); foreach ( $capabilities as $capability ) { $group = __( 'Other', 'admin-optimizer' ); if ( in_array( $capability, $custom_caps, true ) ) { $group = __( 'Custom', 'admin-optimizer' ); } elseif ( false !== strpos( $capability, 'post' ) ) { $group = __( 'Posts', 'admin-optimizer' ); } elseif ( false !== strpos( $capability, 'page' ) ) { $group = __( 'Pages', 'admin-optimizer' ); } elseif ( in_array( $capability, [ 'upload_files', 'unfiltered_upload' ], true ) ) { $group = __( 'Media', 'admin-optimizer' ); } elseif ( false !== strpos( $capability, 'comment' ) ) { $group = __( 'Comments', 'admin-optimizer' ); } elseif ( false !== strpos( $capability, 'user' ) || in_array( $capability, [ 'list_users', 'promote_users', 'remove_users' ], true ) ) { $group = __( 'Users', 'admin-optimizer' ); } elseif ( false !== strpos( $capability, 'theme' ) || in_array( $capability, [ 'customize', 'edit_css' ], true ) ) { $group = __( 'Themes', 'admin-optimizer' ); } elseif ( false !== strpos( $capability, 'plugin' ) ) { $group = __( 'Plugins', 'admin-optimizer' ); } elseif ( false !== strpos( $capability, 'option' ) || in_array( $capability, [ 'manage_options', 'update_core', 'import', 'export' ], true ) ) { $group = __( 'Settings', 'admin-optimizer' ); } $groups[ $group ][] = $capability; } return array_filter( $groups ); } /** * Get custom capabilities. * * @return array */ protected function get_custom_capabilities(): array { $options = $this->normalize_options( $this->options ); return $options['custom_capabilities']; } /** * Get one role setting. * * @param string $role Role key. * @param string $setting Setting key. * @param mixed $default Default value. * * @return mixed */ protected function get_role_setting( string $role, string $setting, $default = null ) { $settings = $this->get_role_settings( $role ); $setting = sanitize_key( $setting ); return array_key_exists( $setting, $settings ) ? $settings[ $setting ] : $default; } /** * Set one role setting. * * @param string $role Role key. * @param string $setting Setting key. * @param mixed $value Setting value. * * @return void */ protected function set_role_setting( string $role, string $setting, $value ) { $role = sanitize_key( $role ); $setting = sanitize_key( $setting ); if ( empty( $role ) || empty( $setting ) ) { return; } $options = $this->get_stored_options(); if ( ! isset( $options['role_settings'][ $role ] ) || ! is_array( $options['role_settings'][ $role ] ) ) { $options['role_settings'][ $role ] = []; } $options['role_settings'][ $role ][ $setting ] = ! empty( $value ) ? 1 : 0; $this->options = $options; update_option( User_Access_Manager::OPTION_NAME, $this->normalize_options( $this->options ) ); } /** * Get the module options as currently stored, normalized. * * Every write must start from this rather than `$this->options`: the * constructor snapshot is normalized before Pro registers its * `adminoptim_uam_normalize_options` filter, so it lacks Pro-owned keys * (`managed_roles`, `menu_visibility`) and writing it back would wipe them. * * @return array */ protected function get_stored_options(): array { $options = get_option( User_Access_Manager::OPTION_NAME, [] ); return $this->normalize_options( is_array( $options ) ? $options : [] ); } /** * Get all settings for a role. * * @param string $role Role key. * * @return array */ protected function get_role_settings( string $role ): array { $role = sanitize_key( $role ); $options = $this->normalize_options( $this->options ); if ( empty( $role ) || empty( $options['role_settings'][ $role ] ) || ! is_array( $options['role_settings'][ $role ] ) ) { return []; } return $options['role_settings'][ $role ]; } /** * Get default module options. * * @return array */ protected function get_default_options(): array { return [ 'custom_capabilities' => [], 'role_settings' => [], ]; } /** * Normalize options. * * @param array $options Raw options. * * @return array */ protected function normalize_options( array $options ): array { $normalized = $this->get_default_options(); if ( isset( $options['custom_capabilities'] ) && is_array( $options['custom_capabilities'] ) ) { foreach ( $options['custom_capabilities'] as $capability => $details ) { $capability = sanitize_key( (string) $capability ); if ( empty( $capability ) || ! preg_match( '/^[a-z0-9_]+$/', $capability ) ) { continue; } $details = is_array( $details ) ? $details : []; $label = isset( $details['label'] ) && is_scalar( $details['label'] ) ? sanitize_text_field( (string) $details['label'] ) : $this->get_capability_label( $capability ); $normalized['custom_capabilities'][ $capability ] = [ 'label' => $label, 'created_at' => isset( $details['created_at'] ) ? absint( $details['created_at'] ) : time(), ]; } } if ( isset( $options['role_settings'] ) && is_array( $options['role_settings'] ) ) { foreach ( $options['role_settings'] as $role => $settings ) { $role = sanitize_key( (string) $role ); if ( empty( $role ) || ! is_array( $settings ) ) { continue; } $normalized['role_settings'][ $role ] = []; foreach ( $settings as $setting => $value ) { $setting = sanitize_key( (string) $setting ); if ( empty( $setting ) ) { continue; } $normalized['role_settings'][ $role ][ $setting ] = ! empty( $value ) ? 1 : 0; } if ( array_key_exists( 'hide_admin_bar', $normalized['role_settings'][ $role ] ) ) { $normalized['role_settings'][ $role ]['show_admin_bar'] = $normalized['role_settings'][ $role ]['hide_admin_bar'] ? 0 : 1; } elseif ( array_key_exists( 'show_admin_bar', $normalized['role_settings'][ $role ] ) ) { $normalized['role_settings'][ $role ]['hide_admin_bar'] = $normalized['role_settings'][ $role ]['show_admin_bar'] ? 0 : 1; } } } /** * Filters the normalized module options. * * Mirrors the filter applied in `User_Access_Manager::normalize_options()` * so Pro-owned keys (`managed_roles`, `menu_visibility`) survive every * save made through this settings UI, not just Free's own module-level * normalization. * * @param array $normalized Normalized options built from Free's own keys. * @param array $options Raw, unnormalized options. */ return apply_filters( 'adminoptim_uam_normalize_options', $normalized, $options ); } /** * Create a readable label. * * @param string $capability Capability key. * * @return string */ protected function get_capability_label( string $capability ): string { return ucwords( str_replace( '_', ' ', sanitize_key( $capability ) ) ); } /** * Determine whether a capability is a core/default capability. * * Public and static so it can double as the seam Pro's standalone * unused-capabilities admin_post handler re-validates submitted * capabilities against, without needing a live Settings instance. * * @param string $capability Capability key. * * @return bool */ public static function is_core_capability( string $capability ): bool { if ( in_array( $capability, self::get_core_capabilities(), true ) ) { return true; } $default_map = self::get_default_role_capability_map(); foreach ( $default_map as $caps ) { if ( is_array( $caps ) && array_key_exists( $capability, $caps ) ) { return true; } } return false; } /** * Common WordPress capabilities. * * @return array */ public static function get_core_capabilities(): array { $capabilities = [ 'activate_plugins', 'create_posts', 'create_users', 'customize', 'delete_others_pages', 'delete_others_posts', 'delete_pages', 'delete_plugins', 'delete_posts', 'delete_private_pages', 'delete_private_posts', 'delete_published_pages', 'delete_published_posts', 'delete_site', 'delete_themes', 'delete_users', 'edit_css', 'edit_dashboard', 'edit_files', 'edit_others_pages', 'edit_others_posts', 'edit_pages', 'edit_plugins', 'edit_posts', 'edit_private_pages', 'edit_private_posts', 'edit_published_pages', 'edit_published_posts', 'edit_theme_options', 'edit_themes', 'edit_users', 'erase_others_personal_data', 'export', 'export_others_personal_data', 'import', 'install_plugins', 'install_themes', 'list_users', 'manage_categories', 'manage_links', 'manage_options', 'manage_privacy_options', 'moderate_comments', 'promote_users', 'publish_pages', 'publish_posts', 'read', 'read_private_pages', 'read_private_posts', 'remove_users', 'resume_plugins', 'resume_themes', 'switch_themes', 'unfiltered_html', 'unfiltered_upload', 'update_core', 'update_php', 'update_plugins', 'update_themes', 'upload_files', ]; for ( $level = 0; $level <= 10; $level++ ) { $capabilities[] = 'level_' . $level; } sort( $capabilities, SORT_NATURAL | SORT_FLAG_CASE ); return $capabilities; } /** * WordPress default capability maps for supported roles. * * @return array */ public static function get_default_role_capability_map(): array { $subscriber = [ 'read' => true, 'level_0' => true, ]; $contributor = array_merge( $subscriber, [ 'edit_posts' => true, 'delete_posts' => true, 'level_1' => true, ] ); $author = array_merge( $contributor, [ 'upload_files' => true, 'publish_posts' => true, 'edit_published_posts' => true, 'delete_published_posts' => true, 'level_2' => true, ] ); $editor = array_merge( $author, [ 'moderate_comments' => true, 'manage_categories' => true, 'manage_links' => true, 'unfiltered_html' => true, 'edit_others_posts' => true, 'delete_others_posts' => true, 'delete_private_posts' => true, 'edit_private_posts' => true, 'read_private_posts' => true, 'edit_pages' => true, 'edit_others_pages' => true, 'edit_published_pages' => true, 'publish_pages' => true, 'delete_pages' => true, 'delete_others_pages' => true, 'delete_published_pages' => true, 'delete_private_pages' => true, 'edit_private_pages' => true, 'read_private_pages' => true, 'level_3' => true, 'level_4' => true, 'level_5' => true, 'level_6' => true, 'level_7' => true, ] ); $administrator = array_merge( $editor, [ 'switch_themes' => true, 'edit_themes' => true, 'activate_plugins' => true, 'edit_plugins' => true, 'edit_users' => true, 'edit_files' => true, 'manage_options' => true, 'import' => true, 'create_users' => true, 'delete_users' => true, 'unfiltered_upload' => true, 'edit_dashboard' => true, 'update_plugins' => true, 'delete_plugins' => true, 'install_plugins' => true, 'update_themes' => true, 'install_themes' => true, 'update_core' => true, 'update_php' => true, 'list_users' => true, 'remove_users' => true, 'promote_users' => true, 'edit_theme_options' => true, 'delete_themes' => true, 'export' => true, 'customize' => true, 'delete_site' => true, 'edit_css' => true, 'resume_plugins' => true, 'resume_themes' => true, 'manage_privacy_options' => true, 'export_others_personal_data' => true, 'erase_others_personal_data' => true, 'level_8' => true, 'level_9' => true, 'level_10' => true, ] ); return [ 'administrator' => $administrator, 'editor' => $editor, 'author' => $author, 'contributor' => $contributor, 'subscriber' => $subscriber, ]; } /** * Get capability names claimed by currently registered post types and * taxonomies (core or from active plugins/themes). * * Custom post types commonly declare their own capability set (e.g. a * "lazyblocks" post type generates edit_lazyblocks, delete_lazyblocks, * publish_lazyblocks, ...) which won't appear in any static core-capability * list but are very much in active use. * * @return array */ public static function get_registered_object_capabilities(): array { $capabilities = []; foreach ( get_post_types( [], 'objects' ) as $post_type ) { if ( isset( $post_type->cap ) && is_object( $post_type->cap ) ) { foreach ( get_object_vars( $post_type->cap ) as $capability ) { if ( is_scalar( $capability ) && '' !== (string) $capability ) { $capabilities[] = sanitize_key( (string) $capability ); } } } } foreach ( get_taxonomies( [], 'objects' ) as $taxonomy ) { if ( isset( $taxonomy->cap ) && is_object( $taxonomy->cap ) ) { foreach ( get_object_vars( $taxonomy->cap ) as $capability ) { if ( is_scalar( $capability ) && '' !== (string) $capability ) { $capabilities[] = sanitize_key( (string) $capability ); } } } } return array_values( array_unique( $capabilities ) ); } /** * Get capabilities granted on at least one registered role that are * neither core WordPress capabilities nor tracked as a Custom * Capability. * * Public, static, and scoped to every registered WordPress role (not * just the roles Free's own screens manage) so Pro's standalone * `admin_post` removal handler can call it directly — re-deriving the * exact same list used to render the tab — without needing a live * Settings instance. * * @param array $custom_capabilities Tracked custom capability keys. * * @return array Map of capability => role keys it is granted on. */ public static function get_unused_capabilities( array $custom_capabilities ): array { $registered_object_caps = self::get_registered_object_capabilities(); $unused = []; foreach ( array_keys( wp_roles()->roles ) as $role_key ) { $role = get_role( $role_key ); if ( ! $role || ! is_array( $role->capabilities ) ) { continue; } foreach ( array_filter( $role->capabilities ) as $capability => $grant ) { $capability = sanitize_key( (string) $capability ); if ( '' === $capability || self::is_core_capability( $capability ) || in_array( $capability, $custom_capabilities, true ) || in_array( $capability, $registered_object_caps, true ) ) { continue; } $unused[ $capability ][] = sanitize_key( (string) $role_key ); } } ksort( $unused, SORT_NATURAL | SORT_FLAG_CASE ); return $unused; } }