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']); + ?> +
+| + | + | + | + |
|---|---|---|---|
| + | |||
| + + + — + + | ++ | + + | ++ + | +