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(); ?>
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' ),
];
?>
$label ) : ?>
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;
}
}