options = is_array( $this->options ) ? $this->options : []; $this->settings = new User_Access_Manager_Settings( $this->options ); $this->block_admin_access = new Block_Admin_Access(); add_filter( 'show_admin_bar', [ $this, 'filter_show_admin_bar' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_user_role_assets' ] ); add_filter( 'admin_body_class', [ $this, 'filter_admin_body_class' ] ); add_action( 'show_user_profile', [ $this, 'add_user_multi_roles_select' ] ); add_action( 'edit_user_profile', [ $this, 'add_user_multi_roles_select' ] ); add_action( 'user_new_form', [ $this, 'add_new_user_multi_roles_select' ] ); add_action( 'user_profile_update_errors', [ $this, 'validate_user_multi_roles_select' ], 10, 3 ); add_action( 'personal_options_update', [ $this, 'save_user_multi_roles_select' ] ); add_action( 'edit_user_profile_update', [ $this, 'save_user_multi_roles_select' ] ); add_action( 'user_register', [ $this, 'save_new_user_multi_roles_select' ] ); add_filter( 'adminoptim_notification_message_' . self::MODULE_SLUG, static function ( string $message, string $display ): string { if ( 'banner' === $display ) { return sprintf( /* translators: 1: Opening strong tag, 2: Closing strong tag. */ __( '%1$sManaging WordPress roles with ease?%2$s Leave a ★★★★★ review on WordPress.org and help others discover Admin Optimizer.', 'admin-optimizer' ), '', '' ); } else { return sprintf( /* translators: 1: Opening strong tag, 2: Closing strong tag. */ __( '%1$sManaging WordPress roles?%2$s Manage custom roles, capabilities, and admin menu access with Admin Optimizer Pro.', 'admin-optimizer' ), '', '' ); } }, 10, 2 ); } /** * Return the module's submenu definition. * * @return array */ protected function get_submenu_config(): array { return [ 'page_title' => __( 'User Access Manager', 'admin-optimizer' ), 'menu_title' => __( 'User Access Manager', 'admin-optimizer' ), 'menu_slug' => self::MENU_SLUG, 'callback' => [ $this->settings, 'render_settings_page' ], ]; } /** * Apply role-based admin toolbar visibility. * * @param bool $show_admin_bar Current toolbar visibility. * * @return bool */ public function filter_show_admin_bar( bool $show_admin_bar ): bool { if ( is_admin() || ! is_user_logged_in() ) { return $show_admin_bar; } $admin_access_options = get_option( Block_Admin_Access::OPTION_NAME, [] ); if ( is_array( $admin_access_options ) && ! empty( $admin_access_options['hide_admin_bar_globally'] ) ) { return false; } $user = wp_get_current_user(); if ( ! $user || ! $user->exists() || ! is_array( $user->roles ) || empty( $user->roles ) ) { return $show_admin_bar; } foreach ( $user->roles as $role ) { if ( ! is_scalar( $role ) ) { continue; } if ( 1 === absint( $this->get_role_setting( (string) $role, 'hide_admin_bar', 0 ) ) ) { return false; } } return $show_admin_bar; } /** * Get one role setting. * * @param string $role Role key. * @param string $setting Setting key. * @param mixed $default Default value. * * @return mixed */ public 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 */ public 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->normalize_options( get_option( self::OPTION_NAME, [] ) ); 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( self::OPTION_NAME, $options ); } /** * Get all settings for a role. * * @param string $role Role key. * * @return array */ public function get_role_settings( string $role ): array { $role = sanitize_key( $role ); $options = $this->normalize_options( get_option( self::OPTION_NAME, [] ) ); if ( empty( $role ) || empty( $options['role_settings'][ $role ] ) || ! is_array( $options['role_settings'][ $role ] ) ) { return []; } return $options['role_settings'][ $role ]; } /** * Normalize module options for runtime reads. * * @param mixed $options Raw options. * * @return array */ protected function normalize_options( $options ): array { $options = is_array( $options ) ? $options : []; $normalized = [ 'custom_capabilities' => [], 'role_settings' => [], ]; if ( isset( $options['custom_capabilities'] ) && is_array( $options['custom_capabilities'] ) ) { $normalized['custom_capabilities'] = $options['custom_capabilities']; } 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; } } } return $normalized; } /** * Enqueue user role assets on supported core user screens. * * @param string $hook_suffix Current admin screen hook. * * @return void */ public function enqueue_user_role_assets( string $hook_suffix ) { if ( ! in_array( $hook_suffix, [ 'profile.php', 'user-edit.php', 'user-new.php' ], true ) ) { return; } wp_enqueue_style( 'adminoptim-user-access-manager', self::MODULE_URL . 'assets/css/user-access-manager.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptim-user-access-manager', self::MODULE_URL . 'assets/js/user-access-manager.min.js', [], ADMINOPTIMIZER_VERSION, true ); } /** * Add a body class for narrowly-scoped role-row replacement styles. * * @param string $classes Existing admin body classes. * * @return string */ public function filter_admin_body_class( string $classes ): string { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $id = $screen && isset( $screen->id ) ? (string) $screen->id : ''; if ( in_array( $id, [ 'profile', 'user-edit', 'user' ], true ) ) { $classes .= ' adminoptim-uam-user-roles-enabled'; } return $classes; } /** * Render the multiple-role selector on Edit User and Profile screens. * * @param mixed $user User object. * * @return void */ public function add_user_multi_roles_select( $user ) { if ( ! $user instanceof \WP_User || ! current_user_can( 'edit_user', $user->ID ) || ! current_user_can( 'promote_user', $user->ID ) ) { return; } $this->render_user_roles_control( $user ); } /** * Render the multiple-role selector on Add New User. * * @param string $type New user form type. * * @return void */ public function add_new_user_multi_roles_select( string $type = 'add-new-user' ) { if ( ! current_user_can( 'create_users' ) || ! current_user_can( 'promote_users' ) ) { return; } $default_role = sanitize_key( (string) get_option( 'default_role', 'subscriber' ) ); $this->render_user_roles_control( null, [ $default_role ], 'new' ); } /** * Render the shared role assignment control. * * @param \WP_User|null $user User object for edit screens. * @param array $selected_roles Preselected roles for add-new. * @param string $context Screen context. * * @return void */ protected function render_user_roles_control( ?\WP_User $user = null, array $selected_roles = [], string $context = 'edit' ) { $render_key = 'new' === $context ? 'new' : 'user-' . absint( $user->ID ?? 0 ); if ( isset( self::$rendered_role_controls[ $render_key ] ) ) { return; } $editable_roles = $this->get_assignable_roles(); if ( empty( $editable_roles ) ) { return; } $registered_roles = $this->get_registered_role_keys(); $user_id = $user instanceof \WP_User ? absint( $user->ID ) : 0; $nonce_action = $this->get_roles_nonce_action( $user_id ); if ( $user instanceof \WP_User ) { $selected_roles = is_array( $user->roles ) ? array_map( 'sanitize_key', $user->roles ) : []; } if ( isset( $_POST[ self::ROLES_MARKER ], $_POST[ self::ROLES_FIELD ] ) && is_array( $_POST[ self::ROLES_FIELD ] ) ) { $nonce = isset( $_POST[ self::ROLES_NONCE ] ) && is_scalar( $_POST[ self::ROLES_NONCE ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::ROLES_NONCE ] ) ) : ''; if ( ! empty( $nonce ) && wp_verify_nonce( $nonce, $nonce_action ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- unslashed and sanitized inside normalize_submitted_roles(). $submitted_selected_roles = self::normalize_submitted_roles( $_POST[ self::ROLES_FIELD ] ); if ( ! empty( $submitted_selected_roles ) ) { $selected_roles = $submitted_selected_roles; } } } $selected_roles = array_values( array_intersect( array_unique( array_map( 'sanitize_key', $selected_roles ) ), $registered_roles ) ); $editable_role_keys = array_keys( $editable_roles ); $preserved_role_keys = array_values( array_diff( $selected_roles, $editable_role_keys ) ); $control_id = 'adminoptim-uam-user-roles-' . $render_key; $has_admin_capability = $this->roles_grant_manage_options( $selected_roles ); self::$rendered_role_controls[ $render_key ] = true; ?> should_process_submitted_roles( $user_id ) ) { return; } $roles = $this->prepare_submitted_roles_for_user( $user_id, 'existing' ); if ( is_wp_error( $roles ) ) { return; } $this->replace_user_roles( $user_id, $roles ); } /** * Save multiple roles after a new user is created. * * @param int $user_id New user ID. * * @return void */ public function save_new_user_multi_roles_select( int $user_id ) { if ( ! $this->should_process_submitted_roles( $user_id, 'new' ) ) { return; } $roles = $this->prepare_submitted_roles_for_user( $user_id, 'new' ); if ( is_wp_error( $roles ) ) { $roles = $this->get_default_role_list(); } $this->replace_user_roles( $user_id, $roles ); } /** * Validate submitted role changes before WordPress updates the user. * * @param \WP_Error $errors Error object. * @param bool $update Whether this is an existing user update. * @param mixed $user User data object. * * @return void */ public function validate_user_multi_roles_select( \WP_Error $errors, bool $update, $user ) { $user_id = isset( $user->ID ) ? absint( $user->ID ) : 0; $context = $update ? 'existing' : 'new'; if ( ! $this->should_process_submitted_roles( $user_id, $context ) ) { return; } $roles = $this->prepare_submitted_roles_for_user( $user_id, $context ); if ( is_wp_error( $roles ) ) { $errors->add( $roles->get_error_code(), $roles->get_error_message() ); } } /** * Normalize submitted role keys against registered WordPress roles. * * @param array $roles Submitted role values. * * @return array */ public static function normalize_submitted_roles( array $roles ): array { $wp_roles = wp_roles(); $registered_roles = isset( $wp_roles->roles ) && is_array( $wp_roles->roles ) ? array_map( 'sanitize_key', array_keys( $wp_roles->roles ) ) : []; $normalized = []; foreach ( wp_unslash( $roles ) as $role ) { if ( ! is_scalar( $role ) ) { continue; } $role = sanitize_key( (string) $role ); if ( '' !== $role && in_array( $role, $registered_roles, true ) && ! in_array( $role, $normalized, true ) ) { $normalized[] = $role; } } return $normalized; } /** * Replace a user's current roles using WordPress role APIs. * * @param int $user_id User ID. * @param array $roles Valid role keys. * * @return void */ protected function replace_user_roles( int $user_id, array $roles ) { $user = get_userdata( $user_id ); if ( ! $user instanceof \WP_User ) { return; } $current_roles = is_array( $user->roles ) ? array_map( 'sanitize_key', $user->roles ) : []; $roles = array_values( array_unique( array_map( 'sanitize_key', $roles ) ) ); foreach ( array_diff( $current_roles, $roles ) as $role ) { $user->remove_role( $role ); } foreach ( array_diff( $roles, $current_roles ) as $role ) { $user->add_role( $role ); } } /** * Determine whether submitted UAM role fields should be processed. * * @param int $user_id User ID. * @param string $context Screen context. * * @return bool */ protected function should_process_submitted_roles( int $user_id = 0, string $context = 'existing' ): bool { if ( ! isset( $_POST[ self::ROLES_MARKER ] ) ) { return false; } $nonce = isset( $_POST[ self::ROLES_NONCE ] ) && is_scalar( $_POST[ self::ROLES_NONCE ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::ROLES_NONCE ] ) ) : ''; if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, $this->get_roles_nonce_action( 'new' === $context ? 0 : $user_id ) ) ) { return false; } if ( 'new' === $context ) { return current_user_can( 'create_users' ) && current_user_can( 'promote_users' ); } return ! empty( $user_id ) && current_user_can( 'edit_user', $user_id ) && current_user_can( 'promote_user', $user_id ); } /** * Prepare submitted roles for saving or validation. * * @param int $user_id User ID. * @param string $context Screen context. * * @return array|\WP_Error */ protected function prepare_submitted_roles_for_user( int $user_id = 0, string $context = 'existing' ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- caller gates on should_process_submitted_roles() for the nonce; value is unslashed/sanitized inside normalize_submitted_roles()/sanitize_role_key_list(). $raw_roles = isset( $_POST[ self::ROLES_FIELD ] ) && is_array( $_POST[ self::ROLES_FIELD ] ) ? $_POST[ self::ROLES_FIELD ] : []; $normalized_roles = self::normalize_submitted_roles( $raw_roles ); $submitted_keys = $this->sanitize_role_key_list( $raw_roles ); $registered_roles = $this->get_registered_role_keys(); $editable_roles = array_keys( $this->get_assignable_roles() ); $unauthorized = array_values( array_diff( $submitted_keys, $editable_roles ) ); $invalid_submitted = array_values( array_diff( $submitted_keys, $registered_roles ) ); if ( ! empty( $invalid_submitted ) || ! empty( $unauthorized ) ) { return new \WP_Error( 'adminoptim_uam_unauthorized_roles', __( 'You are not allowed to assign one or more of the selected roles.', 'admin-optimizer' ) ); } $roles = array_values( array_intersect( $normalized_roles, $editable_roles ) ); if ( 'new' === $context && empty( $roles ) ) { $roles = $this->get_default_role_list(); } if ( 'existing' === $context ) { $roles = $this->merge_preserved_roles( $user_id, $roles ); } if ( empty( $roles ) ) { return new \WP_Error( 'adminoptim_uam_empty_roles', __( 'Select at least one valid role.', 'admin-optimizer' ) ); } if ( 'existing' === $context ) { $lockout_error = $this->validate_lockout_protection( $user_id, $roles ); if ( is_wp_error( $lockout_error ) ) { return $lockout_error; } } return $roles; } /** * Preserve existing valid roles the current administrator is not allowed to edit. * * @param int $user_id User ID. * @param array $roles Submitted editable roles. * * @return array */ protected function merge_preserved_roles( int $user_id, array $roles ): array { $user = get_userdata( $user_id ); if ( ! $user instanceof \WP_User || ! is_array( $user->roles ) ) { return $roles; } $current_roles = array_map( 'sanitize_key', $user->roles ); $editable_roles = array_keys( $this->get_assignable_roles() ); $registered_roles = $this->get_registered_role_keys(); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- caller gates on should_process_submitted_roles() for the nonce; value is unslashed/sanitized inside normalize_submitted_roles(). $preserved_roles = isset( $_POST[ self::PRESERVE_ROLES_FIELD ] ) && is_array( $_POST[ self::PRESERVE_ROLES_FIELD ] ) ? self::normalize_submitted_roles( $_POST[ self::PRESERVE_ROLES_FIELD ] ) : []; foreach ( $preserved_roles as $role ) { if ( in_array( $role, $current_roles, true ) && in_array( $role, $registered_roles, true ) && ! in_array( $role, $editable_roles, true ) ) { $roles[] = $role; } } return array_values( array_unique( $roles ) ); } /** * Validate current-user and site administrator lockout protections. * * @param int $user_id User ID. * @param array $submitted_roles New role list. * * @return true|\WP_Error */ protected function validate_lockout_protection( int $user_id, array $submitted_roles ) { $user = get_userdata( $user_id ); if ( ! $user instanceof \WP_User || ! is_array( $user->roles ) ) { return true; } $current_roles_grant_admin = $this->roles_grant_manage_options( $user->roles ); $submitted_roles_grant_admin = $this->roles_grant_manage_options( $submitted_roles ); if ( ! $current_roles_grant_admin || $submitted_roles_grant_admin || $this->is_super_admin_user( $user_id ) ) { return true; } if ( get_current_user_id() === $user_id ) { return new \WP_Error( 'adminoptim_uam_self_lockout', __( 'You cannot remove your own last role with administrator access.', 'admin-optimizer' ) ); } if ( ! $this->site_has_other_manage_options_user( $user_id ) ) { return new \WP_Error( 'adminoptim_uam_site_lockout', __( 'This change would leave the site without an administrator.', 'admin-optimizer' ) ); } return true; } /** * Get editable roles for the current administrator. * * @return array */ protected function get_assignable_roles(): array { $roles = get_editable_roles(); if ( ! is_array( $roles ) ) { return []; } return array_intersect_key( $roles, array_flip( self::$default_role_keys ) ); } /** * Get all registered role keys for the current site. * * @return array */ protected function get_registered_role_keys(): array { $wp_roles = wp_roles(); return isset( $wp_roles->roles ) && is_array( $wp_roles->roles ) ? array_map( 'sanitize_key', array_keys( $wp_roles->roles ) ) : []; } /** * Get a safe default role list. * * @return array */ protected function get_default_role_list(): array { $default_role = sanitize_key( (string) get_option( 'default_role', 'subscriber' ) ); $editable_roles = array_keys( $this->get_assignable_roles() ); $registered_roles = $this->get_registered_role_keys(); if ( ! empty( $default_role ) && in_array( $default_role, $editable_roles, true ) && in_array( $default_role, $registered_roles, true ) ) { return [ $default_role ]; } if ( in_array( 'subscriber', $editable_roles, true ) && in_array( 'subscriber', $registered_roles, true ) ) { return [ 'subscriber' ]; } return ! empty( $editable_roles ) ? [ reset( $editable_roles ) ] : []; } /** * Sanitize a submitted role list without removing unauthorized values. * * @param array $roles Raw roles. * * @return array */ protected function sanitize_role_key_list( array $roles ): array { $sanitized = []; foreach ( wp_unslash( $roles ) as $role ) { if ( ! is_scalar( $role ) ) { continue; } $role = sanitize_key( (string) $role ); if ( '' !== $role && ! in_array( $role, $sanitized, true ) ) { $sanitized[] = $role; } } return $sanitized; } /** * Whether a role grants manage_options. * * @param string $role Role key. * * @return bool */ protected function role_grants_manage_options( string $role ): bool { $wp_role = get_role( sanitize_key( $role ) ); return $wp_role && isset( $wp_role->capabilities ) && is_array( $wp_role->capabilities ) && ! empty( $wp_role->capabilities['manage_options'] ); } /** * Whether at least one role grants manage_options. * * @param array $roles Role keys. * * @return bool */ protected function roles_grant_manage_options( array $roles ): bool { foreach ( $roles as $role ) { if ( is_scalar( $role ) && $this->role_grants_manage_options( (string) $role ) ) { return true; } } return false; } /** * Whether another user can still manage options on this site. * * @param int $excluded_user_id User being edited. * * @return bool */ protected function site_has_other_manage_options_user( int $excluded_user_id ): bool { $users = get_users( [ 'capability' => 'manage_options', 'exclude' => [ $excluded_user_id ], 'fields' => 'ID', 'number' => 1, ] ); return is_array( $users ) && ! empty( $users ); } /** * Whether the user is a multisite super administrator. * * @param int $user_id User ID. * * @return bool */ protected function is_super_admin_user( int $user_id ): bool { return function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'is_super_admin' ) && is_super_admin( $user_id ); } /** * Get the nonce action for the role control. * * @param int $user_id User ID, or 0 for new users. * * @return string */ protected function get_roles_nonce_action( int $user_id ): string { return 'adminoptim_uam_user_roles_' . absint( $user_id ); } }