VYPR
High severity8.8NVD Advisory· Published Jun 15, 2026· Updated Jun 15, 2026

CVE-2026-49780

CVE-2026-49780

Description

Customer privilege escalation vulnerability in Dokan WordPress plugin <=5.0.2 allows low-privileged users to escalate to admin, risking full site compromise.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Customer privilege escalation vulnerability in Dokan WordPress plugin <=5.0.2 allows low-privileged users to escalate to admin, risking full site compromise.

Vulnerability

In the Dokan WordPress plugin (versions up to and including 5.0.2), a privilege escalation vulnerability exists in the customer role handling. The bug allows a low-privileged user to escalate their privileges to higher levels, such as administrator. The affected code path is reachable by any authenticated user with a customer account. [1]

Exploitation

An authenticated attacker with a low-privileged account (e.g., a customer) can exploit this vulnerability by sending specially crafted requests to the Dokan plugin. No additional user interaction is required. The attacker can directly manipulate the privilege assignment logic to gain elevated permissions. [1]

Impact

Successful exploitation enables an attacker to escalate from a low-privileged role to an administrator-level account. This allows full control over the WordPress site, including the ability to modify content, install plugins, manage users, and potentially compromise the underlying server. [1]

Mitigation

Update to Dokan version 5.0.3 or later, which addresses the vulnerability. If immediate update is not possible, use a security plugin like Patchstack to apply a mitigation rule, or contact your hosting provider for assistance. No workaround other than updating has been disclosed. [1]

AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

2
34784b4e25d5

fix: missing authorization in CustomersController (#3205)

https://github.com/getdokan/dokanKamruzzamanMay 21, 2026Fixed in 5.0.3via llm-release-walk
2 files changed · +299 35
  • includes/REST/CustomersController.php+117 15 modified
    @@ -58,27 +58,105 @@ public function register_routes() {
          * @return WP_Error|boolean
          */
         protected function check_permission( $request, $action ) {
    +        $messages = [
    +            'view'   => __( 'Sorry, you cannot list resources.', 'dokan-lite' ),
    +            'create' => __( 'Sorry, you are not allowed to create resources.', 'dokan-lite' ),
    +            'edit'   => __( 'Sorry, you are not allowed to edit this resource.', 'dokan-lite' ),
    +            'delete' => __( 'Sorry, you are not allowed to delete this resource.', 'dokan-lite' ),
    +            'batch'  => __( 'Sorry, you are not allowed to batch update resources.', 'dokan-lite' ),
    +            'search' => __( 'Sorry, you are not allowed to search customers.', 'dokan-lite' ),
    +        ];
    +
             if ( ! $this->check_vendor_permission() ) {
    -            $messages = [
    -                'view'   => __( 'Sorry, you cannot list resources.', 'dokan-lite' ),
    -                'create' => __( 'Sorry, you are not allowed to create resources.', 'dokan-lite' ),
    -                'edit'   => __( 'Sorry, you are not allowed to edit this resource.', 'dokan-lite' ),
    -                'delete' => __( 'Sorry, you are not allowed to delete this resource.', 'dokan-lite' ),
    -                'batch'  => __( 'Sorry, you are not allowed to batch update resources.', 'dokan-lite' ),
    -                'search' => __( 'Sorry, you are not allowed to search customers.', 'dokan-lite' ),
    -            ];
                 return new WP_Error( "dokan_rest_cannot_$action", $messages[ $action ], [ 'status' => rest_authorization_required_code() ] );
             }
    +
    +        // CVE-2026-8761: object-level authorization for mutating actions.
    +        $target_id = isset( $request['id'] ) ? (int) $request['id'] : 0;
    +        if ( $target_id > 0 && in_array( $action, [ 'view', 'edit', 'delete' ], true ) ) {
    +            $allowed = $this->is_target_user_allowed( $target_id );
    +            if ( is_wp_error( $allowed ) ) {
    +                $status = $allowed->get_error_data();
    +                $status = isset( $status['status'] ) ? (int) $status['status'] : 403;
    +                return new WP_Error( "dokan_rest_cannot_$action", $messages[ $action ], [ 'status' => $status ] );
    +            }
    +        }
    +
    +        return true;
    +    }
    +
    +    /**
    +     * Verify the requesting vendor may mutate the target user.
    +     *
    +     * Rejects targets that are missing, hold admin-grade capabilities,
    +     * are themselves a vendor, or have never placed an order with the
    +     * requesting vendor. CVE-2026-8761.
    +     *
    +     * @param int $target_id Target user id.
    +     *
    +     * @return true|WP_Error
    +     */
    +    protected function is_target_user_allowed( int $target_id ) {
    +        if ( $target_id <= 0 || ! get_userdata( $target_id ) ) {
    +            return new WP_Error( 'dokan_rest_invalid_target', __( 'Invalid user.', 'dokan-lite' ), [ 'status' => 404 ] );
    +        }
    +
    +        $protected_caps = apply_filters(
    +            'dokan_rest_protected_user_caps',
    +            [
    +                'manage_options',
    +				'manage_woocommerce',
    +                'edit_users',
    +				'delete_users',
    +				'list_users',
    +				'promote_users',
    +				'create_users',
    +				'remove_users',
    +			]
    +        );
    +        foreach ( $protected_caps as $cap ) {
    +            if ( user_can( $target_id, $cap ) ) {
    +                return new WP_Error( 'dokan_rest_forbidden_target', __( 'You cannot operate on this user.', 'dokan-lite' ), [ 'status' => 403 ] );
    +            }
    +        }
    +
    +        if ( dokan_is_user_seller( $target_id ) ) {
    +            return new WP_Error( 'dokan_rest_forbidden_target', __( 'You cannot operate on this user.', 'dokan-lite' ), [ 'status' => 403 ] );
    +        }
    +
    +        if ( ! dokan_customer_has_order_from_this_seller( $target_id, dokan_get_current_user_id() ) ) {
    +            return new WP_Error( 'dokan_rest_forbidden_target', __( 'You cannot operate on this customer.', 'dokan-lite' ), [ 'status' => 403 ] );
    +        }
    +
             return true;
         }
     
         /**
          * Check if the current user has vendor permissions.
          *
    +     * Doubles as a callback on the woocommerce_rest_check_permissions
    +     * filter so WooCommerce's internal capability checks for mutating
    +     * operations (create/edit/delete/batch) re-validate the target user.
    +     * Read context is allowed through for the vendor.
    +     *
    +     * @param bool|mixed $permission  Original permission decision when used as a filter callback.
    +     * @param string     $context     Operation context (read/edit/delete/create/batch).
    +     * @param int        $object_id   Target object id.
    +     * @param string     $object_type Object type (expected: user).
    +     *
          * @return bool
          */
    -    public function check_vendor_permission(): bool {
    -        return dokan_is_user_seller( dokan_get_current_user_id() );
    +    public function check_vendor_permission( $permission = false, $context = '', $object_id = 0, $object_type = '' ): bool {
    +        if ( ! dokan_is_user_seller( dokan_get_current_user_id() ) ) {
    +            return false;
    +        }
    +
    +        $object_id = (int) $object_id;
    +        if ( $object_id > 0 && ( '' === $object_type || 'user' === $object_type ) && in_array( $context, [ 'create', 'edit', 'delete', 'batch' ], true ) ) {
    +            return ! is_wp_error( $this->is_target_user_allowed( $object_id ) );
    +        }
    +
    +        return true;
         }
     
         /**
    @@ -90,7 +168,24 @@ public function check_vendor_permission(): bool {
         public function get_items( $request ) {
             return $this->perform_vendor_action(
                 function () use ( $request ) {
    -                return parent::get_items( $request );
    +                $response = parent::get_items( $request );
    +                if ( is_wp_error( $response ) || ! ( $response instanceof WP_REST_Response ) ) {
    +                    return $response;
    +                }
    +
    +                $vendor_id = dokan_get_current_user_id();
    +                $data = array_values(
    +                    array_filter(
    +                        (array) $response->get_data(),
    +                        static function ( $item ) use ( $vendor_id ) {
    +							$id = is_array( $item ) ? ( $item['id'] ?? 0 ) : 0;
    +							return $id && dokan_customer_has_order_from_this_seller( $id, $vendor_id );
    +                        }
    +                    )
    +                );
    +
    +                $response->set_data( $data );
    +                return $response;
                 }
             );
         }
    @@ -255,6 +350,11 @@ public function search_customers( $request ) {
          * @return WP_Error|WC_Data
          */
         protected function prepare_object_for_database( $request, $creating = false ) {
    +        // CVE-2026-8761: never allow role/roles via this endpoint.
    +        if ( null !== $request->get_param( 'role' ) || null !== $request->get_param( 'roles' ) ) {
    +            return new WP_Error( 'dokan_rest_forbidden_field', __( 'You cannot modify the role of a user.', 'dokan-lite' ), [ 'status' => 403 ] );
    +        }
    +
             $customer = parent::prepare_object_for_database( $request, $creating );
     
             if ( is_wp_error( $customer ) ) {
    @@ -278,10 +378,12 @@ protected function prepare_object_for_database( $request, $creating = false ) {
          * @return mixed The result of the action.
          */
         private function perform_vendor_action( callable $action ) {
    -        add_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ] );
    -        $result = $action();
    -        remove_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ] );
    -        return $result;
    +        add_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ], 10, 4 );
    +        try {
    +            return $action();
    +        } finally {
    +            remove_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ], 10 );
    +        }
         }
     
         /**
    
  • tests/php/src/REST/CustomersControllerTest.php+182 20 modified
    @@ -129,12 +129,13 @@ public function test_get_items() {
             $response = $this->get_request( 'customers' );
     
             $this->assertEquals( 200, $response->get_status() );
    -        $this->assertCount( 3, $response->get_data() );
    +        $this->assertCount( count( $this->customers ), $response->get_data() );
     
    -        // Test with per_page parameter
    +        // Test with per_page parameter. The vendor-scoped filter strips users
    +        // without orders from this vendor, so the count may be 0 or 1.
             $response = $this->get_request( 'customers', [ 'per_page' => 1 ] );
             $this->assertEquals( 200, $response->get_status() );
    -        $this->assertCount( 1, $response->get_data() );
    +        $this->assertLessThanOrEqual( 1, count( $response->get_data() ) );
     
             // Test with ordering
             $response = $this->get_request(
    @@ -532,44 +533,205 @@ public function test_search_validation_and_edge_cases() {
         }
     
         /**
    -     * Test customer role handling
    +     * CVE-2026-8761: creating a customer with a roles payload must be rejected.
    +     *
          * @throws Exception
          */
    -    public function test_customer_role_handling() {
    +    public function test_cannot_create_customer_with_roles() {
             wp_set_current_user( $this->seller_id1 );
     
    -        // Test creating customer with additional roles
             $customer_data = [
    -            'email'      => 'role.test@example.com',
    +            'email'      => 'role.create@example.com',
                 'first_name' => 'Role',
    -            'last_name'  => 'Test',
    -            'username'   => 'roletest',
    +            'last_name'  => 'Create',
    +            'username'   => 'rolecreate',
                 'password'   => 'password123',
                 'roles'      => [ 'customer', 'subscriber' ],
             ];
     
    +        $response = $this->post_request( 'customers', $customer_data );
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertFalse( get_user_by( 'email', 'role.create@example.com' ) );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: updating an existing customer with a roles payload must be rejected.
    +     *
    +     * @throws Exception
    +     */
    +    public function test_cannot_update_customer_with_roles() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $customer_data = [
    +            'email'      => 'role.update@example.com',
    +            'first_name' => 'Role',
    +            'last_name'  => 'Update',
    +            'username'   => 'roleupdate',
    +            'password'   => 'password123',
    +        ];
    +
             $response = $this->post_request( 'customers', $customer_data );
             $this->assertEquals( 201, $response->get_status() );
             $customer_id = $response->get_data()['id'];
     
    -        // Verify roles
             $customer = new WC_Customer( $customer_id );
    -        $customer_role = $customer->get_role();
    -        $this->assertEquals( 'customer', $customer_role );
    -
    -        // Test updating roles
    -        $update_data = [
    -            'roles' => [ 'customer' ],
    -        ];
    +        $this->assertEquals( 'customer', $customer->get_role() );
     
    -        $response = $this->put_request( "customers/$customer_id", $update_data );
    -        $this->assertEquals( 200, $response->get_status() );
    +        $response = $this->put_request(
    +            "customers/$customer_id",
    +            [
    +                'roles' => [ 'customer', 'subscriber' ],
    +            ]
    +        );
    +        $this->assertEquals( 403, $response->get_status() );
     
    -        // Verify updated roles
             $customer = new WC_Customer( $customer_id );
             $this->assertEquals( 'customer', $customer->get_role() );
         }
     
    +    /**
    +     * CVE-2026-8761: a vendor must not be able to update a user that
    +     * holds admin-grade capabilities, regardless of payload.
    +     */
    +    public function test_cannot_update_admin_user() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $response = $this->put_request(
    +            "customers/{$this->admin_id}",
    +            [
    +                'first_name' => 'Hijacked',
    +            ]
    +        );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $response->get_data()['code'] );
    +
    +        $admin = get_userdata( $this->admin_id );
    +        $this->assertNotEquals( 'Hijacked', $admin->first_name );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: the password overwrite PoC must be blocked.
    +     */
    +    public function test_admin_password_overwrite_blocked() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $original_hash = get_userdata( $this->admin_id )->user_pass;
    +
    +        $response = $this->put_request(
    +            "customers/{$this->admin_id}",
    +            [
    +                'password' => 'pwned_by_vendor',
    +            ]
    +        );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $response->get_data()['code'] );
    +
    +        $this->assertEquals( $original_hash, get_userdata( $this->admin_id )->user_pass );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: a vendor must not be able to delete an admin user.
    +     */
    +    public function test_cannot_delete_admin_user() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $response = $this->delete_request( "customers/{$this->admin_id}", [ 'force' => true ] );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_delete', $response->get_data()['code'] );
    +        $this->assertNotFalse( get_user_by( 'id', $this->admin_id ) );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: a vendor must not be able to update another vendor.
    +     */
    +    public function test_cannot_update_other_vendor() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $response = $this->put_request(
    +            "customers/{$this->seller_id2}",
    +            [
    +                'first_name' => 'Tampered',
    +            ]
    +        );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $response->get_data()['code'] );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: a vendor must not be able to update a user who has
    +     * never placed an order with them.
    +     */
    +    public function test_cannot_update_unrelated_customer() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        // customers[0] has no order with seller_id1 in this test.
    +        $response = $this->put_request(
    +            "customers/{$this->customers[0]}",
    +            [
    +                'first_name' => 'Tampered',
    +            ]
    +        );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $response->get_data()['code'] );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: batch update must reject any entry that targets an
    +     * admin user, even when other entries are legitimate.
    +     */
    +    public function test_batch_update_blocks_admin_target() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        // Establish a legitimate vendor/customer relationship.
    +        $this->factory()->order->set_seller_id( $this->seller_id1 )->create(
    +            [
    +                'customer_id' => $this->customers[0],
    +            ]
    +        );
    +
    +        $batch_data = [
    +            'update' => [
    +                [
    +                    'id'         => $this->customers[0],
    +                    'first_name' => 'Legit',
    +                ],
    +                [
    +                    'id'       => $this->admin_id,
    +                    'password' => 'pwned_by_vendor',
    +                ],
    +            ],
    +        ];
    +
    +        $original_hash = get_userdata( $this->admin_id )->user_pass;
    +
    +        $response = $this->post_request( 'customers/batch', $batch_data );
    +
    +        $data = $response->get_data();
    +
    +        // Even if the controller returns 200 overall, the admin entry
    +        // must have been rejected with an error, not mutated.
    +        $admin_entry = null;
    +        if ( ! empty( $data['update'] ) ) {
    +            foreach ( $data['update'] as $entry ) {
    +                if ( isset( $entry['id'] ) && (int) $entry['id'] === (int) $this->admin_id ) {
    +                    $admin_entry = $entry;
    +                    break;
    +                }
    +            }
    +        }
    +
    +        $this->assertNotNull( $admin_entry, 'Admin entry missing from batch response.' );
    +        $this->assertArrayHasKey( 'error', $admin_entry, 'Admin user was processed without error.' );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $admin_entry['error']['code'] );
    +
    +        $this->assertEquals( $original_hash, get_userdata( $this->admin_id )->user_pass );
    +    }
    +
         /**
          * Test error responses format
          */
    
d7fadd9a85f1

chore: Release version 5.0.3

https://github.com/getdokan/dokanMd Asif Hossain NadimMay 21, 2026Fixed in 5.0.3via release-tag
60 files changed · +798 284
  • CHANGELOG.md+6 0 modified
    @@ -1,3 +1,9 @@
    +### v5.0.3 ( May 21, 2026 ) ###
    +
    +- **update:** Exposed manual withdrawal availability and withdraw-visibility flags in the vendor dashboard REST API.
    +- **fix:** Restricted the Customers REST endpoint to self-service to prevent vendors from modifying other user accounts.
    +- **fix:** Translated the "Actions" column header on vendor dashboard DataViews tables.
    +
     ### v5.0.2 ( May 18, 2026 ) ###
     
     - **new:** Added a date range filter on the vendor dashboard Withdraw Requests table to filter results by start and end dates.
    
  • composer.lock+4 4 modified
    @@ -4072,12 +4072,12 @@
                 "source": {
                     "type": "git",
                     "url": "https://github.com/wp-phpunit/wp-phpunit.git",
    -                "reference": "95bf7b390a1ce6c1b54f3a07197a3f3b07021e4f"
    +                "reference": "8d17c1f9ca29344f5a4f08c56660044531eda132"
                 },
                 "dist": {
                     "type": "zip",
    -                "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/95bf7b390a1ce6c1b54f3a07197a3f3b07021e4f",
    -                "reference": "95bf7b390a1ce6c1b54f3a07197a3f3b07021e4f",
    +                "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/8d17c1f9ca29344f5a4f08c56660044531eda132",
    +                "reference": "8d17c1f9ca29344f5a4f08c56660044531eda132",
                     "shasum": ""
                 },
                 "default-branch": true,
    @@ -4113,7 +4113,7 @@
                     "issues": "https://github.com/wp-phpunit/issues",
                     "source": "https://github.com/wp-phpunit/wp-phpunit"
                 },
    -            "time": "2025-12-03T01:19:46+00:00"
    +            "time": "2026-05-21T02:56:35+00:00"
             },
             {
                 "name": "yoast/phpunit-polyfills",
    
  • dokan-class.php+1 1 modified
    @@ -27,7 +27,7 @@ final class WeDevs_Dokan {
          *
          * @var string
          */
    -    public $version = '5.0.2';
    +    public $version = '5.0.3';
     
         /**
          * Instance of self
    
  • dokan.php+1 1 modified
    @@ -3,7 +3,7 @@
      * Plugin Name: Dokan
      * Plugin URI: https://dokan.co/wordpress/
      * Description: An e-commerce marketplace plugin for WordPress. Powered by WooCommerce and weDevs.
    - * Version: 5.0.2
    + * Version: 5.0.3
      * Author: Dokan Inc.
      * Author URI: https://dokan.co/wordpress/
      * Text Domain: dokan-lite
    
  • includes/REST/CustomersController.php+117 15 modified
    @@ -58,27 +58,105 @@ public function register_routes() {
          * @return WP_Error|boolean
          */
         protected function check_permission( $request, $action ) {
    +        $messages = [
    +            'view'   => __( 'Sorry, you cannot list resources.', 'dokan-lite' ),
    +            'create' => __( 'Sorry, you are not allowed to create resources.', 'dokan-lite' ),
    +            'edit'   => __( 'Sorry, you are not allowed to edit this resource.', 'dokan-lite' ),
    +            'delete' => __( 'Sorry, you are not allowed to delete this resource.', 'dokan-lite' ),
    +            'batch'  => __( 'Sorry, you are not allowed to batch update resources.', 'dokan-lite' ),
    +            'search' => __( 'Sorry, you are not allowed to search customers.', 'dokan-lite' ),
    +        ];
    +
             if ( ! $this->check_vendor_permission() ) {
    -            $messages = [
    -                'view'   => __( 'Sorry, you cannot list resources.', 'dokan-lite' ),
    -                'create' => __( 'Sorry, you are not allowed to create resources.', 'dokan-lite' ),
    -                'edit'   => __( 'Sorry, you are not allowed to edit this resource.', 'dokan-lite' ),
    -                'delete' => __( 'Sorry, you are not allowed to delete this resource.', 'dokan-lite' ),
    -                'batch'  => __( 'Sorry, you are not allowed to batch update resources.', 'dokan-lite' ),
    -                'search' => __( 'Sorry, you are not allowed to search customers.', 'dokan-lite' ),
    -            ];
                 return new WP_Error( "dokan_rest_cannot_$action", $messages[ $action ], [ 'status' => rest_authorization_required_code() ] );
             }
    +
    +        // CVE-2026-8761: object-level authorization for mutating actions.
    +        $target_id = isset( $request['id'] ) ? (int) $request['id'] : 0;
    +        if ( $target_id > 0 && in_array( $action, [ 'view', 'edit', 'delete' ], true ) ) {
    +            $allowed = $this->is_target_user_allowed( $target_id );
    +            if ( is_wp_error( $allowed ) ) {
    +                $status = $allowed->get_error_data();
    +                $status = isset( $status['status'] ) ? (int) $status['status'] : 403;
    +                return new WP_Error( "dokan_rest_cannot_$action", $messages[ $action ], [ 'status' => $status ] );
    +            }
    +        }
    +
    +        return true;
    +    }
    +
    +    /**
    +     * Verify the requesting vendor may mutate the target user.
    +     *
    +     * Rejects targets that are missing, hold admin-grade capabilities,
    +     * are themselves a vendor, or have never placed an order with the
    +     * requesting vendor. CVE-2026-8761.
    +     *
    +     * @param int $target_id Target user id.
    +     *
    +     * @return true|WP_Error
    +     */
    +    protected function is_target_user_allowed( int $target_id ) {
    +        if ( $target_id <= 0 || ! get_userdata( $target_id ) ) {
    +            return new WP_Error( 'dokan_rest_invalid_target', __( 'Invalid user.', 'dokan-lite' ), [ 'status' => 404 ] );
    +        }
    +
    +        $protected_caps = apply_filters(
    +            'dokan_rest_protected_user_caps',
    +            [
    +                'manage_options',
    +				'manage_woocommerce',
    +                'edit_users',
    +				'delete_users',
    +				'list_users',
    +				'promote_users',
    +				'create_users',
    +				'remove_users',
    +			]
    +        );
    +        foreach ( $protected_caps as $cap ) {
    +            if ( user_can( $target_id, $cap ) ) {
    +                return new WP_Error( 'dokan_rest_forbidden_target', __( 'You cannot operate on this user.', 'dokan-lite' ), [ 'status' => 403 ] );
    +            }
    +        }
    +
    +        if ( dokan_is_user_seller( $target_id ) ) {
    +            return new WP_Error( 'dokan_rest_forbidden_target', __( 'You cannot operate on this user.', 'dokan-lite' ), [ 'status' => 403 ] );
    +        }
    +
    +        if ( ! dokan_customer_has_order_from_this_seller( $target_id, dokan_get_current_user_id() ) ) {
    +            return new WP_Error( 'dokan_rest_forbidden_target', __( 'You cannot operate on this customer.', 'dokan-lite' ), [ 'status' => 403 ] );
    +        }
    +
             return true;
         }
     
         /**
          * Check if the current user has vendor permissions.
          *
    +     * Doubles as a callback on the woocommerce_rest_check_permissions
    +     * filter so WooCommerce's internal capability checks for mutating
    +     * operations (create/edit/delete/batch) re-validate the target user.
    +     * Read context is allowed through for the vendor.
    +     *
    +     * @param bool|mixed $permission  Original permission decision when used as a filter callback.
    +     * @param string     $context     Operation context (read/edit/delete/create/batch).
    +     * @param int        $object_id   Target object id.
    +     * @param string     $object_type Object type (expected: user).
    +     *
          * @return bool
          */
    -    public function check_vendor_permission(): bool {
    -        return dokan_is_user_seller( dokan_get_current_user_id() );
    +    public function check_vendor_permission( $permission = false, $context = '', $object_id = 0, $object_type = '' ): bool {
    +        if ( ! dokan_is_user_seller( dokan_get_current_user_id() ) ) {
    +            return false;
    +        }
    +
    +        $object_id = (int) $object_id;
    +        if ( $object_id > 0 && ( '' === $object_type || 'user' === $object_type ) && in_array( $context, [ 'create', 'edit', 'delete', 'batch' ], true ) ) {
    +            return ! is_wp_error( $this->is_target_user_allowed( $object_id ) );
    +        }
    +
    +        return true;
         }
     
         /**
    @@ -90,7 +168,24 @@ public function check_vendor_permission(): bool {
         public function get_items( $request ) {
             return $this->perform_vendor_action(
                 function () use ( $request ) {
    -                return parent::get_items( $request );
    +                $response = parent::get_items( $request );
    +                if ( is_wp_error( $response ) || ! ( $response instanceof WP_REST_Response ) ) {
    +                    return $response;
    +                }
    +
    +                $vendor_id = dokan_get_current_user_id();
    +                $data = array_values(
    +                    array_filter(
    +                        (array) $response->get_data(),
    +                        static function ( $item ) use ( $vendor_id ) {
    +							$id = is_array( $item ) ? ( $item['id'] ?? 0 ) : 0;
    +							return $id && dokan_customer_has_order_from_this_seller( $id, $vendor_id );
    +                        }
    +                    )
    +                );
    +
    +                $response->set_data( $data );
    +                return $response;
                 }
             );
         }
    @@ -255,6 +350,11 @@ public function search_customers( $request ) {
          * @return WP_Error|WC_Data
          */
         protected function prepare_object_for_database( $request, $creating = false ) {
    +        // CVE-2026-8761: never allow role/roles via this endpoint.
    +        if ( null !== $request->get_param( 'role' ) || null !== $request->get_param( 'roles' ) ) {
    +            return new WP_Error( 'dokan_rest_forbidden_field', __( 'You cannot modify the role of a user.', 'dokan-lite' ), [ 'status' => 403 ] );
    +        }
    +
             $customer = parent::prepare_object_for_database( $request, $creating );
     
             if ( is_wp_error( $customer ) ) {
    @@ -278,10 +378,12 @@ protected function prepare_object_for_database( $request, $creating = false ) {
          * @return mixed The result of the action.
          */
         private function perform_vendor_action( callable $action ) {
    -        add_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ] );
    -        $result = $action();
    -        remove_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ] );
    -        return $result;
    +        add_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ], 10, 4 );
    +        try {
    +            return $action();
    +        } finally {
    +            remove_filter( 'woocommerce_rest_check_permissions', [ $this, 'check_vendor_permission' ], 10 );
    +        }
         }
     
         /**
    
  • includes/REST/VendorDashboardController.php+7 0 modified
    @@ -414,6 +414,7 @@ public function get_preferences() {
                 'language'              => get_locale(),
                 'week_start_on'         => get_option( 'start_of_week' ),
                 'store_color'           => dokan_get_option( 'store_color_pallete', 'dokan_colors', [] ),
    +            'enable_withdraw'       => dokan_get_option( 'hide_withdraw_option', 'dokan_withdraw', 'off' ) === 'off',
                 'timezone_utc'          => $timezone_utc,
                 'ai_settings'           => [
                     'ai_text_enable'    => $is_text_configured,
    @@ -597,6 +598,12 @@ public function get_preferences_schema() {
                         'context'     => [ 'view' ],
                         'readonly'    => true,
                     ],
    +                'enable_withdraw' => [
    +                    'description' => esc_html__( 'Enable withdraw option.', 'dokan-lite' ),
    +                    'type'        => 'boolean',
    +                    'context'     => [ 'view' ],
    +                    'readonly'    => true,
    +                ],
                     'ai_settings'    => [
                         'description' => esc_html__( 'Store AI Settings.', 'dokan-lite' ),
                         'type'        => 'object',
    
  • includes/REST/WithdrawControllerV2.php+1 0 modified
    @@ -100,6 +100,7 @@ function ( $active_method ) {
             return rest_ensure_response(
                 [
                     'withdraw_method' => $default_withdraw_method,
    +                'is_manual_withdraw_enable' => ! empty( dokan_get_option( 'disbursement', 'dokan_withdraw' )['manual'] ?? false ),
                     'payment_methods' => array_values( $payment_methods ),
                     'active_methods'  => $active_methods,
                     'setup_url'       => $setup_url,
    
  • languages/dokan-lite.pot+67 45 modified
    @@ -1,14 +1,14 @@
     # Copyright (c) 2026 Dokan Inc. All Rights Reserved.
     msgid ""
     msgstr ""
    -"Project-Id-Version: Dokan 5.0.2\n"
    +"Project-Id-Version: Dokan 5.0.3\n"
     "Report-Msgid-Bugs-To: https://dokan.co/contact/\n"
     "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
     "Language-Team: LANGUAGE <LL@li.org>\n"
     "MIME-Version: 1.0\n"
     "Content-Type: text/plain; charset=UTF-8\n"
     "Content-Transfer-Encoding: 8bit\n"
    -"POT-Creation-Date: 2026-05-18T09:10:02+00:00\n"
    +"POT-Creation-Date: 2026-05-21T10:33:31+00:00\n"
     "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
     "X-Generator: WP-CLI 2.11.0\n"
     "X-Domain: dokan-lite\n"
    @@ -5467,6 +5467,7 @@ msgstr ""
     
     #: includes/Order/Admin/Hooks.php:106
     #: assets/js/components.js:172
    +#: assets/js/frontend.js:179
     #: assets/js/vue-admin.js:2
     msgid "Actions"
     msgstr ""
    @@ -7076,41 +7077,58 @@ msgstr ""
     msgid "Comma-separated list of customer IDs to exclude."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:63
    +#: includes/REST/CustomersController.php:62
     #: includes/REST/ExportController.php:70
     #: includes/REST/WithdrawExportController.php:201
     msgid "Sorry, you cannot list resources."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:64
    +#: includes/REST/CustomersController.php:63
     msgid "Sorry, you are not allowed to create resources."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:65
    +#: includes/REST/CustomersController.php:64
     msgid "Sorry, you are not allowed to edit this resource."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:66
    +#: includes/REST/CustomersController.php:65
     msgid "Sorry, you are not allowed to delete this resource."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:67
    +#: includes/REST/CustomersController.php:66
     msgid "Sorry, you are not allowed to batch update resources."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:68
    +#: includes/REST/CustomersController.php:67
     msgid "Sorry, you are not allowed to search customers."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:172
    +#: includes/REST/CustomersController.php:101
    +msgid "Invalid user."
    +msgstr ""
    +
    +#: includes/REST/CustomersController.php:119
    +#: includes/REST/CustomersController.php:124
    +msgid "You cannot operate on this user."
    +msgstr ""
    +
    +#: includes/REST/CustomersController.php:128
    +msgid "You cannot operate on this customer."
    +msgstr ""
    +
    +#: includes/REST/CustomersController.php:267
     msgid "You do not have permission to search customers."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:180
    +#: includes/REST/CustomersController.php:275
     msgid "Search term is required."
     msgstr ""
     
    -#: includes/REST/CustomersController.php:265
    +#: includes/REST/CustomersController.php:355
    +msgid "You cannot modify the role of a user."
    +msgstr ""
    +
    +#: includes/REST/CustomersController.php:365
     msgid "Invalid customer."
     msgstr ""
     
    @@ -7889,7 +7907,7 @@ msgid "True the prices included tax during checkout."
     msgstr ""
     
     #: includes/REST/OrderController.php:1055
    -#: includes/REST/VendorDashboardController.php:620
    +#: includes/REST/VendorDashboardController.php:627
     msgid "User ID who owns the order. 0 for guests."
     msgstr ""
     
    @@ -8829,123 +8847,127 @@ msgstr ""
     msgid "Group By"
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:439
    +#: includes/REST/VendorDashboardController.php:440
     msgid "Site title."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:445
    +#: includes/REST/VendorDashboardController.php:446
     msgid "Tagline."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:451
    +#: includes/REST/VendorDashboardController.php:452
     msgid "Favicon."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:457
    +#: includes/REST/VendorDashboardController.php:458
     msgid "Payment currency."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:463
    +#: includes/REST/VendorDashboardController.php:464
     msgid "Currency Options."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:469
    +#: includes/REST/VendorDashboardController.php:470
     msgid "Payment currency position."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:475
    +#: includes/REST/VendorDashboardController.php:476
     msgid "Currency symbol."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:481
    +#: includes/REST/VendorDashboardController.php:482
     msgid "Decimal separator."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:487
    +#: includes/REST/VendorDashboardController.php:488
     msgid "Thousand separator."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:493
    +#: includes/REST/VendorDashboardController.php:494
     msgid "Decimal point."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:499
    +#: includes/REST/VendorDashboardController.php:500
     msgid "Tax Calculation enabled or not."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:505
    +#: includes/REST/VendorDashboardController.php:506
     msgid "Tax display in cart price."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:511
    +#: includes/REST/VendorDashboardController.php:512
     msgid "Tax Tax price round up in subtotal."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:517
    +#: includes/REST/VendorDashboardController.php:518
     msgid "Coupon enabled in store."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:523
    +#: includes/REST/VendorDashboardController.php:524
     msgid "Compound coupon calculation."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:529
    +#: includes/REST/VendorDashboardController.php:530
     msgid "Measurement unit for weight."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:535
    +#: includes/REST/VendorDashboardController.php:536
     msgid "Measurement unit for dimension."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:541
    +#: includes/REST/VendorDashboardController.php:542
     msgid "Enabled product reviews."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:547
    +#: includes/REST/VendorDashboardController.php:548
     msgid "Enabled product rating."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:553
    +#: includes/REST/VendorDashboardController.php:554
     msgid "Enabled product stock management."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:559
    +#: includes/REST/VendorDashboardController.php:560
     msgid "Store timezone."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:565
    +#: includes/REST/VendorDashboardController.php:566
     msgid "Store date format."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:571
    +#: includes/REST/VendorDashboardController.php:572
     msgid "Store time format."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:577
    +#: includes/REST/VendorDashboardController.php:578
     msgid "Store UTC time."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:583
    +#: includes/REST/VendorDashboardController.php:584
     msgid "Store language."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:589
    +#: includes/REST/VendorDashboardController.php:590
     msgid "Store start of week."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:595
    +#: includes/REST/VendorDashboardController.php:596
     msgid "Store color."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:601
    +#: includes/REST/VendorDashboardController.php:602
    +msgid "Enable withdraw option."
    +msgstr ""
    +
    +#: includes/REST/VendorDashboardController.php:608
     msgid "Store AI Settings."
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:626
    +#: includes/REST/VendorDashboardController.php:633
     msgid "Start date to show orders"
     msgstr ""
     
    -#: includes/REST/VendorDashboardController.php:633
    +#: includes/REST/VendorDashboardController.php:640
     msgid "End date to show orders"
     msgstr ""
     
    @@ -9036,16 +9058,16 @@ msgstr ""
     msgid "List of withdraw IDs to be deleted"
     msgstr ""
     
    -#: includes/REST/WithdrawControllerV2.php:135
    +#: includes/REST/WithdrawControllerV2.php:136
     msgid "Please provide Withdraw method."
     msgstr ""
     
    -#: includes/REST/WithdrawControllerV2.php:139
    +#: includes/REST/WithdrawControllerV2.php:140
     #: includes/Withdraw/Hooks.php:253
     msgid "Method not active."
     msgstr ""
     
    -#: includes/REST/WithdrawControllerV2.php:145
    +#: includes/REST/WithdrawControllerV2.php:146
     #: includes/Withdraw/Hooks.php:259
     msgid "Default method update successful."
     msgstr ""
    
  • package.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "name": "dokan",
    -  "version": "5.0.2",
    +  "version": "5.0.3",
       "description": "A WordPress marketplace plugin",
       "author": "Dokan Inc.",
       "license": "GPL",
    
  • package-lock.json+2 2 modified
    @@ -1,12 +1,12 @@
     {
       "name": "dokan",
    -  "version": "5.0.2",
    +  "version": "5.0.3",
       "lockfileVersion": 3,
       "requires": true,
       "packages": {
         "": {
           "name": "dokan",
    -      "version": "5.0.2",
    +      "version": "5.0.3",
           "license": "GPL",
           "dependencies": {
             "@automattic/components": "^2.1.1",
    
  • readme.txt+6 4 modified
    @@ -7,7 +7,7 @@ Tested up to: 6.9
     WC requires at least: 8.5.0
     WC tested up to: 10.4.3
     Requires PHP: 7.4
    -Stable tag: 5.0.2
    +Stable tag: 5.0.3
     License: GPLv2 or later
     License URI: http://www.gnu.org/licenses/gpl-2.0.html
     
    @@ -352,6 +352,11 @@ A. Just install and activate the PRO version without deleting the free plugin. A
     
     == Changelog ==
     
    += v5.0.3 ( May 21, 2026 ) =
    +- **update:** Exposed manual withdrawal availability and withdraw-visibility flags in the vendor dashboard REST API.
    +- **fix:** Restricted the Customers REST endpoint to self-service to prevent vendors from modifying other user accounts.
    +- **fix:** Translated the "Actions" column header on vendor dashboard DataViews tables.
    +
     = v5.0.2 ( May 18, 2026 ) =
     - **new:** Added a date range filter on the vendor dashboard Withdraw Requests table to filter results by start and end dates.
     - **update:** Centralized vendor selling activation and deactivation and introduced a new filter to control vendor selling eligibility.
    @@ -402,7 +407,4 @@ A. Just install and activate the PRO version without deleting the free plugin. A
     - **fix:** Add the missing alt attribute to the store header banner image on the store listing page.
     - **fix:** Display a warning message when admin tries to approve a vendor withdrawal request exceeding the available balance.
     
    -= v4.3.2 ( Mar 13, 2026 ) =
    -- **fix:** Prevent unauthenticated access to customer information via the Store Reviews REST API endpoint.
    -
     [See changelog for all versions](https://github.com/getdokan/dokan/blob/develop/CHANGELOG.md).
    
  • src/dashboard/index.tsx+17 0 modified
    @@ -1,5 +1,7 @@
     import { createRoot } from '@wordpress/element';
     import domReady from '@wordpress/dom-ready';
    +import { addFilter } from '@wordpress/hooks';
    +import { __ } from '@wordpress/i18n';
     import Layout from '../layout';
     import getRoutes, { withRouter } from '../routing';
     import { createHashRouter, RouterProvider } from 'react-router-dom';
    @@ -8,6 +10,21 @@ import coreStore from '@dokan/stores/core';
     import Skeleton from '@src/layout/Skeleton';
     import { generateColorVariants } from '@dokan/utilities';
     
    +// `<DataViews>` (via @wordpress/dataviews) renders its Actions column header
    +// against the core 'default' text domain, whose translations don't load on the
    +// frontend. Re-route the 'default' "Actions" lookup through 'dokan-lite' so
    +// Dokan's translation wins.
    +addFilter(
    +    'i18n.gettext_default',
    +    'dokan-lite/dataviews-actions-label',
    +    ( translation: string, text: string ) => {
    +        if ( text !== 'Actions' ) {
    +            return translation;
    +        }
    +        return __( 'Actions', 'dokan-lite' );
    +    }
    +);
    +
     const App = () => {
         const routes = getRoutes();
         const loading = useSelect( ( select ) => {
    
  • templates/whats-new.php+22 0 modified
    @@ -3,6 +3,28 @@
      * When you are adding new version please follow this sequence for changes: New Feature, New, Improvement, Fix...
      */
     $changelog = [
    +    [
    +        'version'  => 'Version 5.0.3',
    +        'released' => '2026-05-21',
    +        'changes'  => [
    +            'Improvement' => [
    +                [
    +                    'title'       => 'Exposed manual withdrawal availability and withdraw-visibility flags in the vendor dashboard REST API.',
    +                    'description' => '',
    +                ],
    +            ],
    +            'Fix' => [
    +                [
    +                    'title'       => 'Restricted the Customers REST endpoint to self-service to prevent vendors from modifying other user accounts.',
    +                    'description' => '',
    +                ],
    +                [
    +                    'title'       => 'Translated the "Actions" column header on vendor dashboard DataViews tables.',
    +                    'description' => '',
    +                ],
    +            ],
    +        ],
    +    ],
         [
             'version'  => 'Version 5.0.2',
             'released' => '2026-05-18',
    
  • tests/php/src/REST/CustomersControllerTest.php+182 20 modified
    @@ -129,12 +129,13 @@ public function test_get_items() {
             $response = $this->get_request( 'customers' );
     
             $this->assertEquals( 200, $response->get_status() );
    -        $this->assertCount( 3, $response->get_data() );
    +        $this->assertCount( count( $this->customers ), $response->get_data() );
     
    -        // Test with per_page parameter
    +        // Test with per_page parameter. The vendor-scoped filter strips users
    +        // without orders from this vendor, so the count may be 0 or 1.
             $response = $this->get_request( 'customers', [ 'per_page' => 1 ] );
             $this->assertEquals( 200, $response->get_status() );
    -        $this->assertCount( 1, $response->get_data() );
    +        $this->assertLessThanOrEqual( 1, count( $response->get_data() ) );
     
             // Test with ordering
             $response = $this->get_request(
    @@ -532,44 +533,205 @@ public function test_search_validation_and_edge_cases() {
         }
     
         /**
    -     * Test customer role handling
    +     * CVE-2026-8761: creating a customer with a roles payload must be rejected.
    +     *
          * @throws Exception
          */
    -    public function test_customer_role_handling() {
    +    public function test_cannot_create_customer_with_roles() {
             wp_set_current_user( $this->seller_id1 );
     
    -        // Test creating customer with additional roles
             $customer_data = [
    -            'email'      => 'role.test@example.com',
    +            'email'      => 'role.create@example.com',
                 'first_name' => 'Role',
    -            'last_name'  => 'Test',
    -            'username'   => 'roletest',
    +            'last_name'  => 'Create',
    +            'username'   => 'rolecreate',
                 'password'   => 'password123',
                 'roles'      => [ 'customer', 'subscriber' ],
             ];
     
    +        $response = $this->post_request( 'customers', $customer_data );
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertFalse( get_user_by( 'email', 'role.create@example.com' ) );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: updating an existing customer with a roles payload must be rejected.
    +     *
    +     * @throws Exception
    +     */
    +    public function test_cannot_update_customer_with_roles() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $customer_data = [
    +            'email'      => 'role.update@example.com',
    +            'first_name' => 'Role',
    +            'last_name'  => 'Update',
    +            'username'   => 'roleupdate',
    +            'password'   => 'password123',
    +        ];
    +
             $response = $this->post_request( 'customers', $customer_data );
             $this->assertEquals( 201, $response->get_status() );
             $customer_id = $response->get_data()['id'];
     
    -        // Verify roles
             $customer = new WC_Customer( $customer_id );
    -        $customer_role = $customer->get_role();
    -        $this->assertEquals( 'customer', $customer_role );
    -
    -        // Test updating roles
    -        $update_data = [
    -            'roles' => [ 'customer' ],
    -        ];
    +        $this->assertEquals( 'customer', $customer->get_role() );
     
    -        $response = $this->put_request( "customers/$customer_id", $update_data );
    -        $this->assertEquals( 200, $response->get_status() );
    +        $response = $this->put_request(
    +            "customers/$customer_id",
    +            [
    +                'roles' => [ 'customer', 'subscriber' ],
    +            ]
    +        );
    +        $this->assertEquals( 403, $response->get_status() );
     
    -        // Verify updated roles
             $customer = new WC_Customer( $customer_id );
             $this->assertEquals( 'customer', $customer->get_role() );
         }
     
    +    /**
    +     * CVE-2026-8761: a vendor must not be able to update a user that
    +     * holds admin-grade capabilities, regardless of payload.
    +     */
    +    public function test_cannot_update_admin_user() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $response = $this->put_request(
    +            "customers/{$this->admin_id}",
    +            [
    +                'first_name' => 'Hijacked',
    +            ]
    +        );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $response->get_data()['code'] );
    +
    +        $admin = get_userdata( $this->admin_id );
    +        $this->assertNotEquals( 'Hijacked', $admin->first_name );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: the password overwrite PoC must be blocked.
    +     */
    +    public function test_admin_password_overwrite_blocked() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $original_hash = get_userdata( $this->admin_id )->user_pass;
    +
    +        $response = $this->put_request(
    +            "customers/{$this->admin_id}",
    +            [
    +                'password' => 'pwned_by_vendor',
    +            ]
    +        );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $response->get_data()['code'] );
    +
    +        $this->assertEquals( $original_hash, get_userdata( $this->admin_id )->user_pass );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: a vendor must not be able to delete an admin user.
    +     */
    +    public function test_cannot_delete_admin_user() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $response = $this->delete_request( "customers/{$this->admin_id}", [ 'force' => true ] );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_delete', $response->get_data()['code'] );
    +        $this->assertNotFalse( get_user_by( 'id', $this->admin_id ) );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: a vendor must not be able to update another vendor.
    +     */
    +    public function test_cannot_update_other_vendor() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        $response = $this->put_request(
    +            "customers/{$this->seller_id2}",
    +            [
    +                'first_name' => 'Tampered',
    +            ]
    +        );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $response->get_data()['code'] );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: a vendor must not be able to update a user who has
    +     * never placed an order with them.
    +     */
    +    public function test_cannot_update_unrelated_customer() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        // customers[0] has no order with seller_id1 in this test.
    +        $response = $this->put_request(
    +            "customers/{$this->customers[0]}",
    +            [
    +                'first_name' => 'Tampered',
    +            ]
    +        );
    +
    +        $this->assertEquals( 403, $response->get_status() );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $response->get_data()['code'] );
    +    }
    +
    +    /**
    +     * CVE-2026-8761: batch update must reject any entry that targets an
    +     * admin user, even when other entries are legitimate.
    +     */
    +    public function test_batch_update_blocks_admin_target() {
    +        wp_set_current_user( $this->seller_id1 );
    +
    +        // Establish a legitimate vendor/customer relationship.
    +        $this->factory()->order->set_seller_id( $this->seller_id1 )->create(
    +            [
    +                'customer_id' => $this->customers[0],
    +            ]
    +        );
    +
    +        $batch_data = [
    +            'update' => [
    +                [
    +                    'id'         => $this->customers[0],
    +                    'first_name' => 'Legit',
    +                ],
    +                [
    +                    'id'       => $this->admin_id,
    +                    'password' => 'pwned_by_vendor',
    +                ],
    +            ],
    +        ];
    +
    +        $original_hash = get_userdata( $this->admin_id )->user_pass;
    +
    +        $response = $this->post_request( 'customers/batch', $batch_data );
    +
    +        $data = $response->get_data();
    +
    +        // Even if the controller returns 200 overall, the admin entry
    +        // must have been rejected with an error, not mutated.
    +        $admin_entry = null;
    +        if ( ! empty( $data['update'] ) ) {
    +            foreach ( $data['update'] as $entry ) {
    +                if ( isset( $entry['id'] ) && (int) $entry['id'] === (int) $this->admin_id ) {
    +                    $admin_entry = $entry;
    +                    break;
    +                }
    +            }
    +        }
    +
    +        $this->assertNotNull( $admin_entry, 'Admin entry missing from batch response.' );
    +        $this->assertArrayHasKey( 'error', $admin_entry, 'Admin user was processed without error.' );
    +        $this->assertEquals( 'dokan_rest_cannot_edit', $admin_entry['error']['code'] );
    +
    +        $this->assertEquals( $original_hash, get_userdata( $this->admin_id )->user_pass );
    +    }
    +
         /**
          * Test error responses format
          */
    
  • tests/pw/tests/e2e/abuse-reports/abuseReportsPage.ts+6 3 modified
    @@ -283,7 +283,8 @@ export class AbuseReportsPage {
         }
     
         async isSettingsHeadingVisible(): Promise<boolean> {
    -        return await this.page.getByRole('heading', { name: /Product Report Abuse Settings/i }).isVisible();
    +        await this.page.getByRole('heading', { name: /Product Report Abuse Settings/i }).waitFor({ state: 'visible', timeout: 10000 });
    +        return true;
         }
     
         async getSettingsDocLinkHref(): Promise<string> {
    @@ -292,11 +293,13 @@ export class AbuseReportsPage {
         }
     
         async isReportedByHeadingVisible(): Promise<boolean> {
    -        return await this.page.locator(this.admin.reportedByHeading).isVisible();
    +        await this.page.locator(this.admin.reportedByHeading).waitFor({ state: 'visible', timeout: 10000 });
    +        return true;
         }
     
         async isReasonsHeadingVisible(): Promise<boolean> {
    -        return await this.page.locator(this.admin.reasonsHeading).isVisible();
    +        await this.page.locator(this.admin.reasonsHeading).waitFor({ state: 'visible', timeout: 10000 });
    +        return true;
         }
     
         async enableReportedBySliderIfDisabled() {
    
  • tests/pw/tests/e2e/admin/admin.spec.ts+13 13 modified
    @@ -136,7 +136,7 @@ test.describe('Admin Tests @lite', () => {
     test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
         test('Test Case 1 - / (root) route mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/page=dokan-dashboard/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -145,7 +145,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 2 - /vendors mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/vendors');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/vendors/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -154,7 +154,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 3 - /vendors/create mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/vendors/create');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/vendors\/create/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -163,7 +163,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 4 - /pro-modules mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/pro-modules');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/pro-modules/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -172,7 +172,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 5 - /setup (setup guide) mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/setup');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/setup/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -181,7 +181,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 6 - /extensions mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/extensions');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/extensions/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -190,7 +190,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 7 - /changelog mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/changelog');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/changelog/);
             // The changelog renders historical fix messages that include the
             // word "error" or even "fatal error" in body copy. We don't apply
    @@ -204,7 +204,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 8 - /dummy-data mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/dummy-data');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/dummy-data/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -213,7 +213,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 9 - /reverse-withdrawal mounts', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/reverse-withdrawal');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/reverse-withdrawal/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -222,7 +222,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 10 - /withdraw (admin) mounts', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/withdraw');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/withdraw/);
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             await page.close();
    @@ -231,7 +231,7 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 11 - Unknown HashRouter route falls through (NoMatch)', { tag: ['@lite', '@exploratory', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/this-does-not-exist');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(await page.locator(fatalLocator).first().isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
             // The shell still renders (#wpwrap from WP admin layout)
             await expect(page.locator(adminReactRoot).first()).toBeVisible();
    @@ -241,9 +241,9 @@ test.describe('Admin Dokan Dashboard (React) Tests @lite', () => {
     
         test('Test Case 12 - Reload preserves /vendors route', { tag: ['@lite', '@admin'] }, async ({ browser }) => {
             const { ctx, page } = await openAdminRoute(browser, '/vendors');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             await page.reload({ waitUntil: 'domcontentloaded' });
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/vendors/);
             await page.close();
             await ctx.close();
    
  • tests/pw/tests/e2e/announcements/announcementsPage.ts+40 11 modified
    @@ -351,15 +351,32 @@ export class AnnouncementsPage {
             ]);
             await this.page.waitForLoadState('load');
     
    -        // saveAsDraft stays on the edit form; navigate back to the announcement list
    -        await this.goToAnnouncementsPage();
    -        // Default tab is "All" which is paginated; a freshly-created draft
    -        // can land beyond page 1 depending on existing-data ordering. Switch
    -        // to the Draft tab so the new draft is guaranteed on page 1.
    -        await Promise.all([
    -            this.page.waitForResponse(res => res.url().includes('dokan/v1/announcement') && res.status() === 200).catch(() => null),
    -            this.page.locator(this.admin.navTabs.draft).click(),
    -        ]);
    +        // saveAsDraft stays on the edit form; navigate directly to the Draft
    +        // tab via the hash route. Default tab is "All" which is paginated, so
    +        // a freshly-created draft can land beyond page 1 in a dirty DB; the
    +        // Draft filter sorts the new row to page 1.
    +        //
    +        // Why goto() and not navTabs.draft.click(): the tab click races the
    +        // post-navigate re-render — the table loader overlay appears AFTER
    +        // any pre-click wait runs (when the React shell starts fetching) and
    +        // intercepts pointer events for 15s. A hash-route navigation triggers
    +        // the same Vue/React routing without the actionability race.
    +        await this.page.goto(toPath(`wp-admin/admin.php?page=dokan#/announcement?status=draft`));
    +        await this.page.waitForLoadState('load');
    +        // Mirror goToAnnouncementsPage's table-ready wait. A hash-route nav
    +        // resolves before the Vue list re-fetches with the new filter, so a
    +        // straight visibility assertion races the fetch.
    +        await this.page.waitForFunction(
    +            () => {
    +                const table = document.querySelector('table.wp-list-table');
    +                if (!table) return false;
    +                const tbody = table.querySelector('tbody');
    +                if (!tbody) return false;
    +                return tbody.querySelectorAll('tr').length > 0 || !!tbody.querySelector('.no-items');
    +            },
    +            null,
    +            { timeout: 15000 },
    +        );
             // Draft items render as <a> wrapped in <strong>; announcementCell
             // walks up from the inner <a> to the <td>.
             await expect(this.page.locator(this.admin.announcementCell(title))).toBeVisible();
    @@ -623,8 +640,11 @@ export class AnnouncementsPage {
             await this.page.goto(this.adminNewDashboard.announcementsUrl);
             await this.page.waitForLoadState('domcontentloaded');
     
    +        // The status text can appear in column header + badge cells; use
    +        // .first() to avoid strict-mode violations when more than one
    +        // matching node is rendered.
             await expect(
    -            this.page.getByText('Scheduled', { exact: true }),
    +            this.page.getByText('Scheduled', { exact: true }).first(),
                 'Text "Scheduled" should be visible on the page',
             ).toBeVisible();
     
    @@ -634,8 +654,17 @@ export class AnnouncementsPage {
         async trashAnnouncementByTitle(title: string) {
             await this.page.locator(this.adminNewDashboard.rowActionButton(title)).click();
             await this.page.locator(this.adminNewDashboard.moveToTrash).click();
    -        await this.page.locator(this.adminNewDashboard.confirmTrash).click();
    +        // The confirm fires a REST mutation; the table then refetches.
    +        // Sequential trash calls used to race the loader and the next row's
    +        // Actions button became unclickable.
    +        await Promise.all([
    +            this.page.waitForResponse(res => res.url().includes('dokan/v1/announcement') && res.status() < 400),
    +            this.page.locator(this.adminNewDashboard.confirmTrash).click(),
    +        ]);
             await this.page.waitForLoadState('domcontentloaded');
    +        // Wait for the post-mutation table re-render to settle before any
    +        // follow-up Actions-button click on the same view.
    +        await expect(this.page.locator('.loading')).toBeHidden({ timeout: 15000 });
         }
     
         async trashAndPermanentlyDeleteAnnouncementsInNewAdminDashboard() {
    
  • tests/pw/tests/e2e/announcements/announcements.spec.ts+15 0 modified
    @@ -1,8 +1,11 @@
     import { test, expect } from '@utils/test';
    +import { request } from '@playwright/test';
     import { AnnouncementsPage } from './announcementsPage';
     import path from 'path';
     
     import { toPath } from '@utils/helpers';
    +import { ApiUtils } from '@utils/apiUtils';
    +import { payloads } from '@utils/payloads';
     
     // ============================================
     // SESSION STORAGE VARIABLES
    @@ -15,6 +18,18 @@ const v1 = path.join(__dirname, '../../../playwright/.auth/vendorStorageState.js
     // ============================================
     
     test.describe('Announcements Tests @pro', () => {
    +    // Wipe accumulated announcements before this file's tests run. The legacy
    +    // admin list paginates (10/page) and sorts by date desc — once the table
    +    // grows past a few hundred rows (especially with scheduled-in-the-future
    +    // rows that pin to the top), freshly-published rows from these tests fall
    +    // off page 1 and the `<strong>title</strong>` selectors stop finding them.
    +    test.beforeAll(async () => {
    +        const apiUtils = new ApiUtils(await request.newContext());
    +        await apiUtils.deleteAllAnnouncements(payloads.adminAuth);
    +        await apiUtils.dispose();
    +    });
    +
    +
         // ============================================
         // TEST CASES
         // ============================================
    
  • tests/pw/tests/e2e/brand-filter/brandFilter.spec.ts+6 3 modified
    @@ -22,9 +22,12 @@ test.describe('Product Brand Filter (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`shop/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/commission/commissionPage.ts+21 14 modified
    @@ -87,20 +87,27 @@ export class CommissionPage {
                 .locator('div.nav-tab')
                 .filter({ has: this.page.locator('div.nav-title', { hasText: /^\s*Selling Options\s*$/ }) });
     
    -        // Do NOT force the click. If the tab fails actionability, the Vue
    -        // settings shell hasn't fully mounted and a forced click would fire on
    -        // inert markup — the click event dispatches but Vue's handler doesn't
    -        // react, leaving the commission form unrendered and the dropdown wait
    -        // timing out. Retry click + render-check together so a transiently-
    -        // inert tab doesn't poison the run.
    -        await expect(async () => {
    -            await tab.click();
    -            // Proof that the click registered AND Vue rendered the panel:
    -            // the outer `.nav-tab` gains `.nav-tab-active` and the commission
    -            // form mounts.
    -            await expect(tabContainer).toHaveClass(/\bnav-tab-active\b/, { timeout: 2000 });
    -            await this.page.locator(this.admin.commissionTypeDropdown).waitFor({ state: 'visible', timeout: 5000 });
    -        }).toPass({ timeout: 20000 });
    +        // The Vue settings shell persists the last active tab across navigations.
    +        // If Selling Options is already active on entry, clicking it is a no-op
    +        // (no class change → no re-render trigger), which used to leave the
    +        // dropdown wait spinning while Vue was still mid-mount.
    +        const alreadyActive = await tabContainer.evaluate(el => el.classList.contains('nav-tab-active'));
    +        if (!alreadyActive) {
    +            // Do NOT force the click. If the tab fails actionability, the Vue
    +            // settings shell hasn't fully mounted and a forced click would fire
    +            // on inert markup — the click event dispatches but Vue's handler
    +            // doesn't react, leaving the commission form unrendered. Retry
    +            // click + class-check together so a transiently-inert tab doesn't
    +            // poison the run.
    +            await expect(async () => {
    +                await tab.click();
    +                await expect(tabContainer).toHaveClass(/\bnav-tab-active\b/, { timeout: 2000 });
    +            }).toPass({ timeout: 20000 });
    +        }
    +
    +        // Whether we clicked or skipped, the dropdown is the real signal that
    +        // Vue has finished mounting the Selling Options panel.
    +        await this.page.locator(this.admin.commissionTypeDropdown).waitFor({ state: 'visible', timeout: 30000 });
         }
     
         async selectCommissionType(value: string) {
    
  • tests/pw/tests/e2e/export-import/exportImport.spec.ts+6 3 modified
    @@ -24,9 +24,12 @@ test.describe('Vendor Export/Import (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/tools/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/follow-store/followStore.spec.ts+6 3 modified
    @@ -167,9 +167,12 @@ test.describe('Follow Store Vendor (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/followers/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/frontend-badges/frontendBadges.spec.ts+6 3 modified
    @@ -22,9 +22,12 @@ test.describe('Frontend Vendor Badges (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`store/vendor1/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/license/license.spec.ts+8 5 modified
    @@ -63,7 +63,7 @@ test.describe('Admin License Manager (React) Tests @pro', () => {
         test('Test Case 1 - License manager page renders', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/admin.php?page=dokan-license`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/license`));
             await page.waitForLoadState('domcontentloaded');
             await page.waitForTimeout(2000);
             const fatal = await page.locator(".notice-error, body.error-page").first().isVisible({ timeout: 1000 }).catch(() => false);
    @@ -75,11 +75,14 @@ test.describe('Admin License Manager (React) Tests @pro', () => {
         test('Test Case 2 - License page renders content', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/admin.php?page=dokan-license`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/license`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/live-chat/liveChat.spec.ts+6 3 modified
    @@ -122,9 +122,12 @@ test.describe('Live Chat (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/inbox/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/manual-order-pro/manualOrderPro.spec.ts+6 3 modified
    @@ -24,9 +24,12 @@ test.describe('Pro Manual Order (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/orders/?manual_order=1`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/new-coupons/newCoupons.spec.ts+6 3 modified
    @@ -92,9 +92,12 @@ test.describe('Vendor Coupons (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/coupons/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/onboarding/onboarding.spec.ts+9 6 modified
    @@ -4,13 +4,13 @@ import path from 'path';
     const a1 = path.join(__dirname, '../../../playwright/.auth/adminStorageState.json');
     import { toPath } from '@utils/helpers';
     
    -// Admin Onboarding Wizard (React) — admin React surface in Dokan 5.0.0+. Mount URL: /wp-admin/admin.php?page=dokan-onboard
    +// Admin Onboarding Wizard (React) — admin React surface in Dokan 5.0.0+. Mount URL: /wp-admin/admin.php?page=dokan-setup
     
     test.describe('Admin Onboarding Wizard (React) Tests @pro', () => {
         test('Test Case 1 - Page renders without fatal', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/admin.php?page=dokan-onboard`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-setup`));
             await page.waitForLoadState('domcontentloaded');
             await page.waitForTimeout(2000);
             const fatal = await page.locator(".notice-error, body.error-page").first().isVisible({ timeout: 1000 }).catch(() => false);
    @@ -22,11 +22,14 @@ test.describe('Admin Onboarding Wizard (React) Tests @pro', () => {
         test('Test Case 2 - Page renders content', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/admin.php?page=dokan-onboard`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-setup`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/payments/payments.spec.ts+6 3 modified
    @@ -184,9 +184,12 @@ test.describe('Vendor Payments Settings (React) Tests @lite', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/payment/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/printful/printful.spec.ts+6 3 modified
    @@ -79,9 +79,12 @@ test.describe('Printful (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/printful/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/product-addons/productAddons.spec.ts+6 3 modified
    @@ -131,9 +131,12 @@ test.describe('Product Add-ons (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/addon/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/product-bulk-edit/productBulkEdit.spec.ts+6 3 modified
    @@ -24,9 +24,12 @@ test.describe('Product Bulk Edit (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/products/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/product-qa/productQA.spec.ts+6 3 modified
    @@ -185,9 +185,12 @@ test.describe('Product Q&A (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/product-questions-answers/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/product-reviews/productReviews.spec.ts+5 5 modified
    @@ -78,7 +78,7 @@ test.describe('Vendor Reviews (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/reviews/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
     
             const fatal = await page.locator("text=/Fatal error|Parse error|There has been a critical error/i").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal, 'Reviews page should not show a PHP fatal').toBe(false);
    @@ -91,7 +91,7 @@ test.describe('Vendor Reviews (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/reviews/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
     
             const tableVisible = await page.locator('table, [role="table"]').first().isVisible({ timeout: 3000 }).catch(() => false);
             const emptyVisible = await page.locator("text=/no reviews|nothing to show|empty/i").first().isVisible({ timeout: 1000 }).catch(() => false);
    @@ -106,7 +106,7 @@ test.describe('Vendor Reviews (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/reviews/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
     
             await expect(
                 page.locator("h1, h2, h3").filter({ hasText: /reviews?/i }).first(),
    @@ -122,9 +122,9 @@ test.describe('Vendor Reviews (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/reviews/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
             await page.reload({ waitUntil: 'domcontentloaded' });
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
             const fatal = await page.locator("text=/Fatal error|Parse error/i").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal).toBe(false);
             await page.close();
    
  • tests/pw/tests/e2e/product-tabs/productTabs.spec.ts+6 3 modified
    @@ -22,9 +22,12 @@ test.describe('Product Tabs (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`product/p1_v1-simple/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/product-variations/productVariations.spec.ts+6 3 modified
    @@ -24,9 +24,12 @@ test.describe('Product Variations Editor (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/new/#/products/create`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/refunds/refunds.spec.ts+6 3 modified
    @@ -88,9 +88,12 @@ test.describe('Admin Refunds (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`wp-admin/admin.php?page=dokan#/refund`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/request-for-quotes/requestForQuotes.spec.ts+7 3 modified
    @@ -188,10 +188,14 @@ test.describe('Request For Quotes (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/requested-quotes/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
             // Page may show a heading-only screen with "you haven't received any quotes" copy.
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length, 'RFQ page should render some content').toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +                message: 'RFQ page should render some content',
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/setup-guide/setupGuide.spec.ts+7 3 modified
    @@ -78,10 +78,14 @@ test.describe('Admin Setup Guide (React) Tests @lite', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/setup`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
             // Just verify the body has content (not a blank page).
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length, 'Setup guide page should not be blank').toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +                message: 'Setup guide page should not be blank',
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/single-store/singleStore.spec.ts+6 3 modified
    @@ -51,9 +51,12 @@ test.describe('Single Store Front-end (React-aware) Tests @lite', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`store/vendor1/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/social-linking/socialLinking.spec.ts+6 3 modified
    @@ -24,9 +24,12 @@ test.describe('Vendor Social Linking (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/social/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/spmv/spmv.spec.ts+6 3 modified
    @@ -110,9 +110,12 @@ test.describe('SPMV (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`wp-admin/admin.php?page=dokan#/spmv`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/store-listing/storeListing.spec.ts+6 3 modified
    @@ -56,9 +56,12 @@ test.describe('Store Listing Front-end (React-aware) Tests @lite', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`store-listing/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/store-seo/storeSeo.spec.ts+6 3 modified
    @@ -24,9 +24,12 @@ test.describe('Store SEO (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/seo/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/stores/stores.spec.ts+6 6 modified
    @@ -70,7 +70,7 @@ test.describe('Admin Vendors (React) Tests @lite', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/vendors`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/vendors/);
             const fatal = await page.locator(".notice-error, body.error-page").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal).toBe(false);
    @@ -83,7 +83,7 @@ test.describe('Admin Vendors (React) Tests @lite', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/vendors`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             const tableVisible = await page.locator('table, [role="table"], [class*="dataviews"]').first().isVisible({ timeout: 3000 }).catch(() => false);
             expect(tableVisible, 'Vendors page should show a DataViews table').toBe(true);
             await page.close();
    @@ -95,7 +95,7 @@ test.describe('Admin Vendors (React) Tests @lite', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/vendors/create`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/vendors\/create/);
             // Form should have a name / username input
             const formInput = page.locator('input[type="text"], input[type="email"]');
    @@ -109,9 +109,9 @@ test.describe('Admin Vendors (React) Tests @lite', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/vendors`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             await page.reload({ waitUntil: 'domcontentloaded' });
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toMatch(/#\/vendors/);
             await page.close();
             await ctx.close();
    @@ -123,7 +123,7 @@ test.describe('Admin Vendors (React) Tests @lite', () => {
             const vendorId = process.env.VENDOR_ID || '1';
             await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/vendors/${vendorId}`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             expect(page.url()).toContain('#/vendors/');
             const fatal = await page.locator(".notice-error, body.error-page").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal).toBe(false);
    
  • tests/pw/tests/e2e/store-supports/storeSupports.spec.ts+6 6 modified
    @@ -176,7 +176,7 @@ test.describe('Store Supports (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/support/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
             const fatal = await page.locator("text=/Fatal error|Parse error|There has been a critical error/i").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal).toBe(false);
             await page.close();
    @@ -188,7 +188,7 @@ test.describe('Store Supports (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/support/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
             const tableVisible = await page.locator('table, [role="table"]').first().isVisible({ timeout: 3000 }).catch(() => false);
             const emptyVisible = await page.locator("text=/no tickets|no support|nothing/i").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(tableVisible || emptyVisible).toBe(true);
    @@ -199,9 +199,9 @@ test.describe('Store Supports (React) Tests @pro', () => {
         test('Test Case 3 - Admin support page renders', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/edit.php?post_type=dokan_store_support`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/admin-store-support`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-admin-dashboard .pui-root').first()).toBeVisible({ timeout: 30_000 });
             const fatal = await page.locator(".notice-error, body.error-page").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal).toBe(false);
             await page.close();
    @@ -213,9 +213,9 @@ test.describe('Store Supports (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/support/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
             await page.reload({ waitUntil: 'domcontentloaded' });
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
             const fatal = await page.locator("text=/Fatal error|Parse error/i").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal).toBe(false);
             await page.close();
    
  • tests/pw/tests/e2e/table-rate-shipping/tableRateShipping.spec.ts+6 3 modified
    @@ -24,9 +24,12 @@ test.describe('Table Rate Shipping (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/shipping`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-analytics/vendorAnalytics.spec.ts+6 3 modified
    @@ -64,9 +64,12 @@ test.describe('Vendor Analytics (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/analytics/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-auction/vendorAuction.spec.ts+6 3 modified
    @@ -100,9 +100,12 @@ test.describe('Vendor Auction (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/auction/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-booking/vendorBooking.spec.ts+6 3 modified
    @@ -115,9 +115,12 @@ test.describe('Vendor Booking (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/booking/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-delivery-time/vendorDeliveryTime.spec.ts+12 6 modified
    @@ -84,9 +84,12 @@ test.describe('Vendor Delivery Time (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/delivery-time-dashboard/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    @@ -117,9 +120,12 @@ test.describe('Delivery Time Front-end (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`product/p1_v1-simple/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(4000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-reports-admin/vendorReportsAdmin.spec.ts+8 5 modified
    @@ -49,7 +49,7 @@ test.describe('Admin Vendor Reports (React) Tests @pro', () => {
         test('Test Case 1 - Admin reports page renders', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/admin.php?page=dokan_reports`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/reports`));
             await page.waitForLoadState('domcontentloaded');
             await page.waitForTimeout(2000);
             const fatal = await page.locator(".notice-error, body.error-page").first().isVisible({ timeout: 1000 }).catch(() => false);
    @@ -61,11 +61,14 @@ test.describe('Admin Vendor Reports (React) Tests @pro', () => {
         test('Test Case 2 - Admin reports page renders content', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/admin.php?page=dokan_reports`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/reports`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-reports/vendorReports.spec.ts+6 3 modified
    @@ -89,9 +89,12 @@ test.describe('Vendor Reports (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/reports/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-return-request/vendorReturnRequest.spec.ts+6 3 modified
    @@ -96,9 +96,12 @@ test.describe('Vendor Return Request (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/return-request/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-shipping/vendorShipping.spec.ts+5 5 modified
    @@ -48,7 +48,7 @@ test.describe('Vendor Shipping (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/shipping`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
     
             const fatal = await page.locator("text=/Fatal error|Parse error|There has been a critical error/i").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal, 'Vendor shipping page should not show a PHP fatal').toBe(false);
    @@ -61,7 +61,7 @@ test.describe('Vendor Shipping (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/shipping`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
     
             // Either React zone list (e.g. .dokan-shipping-zones) or legacy table (.shipping-method-table)
             const reactZones = await page.locator('[class*="shipping-zone"], [class*="ShippingZone"], .dokan-react-shipping').first().isVisible({ timeout: 3000 }).catch(() => false);
    @@ -81,7 +81,7 @@ test.describe('Vendor Shipping (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/shipping`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
     
             // Look for shipping-policy related text
             const policyVisible = await page.locator("text=/shipping policy|policy|refund/i").first().isVisible({ timeout: 3000 }).catch(() => false);
    @@ -97,9 +97,9 @@ test.describe('Vendor Shipping (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/shipping`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
             await page.reload({ waitUntil: 'domcontentloaded' });
    -        await page.waitForTimeout(2000);
    +        await expect(page.locator('#dokan-vendor-dashboard-layout-root')).toBeVisible({ timeout: 30_000 });
             const fatal = await page.locator("text=/Fatal error|Parse error/i").first().isVisible({ timeout: 1000 }).catch(() => false);
             expect(fatal, 'Reload should not crash the shipping page').toBe(false);
             await page.close();
    
  • tests/pw/tests/e2e/vendor-staff/vendorStaff.spec.ts+6 3 modified
    @@ -99,9 +99,12 @@ test.describe('Vendor Staff (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/vendor-staff/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-subscriptions/vendorSubscriptions.spec.ts+6 3 modified
    @@ -126,9 +126,12 @@ test.describe('Vendor Subscriptions (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/user-subscription/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-tools/vendorTools.spec.ts+6 3 modified
    @@ -68,9 +68,12 @@ test.describe('Vendor Tools (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/tools/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/vendor-verifications/vendorVerifications.spec.ts+14 8 modified
    @@ -185,9 +185,12 @@ test.describe('Vendor Verifications (React) Tests @pro', () => {
             const page = await ctx.newPage();
             await page.goto(toPath(`dashboard/settings/verification/`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    @@ -204,7 +207,7 @@ test.describe('Admin Vendor Verifications (React) Tests @pro', () => {
         test('Test Case 1 - Page renders without fatal', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/admin.php?page=dokan-vendor-verification`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/verifications`));
             await page.waitForLoadState('domcontentloaded');
             await page.waitForTimeout(2000);
             const fatal = await page.locator(".notice-error, body.error-page").first().isVisible({ timeout: 1000 }).catch(() => false);
    @@ -216,11 +219,14 @@ test.describe('Admin Vendor Verifications (React) Tests @pro', () => {
         test('Test Case 2 - Page renders content', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/admin.php?page=dokan-vendor-verification`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/verifications`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    
  • tests/pw/tests/e2e/wholesale/wholesale.spec.ts+8 5 modified
    @@ -156,7 +156,7 @@ test.describe('Wholesale (React) Tests @pro', () => {
         test('Test Case 1 - Admin wholesale customer page renders', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/users.php?page=wholesale_customer`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/wholesale-customer`));
             await page.waitForLoadState('domcontentloaded');
             await page.waitForTimeout(2000);
             const fatal = await page.locator(".notice-error, body.error-page").first().isVisible({ timeout: 1000 }).catch(() => false);
    @@ -168,11 +168,14 @@ test.describe('Wholesale (React) Tests @pro', () => {
         test('Test Case 2 - Wholesale page renders content', { tag: ['@pro', '@admin'] }, async ({ browser }) => {
             const ctx = await browser.newContext({ storageState: a1 });
             const page = await ctx.newPage();
    -        await page.goto(toPath(`wp-admin/users.php?page=wholesale_customer`));
    +        await page.goto(toPath(`wp-admin/admin.php?page=dokan-dashboard#/wholesale-customer`));
             await page.waitForLoadState('domcontentloaded');
    -        await page.waitForTimeout(3000);
    -        const bodyText = await page.locator('body').innerText();
    -        expect(bodyText.trim().length).toBeGreaterThan(50);
    +        await expect
    +            .poll(async () => (await page.locator('body').innerText()).trim().length, {
    +                timeout: 30_000,
    +                intervals: [500, 1000, 2000, 3000],
    +            })
    +            .toBeGreaterThan(50);
             await page.close();
             await ctx.close();
         });
    

Vulnerability mechanics

Root cause

"Missing object-level authorization in the Dokan REST customers endpoint allows a vendor to mutate arbitrary users, including administrators, and to assign privileged roles."

Attack vector

An authenticated vendor (lowest privileged user) sends a crafted REST API request to the Dokan customers endpoint (e.g., `PUT /wp-json/dokan/v1/customers/<admin_id>` or `POST /wp-json/dokan/v1/customers` with a `roles` payload). Because the controller did not verify that the target user belongs to the vendor's customer base or holds admin capabilities, the vendor could overwrite an administrator's password, assign themselves elevated roles, or delete admin accounts. The attack is network-accessible and requires no special configuration beyond a valid vendor session.

Affected code

The vulnerability resides in `includes/REST/CustomersController.php` (the Dokan REST API customers endpoint). The `check_permission()` method lacked object-level authorization for mutating actions (edit/delete), and `prepare_object_for_database()` did not strip the `role`/`roles` parameter, allowing a vendor to escalate privileges or modify admin users. The patch also adds a `is_target_user_allowed()` helper and tightens the `check_vendor_permission()` filter callback.

What the fix does

The patch introduces `is_target_user_allowed()` in `CustomersController.php`, which rejects requests targeting users with admin-level capabilities (`manage_options`, `edit_users`, etc.), other vendors, or customers who have never placed an order with the requesting vendor. It also adds a check in `prepare_object_for_database()` that returns a 403 error if the `role` or `roles` parameter is present, preventing privilege escalation via role assignment. The `check_vendor_permission()` method is refactored into a filter callback that re-validates the target user for create/edit/delete/batch contexts, and the `get_items()` method now filters out customers unrelated to the vendor. These changes collectively enforce object-level authorization on every mutating REST endpoint.

Preconditions

  • authAttacker must be an authenticated vendor (seller) on the Dokan marketplace.
  • configThe Dokan plugin version must be <= 5.0.2.
  • networkThe REST API endpoint must be accessible over the network.
  • inputAttacker sends a crafted JSON payload containing a target user ID or role/roles parameter.

Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

1