From add9f043fcf03fbe6b82e789b448e1987c414078 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:32:57 -0800 Subject: [PATCH 1/4] Add service account experiment --- docs/experiments/service-account.md | 31 + includes/Experiment_Loader.php | 13 +- .../Experiments/Service_Account/Admin_UI.php | 1384 +++++++++++++++++ .../REST_Service_Accounts_Controller.php | 672 ++++++++ .../Service_Account/Service_Account.php | 194 +++ .../Service_Account_Manager.php | 1038 +++++++++++++ .../Includes/Experiment_LoaderTest.php | 8 + .../Service_Account/Service_AccountTest.php | 491 ++++++ 8 files changed, 3828 insertions(+), 3 deletions(-) create mode 100644 docs/experiments/service-account.md create mode 100644 includes/Experiments/Service_Account/Admin_UI.php create mode 100644 includes/Experiments/Service_Account/REST_Service_Accounts_Controller.php create mode 100644 includes/Experiments/Service_Account/Service_Account.php create mode 100644 includes/Experiments/Service_Account/Service_Account_Manager.php create mode 100644 tests/Integration/Includes/Experiments/Service_Account/Service_AccountTest.php diff --git a/docs/experiments/service-account.md b/docs/experiments/service-account.md new file mode 100644 index 00000000..1e244d43 --- /dev/null +++ b/docs/experiments/service-account.md @@ -0,0 +1,31 @@ +# Service Accounts + +## Summary +Adds a service account user type for automated tools and programmatic access. The experiment registers a dedicated role, customizes the wp-admin user creation flow to auto-generate usernames/emails, and provides a service-focused edit screen with ownership metadata. Service accounts are flagged via user meta so the role can be changed without losing service account behavior. Application Passwords are created manually after the account is saved. + +## Key Hooks & Entry Points +- `WordPress\AI\Experiments\Service_Account\Service_Account::register()` initializes the manager, admin UI, and REST routes. +- `Service_Account_Manager::init()` registers the role, filters capabilities, and excludes service accounts from default user queries and counts. +- `Admin_UI::enqueue_new_user_scripts()` injects inline JS on `user-new.php` to show the Service Account name field and auto-populate username/email. +- `Admin_UI::add_service_account_fields()` adds service-specific fields (owner user selector, system, reference) and reorders native fields on the edit screen. +- `Admin_UI::handle_user_register()` sets service account meta for admin-created users. +- `REST_Service_Accounts_Controller` exposes `/wp-json/ai/v1/service-accounts` for CRUD and app-password regeneration. + +## Assets & Data Flow +1. Admin UI enqueues inline CSS and JS only on user-related screens (`users.php`, `user-new.php`, `user-edit.php`). +2. On `user-new.php`, selecting the Service Account role reveals the name field and fills username/email using the site domain. +3. During submission, PHP fills missing username/email as a fallback and writes service account meta. +4. On `user-edit.php`, the service account details section moves the key fields (username, email, role, purpose) to the top and adds owner (WordPress user), system, and reference fields stored in user meta. Owner selection requires `list_users`. +5. Service account meta keys (`service_account_owner_id`, `service_account_system`, `service_account_reference`) are registered via `register_meta()` with sanitization and permission callbacks. +6. Application Passwords are created from the user edit screen after the account is saved. + +## Testing +1. Enable the **Service Accounts** experiment. +2. Go to `Users > Add New`, select the **Service Account** role, and enter a name. +3. Confirm username/email are populated and the password field remains visible. +4. Submit the form and open the created user to generate Application Passwords in the section below. +5. Edit the created user and confirm it is labeled as a service account and has Application Passwords available. + +## Notes +- Application Passwords are generated from the user edit screen (they are not auto-generated on creation). +- Service accounts are excluded from standard user counts and queries unless explicitly requested. diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index eef246ac..9dcbd361 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -104,11 +104,18 @@ public function register_default_experiments(): void { */ private function get_default_experiments(): array { $experiment_classes = array( - \WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class, - \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, - \WordPress\AI\Experiments\Summarization\Summarization::class, + \WordPress\AI\Experiments\Service_Account\Service_Account::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, + \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, + \WordPress\AI\Experiments\Type_Ahead\Type_Ahead::class, + \WordPress\AI\Experiments\Post_Table_Bulk\Post_Table_Bulk::class, + \WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation::class, + \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, + \WordPress\AI\Experiments\MCP\MCP::class, + \WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::class, + \WordPress\AI\Experiments\Extended_Providers\Extended_Providers::class, + \WordPress\AI\Experiments\Writing_Assistant\Writing_Assistant::class, ); /** diff --git a/includes/Experiments/Service_Account/Admin_UI.php b/includes/Experiments/Service_Account/Admin_UI.php new file mode 100644 index 00000000..aa66bb66 --- /dev/null +++ b/includes/Experiments/Service_Account/Admin_UI.php @@ -0,0 +1,1384 @@ +manager = Service_Account_Manager::get_instance(); + } + + /** + * Initializes the admin UI hooks. + * + * @since 0.3.0 + */ + public function init(): void { + // Enqueue admin styles. + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); + + // Add a JS-ready class to prevent layout flash. + add_action( 'admin_head', array( $this, 'maybe_add_layout_class' ) ); + + // Add body class for service account edit pages. + add_filter( 'admin_body_class', array( $this, 'add_body_class' ) ); + + // Mark service account rows in users list. + add_filter( 'user_row_actions', array( $this, 'filter_row_actions' ), 10, 2 ); + + // Ensure service account fields are populated on creation. + add_filter( 'pre_user_login', array( $this, 'maybe_generate_user_login' ) ); + add_filter( 'pre_user_email', array( $this, 'maybe_generate_user_email' ) ); + add_action( 'user_register', array( $this, 'handle_user_register' ) ); + + // Validate service account fields on save. + add_action( 'user_profile_update_errors', array( $this, 'validate_service_account_fields' ), 10, 3 ); + + // Add service account fields on the user edit screen. + add_action( 'show_user_profile', array( $this, 'add_service_account_fields' ) ); + add_action( 'edit_user_profile', array( $this, 'add_service_account_fields' ) ); + add_action( 'personal_options_update', array( $this, 'save_service_account_fields' ) ); + add_action( 'edit_user_profile_update', array( $this, 'save_service_account_fields' ) ); + + // Hide password fields for service accounts. + add_action( 'show_user_profile', array( $this, 'maybe_hide_password_section' ) ); + add_action( 'edit_user_profile', array( $this, 'maybe_hide_password_section' ) ); + } + + /** + * Enqueues admin styles for service account differentiation. + * + * @since 0.3.0 + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_styles( string $hook_suffix ): void { + // Only load on user-related pages. + $allowed_hooks = array( 'users.php', 'user-edit.php', 'profile.php', 'user-new.php' ); + + if ( ! in_array( $hook_suffix, $allowed_hooks, true ) ) { + return; + } + + $css = $this->get_styles(); + + wp_register_style( 'service-account-admin', false, array(), '0.3.0' ); + wp_enqueue_style( 'service-account-admin' ); + wp_add_inline_style( 'service-account-admin', $css ); + + // Add JavaScript for row marking and new user form. + $this->enqueue_scripts( $hook_suffix ); + } + + /** + * Enqueues admin scripts. + * + * @since 0.3.0 + * + * @param string $hook_suffix The current admin page hook suffix. + */ + protected function enqueue_scripts( string $hook_suffix ): void { + if ( 'users.php' === $hook_suffix ) { + $this->enqueue_users_list_scripts(); + } + + if ( 'user-new.php' === $hook_suffix ) { + $this->enqueue_new_user_scripts(); + } + + if ( in_array( $hook_suffix, array( 'user-edit.php', 'profile.php' ), true ) ) { + $this->enqueue_user_edit_scripts(); + } + } + + /** + * Enqueues scripts for the users list page. + * + * @since 0.3.0 + */ + protected function enqueue_users_list_scripts(): void { + // Get service account IDs for row marking. + // Note: 'fields' => 'ID' returns an array of IDs directly, not user objects. + $service_account_ids = $this->manager->get_service_accounts( array( 'fields' => 'ID' ) ); + + if ( empty( $service_account_ids ) ) { + return; + } + + $js = sprintf( + 'document.addEventListener("DOMContentLoaded", function() { + var serviceAccountIds = %s; + serviceAccountIds.forEach(function(id) { + var row = document.querySelector("tr#user-" + id); + if (row) { + row.classList.add("service-account-row"); + } + }); + });', + wp_json_encode( array_map( 'intval', $service_account_ids ) ) + ); + + wp_add_inline_script( 'jquery', $js ); + } + + /** + * Enqueues scripts for the new user page. + * + * Adds JavaScript to conditionally show/hide form fields when the + * Service role is selected. + * + * @since 0.3.0 + */ + protected function enqueue_new_user_scripts(): void { + wp_enqueue_script( 'wp-i18n' ); + + $service_role = Service_Account_Manager::ROLE; + $site_domain = wp_parse_url( home_url(), PHP_URL_HOST ) ?: 'localhost'; + $site_domain = wp_json_encode( $site_domain ); + + $js = <<(' + wp.i18n.__("required", "ai") + ')' + + '' + + '

' + wp.i18n.__("Used to generate the username and email. Create Application Passwords after saving the account.", "ai") + '

'; + + // Insert the field row at the top of the table. + var formTableBody = document.querySelector("#createuser table.form-table tbody"); + if (formTableBody) { + formTableBody.insertBefore(serviceAccountRow, formTableBody.firstChild); + } + + var nameInput = document.getElementById("service_account_name"); + + // Store original field state. + var userLoginField = document.getElementById("user_login"); + var userLoginRow = userLoginField ? userLoginField.closest(".form-field") : null; + var userLoginDescription = null; + var originalLoginValue = userLoginField ? userLoginField.value : ""; + + var emailField = document.getElementById("email"); + var emailRow = emailField ? emailField.closest(".form-field") : null; + var emailDescription = null; + var originalEmailValue = emailField ? emailField.value : ""; + + var sendNotificationField = document.getElementById("send_user_notification"); + var sendNotificationRow = sendNotificationField ? sendNotificationField.closest(".form-field") : null; + var originalNotificationChecked = sendNotificationField ? sendNotificationField.checked : false; + + if (userLoginRow && userLoginField) { + userLoginDescription = document.createElement("p"); + userLoginDescription.className = "description service-account-username-desc"; + userLoginDescription.textContent = wp.i18n.__("Auto-generated from the service account name.", "ai"); + userLoginDescription.style.display = "none"; + userLoginField.parentNode.insertBefore(userLoginDescription, userLoginField.nextSibling); + } + + if (emailRow) { + // Add a description for service accounts. + emailDescription = document.createElement("p"); + emailDescription.className = "description service-account-email-desc"; + emailDescription.textContent = wp.i18n.__("Auto-generated based on the service account name and site domain.", "ai"); + emailDescription.style.display = "none"; + emailField.parentNode.insertBefore(emailDescription, emailField.nextSibling); + } + + var generatedSuffix = null; + + function getSuffix() { + if (generatedSuffix) { + return generatedSuffix; + } + + if (window.crypto && window.crypto.getRandomValues) { + var bytes = new Uint8Array(4); + window.crypto.getRandomValues(bytes); + generatedSuffix = Array.from(bytes).map(function(byte) { + return byte.toString(16).padStart(2, "0"); + }).join(""); + } else { + generatedSuffix = Math.random().toString(16).slice(2, 10).padEnd(8, "0"); + } + + return generatedSuffix; + } + + function slugify(value) { + return value + .toLowerCase() + .trim() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + } + + function generateUsername(nameValue) { + var slug = slugify(nameValue); + if (!slug) { + slug = "service-account"; + } + var suffix = getSuffix(); + var username = "service-" + slug + "-" + suffix; + if (username.length > 60) { + username = username.slice(0, 50) + "-" + suffix; + } + return username; + } + + function applyGeneratedValues() { + if (!nameInput) { + return; + } + + var nameValue = nameInput.value.trim(); + if (!nameValue) { + if (userLoginField) { + userLoginField.value = ""; + } + if (emailField) { + emailField.value = ""; + } + return; + } + + var username = generateUsername(nameValue); + if (userLoginField) { + userLoginField.value = username; + } + if (emailField) { + emailField.value = username + "@" + siteDomain; + } + } + + function toggleServiceAccountFields() { + var isService = roleSelect.value === serviceRole; + + // Toggle visibility of standard fields. + fieldIdsToHide.forEach(function(id) { + var field = document.getElementById(id); + var row = field ? field.closest("tr") : null; + if (row) { + row.style.display = isService ? "none" : ""; + } + }); + + // Toggle the service account name field. + serviceAccountRow.style.display = isService ? "table-row" : "none"; + + // Handle username field - show but mark as auto-generated. + if (userLoginField && userLoginRow) { + userLoginField.readOnly = isService; + userLoginField.style.backgroundColor = isService ? "#f0f0f1" : ""; + if (userLoginDescription) { + userLoginDescription.style.display = isService ? "block" : "none"; + } + if (!isService) { + userLoginField.value = originalLoginValue; + } + } + + // Handle email field - show but mark as auto-generated. + if (emailField && emailRow) { + emailField.readOnly = isService; + emailField.style.backgroundColor = isService ? "#f0f0f1" : ""; + if (emailDescription) { + emailDescription.style.display = isService ? "block" : "none"; + } + if (!isService) { + emailField.value = originalEmailValue; + emailField.placeholder = ""; + } + } + + // Hide and disable user notifications for service accounts. + if (sendNotificationRow) { + sendNotificationRow.style.display = isService ? "none" : ""; + } + if (sendNotificationField) { + sendNotificationField.disabled = isService; + sendNotificationField.checked = isService ? false : originalNotificationChecked; + } + + // If service is selected, make the name field required. + if (nameInput) { + nameInput.required = isService; + } + + if (isService) { + applyGeneratedValues(); + } else { + generatedSuffix = null; + } + } + + if (nameInput) { + nameInput.addEventListener("input", function() { + if (roleSelect.value === serviceRole) { + applyGeneratedValues(); + } + }); + } + + // Initial check. + toggleServiceAccountFields(); + + // Listen for role changes. + roleSelect.addEventListener("change", toggleServiceAccountFields); +}); +JS; + + wp_add_inline_script( 'wp-i18n', $js ); + } + + /** + * Enqueues scripts for the user edit pages. + * + * @since 0.3.0 + */ + protected function enqueue_user_edit_scripts(): void { + wp_enqueue_script( 'wp-i18n' ); + wp_enqueue_script( 'wp-api-fetch' ); + wp_enqueue_script( 'wp-components' ); + wp_enqueue_style( 'wp-components' ); + + $js = <<manager->is_service_account( $user ) ) { + return; + } + + $can_assign_owner = current_user_can( 'list_users' ); + $owner_id = (int) get_user_meta( $user->ID, 'service_account_owner_id', true ); + $owner = $owner_id ? get_user_by( 'id', $owner_id ) : null; + $owner_label = ''; + if ( $owner instanceof \WP_User ) { + $owner_label = sprintf( '%s (%s)', $owner->display_name, $owner->user_login ); + } + $system = (string) get_user_meta( $user->ID, 'service_account_system', true ); + $ref = (string) get_user_meta( $user->ID, 'service_account_reference', true ); + + ?> + + manager->is_service_account( $user ) ) { + return; + } + + if ( isset( $_POST['service_account_name'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $name = sanitize_text_field( wp_unslash( $_POST['service_account_name'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( '' !== $name ) { + wp_update_user( + array( + 'ID' => $user_id, + 'display_name' => $name, + 'nickname' => $name, + ) + ); + } + } + + if ( isset( $_POST['service_account_owner_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( current_user_can( 'list_users' ) ) { + $owner_id = absint( wp_unslash( $_POST['service_account_owner_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( 0 === $owner_id || ! get_user_by( 'id', $owner_id ) ) { + delete_user_meta( $user_id, 'service_account_owner_id' ); + } else { + update_user_meta( $user_id, 'service_account_owner_id', $owner_id ); + } + delete_user_meta( $user_id, 'service_account_owner' ); + } + } + + if ( isset( $_POST['service_account_system'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $system = sanitize_text_field( wp_unslash( $_POST['service_account_system'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( '' === $system ) { + delete_user_meta( $user_id, 'service_account_system' ); + } else { + update_user_meta( $user_id, 'service_account_system', $system ); + } + } + + if ( isset( $_POST['service_account_reference'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + $ref = sanitize_text_field( wp_unslash( $_POST['service_account_reference'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( '' === $ref ) { + delete_user_meta( $user_id, 'service_account_reference' ); + } else { + update_user_meta( $user_id, 'service_account_reference', $ref ); + } + } + } + + /** + * Validates service account fields before saving. + * + * @since 0.3.0 + * + * @param \WP_Error $errors WP_Error object. + * @param bool $update Whether this is a user update. + * @param \stdClass $user User data object. + */ + public function validate_service_account_fields( \WP_Error $errors, bool $update, \stdClass $user ): void { + if ( ! $this->is_service_account_form_submission() ) { + if ( $update ) { + $current_user = get_user_by( 'id', (int) $user->ID ); + if ( ! $current_user || ! $this->manager->is_service_account( $current_user ) ) { + return; + } + } else { + return; + } + } + + $name = $this->get_service_account_name_from_post(); + if ( '' === $name ) { + $errors->add( + 'service_account_name', + __( 'Service account name is required.', 'ai' ) + ); + } + + if ( isset( $_POST['service_account_owner_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( current_user_can( 'list_users' ) ) { + $owner_id = absint( wp_unslash( $_POST['service_account_owner_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( $owner_id && ! get_user_by( 'id', $owner_id ) ) { + $errors->add( + 'service_account_owner_id', + __( 'Owner must be a valid WordPress user.', 'ai' ) + ); + } + } + } + } + + /** + * Checks whether the current request is a service account form submission. + * + * @since 0.3.0 + * + * @return bool True when handling a service account submission. + */ + private function is_service_account_form_submission(): bool { + if ( ! is_admin() ) { + return false; + } + + if ( empty( $_POST['role'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + return false; + } + + $role = sanitize_text_field( wp_unslash( $_POST['role'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + + return Service_Account_Manager::ROLE === $role; + } + + /** + * Gets the service account name from the current request. + * + * @since 0.3.0 + * + * @return string The sanitized service account name, or an empty string. + */ + private function get_service_account_name_from_post(): string { + if ( empty( $_POST['service_account_name'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + return ''; + } + + return sanitize_text_field( wp_unslash( $_POST['service_account_name'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + } + + /** + * Generates a username for a service account. + * + * @since 0.3.0 + * + * @param string $name Service account name. + * @return string Generated username. + */ + private function generate_username( string $name ): string { + $sanitized_name = sanitize_title( $name ); + if ( '' === $sanitized_name ) { + $sanitized_name = 'service-account'; + } + + $short_uuid = substr( wp_generate_uuid4(), 0, 8 ); + $username = 'service-' . $sanitized_name . '-' . $short_uuid; + + if ( strlen( $username ) > 60 ) { + $username = substr( $username, 0, 50 ) . '-' . $short_uuid; + } + + return $username; + } + + /** + * Generates an email address for a service account. + * + * @since 0.3.0 + * + * @param string $username Service account username. + * @return string Generated email address. + */ + private function generate_email( string $username ): string { + $site_domain = wp_parse_url( home_url(), PHP_URL_HOST ) ?: 'localhost'; + + return $username . '@' . $site_domain; + } + + /** + * Gets or generates a username for the current request. + * + * @since 0.3.0 + * + * @param string $name Service account name. + * @return string Generated username. + */ + private function get_generated_username( string $name ): string { + if ( null !== $this->generated_username ) { + return $this->generated_username; + } + + $this->generated_username = $this->generate_username( $name ); + + return $this->generated_username; + } + + /** + * Populates the user login when creating service accounts. + * + * @since 0.3.0 + * + * @param string $user_login Proposed user login. + * @return string Filtered user login. + */ + public function maybe_generate_user_login( string $user_login ): string { + if ( ! $this->is_service_account_form_submission() ) { + return $user_login; + } + + if ( '' !== $user_login ) { + return $user_login; + } + + $name = $this->get_service_account_name_from_post(); + + return $this->get_generated_username( $name ); + } + + /** + * Populates the user email when creating service accounts. + * + * @since 0.3.0 + * + * @param string $user_email Proposed user email. + * @return string Filtered user email. + */ + public function maybe_generate_user_email( string $user_email ): string { + if ( ! $this->is_service_account_form_submission() ) { + return $user_email; + } + + if ( '' !== $user_email ) { + return $user_email; + } + + $name = $this->get_service_account_name_from_post(); + $username = $this->get_generated_username( $name ); + + return $this->generate_email( $username ); + } + + /** + * Handles post-processing after service account creation in wp-admin. + * + * @since 0.3.0 + * + * @param int $user_id The created user ID. + */ + public function handle_user_register( int $user_id ): void { + if ( ! $this->is_service_account_form_submission() ) { + return; + } + + $user = get_user_by( 'id', $user_id ); + if ( ! $user || ! $this->manager->is_service_account( $user ) ) { + return; + } + + // Ensure service account meta is set for admin-created users. + update_user_meta( $user_id, Service_Account_Manager::META_KEY, true ); + if ( ! get_user_meta( $user_id, '_service_account_created', true ) ) { + update_user_meta( $user_id, '_service_account_created', time() ); + } + + $name = $this->get_service_account_name_from_post(); + if ( '' !== $name && $name !== $user->display_name ) { + wp_update_user( + array( + 'ID' => $user_id, + 'display_name' => $name, + 'nickname' => $name, + ) + ); + } + } + + /** + * Gets the admin CSS styles. + * + * @since 0.3.0 + * + * @return string CSS styles. + */ + protected function get_styles(): string { + $css = ' + /* ============================================ + * Service Account - Users List Table + * ============================================ */ + + /* Row highlighting */ + .users-php tr.service-account-row { + background: linear-gradient(to right, rgba(34, 113, 177, 0.04), transparent); + border-left: 3px solid var(--wp-admin-theme-color, #2271b1); + opacity: 0.8; + } + + .users-php tr.service-account-row:hover { + background: linear-gradient(to right, rgba(34, 113, 177, 0.08), #f6f7f7); + } + + .users-php tr.service-account-row td { + border-top-color: rgba(34, 113, 177, 0.2); + } + + .users-php tr.service-account-row .username strong { + display: inline-flex; + align-items: center; + gap: 6px; + } + + /* ============================================ + * Service Account - Edit Page + * ============================================ */ + + /* Page header notice */ + body.is-service-account-edit #wpbody-content > .wrap > h1 { + display: flex; + align-items: center; + gap: 12px; + } + + /* Hide Add New button on service account edit screens */ + body.is-service-account-edit .page-title-action { + display: none; + } + + .service-account-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--wp-admin-theme-color, #2271b1); + color: #fff; + font-size: 12px; + font-weight: 500; + padding: 4px 12px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + /* Service account notice styled like application passwords */ + .service-account-notice { + background: rgba(34, 113, 177, 0.02); + border: 1px solid rgba(34, 113, 177, 0.1); + border-radius: 4px; + padding: 15px; + margin: 0 0 15px; + } + + /* Service account details section */ + .service-account-details-section { + margin: 20px 0 10px; + } + + .service-account-details-section .description { + margin-top: 4px; + } + + .service-account-owner-control .components-spinner { + margin-left: 8px; + } + + .service-account-owner-control .button-link { + margin-top: 6px; + } + + body.is-service-account-edit .service-account-owner-control .components-combobox-control__input, + body.is-service-account-edit .service-account-owner-control .components-text-control__input, + body.is-service-account-edit .service-account-owner-control .components-input-control__container, + body.is-service-account-edit .service-account-owner-control .components-combobox-control__input-wrapper, + body.is-service-account-edit .service-account-owner-control .components-combobox-control__suggestions-container, + body.is-service-account-edit .service-account-owner-control .components-flex { + background-color: #fff; + } + + .service-account-notice h3 { + margin: 0 0 8px; + font-size: 14px; + color: #1d2327; + } + + .service-account-notice p { + margin: 0 0 8px; + color: #50575e; + } + + .service-account-notice p:last-child { + margin-bottom: 0; + } + + .service-account-notice ul { + margin: 8px 0 0 20px; + list-style: disc; + } + + .service-account-notice li { + margin: 4px 0; + color: #50575e; + } + + /* Subtle edit page background */ + body.is-service-account-edit .form-table th { + color: #1d2327; + } + + /* Hide default profile sections on service account edit (prevents flash) */ + html.service-account-js body.is-service-account-edit #your-profile > h2:nth-of-type(1), + html.service-account-js body.is-service-account-edit #your-profile > h2:nth-of-type(2), + html.service-account-js body.is-service-account-edit #your-profile > h2:nth-of-type(3), + html.service-account-js body.is-service-account-edit #your-profile > h2:nth-of-type(4), + html.service-account-js body.is-service-account-edit #your-profile > table.form-table:nth-of-type(1), + html.service-account-js body.is-service-account-edit #your-profile > table.form-table:nth-of-type(2), + html.service-account-js body.is-service-account-edit #your-profile > table.form-table:nth-of-type(3), + html.service-account-js body.is-service-account-edit #your-profile > table.form-table:nth-of-type(4) { + display: none; + } + + html.service-account-js body.is-service-account-edit .user-rich-editing-wrap, + html.service-account-js body.is-service-account-edit .user-comment-shortcuts-wrap, + html.service-account-js body.is-service-account-edit .user-admin-bar-front-wrap, + html.service-account-js body.is-service-account-edit .user-language-wrap, + html.service-account-js body.is-service-account-edit .user-profile-picture, + html.service-account-js body.is-service-account-edit .user-syntax-highlighting-wrap, + html.service-account-js body.is-service-account-edit .user-admin-color-wrap { + display: none; + } + + /* Application passwords section highlight */ + body.is-service-account-edit .application-passwords { + background: rgba(34, 113, 177, 0.02); + border: 1px solid rgba(34, 113, 177, 0.1); + border-radius: 4px; + padding: 15px; + margin-top: 10px; + } + + '; + + /** + * Filters the admin CSS styles for service accounts. + * + * @since 0.3.0 + * + * @param string $css The CSS styles. + */ + return apply_filters( 'service_account_admin_styles', $css ); + } + + /** + * Adds body class for service account edit pages. + * + * @since 0.3.0 + * + * @param string $classes Space-separated body classes. + * @return string Modified body classes. + */ + public function add_body_class( string $classes ): string { + $screen = get_current_screen(); + + if ( ! $screen ) { + return $classes; + } + + // Check if editing a service account. + if ( 'user-edit' === $screen->base ) { + $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( $user_id && $this->manager->is_service_account( $user_id ) ) { + $classes .= ' is-service-account-edit'; + } + } + + // Check if viewing own profile as service account. + if ( 'profile' === $screen->base && $this->manager->is_service_account( get_current_user_id() ) ) { + $classes .= ' is-service-account-edit'; + } + + return $classes; + } + + /** + * Adds a JS-ready class to the document for service account layouts. + * + * @since 0.3.0 + */ + public function maybe_add_layout_class(): void { + if ( ! $this->is_service_account_edit_screen() ) { + return; + } + + if ( function_exists( 'wp_print_inline_script_tag' ) ) { + wp_print_inline_script_tag( "document.documentElement.classList.add('service-account-js');" ); + } else { + echo ""; + } + } + + /** + * Checks if the current screen is a service account edit screen. + * + * @since 0.3.0 + * + * @return bool True if editing a service account. + */ + private function is_service_account_edit_screen(): bool { + $screen = get_current_screen(); + + if ( ! $screen ) { + return false; + } + + if ( 'user-edit' === $screen->base ) { + $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $user_id && $this->manager->is_service_account( $user_id ); + } + + if ( 'profile' === $screen->base ) { + return $this->manager->is_service_account( get_current_user_id() ); + } + + return false; + } + + /** + * Filters user row actions. + * + * @since 0.3.0 + * + * @param array $actions User row actions. + * @param \WP_User $user User object. + * @return array Modified actions. + */ + public function filter_row_actions( array $actions, \WP_User $user ): array { + if ( ! $this->manager->is_service_account( $user ) ) { + return $actions; + } + + // Remove actions that don't apply to service accounts. + unset( $actions['resetpassword'] ); + + return $actions; + } + + /** + * Hides the password section for service accounts. + * + * @since 0.3.0 + * + * @param \WP_User $user The user being edited. + */ + public function maybe_hide_password_section( \WP_User $user ): void { + if ( ! $this->manager->is_service_account( $user ) ) { + return; + } + + // Add CSS to de-emphasize the password section. + ?> + + namespace = 'wp/v2'; + $this->rest_base = 'service-accounts'; + $this->manager = Service_Account_Manager::get_instance(); + } + + /** + * Registers the routes for service accounts. + * + * @since 0.3.0 + * + * @see register_rest_route() + */ + public function register_routes(): void { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the service account.', 'ai' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE ), + ), + array( + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Required to be true, as service accounts do not support trashing.', 'ai' ), + ), + 'reassign' => array( + 'type' => 'integer', + 'description' => __( 'Reassign the deleted user\'s posts and links to this user ID.', 'ai' ), + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + // Application password regeneration endpoint. + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/app-password', + array( + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the service account.', 'ai' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'regenerate_app_password' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => array( + 'name' => array( + 'type' => 'string', + 'description' => __( 'Name for the application password.', 'ai' ), + ), + ), + ), + ) + ); + } + + /** + * Checks if a given request has access to read service accounts. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return true|\WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( ! current_user_can( 'list_users' ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to list service accounts.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves all service accounts. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $args = array( + 'number' => $request->get_param( 'per_page' ), + 'offset' => ( $request->get_param( 'page' ) - 1 ) * $request->get_param( 'per_page' ), + 'orderby' => $request->get_param( 'orderby' ), + 'order' => $request->get_param( 'order' ), + 'search' => $request->get_param( 'search' ), + ); + + if ( $request->get_param( 'search' ) ) { + $args['search'] = '*' . $request->get_param( 'search' ) . '*'; + } + + $users = $this->manager->get_service_accounts( $args ); + + // Get total count for pagination. + $total = $this->manager->get_service_account_count(); + + $response_users = array(); + foreach ( $users as $user ) { + $data = $this->prepare_item_for_response( $user, $request ); + $response_users[] = $this->prepare_response_for_collection( $data ); + } + + $response = rest_ensure_response( $response_users ); + + // Add pagination headers. + $max_pages = (int) ceil( $total / $request->get_param( 'per_page' ) ); + + $response->header( 'X-WP-Total', $total ); + $response->header( 'X-WP-TotalPages', $max_pages ); + + return $response; + } + + /** + * Checks if a given request has access to read a service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return true|\WP_Error True if the request has read access for the item, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $user = $this->manager->get_service_account( (int) $request->get_param( 'id' ) ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + if ( ! current_user_can( 'list_users' ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to view this service account.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves a single service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $user = $this->manager->get_service_account( (int) $request->get_param( 'id' ) ); + + if ( is_wp_error( $user ) ) { + $user->add_data( array( 'status' => 404 ) ); + return $user; + } + + return $this->prepare_item_for_response( $user, $request ); + } + + /** + * Checks if a given request has access to create a service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return true|\WP_Error True if the request has access to create items, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) { + if ( ! current_user_can( 'create_users' ) ) { + return new \WP_Error( + 'rest_cannot_create_user', + __( 'Sorry, you are not allowed to create service accounts.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Creates a single service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + if ( ! empty( $request->get_param( 'id' ) ) ) { + return new \WP_Error( + 'rest_user_exists', + __( 'Cannot create existing service account.', 'ai' ), + array( 'status' => 400 ) + ); + } + + $user = $this->manager->create_service_account( + array( + 'name' => $request->get_param( 'name' ), + 'description' => $request->get_param( 'description' ), + ) + ); + + if ( is_wp_error( $user ) ) { + $user->add_data( array( 'status' => 400 ) ); + return $user; + } + + $response = $this->prepare_item_for_response( $user, $request ); + $response = rest_ensure_response( $response ); + $response->set_status( 201 ); + + $response->header( + 'Location', + rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $user->ID ) ) + ); + + return $response; + } + + /** + * Checks if a given request has access to update a service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return true|\WP_Error True if the request has access to update the item, WP_Error object otherwise. + */ + public function update_item_permissions_check( $request ) { + $user = $this->manager->get_service_account( (int) $request->get_param( 'id' ) ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + if ( ! current_user_can( 'edit_user', $user->ID ) ) { + return new \WP_Error( + 'rest_cannot_edit', + __( 'Sorry, you are not allowed to edit this service account.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Updates a single service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function update_item( $request ) { + $user_id = (int) $request->get_param( 'id' ); + + $args = array(); + + // Only name and description can be updated (username/email are auto-generated). + if ( $request->has_param( 'name' ) ) { + $args['display_name'] = $request->get_param( 'name' ); + } + + if ( $request->has_param( 'description' ) ) { + $args['description'] = $request->get_param( 'description' ); + } + + $user = $this->manager->update_service_account( $user_id, $args ); + + if ( is_wp_error( $user ) ) { + $user->add_data( array( 'status' => 400 ) ); + return $user; + } + + return $this->prepare_item_for_response( $user, $request ); + } + + /** + * Checks if a given request has access to delete a service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return true|\WP_Error True if the request has access to delete the item, WP_Error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $user = $this->manager->get_service_account( (int) $request->get_param( 'id' ) ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + if ( ! current_user_can( 'delete_user', $user->ID ) ) { + return new \WP_Error( + 'rest_user_cannot_delete', + __( 'Sorry, you are not allowed to delete this service account.', 'ai' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Deletes a single service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_item( $request ) { + $user_id = (int) $request->get_param( 'id' ); + $reassign = $request->get_param( 'reassign' ); + $force = $request->get_param( 'force' ); + + if ( ! $force ) { + return new \WP_Error( + 'rest_trash_not_supported', + __( 'Service accounts do not support trashing. Set force=true to delete.', 'ai' ), + array( 'status' => 501 ) + ); + } + + // Get the user data before deletion for the response. + $user = $this->manager->get_service_account( $user_id ); + $response = $this->prepare_item_for_response( $user, $request ); + + $result = $this->manager->delete_service_account( $user_id, $reassign ); + + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => 500 ) ); + return $result; + } + + $data = $response->get_data(); + $data['deleted'] = true; + + return new \WP_REST_Response( $data ); + } + + /** + * Regenerates an application password for a service account. + * + * @since 0.3.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function regenerate_app_password( $request ) { + $user_id = (int) $request->get_param( 'id' ); + $name = $request->get_param( 'name' ) ?? ''; + + $result = $this->manager->regenerate_app_password( $user_id, $name ); + + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => 500 ) ); + return $result; + } + + return rest_ensure_response( $result ); + } + + /** + * Prepares a single service account output for response. + * + * @since 0.3.0 + * + * @param \WP_User $user User object. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response Response object. + */ + public function prepare_item_for_response( $user, $request ) { + $data = array( + 'id' => $user->ID, + 'username' => $user->user_login, + 'name' => $user->display_name, + 'email' => $user->user_email, + 'description' => $user->description, + 'registered_date' => gmdate( 'c', strtotime( $user->user_registered ) ), + 'roles' => array_values( $user->roles ), + 'capabilities' => $this->get_user_capabilities( $user ), + 'meta' => array( + 'is_service_account' => true, + ), + ); + + // Add additional fields from the schema. + $context = $request->get_param( 'context' ) ?? 'view'; + + /** + * Filters the service account data for the REST API response. + * + * @since 0.3.0 + * + * @param array $data The prepared response data. + * @param \WP_User $user The user object. + * @param \WP_REST_Request $request The request object. + */ + $data = apply_filters( 'rest_prepare_service_account', $data, $user, $request ); + + $response = rest_ensure_response( $data ); + + $response->add_links( $this->prepare_links( $user ) ); + + return $response; + } + + /** + * Gets the capabilities to expose for a service account. + * + * @since 0.3.0 + * + * @param \WP_User $user The user object. + * @return array Subset of capabilities relevant for service accounts. + */ + protected function get_user_capabilities( \WP_User $user ): array { + $relevant_caps = array( + 'read', + 'edit_posts', + 'publish_posts', + 'delete_posts', + 'edit_others_posts', + 'upload_files', + ); + + $caps = array(); + foreach ( $relevant_caps as $cap ) { + $caps[ $cap ] = $user->has_cap( $cap ); + } + + return $caps; + } + + /** + * Prepares links for the response. + * + * @since 0.3.0 + * + * @param \WP_User $user User object. + * @return array>> Links for the given user. + */ + protected function prepare_links( \WP_User $user ): array { + return array( + 'self' => array( + array( + 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $user->ID ) ), + ), + ), + 'collection' => array( + array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + ), + ); + } + + /** + * Retrieves the query params for collections. + * + * @since 0.3.0 + * + * @return array> Collection parameters. + */ + public function get_collection_params(): array { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'view'; + + $query_params['orderby'] = array( + 'description' => __( 'Sort collection by user attribute.', 'ai' ), + 'type' => 'string', + 'default' => 'registered', + 'enum' => array( + 'id', + 'name', + 'registered', + 'email', + ), + ); + + $query_params['order'] = array( + 'description' => __( 'Order sort attribute ascending or descending.', 'ai' ), + 'type' => 'string', + 'default' => 'desc', + 'enum' => array( 'asc', 'desc' ), + ); + + return $query_params; + } + + /** + * Retrieves the service account's schema, conforming to JSON Schema. + * + * @since 0.3.0 + * + * @return array Item schema data. + */ + public function get_item_schema(): array { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'service-account', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the service account.', 'ai' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'username' => array( + 'description' => __( 'Auto-generated login name for the service account.', 'ai' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'Human-readable name for the service account.', 'ai' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'required' => true, + 'arg_options' => array( + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'email' => array( + 'description' => __( 'Auto-generated email address for the service account.', 'ai' ), + 'type' => 'string', + 'format' => 'email', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'description' => array( + 'description' => __( 'Description of the service account\'s purpose.', 'ai' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + 'registered_date' => array( + 'description' => __( 'Registration date for the service account.', 'ai' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'roles' => array( + 'description' => __( 'Roles assigned to the service account.', 'ai' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'capabilities' => array( + 'description' => __( 'All capabilities assigned to the service account.', 'ai' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'meta' => array( + 'description' => __( 'Meta fields.', 'ai' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'properties' => array( + 'is_service_account' => array( + 'type' => 'boolean', + ), + ), + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/includes/Experiments/Service_Account/Service_Account.php b/includes/Experiments/Service_Account/Service_Account.php new file mode 100644 index 00000000..09da2728 --- /dev/null +++ b/includes/Experiments/Service_Account/Service_Account.php @@ -0,0 +1,194 @@ + 'service-account', + 'label' => __( 'Service Accounts', 'ai' ), + 'description' => __( 'Adds service accounts for automated tools like Claude Code and other automation bots.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.3.0 + */ + public function register(): void { + // Initialize the manager (core functionality). + $this->manager = Service_Account_Manager::get_instance(); + $this->manager->init(); + + // Initialize admin UI. + if ( is_admin() ) { + $this->admin_ui = new Admin_UI(); + $this->admin_ui->init(); + } + + // Register REST API routes. + add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); + + /** + * Fires after the Service Account experiment is registered. + * + * @since 0.3.0 + * + * @param self $experiment The experiment instance. + */ + do_action( 'service_account_experiment_registered', $this ); + } + + /** + * Registers REST API routes. + * + * @since 0.3.0 + */ + public function register_rest_routes(): void { + $this->rest_controller = new REST_Service_Accounts_Controller(); + $this->rest_controller->register_routes(); + } + + /** + * Gets the manager instance. + * + * @since 0.3.0 + * + * @return Service_Account_Manager The manager instance. + */ + public function get_manager(): Service_Account_Manager { + return $this->manager; + } + + /** + * Checks if a user is a service account. + * + * Convenience method that delegates to the manager. + * + * @since 0.3.0 + * + * @param \WP_User|int $user User object or ID. + * @return bool True if the user is a service account. + */ + public function is_service_account( $user ): bool { + return $this->manager->is_service_account( $user ); + } + + /** + * Creates a new service account. + * + * Convenience method that delegates to the manager. + * + * @since 0.3.0 + * + * @param array $args User data arguments. + * @return \WP_User|\WP_Error The created user object or error. + */ + public function create_service_account( array $args ) { + return $this->manager->create_service_account( $args ); + } + + /** + * Deletes a service account. + * + * Convenience method that delegates to the manager. + * + * @since 0.3.0 + * + * @param int $user_id The user ID to delete. + * @param int|null $reassign Optional. User ID to reassign content to. + * @return bool|\WP_Error True on success, error on failure. + */ + public function delete_service_account( int $user_id, ?int $reassign = null ) { + return $this->manager->delete_service_account( $user_id, $reassign ); + } + + /** + * Gets all service accounts. + * + * Convenience method that delegates to the manager. + * + * @since 0.3.0 + * + * @param array $args Optional. Query arguments. + * @return array<\WP_User> Array of service account objects. + */ + public function get_service_accounts( array $args = array() ): array { + return $this->manager->get_service_accounts( $args ); + } +} diff --git a/includes/Experiments/Service_Account/Service_Account_Manager.php b/includes/Experiments/Service_Account/Service_Account_Manager.php new file mode 100644 index 00000000..22312438 --- /dev/null +++ b/includes/Experiments/Service_Account/Service_Account_Manager.php @@ -0,0 +1,1038 @@ +initialized ) { + return; + } + + // Register the service account role. + $this->register_role(); + + // Register service account user meta. + $this->register_user_meta(); + + // Filter user capabilities for service accounts. + add_filter( 'user_has_cap', array( $this, 'filter_capabilities' ), 10, 4 ); + + // Ensure service role assignments are flagged as service accounts. + add_action( 'set_user_role', array( $this, 'maybe_mark_service_account_role' ), 10, 3 ); + add_action( 'add_user_role', array( $this, 'maybe_mark_service_account_role' ), 10, 2 ); + + // Exclude service accounts from standard user queries. + add_action( 'pre_get_users', array( $this, 'filter_user_queries' ) ); + + // Filter user counts to exclude service accounts. + add_filter( 'pre_count_users', array( $this, 'filter_user_counts' ), 10, 3 ); + + // Add views to users list table. + add_filter( 'views_users', array( $this, 'add_users_views' ) ); + + // Handle the service accounts view. + add_action( 'pre_get_users', array( $this, 'handle_users_view' ) ); + + $this->initialized = true; + + /** + * Fires after the service account manager is initialized. + * + * @since 0.3.0 + * + * @param self $manager The manager instance. + */ + do_action( 'service_account_manager_init', $this ); + } + + /** + * Registers user meta used by service accounts. + * + * @since 0.3.0 + */ + private function register_user_meta(): void { + register_meta( + 'user', + 'service_account_owner_id', + array( + 'type' => 'integer', + 'single' => true, + 'sanitize_callback' => array( $this, 'sanitize_owner_id' ), + 'auth_callback' => array( $this, 'authorize_service_account_meta' ), + 'show_in_rest' => false, + ) + ); + + register_meta( + 'user', + 'service_account_system', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'auth_callback' => array( $this, 'authorize_service_account_meta' ), + 'show_in_rest' => false, + ) + ); + + register_meta( + 'user', + 'service_account_reference', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'auth_callback' => array( $this, 'authorize_service_account_meta' ), + 'show_in_rest' => false, + ) + ); + } + + /** + * Marks users as service accounts when the service role is assigned. + * + * @since 0.3.0 + * + * @param int $user_id User ID. + * @param string $role Role assigned. + * @param array $old_roles Previous roles. + */ + public function maybe_mark_service_account_role( int $user_id, string $role, array $old_roles = array() ): void { + if ( self::ROLE !== $role ) { + return; + } + + if ( ! get_user_meta( $user_id, self::META_KEY, true ) ) { + update_user_meta( $user_id, self::META_KEY, true ); + } + } + + /** + * Builds a service account meta clause. + * + * @since 0.3.0 + * + * @param string $compare Compare operator. + * @return array Meta clause. + */ + private function get_service_account_meta_clause( string $compare = 'EXISTS' ): array { + return array( + 'key' => self::META_KEY, + 'compare' => $compare, + ); + } + + /** + * Appends a meta query clause while preserving existing conditions. + * + * @since 0.3.0 + * + * @param mixed $existing Existing meta query. + * @param array $clause Meta clause to append. + * @return array Combined meta query. + */ + private function append_meta_query( $existing, array $clause ): array { + if ( ! is_array( $existing ) || empty( $existing ) ) { + return array( $clause ); + } + + return array( + 'relation' => 'AND', + $existing, + $clause, + ); + } + + /** + * Sanitizes the service account owner ID. + * + * @since 0.3.0 + * + * @param mixed $value Meta value. + * @param string $meta_key Meta key. + * @param string $object_type Object type. + * @return int Sanitized user ID, or 0 if invalid. + */ + public function sanitize_owner_id( $value, string $meta_key = '', string $object_type = '' ): int { + $owner_id = absint( $value ); + + if ( 0 === $owner_id ) { + return 0; + } + + return get_user_by( 'id', $owner_id ) ? $owner_id : 0; + } + + /** + * Authorizes access to service account meta. + * + * @since 0.3.0 + * + * @param bool $allowed Whether access is allowed. + * @param string $meta_key Meta key. + * @param int $object_id Object ID. + * @param int $user_id User ID. + * @param string $cap Capability name. + * @param array $caps User capabilities. + * @return bool Whether access is allowed. + */ + public function authorize_service_account_meta( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ): bool { + $target_id = (int) $object_id; + + if ( ! $this->is_service_account( $target_id ) ) { + return false; + } + + return current_user_can( 'edit_user', $target_id ); + } + + /** + * Gets the default capabilities for the service account role. + * + * @since 0.3.0 + * + * @return array Default capabilities. + */ + public function get_default_role_capabilities(): array { + $capabilities = array( + 'read' => true, + 'edit_posts' => true, + 'delete_posts' => false, + 'publish_posts' => false, + ); + + /** + * Filters the default capabilities for the service account role. + * + * @since 0.3.0 + * + * @param array $capabilities Default role capabilities. + */ + return apply_filters( 'service_account_default_role_capabilities', $capabilities ); + } + + /** + * Gets the list of restricted capabilities for service accounts. + * + * These capabilities are always denied to service accounts regardless of role. + * + * @since 0.3.0 + * + * @return array List of restricted capability names. + */ + public function get_restricted_capabilities(): array { + $restricted = array( + // Site administration. + 'manage_options', + 'export', + 'import', + + // Plugin management. + 'install_plugins', + 'activate_plugins', + 'edit_plugins', + 'delete_plugins', + 'update_plugins', + + // Theme management. + 'install_themes', + 'switch_themes', + 'edit_themes', + 'delete_themes', + 'update_themes', + + // User management. + 'list_users', + 'edit_users', + 'delete_users', + 'create_users', + 'promote_users', + 'remove_users', + + // File and core management. + 'edit_files', + 'update_core', + 'unfiltered_html', + 'unfiltered_upload', + ); + + /** + * Filters the list of restricted capabilities for service accounts. + * + * @since 0.3.0 + * + * @param array $restricted List of capability names to restrict. + */ + return apply_filters( 'service_account_restricted_capabilities', $restricted ); + } + + /** + * Registers the service account role. + * + * @since 0.3.0 + */ + public function register_role(): void { + $existing_role = get_role( self::ROLE ); + + if ( $existing_role ) { + /** + * Fires when the service account role already exists. + * + * @since 0.3.0 + * + * @param \WP_Role $role The existing service account role. + */ + do_action( 'service_account_role_exists', $existing_role ); + return; + } + + $capabilities = $this->get_default_role_capabilities(); + + add_role( + self::ROLE, + __( 'Service', 'ai' ), + $capabilities + ); + + /** + * Fires after the service account role is registered. + * + * @since 0.3.0 + * + * @param \WP_Role $role The newly created role. + * @param array $capabilities The capabilities assigned to the role. + */ + do_action( 'service_account_role_registered', get_role( self::ROLE ), $capabilities ); + } + + /** + * Filters capabilities for service accounts. + * + * @since 0.3.0 + * + * @param array $allcaps All capabilities of the user. + * @param array $caps Required primitive capabilities. + * @param array $args Arguments accompanying the capability check. + * @param \WP_User $user The user object. + * @return array Filtered capabilities. + */ + public function filter_capabilities( array $allcaps, array $caps, array $args, \WP_User $user ): array { + if ( ! $this->is_service_account( $user ) ) { + return $allcaps; + } + + // Service accounts always have read capability. + $allcaps['read'] = true; + + // Restrict dangerous capabilities. + $restricted_caps = $this->get_restricted_capabilities(); + + foreach ( $restricted_caps as $cap ) { + $allcaps[ $cap ] = false; + } + + /** + * Filters the final capabilities for a service account. + * + * @since 0.3.0 + * + * @param array $allcaps All capabilities of the service account. + * @param \WP_User $user The service account object. + * @param array $caps Required primitive capabilities for current check. + * @param array $restricted_caps Capabilities that were restricted. + */ + return apply_filters( 'service_account_capabilities', $allcaps, $user, $caps, $restricted_caps ); + } + + /** + * Checks if a user is a service account. + * + * @since 0.3.0 + * + * @param \WP_User|int $user User object or ID. + * @return bool True if the user is a service account. + */ + public function is_service_account( $user ): bool { + if ( is_int( $user ) ) { + $user = get_user_by( 'id', $user ); + } + + if ( ! $user instanceof \WP_User ) { + return false; + } + + // Check meta flag (persists even if role changes). + $is_service_account = (bool) get_user_meta( $user->ID, self::META_KEY, true ); + + // Fall back to role for legacy accounts missing meta. + if ( ! $is_service_account && in_array( self::ROLE, $user->roles, true ) ) { + $is_service_account = true; + } + + /** + * Filters whether a user is considered a service account. + * + * @since 0.3.0 + * + * @param bool $is_service_account Whether the user is a service account. + * @param \WP_User $user The user object. + */ + return apply_filters( 'is_service_account', $is_service_account, $user ); + } + + /** + * Filters user queries to exclude service accounts by default. + * + * @since 0.3.0 + * + * @param \WP_User_Query $query The user query. + */ + public function filter_user_queries( \WP_User_Query $query ): void { + // Allow explicit inclusion of service accounts. + if ( $query->get( 'include_service_accounts' ) ) { + return; + } + + // Don't filter if explicitly querying for service accounts. + $role = $query->get( 'role' ); + if ( self::ROLE === $role ) { + return; + } + + // Don't filter in the admin users list when viewing service accounts. + if ( is_admin() && isset( $_GET['service_accounts'] ) && '1' === $_GET['service_accounts'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + // Include service accounts in the main users list table. + if ( is_admin() && function_exists( 'get_current_screen' ) ) { + $screen = get_current_screen(); + if ( $screen && 'users' === $screen->base ) { + return; + } + } + + // Exclude service accounts from default queries. + $role__not_in = $query->get( 'role__not_in' ); + if ( ! is_array( $role__not_in ) ) { + $role__not_in = array(); + } + $role__not_in[] = self::ROLE; + $query->set( 'role__not_in', array_unique( $role__not_in ) ); + + $meta_query = $this->append_meta_query( + $query->get( 'meta_query' ), + $this->get_service_account_meta_clause( 'NOT EXISTS' ) + ); + $query->set( 'meta_query', $meta_query ); + } + + /** + * Filters user counts to exclude service accounts from the main count. + * + * @since 0.3.0 + * + * @param array|null $result The count result, or null to calculate. + * @param string $strategy The counting strategy. + * @param int|null $site_id The site ID, or null for current site. + * @return array|null Modified count or null. + */ + public function filter_user_counts( $result, string $strategy, ?int $site_id ) { + // Only filter if we need to calculate. + if ( null !== $result ) { + return $result; + } + + global $wpdb; + + $blog_id = $site_id ?? get_current_blog_id(); + $cap_key = is_multisite() ? $wpdb->get_blog_prefix( $blog_id ) . 'capabilities' : $wpdb->prefix . 'capabilities'; + + // Get all counts including service accounts. + $result = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_value, COUNT(*) AS count + FROM {$wpdb->usermeta} + WHERE meta_key = %s + GROUP BY meta_value", + $cap_key + ), + ARRAY_A + ); + + // Get counts for service accounts based on meta flag. + $service_result = $wpdb->get_results( + $wpdb->prepare( + "SELECT um_cap.meta_value, COUNT(*) AS count + FROM {$wpdb->usermeta} um_cap + INNER JOIN {$wpdb->usermeta} um_sa + ON um_cap.user_id = um_sa.user_id + WHERE um_cap.meta_key = %s + AND um_sa.meta_key = %s + GROUP BY um_cap.meta_value", + $cap_key, + self::META_KEY + ), + ARRAY_A + ); + + $service_account_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT user_id) + FROM {$wpdb->usermeta} + WHERE meta_key = %s", + self::META_KEY + ) + ); + + $avail_roles = array(); + $total_users = 0; + $include_service_in_all = false; + + if ( is_admin() && function_exists( 'get_current_screen' ) ) { + $screen = get_current_screen(); + if ( $screen && 'users' === $screen->base ) { + $include_service_in_all = true; + } + } + + if ( $result ) { + foreach ( $result as $row ) { + $role_data = maybe_unserialize( $row['meta_value'] ); + if ( ! is_array( $role_data ) ) { + continue; + } + + $count = (int) $row['count']; + + foreach ( array_keys( $role_data ) as $role_name ) { + if ( ! isset( $avail_roles[ $role_name ] ) ) { + $avail_roles[ $role_name ] = 0; + } + $avail_roles[ $role_name ] += $count; + $total_users += $count; + } + } + } + + if ( $service_result ) { + foreach ( $service_result as $row ) { + $role_data = maybe_unserialize( $row['meta_value'] ); + if ( ! is_array( $role_data ) ) { + continue; + } + + $count = (int) $row['count']; + + foreach ( array_keys( $role_data ) as $role_name ) { + if ( isset( $avail_roles[ $role_name ] ) ) { + $avail_roles[ $role_name ] = max( 0, $avail_roles[ $role_name ] - $count ); + } + $total_users -= $count; + } + } + } + + foreach ( $avail_roles as $role_name => $count ) { + if ( $count <= 0 ) { + unset( $avail_roles[ $role_name ] ); + } + } + + if ( $total_users < 0 ) { + $total_users = 0; + } + + if ( $include_service_in_all ) { + $total_users += $service_account_count; + } + + // Store service account count for the views filter. + $this->service_account_count = max( 0, $service_account_count ); + + return array( + 'total_users' => $total_users, + 'avail_roles' => $avail_roles, + ); + } + + /** + * Cached service account count from filter_user_counts. + * + * @since 0.3.0 + * @var int + */ + private int $service_account_count = 0; + + /** + * Adds a "Service Accounts" view to the users list table. + * + * @since 0.3.0 + * + * @param array $views The current views. + * @return array Modified views. + */ + public function add_users_views( array $views ): array { + $count = $this->get_service_account_count(); + + if ( 0 === $count ) { + return $views; + } + + $current = isset( $_GET['service_accounts'] ) && '1' === $_GET['service_accounts']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $class = $current ? 'current' : ''; + + $views['service_accounts'] = sprintf( + '%s (%s)', + esc_url( admin_url( 'users.php?service_accounts=1' ) ), + esc_attr( $class ), + esc_html__( 'Service Accounts', 'ai' ), + number_format_i18n( $count ) + ); + + return $views; + } + + /** + * Handles the service accounts view in the users list. + * + * @since 0.3.0 + * + * @param \WP_User_Query $query The user query. + */ + public function handle_users_view( \WP_User_Query $query ): void { + if ( ! is_admin() ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['service_accounts'] ) || '1' !== $_GET['service_accounts'] ) { + return; + } + + // Override the query to show only service accounts. + $query->set( + 'meta_query', + $this->append_meta_query( + $query->get( 'meta_query' ), + $this->get_service_account_meta_clause( 'EXISTS' ) + ) + ); + $query->set( 'role', '' ); + $query->set( 'role__not_in', array() ); + $query->set( 'include_service_accounts', true ); + } + + /** + * Gets the count of service accounts. + * + * @since 0.3.0 + * + * @return int The number of service accounts. + */ + public function get_service_account_count(): int { + // Use cached count if available. + if ( $this->service_account_count > 0 ) { + return $this->service_account_count; + } + + $query = new \WP_User_Query( + array( + 'fields' => 'ID', + 'number' => 1, + 'count_total' => true, + 'include_service_accounts' => true, + 'meta_query' => array( + $this->get_service_account_meta_clause( 'EXISTS' ), + ), + ) + ); + + $this->service_account_count = (int) $query->get_total(); + + return $this->service_account_count; + } + + /** + * Gets all service accounts. + * + * @since 0.3.0 + * + * @param array $args Optional. Query arguments. + * @return array<\WP_User> Array of service account objects. + */ + public function get_service_accounts( array $args = array() ): array { + $defaults = array( + 'include_service_accounts' => true, + 'orderby' => 'registered', + 'order' => 'DESC', + ); + + $args = wp_parse_args( $args, $defaults ); + $args['meta_query'] = $this->append_meta_query( + $args['meta_query'] ?? array(), + $this->get_service_account_meta_clause( 'EXISTS' ) + ); + + return get_users( $args ); + } + + /** + * Creates a new service account. + * + * @since 0.3.0 + * + * @param array $args { + * User data arguments. + * + * @type string $name Required. Human-readable name for the service account (e.g., "Claude Code"). + * @type string $description Optional. Description of the service account's purpose. + * } + * @return \WP_User|\WP_Error The created user object or error. + */ + public function create_service_account( array $args ) { + if ( empty( $args['name'] ) ) { + return new \WP_Error( + 'missing_required_fields', + __( 'Name is required.', 'ai' ) + ); + } + + $name = sanitize_text_field( $args['name'] ); + $description = isset( $args['description'] ) ? sanitize_text_field( $args['description'] ) : ''; + + // Generate username: service-{sanitized-name}-{short-uuid}. + $sanitized_name = sanitize_title( $name ); + $short_uuid = substr( wp_generate_uuid4(), 0, 8 ); + $username = 'service-' . $sanitized_name . '-' . $short_uuid; + + // Ensure username fits within WP limits (60 chars). + if ( strlen( $username ) > 60 ) { + $username = substr( $username, 0, 50 ) . '-' . $short_uuid; + } + + // Generate email: {username}@{site-domain}. + $site_domain = wp_parse_url( home_url(), PHP_URL_HOST ) ?: 'localhost'; + $email = $username . '@' . $site_domain; + + $display_name = $name; + + // Generate a secure random password. + $password = wp_generate_password( 32, true, true ); + + /** + * Filters the service account data before creation. + * + * @since 0.3.0 + * + * @param array $user_data User data for wp_insert_user. + * @param array $args Original arguments. + */ + $user_data = apply_filters( + 'service_account_pre_create', + array( + 'user_login' => $username, + 'user_email' => $email, + 'user_pass' => $password, + 'role' => self::ROLE, + 'description' => $description, + 'display_name' => $display_name, + ), + $args + ); + + $user_id = wp_insert_user( $user_data ); + + if ( is_wp_error( $user_id ) ) { + return $user_id; + } + + // Mark as service account via meta. + update_user_meta( $user_id, self::META_KEY, true ); + + // Store creation timestamp. + update_user_meta( $user_id, '_service_account_created', time() ); + + $user = get_user_by( 'id', $user_id ); + + /** + * Fires after a service account is created. + * + * @since 0.3.0 + * + * @param \WP_User $user The created service account. + * @param string|null $app_password The application password if generated, null otherwise. + */ + do_action( 'service_account_created', $user, null ); + + return $user; + } + + /** + * Deletes a service account. + * + * @since 0.3.0 + * + * @param int $user_id The user ID to delete. + * @param int|null $reassign Optional. User ID to reassign content to. + * @return bool|\WP_Error True on success, error on failure. + */ + public function delete_service_account( int $user_id, ?int $reassign = null ) { + $user = get_user_by( 'id', $user_id ); + + if ( ! $user || ! $this->is_service_account( $user ) ) { + return new \WP_Error( + 'not_found', + __( 'Service account not found.', 'ai' ) + ); + } + + /** + * Fires before a service account is deleted. + * + * @since 0.3.0 + * + * @param \WP_User $user The service account being deleted. + * @param int|null $reassign User ID content will be reassigned to. + */ + do_action( 'service_account_before_delete', $user, $reassign ); + + require_once ABSPATH . 'wp-admin/includes/user.php'; + $result = wp_delete_user( $user_id, $reassign ); + + if ( ! $result ) { + return new \WP_Error( + 'delete_failed', + __( 'Failed to delete service account.', 'ai' ) + ); + } + + /** + * Fires after a service account is deleted. + * + * @since 0.3.0 + * + * @param int $user_id The deleted user ID. + * @param int|null $reassign User ID content was reassigned to. + */ + do_action( 'service_account_deleted', $user_id, $reassign ); + + return true; + } + + /** + * Gets a service account by ID. + * + * @since 0.3.0 + * + * @param int $user_id The user ID. + * @return \WP_User|\WP_Error The user object or error. + */ + public function get_service_account( int $user_id ) { + $user = get_user_by( 'id', $user_id ); + + if ( ! $user || ! $this->is_service_account( $user ) ) { + return new \WP_Error( + 'not_found', + __( 'Service account not found.', 'ai' ) + ); + } + + return $user; + } + + /** + * Updates a service account. + * + * @since 0.3.0 + * + * @param int $user_id The user ID to update. + * @param array $args User data to update. + * @return \WP_User|\WP_Error The updated user or error. + */ + public function update_service_account( int $user_id, array $args ) { + $user = $this->get_service_account( $user_id ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + $user_data = array( 'ID' => $user_id ); + + if ( isset( $args['description'] ) ) { + $user_data['description'] = sanitize_text_field( $args['description'] ); + } + + if ( isset( $args['display_name'] ) ) { + $user_data['display_name'] = sanitize_text_field( $args['display_name'] ); + } + + /** + * Filters the service account data before update. + * + * @since 0.3.0 + * + * @param array $user_data User data for wp_update_user. + * @param \WP_User $user The current user object. + * @param array $args Original arguments. + */ + $user_data = apply_filters( 'service_account_pre_update', $user_data, $user, $args ); + + $result = wp_update_user( $user_data ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $updated_user = get_user_by( 'id', $user_id ); + + /** + * Fires after a service account is updated. + * + * @since 0.3.0 + * + * @param \WP_User $user The updated service account. + * @param array $args The update arguments. + */ + do_action( 'service_account_updated', $updated_user, $args ); + + return $updated_user; + } + + /** + * Regenerates the application password for a service account. + * + * @since 0.3.0 + * + * @param int $user_id The user ID. + * @param string $name Optional. Name for the new app password. + * @return array|\WP_Error App password data or error. + */ + public function regenerate_app_password( int $user_id, string $name = '' ) { + $user = $this->get_service_account( $user_id ); + + if ( is_wp_error( $user ) ) { + return $user; + } + + if ( ! class_exists( 'WP_Application_Passwords' ) ) { + return new \WP_Error( + 'not_supported', + __( 'Application Passwords are not available.', 'ai' ) + ); + } + + if ( empty( $name ) ) { + $name = sprintf( + /* translators: %s: Date and time */ + __( 'Regenerated %s', 'ai' ), + wp_date( 'Y-m-d H:i:s' ) + ); + } + + $app_password_data = \WP_Application_Passwords::create_new_application_password( + $user_id, + array( 'name' => $name ) + ); + + if ( is_wp_error( $app_password_data ) ) { + return $app_password_data; + } + + /** + * Fires after an application password is regenerated for a service account. + * + * @since 0.3.0 + * + * @param \WP_User $user The service account. + * @param array $app_data The application password data. + */ + do_action( 'service_account_app_password_regenerated', $user, $app_password_data ); + + return array( + 'password' => $app_password_data[0], + 'uuid' => $app_password_data[1]['uuid'], + 'name' => $name, + ); + } +} diff --git a/tests/Integration/Includes/Experiment_LoaderTest.php b/tests/Integration/Includes/Experiment_LoaderTest.php index 617dddd4..192dc9cb 100644 --- a/tests/Integration/Includes/Experiment_LoaderTest.php +++ b/tests/Integration/Includes/Experiment_LoaderTest.php @@ -127,6 +127,10 @@ public function test_register_default_experiments() { $this->registry->has_experiment( 'title-generation' ), 'Title generation experiment should be registered' ); + $this->assertTrue( + $this->registry->has_experiment( 'service-account' ), + 'Service account experiment should be registered' + ); $abilities_explorer_experiment = $this->registry->get_experiment( 'abilities-explorer' ); $this->assertNotNull( $abilities_explorer_experiment, 'Abilities explorer experiment should exist' ); @@ -147,6 +151,10 @@ public function test_register_default_experiments() { $title_experiment = $this->registry->get_experiment( 'title-generation' ); $this->assertNotNull( $title_experiment, 'Title generation experiment should exist' ); $this->assertEquals( 'title-generation', $title_experiment->get_id() ); + + $service_account_experiment = $this->registry->get_experiment( 'service-account' ); + $this->assertNotNull( $service_account_experiment, 'Service account experiment should exist' ); + $this->assertEquals( 'service-account', $service_account_experiment->get_id() ); } /** diff --git a/tests/Integration/Includes/Experiments/Service_Account/Service_AccountTest.php b/tests/Integration/Includes/Experiments/Service_Account/Service_AccountTest.php new file mode 100644 index 00000000..3c44ce22 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Service_Account/Service_AccountTest.php @@ -0,0 +1,491 @@ + 'test-api-key' ) ); + + // Mock has_valid_ai_credentials to return true for tests. + add_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' ); + + // Enable experiments globally and individually. + update_option( 'ai_experiments_enabled', true ); + update_option( 'ai_experiment_service-account_enabled', true ); + + $registry = new Experiment_Registry(); + $loader = new Experiment_Loader( $registry ); + $loader->register_default_experiments(); + $loader->initialize_experiments(); + + $this->experiment = $registry->get_experiment( 'service-account' ); + $this->assertInstanceOf( Service_Account::class, $this->experiment, 'Service Account experiment should be registered in the registry.' ); + + $this->manager = Service_Account_Manager::get_instance(); + } + + /** + * Tear down test case. + * + * @since 0.3.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + delete_option( 'ai_experiments_enabled' ); + delete_option( 'ai_experiment_service-account_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' ); + remove_all_filters( 'service_account_default_role_capabilities' ); + remove_all_filters( 'service_account_restricted_capabilities' ); + remove_all_filters( 'service_account_capabilities' ); + remove_all_filters( 'is_service_account' ); + + // Clean up the role. + remove_role( Service_Account::ROLE ); + + parent::tearDown(); + } + + /** + * Test that the experiment is registered correctly. + * + * @since 0.3.0 + */ + public function test_experiment_registration() { + $this->assertEquals( 'service-account', $this->experiment->get_id() ); + $this->assertEquals( 'Service Accounts', $this->experiment->get_label() ); + $this->assertTrue( $this->experiment->is_enabled() ); + } + + /** + * Test that the service account role is registered. + * + * @since 0.3.0 + */ + public function test_service_account_role_registration() { + $role = get_role( Service_Account::ROLE ); + + $this->assertNotNull( $role, 'Service account role should be registered.' ); + $this->assertTrue( $role->has_cap( 'read' ) ); + $this->assertTrue( $role->has_cap( 'edit_posts' ) ); + $this->assertFalse( $role->has_cap( 'delete_posts' ) ); + $this->assertFalse( $role->has_cap( 'publish_posts' ) ); + } + + /** + * Test that service accounts have restricted capabilities. + * + * @since 0.3.0 + */ + public function test_service_account_capability_restrictions() { + $service_account_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + wp_set_current_user( $service_account_id ); + + $this->assertFalse( current_user_can( 'manage_options' ) ); + $this->assertFalse( current_user_can( 'install_plugins' ) ); + $this->assertFalse( current_user_can( 'edit_users' ) ); + $this->assertFalse( current_user_can( 'update_core' ) ); + $this->assertFalse( current_user_can( 'list_users' ) ); + } + + /** + * Test that is_service_account correctly identifies service accounts. + * + * @since 0.3.0 + */ + public function test_is_service_account() { + $service_account_id = $this->factory->user->create( array( 'role' => Service_Account::ROLE ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + $regular_user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + + $this->assertTrue( $this->experiment->is_service_account( $service_account_id ) ); + $this->assertFalse( $this->experiment->is_service_account( $regular_user_id ) ); + } + + /** + * Test that service accounts are excluded from default queries. + * + * @since 0.3.0 + */ + public function test_service_accounts_excluded_from_queries() { + $regular_user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + + $service_account_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + $users = get_users(); + $user_ids = wp_list_pluck( $users, 'ID' ); + + $this->assertContains( $regular_user_id, $user_ids ); + $this->assertNotContains( $service_account_id, $user_ids ); + } + + /** + * Test that service accounts can be explicitly included in queries. + * + * @since 0.3.0 + */ + public function test_service_accounts_can_be_included_in_queries() { + $service_account_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + $users = get_users( array( 'include_service_accounts' => true ) ); + $user_ids = wp_list_pluck( $users, 'ID' ); + + $this->assertContains( $service_account_id, $user_ids ); + } + + /** + * Test that get_service_accounts includes non-service roles. + * + * @since 0.3.0 + */ + public function test_get_service_accounts_includes_non_service_role() { + $service_account_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + $accounts = $this->manager->get_service_accounts(); + $account_ids = wp_list_pluck( $accounts, 'ID' ); + + $this->assertContains( $service_account_id, $account_ids ); + } + + /** + * Test that REST routes are registered. + * + * @since 0.3.0 + */ + public function test_rest_routes_registration() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wp/v2/service-accounts', $routes ); + $this->assertArrayHasKey( '/wp/v2/service-accounts/(?P[\\d]+)', $routes ); + $this->assertArrayHasKey( '/wp/v2/service-accounts/(?P[\\d]+)/app-password', $routes ); + } + + /** + * Test creating a service account via manager. + * + * @since 0.3.0 + */ + public function test_create_service_account() { + $user = $this->manager->create_service_account( + array( + 'name' => 'Test Service Bot', + 'description' => 'Test service account', + ) + ); + + $this->assertInstanceOf( \WP_User::class, $user ); + $this->assertNotEmpty( $user->user_login ); + $this->assertNotEmpty( $user->user_email ); + $this->assertContains( Service_Account::ROLE, $user->roles ); + $this->assertTrue( $this->manager->is_service_account( $user ) ); + $this->assertTrue( (bool) get_user_meta( $user->ID, Service_Account::META_KEY, true ) ); + } + + /** + * Test creating a service account via REST API. + * + * @since 0.3.0 + */ + public function test_create_service_account_via_rest() { + $this->logInAsAdmin(); + + $request = new \WP_REST_Request( 'POST', '/wp/v2/service-accounts' ); + $request->set_param( 'name', 'REST test bot' ); + $request->set_param( 'description', 'REST test service account' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'REST test bot', $data['name'] ); + $this->assertNotEmpty( $data['email'] ); + $this->assertContains( Service_Account::ROLE, $data['roles'] ); + $this->assertArrayNotHasKey( 'app_password', $data ); + $this->assertTrue( $data['meta']['is_service_account'] ); + } + + /** + * Test getting service accounts via REST API. + * + * @since 0.3.0 + */ + public function test_get_service_accounts_via_rest() { + $this->logInAsAdmin(); + + $service_account_id = $this->factory->user->create( array( 'role' => Service_Account::ROLE ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + $request = new \WP_REST_Request( 'GET', '/wp/v2/service-accounts' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertIsArray( $data ); + $this->assertGreaterThanOrEqual( 1, count( $data ) ); + + $this->assertNotEmpty( $response->get_headers()['X-WP-Total'] ); + $this->assertNotEmpty( $response->get_headers()['X-WP-TotalPages'] ); + } + + /** + * Test updating a service account via REST API. + * + * @since 0.3.0 + */ + public function test_update_service_account_via_rest() { + $this->logInAsAdmin(); + + $user = $this->manager->create_service_account( + array( + 'name' => 'Update test bot', + ) + ); + + $request = new \WP_REST_Request( 'PUT', '/wp/v2/service-accounts/' . $user->ID ); + $request->set_param( 'description', 'Updated description' ); + $request->set_param( 'name', 'Updated Bot' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'Updated description', $data['description'] ); + $this->assertEquals( 'Updated Bot', $data['name'] ); + } + + /** + * Test deleting a service account via REST API. + * + * @since 0.3.0 + */ + public function test_delete_service_account_via_rest() { + $this->logInAsAdmin(); + + $service_account_id = $this->factory->user->create( array( 'role' => Service_Account::ROLE ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + $request = new \WP_REST_Request( 'DELETE', '/wp/v2/service-accounts/' . $service_account_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $data['deleted'] ); + $this->assertFalse( get_user_by( 'id', $service_account_id ) ); + } + + /** + * Test that delete requires force=true. + * + * @since 0.3.0 + */ + public function test_delete_requires_force() { + $this->logInAsAdmin(); + + $service_account_id = $this->factory->user->create( array( 'role' => Service_Account::ROLE ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + $request = new \WP_REST_Request( 'DELETE', '/wp/v2/service-accounts/' . $service_account_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 501, $response->get_status() ); + } + + /** + * Test REST permission denies non-admin access. + * + * @since 0.3.0 + */ + public function test_rest_permission_denies_non_admin() { + $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + $request = new \WP_REST_Request( 'GET', '/wp/v2/service-accounts' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 403, $response->get_status() ); + } + + /** + * Test the service_account_capabilities filter. + * + * @since 0.3.0 + */ + public function test_service_account_capabilities_filter() { + $service_account_id = $this->factory->user->create( array( 'role' => Service_Account::ROLE ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + add_filter( + 'service_account_capabilities', + function ( $allcaps, $user ) { + $allcaps['edit_others_posts'] = true; + return $allcaps; + }, + 10, + 2 + ); + + wp_set_current_user( $service_account_id ); + + $this->assertTrue( current_user_can( 'edit_others_posts' ) ); + + remove_all_filters( 'service_account_capabilities' ); + } + + /** + * Test the service_account_default_role_capabilities filter. + * + * @since 0.3.0 + */ + public function test_default_role_capabilities_filter() { + remove_role( Service_Account::ROLE ); + + add_filter( + 'service_account_default_role_capabilities', + function ( $capabilities ) { + $capabilities['upload_files'] = true; + return $capabilities; + } + ); + + $this->manager->register_role(); + + $role = get_role( Service_Account::ROLE ); + $this->assertTrue( $role->has_cap( 'upload_files' ) ); + } + + /** + * Test the service_account_restricted_capabilities filter. + * + * @since 0.3.0 + */ + public function test_restricted_capabilities_filter() { + $service_account_id = $this->factory->user->create( array( 'role' => Service_Account::ROLE ) ); + update_user_meta( $service_account_id, Service_Account::META_KEY, true ); + + wp_set_current_user( $service_account_id ); + $this->assertFalse( current_user_can( 'list_users' ) ); + + add_filter( + 'service_account_restricted_capabilities', + function ( $restricted ) { + return array_diff( $restricted, array( 'list_users' ) ); + } + ); + + $role = get_role( Service_Account::ROLE ); + $role->add_cap( 'list_users' ); + + $user = wp_get_current_user(); + $user->get_role_caps(); + + $this->assertTrue( current_user_can( 'list_users' ) ); + + $role->remove_cap( 'list_users' ); + } + + /** + * Test the is_service_account filter. + * + * @since 0.3.0 + */ + public function test_is_service_account_filter() { + $regular_user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + + $this->assertFalse( $this->manager->is_service_account( $regular_user_id ) ); + + add_filter( + 'is_service_account', + function ( $is_service_account, $user ) { + if ( in_array( 'editor', $user->roles, true ) ) { + return true; + } + return $is_service_account; + }, + 10, + 2 + ); + + $this->assertTrue( $this->manager->is_service_account( $regular_user_id ) ); + } + + /** + * Test regenerating application password. + * + * @since 0.3.0 + */ + public function test_regenerate_app_password() { + $this->logInAsAdmin(); + + $user = $this->manager->create_service_account( + array( + 'name' => 'App password test', + ) + ); + + $request = new \WP_REST_Request( 'POST', '/wp/v2/service-accounts/' . $user->ID . '/app-password' ); + $request->set_param( 'name', 'Test Password' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'password', $data ); + $this->assertArrayHasKey( 'uuid', $data ); + $this->assertEquals( 'Test Password', $data['name'] ); + } + + /** + * Logs in a user with administrator privileges. + * + * @since 0.3.0 + */ + protected function logInAsAdmin(): void { + $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + } +} From d83aba21a665a2d7e9e77b32ff657bb8c2ac53b3 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:55:55 -0800 Subject: [PATCH 2/4] Align default experiments with upstream --- includes/Experiment_Loader.php | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index 9dcbd361..d2d46b8d 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -104,18 +104,12 @@ public function register_default_experiments(): void { */ private function get_default_experiments(): array { $experiment_classes = array( + \WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class, + \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, + \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Service_Account\Service_Account::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, - \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, - \WordPress\AI\Experiments\Type_Ahead\Type_Ahead::class, - \WordPress\AI\Experiments\Post_Table_Bulk\Post_Table_Bulk::class, - \WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation::class, - \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, - \WordPress\AI\Experiments\MCP\MCP::class, - \WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::class, - \WordPress\AI\Experiments\Extended_Providers\Extended_Providers::class, - \WordPress\AI\Experiments\Writing_Assistant\Writing_Assistant::class, ); /** From a5c2581bfd8a156d6734be18e45e3236cb0a2c48 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:27:43 -0800 Subject: [PATCH 3/4] Fix service account lint and static analysis --- .../Experiments/Service_Account/Admin_UI.php | 2 + .../REST_Service_Accounts_Controller.php | 55 +++++++++------ .../Service_Account/Service_Account.php | 8 +-- .../Service_Account_Manager.php | 67 ++++++++++++------- 4 files changed, 85 insertions(+), 47 deletions(-) diff --git a/includes/Experiments/Service_Account/Admin_UI.php b/includes/Experiments/Service_Account/Admin_UI.php index aa66bb66..d3a2f9b1 100644 --- a/includes/Experiments/Service_Account/Admin_UI.php +++ b/includes/Experiments/Service_Account/Admin_UI.php @@ -140,7 +140,9 @@ protected function enqueue_scripts( string $hook_suffix ): void { protected function enqueue_users_list_scripts(): void { // Get service account IDs for row marking. // Note: 'fields' => 'ID' returns an array of IDs directly, not user objects. + /** @var int[] $service_account_ids */ $service_account_ids = $this->manager->get_service_accounts( array( 'fields' => 'ID' ) ); + $service_account_ids = array_values( array_filter( array_map( 'absint', $service_account_ids ) ) ); if ( empty( $service_account_ids ) ) { return; diff --git a/includes/Experiments/Service_Account/REST_Service_Accounts_Controller.php b/includes/Experiments/Service_Account/REST_Service_Accounts_Controller.php index 55b908a6..130527ee 100644 --- a/includes/Experiments/Service_Account/REST_Service_Accounts_Controller.php +++ b/includes/Experiments/Service_Account/REST_Service_Accounts_Controller.php @@ -28,7 +28,7 @@ class REST_Service_Accounts_Controller extends \WP_REST_Controller { * The service account manager instance. * * @since 0.3.0 - * @var Service_Account_Manager + * @var \WordPress\AI\Experiments\Service_Account\Service_Account_Manager */ protected Service_Account_Manager $manager; @@ -147,6 +147,7 @@ public function register_routes(): void { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return true|\WP_Error True if the request has read access, WP_Error object otherwise. */ public function get_items_permissions_check( $request ) { @@ -167,7 +168,8 @@ public function get_items_permissions_check( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. - * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + * @phpstan-param \WP_REST_Request> $request + * @return \WP_REST_Response Response object on success. */ public function get_items( $request ) { $args = array( @@ -189,7 +191,7 @@ public function get_items( $request ) { $response_users = array(); foreach ( $users as $user ) { - $data = $this->prepare_item_for_response( $user, $request ); + $data = $this->prepare_item_for_response( $user, $request ); $response_users[] = $this->prepare_response_for_collection( $data ); } @@ -198,8 +200,8 @@ public function get_items( $request ) { // Add pagination headers. $max_pages = (int) ceil( $total / $request->get_param( 'per_page' ) ); - $response->header( 'X-WP-Total', $total ); - $response->header( 'X-WP-TotalPages', $max_pages ); + $response->header( 'X-WP-Total', (string) $total ); + $response->header( 'X-WP-TotalPages', (string) $max_pages ); return $response; } @@ -210,6 +212,7 @@ public function get_items( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return true|\WP_Error True if the request has read access for the item, WP_Error object otherwise. */ public function get_item_permissions_check( $request ) { @@ -236,6 +239,7 @@ public function get_item_permissions_check( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function get_item( $request ) { @@ -255,6 +259,7 @@ public function get_item( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return true|\WP_Error True if the request has access to create items, WP_Error object otherwise. */ public function create_item_permissions_check( $request ) { @@ -275,6 +280,7 @@ public function create_item_permissions_check( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function create_item( $request ) { @@ -316,6 +322,7 @@ public function create_item( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return true|\WP_Error True if the request has access to update the item, WP_Error object otherwise. */ public function update_item_permissions_check( $request ) { @@ -342,6 +349,7 @@ public function update_item_permissions_check( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function update_item( $request ) { @@ -374,6 +382,7 @@ public function update_item( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return true|\WP_Error True if the request has access to delete the item, WP_Error object otherwise. */ public function delete_item_permissions_check( $request ) { @@ -400,6 +409,7 @@ public function delete_item_permissions_check( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function delete_item( $request ) { @@ -416,7 +426,12 @@ public function delete_item( $request ) { } // Get the user data before deletion for the response. - $user = $this->manager->get_service_account( $user_id ); + $user = $this->manager->get_service_account( $user_id ); + if ( is_wp_error( $user ) ) { + $user->add_data( array( 'status' => 404 ) ); + return $user; + } + $response = $this->prepare_item_for_response( $user, $request ); $result = $this->manager->delete_service_account( $user_id, $reassign ); @@ -426,7 +441,7 @@ public function delete_item( $request ) { return $result; } - $data = $response->get_data(); + $data = $response->get_data(); $data['deleted'] = true; return new \WP_REST_Response( $data ); @@ -438,6 +453,7 @@ public function delete_item( $request ) { * @since 0.3.0 * * @param \WP_REST_Request $request Full details about the request. + * @phpstan-param \WP_REST_Request> $request * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function regenerate_app_password( $request ) { @@ -461,26 +477,27 @@ public function regenerate_app_password( $request ) { * * @param \WP_User $user User object. * @param \WP_REST_Request $request Request object. + * @phpstan-param \WP_REST_Request> $request * @return \WP_REST_Response Response object. */ public function prepare_item_for_response( $user, $request ) { + $registered_timestamp = strtotime( $user->user_registered ); + $registered_timestamp = false !== $registered_timestamp ? $registered_timestamp : time(); + $data = array( - 'id' => $user->ID, - 'username' => $user->user_login, - 'name' => $user->display_name, - 'email' => $user->user_email, - 'description' => $user->description, - 'registered_date' => gmdate( 'c', strtotime( $user->user_registered ) ), - 'roles' => array_values( $user->roles ), - 'capabilities' => $this->get_user_capabilities( $user ), - 'meta' => array( + 'id' => $user->ID, + 'username' => $user->user_login, + 'name' => $user->display_name, + 'email' => $user->user_email, + 'description' => $user->description, + 'registered_date' => gmdate( 'c', $registered_timestamp ), + 'roles' => array_values( $user->roles ), + 'capabilities' => $this->get_user_capabilities( $user ), + 'meta' => array( 'is_service_account' => true, ), ); - // Add additional fields from the schema. - $context = $request->get_param( 'context' ) ?? 'view'; - /** * Filters the service account data for the REST API response. * diff --git a/includes/Experiments/Service_Account/Service_Account.php b/includes/Experiments/Service_Account/Service_Account.php index 09da2728..fccad4fb 100644 --- a/includes/Experiments/Service_Account/Service_Account.php +++ b/includes/Experiments/Service_Account/Service_Account.php @@ -52,7 +52,7 @@ class Service_Account extends Abstract_Experiment { * The manager instance. * * @since 0.3.0 - * @var Service_Account_Manager + * @var \WordPress\AI\Experiments\Service_Account\Service_Account_Manager */ protected Service_Account_Manager $manager; @@ -60,7 +60,7 @@ class Service_Account extends Abstract_Experiment { * The admin UI instance. * * @since 0.3.0 - * @var Admin_UI + * @var \WordPress\AI\Experiments\Service_Account\Admin_UI */ protected Admin_UI $admin_ui; @@ -68,7 +68,7 @@ class Service_Account extends Abstract_Experiment { * The REST controller instance. * * @since 0.3.0 - * @var REST_Service_Accounts_Controller + * @var \WordPress\AI\Experiments\Service_Account\REST_Service_Accounts_Controller */ protected REST_Service_Accounts_Controller $rest_controller; @@ -129,7 +129,7 @@ public function register_rest_routes(): void { * * @since 0.3.0 * - * @return Service_Account_Manager The manager instance. + * @return \WordPress\AI\Experiments\Service_Account\Service_Account_Manager The manager instance. */ public function get_manager(): Service_Account_Manager { return $this->manager; diff --git a/includes/Experiments/Service_Account/Service_Account_Manager.php b/includes/Experiments/Service_Account/Service_Account_Manager.php index 22312438..cf4c8b40 100644 --- a/includes/Experiments/Service_Account/Service_Account_Manager.php +++ b/includes/Experiments/Service_Account/Service_Account_Manager.php @@ -173,18 +173,20 @@ private function register_user_meta(): void { * * @since 0.3.0 * - * @param int $user_id User ID. - * @param string $role Role assigned. - * @param array $old_roles Previous roles. + * @param int $user_id User ID. + * @param string $role Role assigned. + * @param array $old_roles Previous roles. */ public function maybe_mark_service_account_role( int $user_id, string $role, array $old_roles = array() ): void { if ( self::ROLE !== $role ) { return; } - if ( ! get_user_meta( $user_id, self::META_KEY, true ) ) { - update_user_meta( $user_id, self::META_KEY, true ); + if ( get_user_meta( $user_id, self::META_KEY, true ) ) { + return; } + + update_user_meta( $user_id, self::META_KEY, true ); } /** @@ -207,9 +209,9 @@ private function get_service_account_meta_clause( string $compare = 'EXISTS' ): * * @since 0.3.0 * - * @param mixed $existing Existing meta query. - * @param array $clause Meta clause to append. - * @return array Combined meta query. + * @param mixed $existing Existing meta query. + * @param array $clause Meta clause to append. + * @return array Combined meta query. */ private function append_meta_query( $existing, array $clause ): array { if ( ! is_array( $existing ) || empty( $existing ) ) { @@ -248,12 +250,12 @@ public function sanitize_owner_id( $value, string $meta_key = '', string $object * * @since 0.3.0 * - * @param bool $allowed Whether access is allowed. - * @param string $meta_key Meta key. - * @param int $object_id Object ID. - * @param int $user_id User ID. - * @param string $cap Capability name. - * @param array $caps User capabilities. + * @param bool $allowed Whether access is allowed. + * @param string $meta_key Meta key. + * @param int $object_id Object ID. + * @param int $user_id User ID. + * @param string $cap Capability name. + * @param array $caps User capabilities. * @return bool Whether access is allowed. */ public function authorize_service_account_meta( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ): bool { @@ -368,18 +370,18 @@ public function register_role(): void { $capabilities = $this->get_default_role_capabilities(); - add_role( - self::ROLE, - __( 'Service', 'ai' ), - $capabilities - ); + if ( function_exists( 'wpcom_vip_add_role' ) ) { + wpcom_vip_add_role( self::ROLE, __( 'Service', 'ai' ), $capabilities ); + } else { + add_role( self::ROLE, __( 'Service', 'ai' ), $capabilities ); + } /** * Fires after the service account role is registered. * * @since 0.3.0 * - * @param \WP_Role $role The newly created role. + * @param \WP_Role|null $role The newly created role. * @param array $capabilities The capabilities assigned to the role. */ do_action( 'service_account_role_registered', get_role( self::ROLE ), $capabilities ); @@ -515,7 +517,7 @@ public function filter_user_queries( \WP_User_Query $query ): void { * @param array|null $result The count result, or null to calculate. * @param string $strategy The counting strategy. * @param int|null $site_id The site ID, or null for current site. - * @return array|null Modified count or null. + * @return array{total_users:int, avail_roles:array}|null Modified count or null. */ public function filter_user_counts( $result, string $strategy, ?int $site_id ) { // Only filter if we need to calculate. @@ -614,9 +616,11 @@ public function filter_user_counts( $result, string $strategy, ?int $site_id ) { } foreach ( $avail_roles as $role_name => $count ) { - if ( $count <= 0 ) { - unset( $avail_roles[ $role_name ] ); + if ( $count > 0 ) { + continue; } + + unset( $avail_roles[ $role_name ] ); } if ( $total_users < 0 ) { @@ -792,7 +796,8 @@ public function create_service_account( array $args ) { } // Generate email: {username}@{site-domain}. - $site_domain = wp_parse_url( home_url(), PHP_URL_HOST ) ?: 'localhost'; + $site_domain = wp_parse_url( home_url(), PHP_URL_HOST ); + $site_domain = $site_domain ? $site_domain : 'localhost'; $email = $username . '@' . $site_domain; $display_name = $name; @@ -835,6 +840,13 @@ public function create_service_account( array $args ) { $user = get_user_by( 'id', $user_id ); + if ( ! $user instanceof \WP_User ) { + return new \WP_Error( + 'service_account_create_failed', + __( 'Service account could not be loaded after creation.', 'ai' ) + ); + } + /** * Fires after a service account is created. * @@ -966,6 +978,13 @@ public function update_service_account( int $user_id, array $args ) { $updated_user = get_user_by( 'id', $user_id ); + if ( ! $updated_user instanceof \WP_User ) { + return new \WP_Error( + 'service_account_update_failed', + __( 'Service account could not be loaded after update.', 'ai' ) + ); + } + /** * Fires after a service account is updated. * From 58f3e3f1b5adec45333f67a8077313680371b254 Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:10:34 -0800 Subject: [PATCH 4/4] Use core role registration --- .../Experiments/Service_Account/Service_Account_Manager.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/includes/Experiments/Service_Account/Service_Account_Manager.php b/includes/Experiments/Service_Account/Service_Account_Manager.php index cf4c8b40..b64d3385 100644 --- a/includes/Experiments/Service_Account/Service_Account_Manager.php +++ b/includes/Experiments/Service_Account/Service_Account_Manager.php @@ -370,11 +370,7 @@ public function register_role(): void { $capabilities = $this->get_default_role_capabilities(); - if ( function_exists( 'wpcom_vip_add_role' ) ) { - wpcom_vip_add_role( self::ROLE, __( 'Service', 'ai' ), $capabilities ); - } else { - add_role( self::ROLE, __( 'Service', 'ai' ), $capabilities ); - } + add_role( self::ROLE, __( 'Service', 'ai' ), $capabilities ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wpcom_vip_add_role -- core-compatible usage. /** * Fires after the service account role is registered.