PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
7.2.2 7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 All 37 releases
← All changes | includes/mlsimport-onboarding.php +291 -342 7.0.4 → 7.2.1 View file →
@@ -3,8 +3,12 @@
3 3 * MLSImport Onboarding Wizard
4 4 *
5 5 * This file contains all the functionality for the onboarding wizard
6 6 * that guides users through the initial setup of the MLSImport plugin.
7 + * Manual account checks discard the prior login verdict before testing the
8 + * submitted credentials, so a failed retry cannot reuse a subscription notice.
9 + * The account identifier is a username or email; both use the established
10 + * mlsimport_username option and token API parameter for compatibility.
7 11 *includes\mlsimport-onboarding.php
8 12 * @link https://mlsimport.com/
9 13 * @since 6.1.0
10 14 *
@@ -40,17 +44,12 @@
40 44
41 45 // Add assets for onboarding
42 46 add_action('admin_enqueue_scripts', 'mlsimport_enqueue_onboarding_assets');
43 47
44 - // Register AJAX handlers
45 - add_action('wp_ajax_mlsimport_test_account_connection', 'mlsimport_ajax_test_account_connection');
46 - add_action('wp_ajax_mlsimport_test_mls_connection', 'mlsimport_ajax_test_mls_connection');
48 + // Register the AJAX handlers used by the onboarding wizard.
47 49 add_action('wp_ajax_mlsimport_run_test_import', 'mlsimport_ajax_run_test_import');
48 50 add_action('wp_ajax_mlsimport_save_step_data', 'mlsimport_ajax_save_step_data');
49 51
50 - // Add admin notice for incomplete onboarding
51 - add_action('admin_notices', 'mlsimport_onboarding_admin_notice');
52 -
53 52 // Intercept form submissions
54 53 add_action('admin_init', 'mlsimport_handle_step_submission');
55 54 }
56 55
@@ -126,10 +125,24 @@
126 125 * @since 6.1.0
127 126 * @param string $hook The current admin page
128 127 */
129 128 function mlsimport_enqueue_onboarding_assets($hook) {
130 - // Bail unless we are on one of the two hook slugs the wizard renders under.
131 - if ($hook != 'admin_page_mlsimport-onboarding' && $hook != 'mlsimport_plugin_options_page_mlsimport-onboarding') {
129 + /*
130 + * Bail unless we are on the wizard page.
131 + *
132 + * This deliberately tests the page slug rather than $hook. WordPress does not
133 + * build a submenu's hook from its parent SLUG — it uses the sanitized parent
134 + * MENU TITLE. The wizard's parent is registered with the title "MLS Import
135 + * Settings", so the real hook is 'mls-import-settings_page_mlsimport-onboarding',
136 + * which matched neither of the two hook strings previously hardcoded here.
137 + * The result was that this function returned immediately on the wizard page:
138 + * mlsimport-onboarding.js and the MLS autocomplete data were never enqueued,
139 + * so the "search your MLS" field had no autocomplete at all.
140 + *
141 + * The page slug is what the wizard itself is registered and routed by, and it
142 + * does not change when a menu title is edited or a parent is moved.
143 + */
144 + if ( ! isset( $_GET['page'] ) || 'mlsimport-onboarding' !== $_GET['page'] ) {
132 145 return;
133 146 }
134 147 // Fetch the list of MLS providers (used to feed the account-step autocomplete).
135 148 $mls_import_list = mlsimport_saas_request_list();
@@ -162,9 +175,8 @@
162 175 'strings' => array(
163 176 'saving' => __('Saving...', 'mlsimport'),
164 177 'next' => __('Next', 'mlsimport'),
165 178 'back' => __('Back', 'mlsimport'),
166 - 'skip' => __('Skip', 'mlsimport'),
167 179 'connecting' => __('Connecting...', 'mlsimport'),
168 180 'testing' => __('Testing...', 'mlsimport'),
169 181 'importing' => __('Importing...', 'mlsimport'),
170 182 'success' => __('Success!', 'mlsimport'),
@@ -172,21 +184,31 @@
172 184 )
173 185 )
174 186 );
175 187
176 - // Only on the account step of the wizard page: inject the autocomplete data.
177 - // Clauses test hook slug, page=mlsimport-onboarding, and step=account.
178 - if ( $hook === 'admin_page_mlsimport-onboarding' &&
179 - isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding' &&
180 - isset($_GET['step']) && $_GET['step'] === 'account') {
188 + /*
189 + * Only the account step needs the MLS-provider autocomplete data.
190 + *
191 + * The step is resolved with mlsimport_get_current_step() — the SAME call the
192 + * wizard itself uses to decide which step to render — rather than by reading
193 + * $_GET['step'] directly. Those are not equivalent: the step falls back to the
194 + * saved 'mlsimport_onboarding_current_step' option when the URL has no step
195 + * parameter, which is what happens on the two entry points that matter most —
196 + * the post-activation redirect and the "Setup Wizard" submenu link, both of
197 + * which point at plain admin.php?page=mlsimport-onboarding. On those the
198 + * account step renders while $_GET['step'] is unset, so a $_GET-based test
199 + * skips the script and the MLS field silently has no autocomplete.
200 + *
201 + * The hook slug is likewise not re-tested here: this function already returned
202 + * early above unless $hook is one of the wizard's two slugs. Re-testing only
203 + * 'admin_page_mlsimport-onboarding' dropped the script whenever WordPress
204 + * resolved the page under the submenu hook instead.
205 + */
206 + if ( mlsimport_get_current_step() === 'account' && ! empty( $mls_import_list ) ) {
181 207
182 - // Only add the inline script when the provider list is non-empty.
183 - if (!empty($mls_import_list)) {
184 -
185 - // Build a ready-handler that primes the MLS-selection autocomplete widget.
186 - $inline_script = 'jQuery(document).ready(function($){ var autofill=' . wp_kses_post($mls_import_list) . '; mlsimport_autocomplte_mls_selection(autofill); });';
187 - wp_add_inline_script('mlsimport-onboarding-script', $inline_script);
188 - }
208 + // Build a ready-handler that primes the MLS-selection autocomplete widget.
209 + $inline_script = 'jQuery(document).ready(function($){ var autofill=' . wp_kses_post($mls_import_list) . '; mlsimport_autocomplte_mls_selection(autofill); });';
210 + wp_add_inline_script('mlsimport-onboarding-script', $inline_script);
189 211 }
190 212
191 213
192 214
@@ -295,11 +317,15 @@
295 317 $user_data = get_option('mlsimport_onboarding_user_data', array());
296 318
297 319 // Sanitize data
298 320 // Walk each posted field; array values are sanitized element-by-element.
321 + // Credential keys (password/secret/token) are kept verbatim — the
322 + // sanitizer strips %[hex][hex] sequences and would corrupt them (#204).
299 323 $sanitized_data = array();
300 324 foreach ($data as $key => $value) {
301 - if (is_array($value)) {
325 + if (mlsimport_is_credential_key($key)) {
326 + $sanitized_data[$key] = trim((string) $value);
327 + } elseif (is_array($value)) {
302 328 $sanitized_data[$key] = array_map('sanitize_text_field', $value);
303 329 } else {
304 330 $sanitized_data[$key] = sanitize_text_field($value);
305 331 }
@@ -420,37 +446,81 @@
420 446 mlsimport_redirect_to_next_step($current_step);
421 447 break;
422 448
423 449 case 'account':
424 - // Save account information only if all required fields are filled
425 - $username = isset($_POST['mlsimport_username']) ? trim($_POST['mlsimport_username']) : '';
426 - $password = isset($_POST['mlsimport_password']) ? trim($_POST['mlsimport_password']) : '';
427 - $mls_id = isset($_POST['mlsimport_mls_name']) ? trim($_POST['mlsimport_mls_name']) : '';
428 - $token = isset($_POST['mlsimport_mls_token']) ? trim($_POST['mlsimport_mls_token']) : '';
429 -
430 - // Only save if all fields are non-empty
431 - // Requires SaaS username AND password AND MLS id AND MLS token.
432 - if ($username !== '' && $password !== '' && $mls_id !== '' && $token !== '') {
433 - $account_data = array(
434 - 'username' => $username,
435 - 'password' => $password,
436 - 'mls_id' => $mls_id,
437 - 'mls_token' => $token,
450 + /*
451 + * The account partial uses Settings API names such as
452 + * mlsimport_admin_options[mlsimport_password]. Read that real form
453 + * contract as one array; the former flat-key reads could never see
454 + * a submitted value and made every native form POST a silent no-op.
455 + */
456 + $submitted_options = isset($_POST['mlsimport_admin_options']) && is_array($_POST['mlsimport_admin_options'])
457 + ? wp_unslash($_POST['mlsimport_admin_options'])
458 + : array();
459 +
460 + // Usernames and ids are plain text. Credential values are trimmed
461 + // only because text sanitization corrupts valid %xx sequences (#204).
462 + $username = isset($submitted_options['mlsimport_username'])
463 + ? sanitize_text_field($submitted_options['mlsimport_username'])
464 + : '';
465 + $password = isset($submitted_options['mlsimport_password'])
466 + ? trim((string) $submitted_options['mlsimport_password'])
467 + : '';
468 + $mls_id = isset($submitted_options['mlsimport_mls_name'])
469 + ? sanitize_text_field($submitted_options['mlsimport_mls_name'])
470 + : '';
471 + $token = isset($submitted_options['mlsimport_mls_token'])
472 + ? trim((string) $submitted_options['mlsimport_mls_token'])
473 + : '';
474 +
475 + // Build one message from administrator-facing labels so every
476 + // missing value is actionable on the same re-rendered form.
477 + $required_fields = array(
478 + __('MLSImport.com Username or email', 'mlsimport') => $username,
479 + __('MLSImport.com Password', 'mlsimport') => $password,
480 + __('Your MLS', 'mlsimport') => $mls_id,
481 + __('Your API Server token', 'mlsimport') => $token,
482 + );
483 + $missing_fields = array();
484 + foreach ($required_fields as $label => $value) {
485 + if ('' === $value) {
486 + $missing_fields[] = $label;
487 + }
488 + }
489 +
490 + if (!empty($missing_fields)) {
491 + add_settings_error(
492 + 'mlsimport_onboarding',
493 + 'mlsimport_onboarding_required_fields',
494 + sprintf(
495 + /* translators: %s: comma-separated required onboarding field labels. */
496 + __('Please complete the following required field(s): %s.', 'mlsimport'),
497 + implode(', ', $missing_fields)
498 + ),
499 + 'error'
438 500 );
439 -
440 - mlsimport_save_step_data($current_step, $account_data);
441 -
442 - // Save to plugin options
443 - $options = get_option('mlsimport_admin_options', array());
444 - $options['mlsimport_username'] = $username;
445 - $options['mlsimport_password'] = $password;
446 - $options['mlsimport_mls_name'] = $mls_id;
447 - $options['mlsimport_mls_token'] = $token;
448 - update_option('mlsimport_admin_options', $options);
449 -
450 - // Redirect to next step
451 - mlsimport_redirect_to_next_step($current_step);
501 + break;
452 502 }
503 +
504 + $account_data = array(
505 + 'username' => $username,
506 + 'password' => $password,
507 + 'mls_id' => $mls_id,
508 + 'mls_token' => $token,
509 + );
510 +
511 + mlsimport_save_step_data($current_step, $account_data);
512 +
513 + // Mirror accepted values into the live settings used by the
514 + // account and MLS connection clients.
515 + $options = get_option('mlsimport_admin_options', array());
516 + $options['mlsimport_username'] = $username;
517 + $options['mlsimport_password'] = $password;
518 + $options['mlsimport_mls_name'] = $mls_id;
519 + $options['mlsimport_mls_token'] = $token;
520 + update_option('mlsimport_admin_options', $options);
521 +
522 + mlsimport_redirect_to_next_step($current_step);
453 523 break;
454 524
455 525
456 526 case 'field-mapping':
@@ -577,10 +647,15 @@
577 647 'post_type' => 'mlsimport_item',
578 648 );
579 649
580 650 $post_id = wp_insert_post($post_data);
581 -
651 +
582 652 if (!is_wp_error($post_id)) {
653 + // Connection binding (#277): stamp the new task's MLS at creation —
654 + // onboarding always runs against the connection being set up, which
655 + // the helper resolves (single registered connection or the current
656 + // selection).
657 + mlsimport_bind_task_connection((int) $post_id, 0);
583 658 // Set up import item defaults
584 659 mlsimport_setup_import_item_defaults($post_id, $import_data);
585 660 }
586 661
@@ -649,145 +724,128 @@
649 724 // Write to plugin logs
650 725 mlsimport_saas_single_write_import_custom_logs($formatted_message, 'onboarding');
651 726 }
652 727
728 +add_action('wp_ajax_mlsimport_save_account', 'mlsimport_save_account_callback');
653 729 /**
654 - * Display admin notice for incomplete onboarding
730 + * AJAX handler: save the MLSImport username or email/password and test login.
655 731 *
656 - * @since 6.1.0
657 - */
658 -function mlsimport_onboarding_admin_notice() {
659 - // Allow disabling the notice via constant
660 - if (defined('MLSIMPORT_HIDE_SETUP_NOTICE') && MLSIMPORT_HIDE_SETUP_NOTICE) {
661 - return;
662 - }
663 - // Only show on plugin pages
664 - $screen = get_current_screen();
665 - if (!$screen || strpos($screen->id, 'mlsimport') === false) {
666 - return;
667 - }
668 -
669 - // Don't show on onboarding page
670 - if (isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding') {
671 - return;
672 - }
673 -
674 - // Check if onboarding is complete
675 - $onboarding_completed = get_option('mlsimport_onboarding_completed', false);
676 - if ($onboarding_completed) {
677 - return;
678 - }
679 -
680 - // Get current step
681 - $current_step = get_option('mlsimport_onboarding_current_step', 'welcome');
682 - $steps = mlsimport_get_steps();
683 -
684 - // Display notice
685 - ?>
686 -
687 - <?php
688 -}
689 -
690 -/**
691 - * Handle AJAX test account connection
732 + * Verifies the onboarding nonce, stores credentials in mlsimport_admin_options,
733 + * fetches a fresh API token, and returns connected/not-connected HTML + flag.
734 + * Clears the prior account verdict before that request: HTTP 400 or a transport
735 + * failure must not inherit a no-subscription message from an earlier login.
736 + * A successful login also re-reads the account's entitlements (connection
737 + * cap) from the SaaS — see mlsimport_refresh_entitlements().
692 738 *
693 - * Verifies the 'mlsimport_onboarding_nonce' nonce; performs no capability
694 - * check. Persists the posted SaaS username/password into mlsimport_admin_options,
695 - * clears the cached token transient, then reports whether a fresh SaaS token can
696 - * be obtained with those credentials.
739 + * Defined in the onboarding module so the callback and its credential-handling
740 + * dependencies are loadable by the pure-PHP credentials regression harness.
697 741 *
698 - * @since 6.1.0
742 + * @return void
699 743 */
700 -function mlsimport_ajax_test_account_connection() {
701 - // Check nonce (no current_user_can capability check is performed here).
702 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mlsimport_onboarding_nonce')) {
703 - wp_send_json_error(array('message' => __('Security check failed', 'mlsimport')));
704 - }
705 -
706 - // Get credentials
707 - // Read and sanitize the posted SaaS account username/password.
708 - $username = isset($_POST['username']) ? sanitize_text_field($_POST['username']) : '';
709 - $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : '';
744 +function mlsimport_save_account_callback() {
745 + // Verify the shared onboarding AJAX nonce.
746 + check_ajax_referer('mlsimport_onboarding_nonce', 'security');
747 + if ( ! current_user_can( 'manage_options' ) ) {
748 + wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
749 + }
710 750
711 - // Both credentials are required to attempt a connection.
712 - if (empty($username) || empty($password)) {
713 - wp_send_json_error(array('message' => __('Username and password are required', 'mlsimport')));
714 - }
715 -
716 - // Save to temporary storage for test
717 - // Write the credentials into the plugin options so the token request uses them.
718 - $options = get_option('mlsimport_admin_options', array());
719 - $options['mlsimport_username'] = $username;
720 - $options['mlsimport_password'] = $password;
721 - update_option('mlsimport_admin_options', $options);
722 -
723 - // Delete token to force fresh request
724 - delete_transient('mlsimport_saas_token');
725 -
726 - // Test connection using existing methods
727 - // Ask the admin class for a token; a non-empty token means the login worked.
728 - global $mlsimport;
729 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
751 + /*
752 + * Validate the live Save Account payload before loading prior account state.
753 + * Falling through on blanks let a warm token from the saved account answer
754 + * "connected" for an empty form (#306).
755 + */
756 + $username = isset($_POST['mlsimport_username'])
757 + ? sanitize_text_field(wp_unslash($_POST['mlsimport_username']))
758 + : '';
759 + $password = isset($_POST['mlsimport_password'])
760 + ? trim(wp_unslash($_POST['mlsimport_password']))
761 + : '';
762 + $missing_fields = array();
763 + if ('' === $username) {
764 + $missing_fields[] = __('MLSImport.com Username or email', 'mlsimport');
765 + }
766 + if ('' === $password) {
767 + $missing_fields[] = __('MLSImport.com Password', 'mlsimport');
768 + }
730 769
731 - // No token returned -> credentials rejected or the SaaS was unreachable.
732 - if (empty($token)) {
733 - wp_send_json_error(array('message' => __('Unable to connect to MLS Import. Please check your credentials.', 'mlsimport')));
734 - }
735 -
736 - // Log success
737 - mlsimport_log_onboarding_event('Successfully connected to MLS Import account', 'info');
738 -
739 - wp_send_json_success(array('message' => __('Successfully connected to MLS Import', 'mlsimport')));
740 -}
770 + if (!empty($missing_fields)) {
771 + $message = 1 === count($missing_fields)
772 + ? sprintf(
773 + /* translators: %s: one missing MLSImport account field label. */
774 + __('%s is required.', 'mlsimport'),
775 + $missing_fields[0]
776 + )
777 + : sprintf(
778 + /* translators: 1: username label, 2: password label. */
779 + __('%1$s and %2$s are required.', 'mlsimport'),
780 + $missing_fields[0],
781 + $missing_fields[1]
782 + );
741 783
742 -/**
743 - * Handle AJAX test MLS connection
744 - *
745 - * Verifies the 'mlsimport_onboarding_nonce' nonce; performs no capability
746 - * check. Persists the posted MLS id/token into mlsimport_admin_options, runs the
747 - * admin class connection check, then reports the resulting
748 - * mlsimport_connection_test option value.
749 - *
750 - * @since 6.1.0
751 - */
752 -function mlsimport_ajax_test_mls_connection() {
753 - // Check nonce (no current_user_can capability check is performed here).
754 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mlsimport_onboarding_nonce')) {
755 - wp_send_json_error(array('message' => __('Security check failed', 'mlsimport')));
756 - }
757 -
758 - // Get MLS info
759 - // Read and sanitize the posted MLS id and (optional) provider token.
760 - $mls_id = isset($_POST['mls_id']) ? sanitize_text_field($_POST['mls_id']) : '';
761 - $mls_token = isset($_POST['mls_token']) ? sanitize_text_field($_POST['mls_token']) : '';
784 + wp_send_json_error(array('message' => $message));
785 + }
762 786
763 - // An MLS selection is mandatory; the token may be blank for some providers.
764 - if (empty($mls_id)) {
765 - wp_send_json_error(array('message' => __('MLS selection is required', 'mlsimport')));
766 - }
767 -
768 - // Save to temporary storage for test
769 - // Store the MLS id/token in the plugin options so the check uses them.
770 - $options = get_option('mlsimport_admin_options', array());
771 - $options['mlsimport_mls_name'] = $mls_id;
772 - $options['mlsimport_mls_token'] = $mls_token;
773 - update_option('mlsimport_admin_options', $options);
774 -
775 - // Test connection using existing methods
776 - // Run the connection check; it writes the 'yes'/'' flag we read back below.
777 - global $mlsimport;
778 - $connection_result = $mlsimport->admin->mlsimport_saas_check_mls_connection();
779 - $is_connected = get_option('mlsimport_connection_test', '');
787 + // Load current plugin options.
788 + $options = get_option('mlsimport_admin_options', []);
789 + // Both values are present. Preserve the password verbatim after unslashing
790 + // and trim because text sanitization strips valid %xx sequences (#204).
791 + $options['mlsimport_username'] = $username;
792 + $options['mlsimport_password'] = $password;
793 + update_option('mlsimport_admin_options', $options);
780 794
781 - // Anything other than 'yes' is treated as a failed MLS connection.
782 - if ($is_connected !== 'yes') {
783 - wp_send_json_error(array('message' => __('Unable to connect to MLS. Please check your credentials.', 'mlsimport')));
784 - }
785 -
786 - // Log success
787 - mlsimport_log_onboarding_event('Successfully connected to MLS provider', 'info');
788 -
789 - wp_send_json_success(array('message' => __('Successfully connected to MLS', 'mlsimport')));
795 + // Force the check below to exercise the credentials just saved rather than
796 + // a token minted from the previous password (#205).
797 + delete_transient('mlsimport_saas_token');
798 + delete_option('mlsimport_token_expiry');
799 + // This is a new login attempt. Only its response may confirm no subscription;
800 + // short passwords (HTTP 400) and timeouts do not overwrite an old verdict.
801 + delete_option( MLSIMPORT_ACCOUNT_STATUS_OPTION );
802 +
803 + global $mlsimport;
804 +
805 + // Refresh token
806 + $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
807 +
808 + // Empty token means the login failed. The box names the reason the
809 + // server gave (no subscription vs wrong password) — see
810 + // includes/mlsimport-account-status.php.
811 + if (trim($token) === '') {
812 + $html = mlsimport_account_not_connected_html();
813 + $account_status = mlsimport_account_status();
814 + $is_unsubscribed = 'no_subscription' === $account_status;
815 +
816 + // Return one public account-state contract for both consumers. The
817 + // onboarding and Connections screens both render the shared escaped
818 + // notice HTML. Plain message and link fields remain available to callers.
819 + // Link data is present only for a confirmed no-subscription verdict, so
820 + // invalid credentials never receive a misleading purchase action.
821 + wp_send_json_success([
822 + 'message' => mlsimport_account_not_connected_message(),
823 + 'html' => $html,
824 + 'connected' => false,
825 + 'account_status' => $account_status,
826 + 'subscribe_url' => $is_unsubscribed ? MLSIMPORT_ACCOUNT_SUBSCRIBE_URL : '',
827 + 'subscribe_label' => $is_unsubscribed ? esc_html__( 'View plans', 'mlsimport' ) : '',
828 + ]);
829 + } else {
830 + // Signed in: re-read the account's entitlements (connection cap +
831 + // registered MLS blocks) so a plan change shows up on reconnect.
832 + mlsimport_refresh_entitlements();
833 +
834 + ob_start();
835 + ?>
836 + <div class="mlsimport_warning mlsimport_validated">
837 + <?php esc_html_e('You are connected to your MlsImport account!', 'mlsimport'); ?>
838 + </div>
839 + <?php
840 + $html = ob_get_clean();
841 +
842 + wp_send_json_success([
843 + 'message' => __('Connected successfully!', 'mlsimport'),
844 + 'html' => $html,
845 + 'connected' => true
846 + ]);
847 + }
790 848 }
791 849
792 850 /**
793 851 * Handle AJAX run test import
@@ -799,9 +857,9 @@
799 857 *
800 858 * @since 6.1.0
801 859 */
802 860 function mlsimport_ajax_run_test_import() {
803 - // Check nonce (no current_user_can capability check is performed here).
861 + // Prove request intent before resolving and authorizing the saved Import Task.
804 862 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mlsimport_onboarding_nonce')) {
805 863 wp_send_json_error(array('message' => __('Security check failed', 'mlsimport')));
806 864 }
807 865
@@ -814,78 +872,51 @@
814 872 if (empty($import_id)) {
815 873 wp_send_json_error(array('message' => __('No import configuration found', 'mlsimport')));
816 874 }
817 875
818 - // Set a lower limit for test import
819 - update_post_meta($import_id, 'mlsimport_item_how_many', 5);
820 -
821 - // Run a limited import using the admin class methods
876 + // The saved onboarding id must still be a task this user may manage.
877 + if ( 'mlsimport_item' !== get_post_type( $import_id ) || ! current_user_can( 'edit_post', $import_id ) ) {
878 + wp_send_json_error( array( 'message' => __( 'You are not allowed to manage this import task.', 'mlsimport' ) ), 403 );
879 + }
880 +
881 + update_post_meta( $import_id, 'mlsimport_item_how_many', 5 );
822 882 global $mlsimport;
823 -
883 +
824 884 try {
825 - // Set up import parameters
826 - // Batch descriptor passed through the import pipeline (capped at 5).
827 - $item_id_array = array(
828 - 'item_id' => $import_id,
829 - 'how_many' => 5,
830 - 'max_number' => 5,
831 - 'batch_counter' => 1,
885 + // Setup has no count displayed by the page, so this small scheduling
886 + // adapter performs one count and passes it into the shared manual run.
887 + $mlsrequest = $mlsimport->admin->mlsimport_make_listing_requests( $import_id );
888 + if ( ! isset( $mlsrequest['results'] ) || 0 === intval( $mlsrequest['results'] ) ) {
889 + wp_send_json_error( array( 'message' => __( 'No listings found with current configuration', 'mlsimport' ) ) );
890 + }
891 + $found_items = min( 5, max( 0, intval( $mlsrequest['results'] ) ) );
892 + $start = $mlsimport->admin->mlsimport_import_task_execution()->start(
893 + array(
894 + 'task_id' => (int) $import_id,
895 + 'source' => 'manual',
896 + 'found' => $found_items,
897 + 'limit' => 5,
898 + 'is_onboard' => 1,
899 + )
832 900 );
833 -
834 - // Make sure we're starting clean
835 - // Clear any prior stop flag and stale attachment-move payload.
836 - update_option('mlsimport_force_stop_' . $import_id, 'no', false);
837 - update_post_meta($import_id, 'mlsimport_attach_to_move_' . $import_id, '');
838 -
839 - // Get listings
840 - // Query the MLS to see how many listings match the item's configuration.
841 - $mlsrequest = $mlsimport->admin->mlsimport_make_listing_requests($import_id);
901 + if ( true !== ( $start['accepted'] ?? false ) ) {
902 + wp_send_json_error(
903 + array( 'message' => __( 'Another import is already running. Please wait for it to finish.', 'mlsimport' ) )
904 + );
905 + }
842 906
843 - // No matching listings -> nothing to import.
844 - if (!isset($mlsrequest['results']) || $mlsrequest['results'] == 0) {
845 - wp_send_json_error(array('message' => __('No listings found with current configuration', 'mlsimport')));
846 - }
907 + mlsimport_log_onboarding_event( 'Starting test import of up to 5 properties', 'info' );
908 + // Shared worker scheduling: clears dead/superseded queue entries first
909 + // so a previously crashed worker can never block this start.
910 + $mlsimport->admin->mlsimport_enqueue_import_worker( (string) $start['run_id'] );
847 911
848 - // Clamp the found count down to the 5-listing test ceiling.
849 - $found_items = intval($mlsrequest['results']);
850 - if ($found_items > 5) {
851 - $found_items = 5;
852 - }
853 -
854 - $item_id_array['max_number'] = $found_items;
855 -
856 - // Generate import requests
857 - // Build the per-item import request set and stash it on the import post.
858 - $attachments_to_move = (array)$mlsimport->admin->mlsimport_saas_generate_import_requests_per_item($item_id_array);
859 - update_post_meta($import_id, 'mlsimport_attach_to_move_' . $import_id, $attachments_to_move);
860 -
861 - // Prepare background process arguments
862 - // Payload handed to the async worker action.
863 - $attachments_to_send = array(
864 - 'args' => array(
865 - 'attachments_to_move' => $import_id,
866 - 'item_id_array' => $item_id_array,
867 - ),
912 + wp_send_json_success(
913 + array(
914 + 'message' => __( 'Import process started', 'mlsimport' ),
915 + 'import_id' => (int) $import_id,
916 + 'run_id' => (string) $start['run_id'],
917 + )
868 918 );
869 -
870 - // Start background process
871 -// Mark the spawn state and record the start in the onboarding log.
872 -update_post_meta($import_id, 'mlsimport_spawn_status', 'started');
873 -mlsimport_log_onboarding_event('Starting test import of 5 properties', 'info');
874 -
875 -// Use the async action system instead of direct execution
876 -// Enqueue the worker and nudge WP-Cron so it runs promptly.
877 -as_enqueue_async_action('mlsimport_background_process_per_item', $attachments_to_send);
878 -spawn_cron();
879 -
880 -// Return success data without checking import count
881 -// Respond immediately; the import continues asynchronously in the background.
882 -wp_send_json_success(array(
883 - 'message' => __('Import process started', 'mlsimport'),
884 - 'import_id' => $import_id
885 -));
886 -
887 -
888 919 } catch (Exception $e) {
889 920 mlsimport_log_onboarding_event('Test import failed: ' . $e->getMessage(), 'error');
890 921 wp_send_json_error(array('message' => __('Import failed: ', 'mlsimport') . $e->getMessage()));
891 922 }
@@ -893,19 +924,33 @@
893 924
894 925 /**
895 926 * Handle AJAX save step data
896 927 *
897 - * Verifies the 'mlsimport_onboarding_nonce' nonce; performs no capability
898 - * check. Delegates to mlsimport_save_step_data(), which sanitizes and persists
899 - * the posted per-step form data.
928 + * Verifies the 'mlsimport_onboarding_nonce' nonce, then requires the same
929 + * manage_options capability as the onboarding settings screen before reading
930 + * or persisting any submitted values. Delegates accepted requests to
931 + * mlsimport_save_step_data(), which sanitizes and persists the posted
932 + * per-step form data.
900 933 *
901 934 * @since 6.1.0
902 935 */
903 936 function mlsimport_ajax_save_step_data() {
904 - // Check nonce (no current_user_can capability check is performed here).
937 + // First prove that the request originated from the onboarding screen.
905 938 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mlsimport_onboarding_nonce')) {
906 939 wp_send_json_error(array('message' => __('Security check failed', 'mlsimport')));
907 940 }
941 +
942 + /*
943 + * A nonce prevents cross-site request forgery but does not grant permission.
944 + * Stop non-administrators before the submitted step or data is inspected and
945 + * before the persistence helper reaches the shared option write boundary.
946 + */
947 + if ( ! current_user_can( 'manage_options' ) ) {
948 + wp_send_json_error(
949 + array( 'message' => __( 'You are not allowed to change onboarding settings.', 'mlsimport' ) ),
950 + 403
951 + );
952 + }
908 953
909 954 // Get step and data
910 955 // Step id is sanitized; the raw data array is sanitized inside save_step_data().
911 956 $step = isset($_POST['step']) ? sanitize_text_field($_POST['step']) : '';
@@ -989,104 +1034,8 @@
989 1034 exit;
990 1035 }
991 1036 }
992 1037 add_action('admin_init', 'mlsimport_maybe_restart_wizard');
993 -/**
994 - * Get a template configuration based on MLS provider
995 - *
996 - * @since 6.1.0
997 - * @param int $mls_id The MLS provider ID
998 - * @return array Default settings for the specified MLS
999 - */
1000 -function mlsimport_get_import_item_template($mls_id) {
1001 - // Default template
1002 - $template = array(
1003 - 'title_format' => '{Address}, {City}, {CountyOrParish}, {PropertyType}',
1004 - 'property_status' => 'publish',
1005 - 'auto_update' => 1,
1006 - 'standard_status' => array('Active', 'Coming Soon'),
1007 - 'property_types' => array('Residential', 'Condo/Townhome/Row Home/Co-Op'),
1008 - );
1009 -
1010 - // Customize based on MLS ID if needed
1011 - switch ($mls_id) {
1012 - // Add MLS-specific customizations here
1013 - case '111': // Example - Rae Edmonton
1014 - $template['standard_status'] = array('Active');
1015 - break;
1016 -
1017 - default:
1018 - // Use defaults
1019 - break;
1020 - }
1021 -
1022 - return $template;
1023 -}
1024 -
1025 -/**
1026 - * Display a condensed log summary
1027 - *
1028 - * @since 6.1.0
1029 - * @param int $num_entries Number of entries to show
1030 - * @return string HTML output of log summary
1031 - */
1032 -function mlsimport_display_onboarding_log_summary($num_entries = 10) {
1033 - $path = WP_PLUGIN_DIR . '/mlsimport/logs/onboarding_logs.log';
1034 -
1035 - if (!file_exists($path)) {
1036 - return '<div class="mlsimport-log-summary empty">' . __('No logs available', 'mlsimport') . '</div>';
1037 - }
1038 -
1039 - // Get the last N lines
1040 - $lines = file($path);
1041 - $lines = array_slice($lines, -$num_entries);
1042 -
1043 - $output = '<div class="mlsimport-log-summary">';
1044 - $output .= '<h4>' . __('Recent Activity', 'mlsimport') . '</h4>';
1045 - $output .= '<ul class="mlsimport-logs">';
1046 -
1047 - // Build one list item per log line, colour-coded by the severity tag it contains.
1048 - foreach ($lines as $line) {
1049 - // Extract log type for styling
1050 - if (strpos($line, '[INFO]') !== false) {
1051 - $class = 'info';
1052 - } elseif (strpos($line, '[WARNING]') !== false) {
1053 - $class = 'warning';
1054 - } elseif (strpos($line, '[ERROR]') !== false) {
1055 - $class = 'error';
1056 - } else {
1057 - // No recognised tag -> no severity class.
1058 - $class = '';
1059 - }
1060 -
1061 - // esc_html() escapes the raw log line before embedding it in the markup.
1062 - $output .= '<li class="log-item ' . $class . '">' . esc_html($line) . '</li>';
1063 - }
1064 -
1065 - $output .= '</ul>';
1066 - $output .= '</div>';
1067 -
1068 - return $output;
1069 -}
1070 -
1071 -/**
1072 - * Register onboarding-specific log types with logging system
1073 - *
1074 - * @since 6.1.0
1075 - */
1076 -function mlsimport_register_onboarding_logs() {
1077 - // Create logs directory if it doesn't exist
1078 - $log_dir = WP_PLUGIN_DIR . '/mlsimport/logs';
1079 - if (!file_exists($log_dir)) {
1080 - mkdir($log_dir, 0755, true);
1081 - }
1082 -
1083 - // Create onboarding log file if it doesn't exist
1084 - $log_file = $log_dir . '/onboarding_logs.log';
1085 - if (!file_exists($log_file)) {
1086 - touch($log_file);
1087 - }
1088 -}
1089 1038
1090 1039 // Initialize onboarding
1091 1040 add_action('init', 'mlsimport_init_onboarding');
1092 1041