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