From 896a22a1949edba045c0b69fe1706adc3125bf58 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 10 May 2026 18:46:35 -0400 Subject: [PATCH] feat(admin): users-management page with admin/agent role toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Laravel reference Users management surface (escalated-laravel #94) to a native WordPress admin screen. New Users submenu lists WP users with their Escalated admin/agent state, supports name/email search, and is paginated 20 per page. Admins can grant or revoke the escalated_admin / escalated_agent roles per user. The same self-demote guard and admin->agent cascade rules as the Laravel reference apply. WordPress deviation: instead of toggling is_admin / is_agent columns on a host User row, the toggle flips native WP roles (escalated_admin, escalated_agent) on the WP_User — that is the permission surface every other Escalated admin gate already uses (see Activator::create_roles / add_admin_caps). Gated by the existing escalated_user_manage capability. Tests: 7 PHPUnit cases mirroring tests/Feature/Admin/UserControllerTest in the Laravel reference (list/search renders, capability gate, admin promotion cascade, agent-only promotion, self-demote guard, agent-revoke-on-admin cascade, search filter). --- CHANGELOG.md | 1 + includes/Admin/class-admin-menu.php | 1 + includes/Admin/class-admin-users.php | 257 +++++++++++++++++++++++++++ templates/admin/users.php | 161 +++++++++++++++++ tests/Test_Admin_Users.php | 209 ++++++++++++++++++++++ 5 files changed, 629 insertions(+) create mode 100644 includes/Admin/class-admin-users.php create mode 100644 templates/admin/users.php create mode 100644 tests/Test_Admin_Users.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9226e1d..cce61dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. - Granular permission tables and seeder on activation. - "Powered by Escalated" badge with admin toggle. - Workflow `delay` action — pauses a workflow run for N seconds and resumes the remaining actions via a per-minute WP-Cron sweep. Backed by a new `escalated_deferred_workflow_jobs` table with a composite `(status, run_at)` index for efficient polling. Existing installs need to reactivate the plugin to pick up the new table. +- Users management admin page (Escalated → Users) — list WordPress users with their Escalated admin/agent roles, search by name/email, paginated 20 per page. Toggle the `escalated_admin` / `escalated_agent` WP roles per user with the same self-demote and admin→agent cascade rules as the Laravel reference (escalated-laravel #94). Gated by the `escalated_user_manage` capability (held by `escalated_admin` and `administrator` roles). ### Changed - License changed from GPL-2.0-or-later to MIT. diff --git a/includes/Admin/class-admin-menu.php b/includes/Admin/class-admin-menu.php index 4dbe150..d6ea699 100644 --- a/includes/Admin/class-admin-menu.php +++ b/includes/Admin/class-admin-menu.php @@ -39,6 +39,7 @@ public function add_menus(): void add_submenu_page('escalated', __('Canned Responses', 'escalated'), __('Canned Responses', 'escalated'), 'escalated_macro_manage', 'escalated-canned-responses', [new Admin_Canned_Responses, 'render']); add_submenu_page('escalated', __('Macros', 'escalated'), __('Macros', 'escalated'), 'escalated_macro_view', 'escalated-macros', [new Admin_Macros, 'render']); add_submenu_page('escalated', __('Reports', 'escalated'), __('Reports', 'escalated'), 'escalated_report_view', 'escalated-reports', [new Admin_Reports, 'render']); + add_submenu_page('escalated', __('Users', 'escalated'), __('Users', 'escalated'), 'escalated_user_manage', 'escalated-users', [new Admin_Users, 'render']); add_submenu_page('escalated', __('API Tokens', 'escalated'), __('API Tokens', 'escalated'), 'escalated_api_token_view', 'escalated-api-tokens', [new Admin_Api_Tokens, 'render']); add_submenu_page('escalated', __('Settings', 'escalated'), __('Settings', 'escalated'), 'escalated_settings_view', 'escalated-settings', [new Admin_Settings, 'render']); } diff --git a/includes/Admin/class-admin-users.php b/includes/Admin/class-admin-users.php new file mode 100644 index 0000000..7e4edaf --- /dev/null +++ b/includes/Admin/class-admin-users.php @@ -0,0 +1,257 @@ + $per_page, + 'paged' => $paged, + 'orderby' => 'ID', + 'order' => 'ASC', + 'count_total' => true, + ]; + + if ($search !== '') { + // Match against email OR display name/user_login — mirrors the + // Laravel reference's "name + email" LIKE filter. + $query_args['search'] = '*'.$search.'*'; + $query_args['search_columns'] = ['user_email', 'user_login', 'display_name']; + } + + $user_query = new \WP_User_Query($query_args); + $rows = []; + foreach ($user_query->get_results() as $user) { + $rows[] = self::user_to_row($user); + } + + // Match Laravel's ordering: admins first, then agents, then others. + usort($rows, function (array $a, array $b): int { + if ($a['is_admin'] !== $b['is_admin']) { + return $b['is_admin'] <=> $a['is_admin']; + } + if ($a['is_agent'] !== $b['is_agent']) { + return $b['is_agent'] <=> $a['is_agent']; + } + + return $a['id'] <=> $b['id']; + }); + + $total = (int) $user_query->get_total(); + $total_pages = (int) max(1, ceil($total / $per_page)); + $current_user_id = get_current_user_id(); + $message = isset($_GET['message']) ? sanitize_text_field(wp_unslash($_GET['message'])) : ''; + $error = isset($_GET['error']) ? sanitize_text_field(wp_unslash($_GET['error'])) : ''; + + $users = $rows; + + include ESCALATED_PLUGIN_DIR.'templates/admin/users.php'; + } + + /** + * Handle POST actions: update a user's role. + */ + public function handle_actions(): void + { + if (! isset($_POST['escalated_user_action'])) { + return; + } + + if (! current_user_can('escalated_user_manage')) { + wp_die(esc_html__('Permission denied.', 'escalated')); + } + + $action = sanitize_text_field(wp_unslash($_POST['escalated_user_action'])); + $redirect = admin_url('admin.php?page=escalated-users'); + + if ($action === 'update_role') { + $user_id = absint($_POST['user_id'] ?? 0); + $nonce = sanitize_text_field(wp_unslash($_POST['_escalated_nonce'] ?? '')); + + if (! wp_verify_nonce($nonce, 'escalated_user_role_'.$user_id)) { + wp_die(esc_html__('Security check failed.', 'escalated')); + } + + $role = sanitize_text_field(wp_unslash($_POST['role'] ?? '')); + $value_raw = $_POST['value'] ?? ''; + if (is_string($value_raw)) { + $value = in_array(strtolower($value_raw), ['1', 'true', 'on', 'yes'], true); + } else { + $value = (bool) $value_raw; + } + + $result = self::update_role($user_id, $role, $value, get_current_user_id()); + + if ($result['ok'] ?? false) { + $redirect = add_query_arg('message', 'updated', $redirect); + } else { + $code = $result['error'] ?? 'error'; + $redirect = add_query_arg('error', $code, $redirect); + } + } + + wp_safe_redirect($redirect); + exit; + } + + /** + * Grant or revoke an Escalated role on a WP user. + * + * Pure-ish: only touches the WP user's roles via WP_User::add_role / + * remove_role. Caller is responsible for permission gating. + * + * Cascade rules (mirror the Laravel reference): + * - role=admin, value=true → also grants agent. + * - role=admin, value=false → only clears admin (agent stays). + * - role=agent, value=false → also clears admin if user is admin + * (avoids leaving the admin gate on while the agent gate is off). + * + * Self-demotion guard: an admin cannot remove their own admin role. + * + * @return array{ok: bool, error?: string} + */ + public static function update_role(int $user_id, string $role, bool $value, ?int $current_user_id): array + { + if (! in_array($role, ['admin', 'agent'], true)) { + return ['ok' => false, 'error' => 'invalid_role']; + } + + $user = get_userdata($user_id); + if (! $user) { + return ['ok' => false, 'error' => 'not_found']; + } + + // Self-demote guard: don't let an admin remove their own admin role. + if ($role === 'admin' && ! $value && $current_user_id && (int) $current_user_id === (int) $user->ID) { + return ['ok' => false, 'error' => 'self_demote']; + } + + if ($role === 'admin') { + if ($value) { + self::grant_role($user, self::ADMIN_ROLE); + // Admins are agents. + self::grant_role($user, self::AGENT_ROLE); + } else { + self::revoke_role($user, self::ADMIN_ROLE); + // Demoting an admin does NOT also revoke agent — an ex-admin + // can still answer tickets unless explicitly demoted from agent. + } + } else { // role === 'agent' + if ($value) { + self::grant_role($user, self::AGENT_ROLE); + } else { + self::revoke_role($user, self::AGENT_ROLE); + // Revoking agent from an admin would leave the admin gate on + // and the agent gate off — confusing. Demote them fully. + if (self::user_is_admin($user)) { + self::revoke_role($user, self::ADMIN_ROLE); + } + } + } + + return ['ok' => true]; + } + + /** + * Project a WP_User into the row shape the admin template consumes. + * + * @return array{id: int, name: string, email: string, is_admin: bool, is_agent: bool} + */ + public static function user_to_row(\WP_User $user): array + { + return [ + 'id' => (int) $user->ID, + 'name' => $user->display_name ?: $user->user_login, + 'email' => $user->user_email, + 'is_admin' => self::user_is_admin($user), + 'is_agent' => self::user_is_agent($user), + ]; + } + + /** + * Does this user count as an Escalated admin? + * + * True for users with the `escalated_admin` role or the native WP + * `administrator` role — Activator::add_admin_caps grants every + * Escalated capability to the WP administrator role, so it would + * be inconsistent for the user list to claim they're not an admin. + */ + public static function user_is_admin(\WP_User $user): bool + { + return in_array(self::ADMIN_ROLE, (array) $user->roles, true) + || in_array('administrator', (array) $user->roles, true); + } + + /** + * Does this user count as an Escalated agent? + * + * Admins are agents (see update_role cascade), but a WP administrator + * with no Escalated role still has the admin capability set, so we + * count them too. Light agents are not "agents" for the purpose of + * this toggle — the toggle only manages the `escalated_agent` role. + */ + public static function user_is_agent(\WP_User $user): bool + { + $roles = (array) $user->roles; + + return in_array(self::AGENT_ROLE, $roles, true) + || in_array(self::ADMIN_ROLE, $roles, true) + || in_array('administrator', $roles, true); + } + + private static function grant_role(\WP_User $user, string $role): void + { + if (! in_array($role, (array) $user->roles, true)) { + $user->add_role($role); + } + } + + private static function revoke_role(\WP_User $user, string $role): void + { + if (in_array($role, (array) $user->roles, true)) { + $user->remove_role($role); + } + } +} diff --git a/templates/admin/users.php b/templates/admin/users.php new file mode 100644 index 0000000..e8ee73e --- /dev/null +++ b/templates/admin/users.php @@ -0,0 +1,161 @@ + +
+

+
+ + +
+

+
+ + + +
+

+ __('You cannot remove your own admin role.', 'escalated'), + 'not_found' => __('That user no longer exists.', 'escalated'), + 'invalid_role' => __('Unknown role.', 'escalated'), + 'error' => __('Could not update user.', 'escalated'), + ]; + echo esc_html($error_messages[$error] ?? $error_messages['error']); + ?> +

+
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + + +
+
+
+ + + + + + + + + + +
+
+ + 1) { ?> +
+
+ + + + + 1) { + $prev = add_query_arg('paged', $paged - 1, $base); + echo '‹ '.esc_html__('Prev', 'escalated').' '; + } + + /* translators: 1: current page number, 2: total page count */ + echo ''.esc_html(sprintf(__('Page %1$s of %2$s', 'escalated'), $paged, $total_pages)).''; + + if ($paged < $total_pages) { + $next = add_query_arg('paged', $paged + 1, $base); + echo ' '.esc_html__('Next', 'escalated').' ›'; + } + ?> + +
+
+ +
diff --git a/tests/Test_Admin_Users.php b/tests/Test_Admin_Users.php new file mode 100644 index 0000000..1773d0a --- /dev/null +++ b/tests/Test_Admin_Users.php @@ -0,0 +1,209 @@ +admin_id = $this->factory->user->create([ + 'role' => 'escalated_admin', + 'user_email' => 'admin@example.com', + ]); + } + + /** + * Helper: refresh a WP_User instance so role changes from update_role() + * are reflected on the in-memory object. + */ + private function refresh(int $user_id): \WP_User + { + clean_user_cache($user_id); + + return new \WP_User($user_id); + } + + // ========================================================================= + // 1. Lists users with their admin/agent flags for an admin + // ========================================================================= + + public function test_render_lists_users_with_admin_and_agent_flags(): void + { + $this->factory->user->create([ + 'role' => 'subscriber', + 'user_email' => 'customer@example.com', + ]); + $this->factory->user->create([ + 'role' => 'escalated_agent', + 'user_email' => 'agent@example.com', + ]); + + wp_set_current_user($this->admin_id); + + ob_start(); + (new Admin_Users)->render(); + $html = ob_get_clean(); + + $this->assertStringContainsString('admin@example.com', $html); + $this->assertStringContainsString('customer@example.com', $html); + $this->assertStringContainsString('agent@example.com', $html); + } + + // ========================================================================= + // 2. Blocks non-admins from the user list — caller's gate + // ========================================================================= + + public function test_non_admin_lacks_user_manage_capability(): void + { + // The admin menu registers this page with the `escalated_user_manage` + // capability. WordPress refuses to render submenu pages the user + // does not have permission for. Verify both the escalated_agent and + // escalated_light_agent roles lack that cap — they're the only + // non-admin Escalated roles a real user is likely to hold. + $agent_id = $this->factory->user->create(['role' => 'escalated_agent']); + $light_id = $this->factory->user->create(['role' => 'escalated_light_agent']); + + wp_set_current_user($agent_id); + $this->assertFalse(current_user_can('escalated_user_manage')); + + wp_set_current_user($light_id); + $this->assertFalse(current_user_can('escalated_user_manage')); + + wp_set_current_user($this->admin_id); + $this->assertTrue(current_user_can('escalated_user_manage')); + } + + // ========================================================================= + // 3. Promotes a user to admin via the panel (admin → also agent) + // ========================================================================= + + public function test_promotes_user_to_admin_also_grants_agent(): void + { + $target_id = $this->factory->user->create([ + 'role' => 'subscriber', + 'user_email' => 'someone@example.com', + ]); + + $result = Admin_Users::update_role($target_id, 'admin', true, $this->admin_id); + + $this->assertTrue($result['ok']); + + $target = $this->refresh($target_id); + $this->assertTrue(Admin_Users::user_is_admin($target)); + $this->assertTrue(Admin_Users::user_is_agent($target)); + $this->assertContains(Admin_Users::ADMIN_ROLE, (array) $target->roles); + $this->assertContains(Admin_Users::AGENT_ROLE, (array) $target->roles); + } + + // ========================================================================= + // 4. Promotes a user to agent only (does not also grant admin) + // ========================================================================= + + public function test_promotes_user_to_agent_only(): void + { + $target_id = $this->factory->user->create([ + 'role' => 'subscriber', + 'user_email' => 'someone@example.com', + ]); + + $result = Admin_Users::update_role($target_id, 'agent', true, $this->admin_id); + + $this->assertTrue($result['ok']); + + $target = $this->refresh($target_id); + $this->assertTrue(Admin_Users::user_is_agent($target)); + $this->assertFalse(Admin_Users::user_is_admin($target)); + $this->assertContains(Admin_Users::AGENT_ROLE, (array) $target->roles); + $this->assertNotContains(Admin_Users::ADMIN_ROLE, (array) $target->roles); + } + + // ========================================================================= + // 5. Prevents admins from demoting themselves + // ========================================================================= + + public function test_prevents_admin_from_demoting_themselves(): void + { + $result = Admin_Users::update_role($this->admin_id, 'admin', false, $this->admin_id); + + $this->assertFalse($result['ok']); + $this->assertSame('self_demote', $result['error']); + + $admin = $this->refresh($this->admin_id); + $this->assertTrue(Admin_Users::user_is_admin($admin)); + $this->assertContains(Admin_Users::ADMIN_ROLE, (array) $admin->roles); + } + + // ========================================================================= + // 6. Demotes an admin and turns off agent in one step + // ========================================================================= + + public function test_revoking_agent_from_admin_also_revokes_admin(): void + { + // A second admin, so the operator can demote them without tripping + // the self-demote guard. + $target_id = $this->factory->user->create([ + 'role' => 'escalated_admin', + 'user_email' => 'someone@example.com', + ]); + // Real admins are agents too — mirror that here so the test starts + // from the same baseline as the Laravel reference. + (new \WP_User($target_id))->add_role(Admin_Users::AGENT_ROLE); + + $result = Admin_Users::update_role($target_id, 'agent', false, $this->admin_id); + + $this->assertTrue($result['ok']); + + $target = $this->refresh($target_id); + $this->assertFalse(Admin_Users::user_is_agent($target)); + $this->assertFalse(Admin_Users::user_is_admin($target)); + $this->assertNotContains(Admin_Users::ADMIN_ROLE, (array) $target->roles); + $this->assertNotContains(Admin_Users::AGENT_ROLE, (array) $target->roles); + } + + // ========================================================================= + // 7. Filters users by search term + // ========================================================================= + + public function test_filters_users_by_search_term(): void + { + $this->factory->user->create([ + 'role' => 'subscriber', + 'user_email' => 'jane@acme.test', + 'user_login' => 'jane_acme', + ]); + $this->factory->user->create([ + 'role' => 'subscriber', + 'user_email' => 'bob@globex.test', + 'user_login' => 'bob_globex', + ]); + + wp_set_current_user($this->admin_id); + + // Simulate the GET search query the form would submit. + $_GET = ['s' => 'acme']; + + ob_start(); + (new Admin_Users)->render(); + $html = ob_get_clean(); + + $_GET = []; + + $this->assertStringContainsString('jane@acme.test', $html); + $this->assertStringNotContainsString('bob@globex.test', $html); + } +}