PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
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 +316 -308 6.3.57.2 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
@@ -75,8 +74,10 @@
75 74 exit;
76 75 }
77 76
78 77 // Check if we're on the plugin activation page
78 + // Fires when WP's plugins screen just activated (activate=true) a plugin
79 + // (plugin=...) whose path contains 'mlsimport'.
79 80 if (isset($_GET['activate']) && $_GET['activate'] == 'true' && isset($_GET['plugin']) && strpos($_GET['plugin'], 'mlsimport') !== false) {
80 81 // Redirect to onboarding welcome page
81 82 wp_redirect(admin_url('admin.php?page=mlsimport-onboarding'));
82 83 exit;
@@ -124,11 +125,27 @@
124 125 * @since 6.1.0
125 126 * @param string $hook The current admin page
126 127 */
127 128 function mlsimport_enqueue_onboarding_assets($hook) {
128 - 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'] ) {
129 145 return;
130 146 }
147 + // Fetch the list of MLS providers (used to feed the account-step autocomplete).
131 148 $mls_import_list = mlsimport_saas_request_list();
132 149 // Enqueue styles
133 150 wp_enqueue_style(
134 151 'mlsimport-onboarding-style',
@@ -158,9 +175,8 @@
158 175 'strings' => array(
159 176 'saving' => __('Saving...', 'mlsimport'),
160 177 'next' => __('Next', 'mlsimport'),
161 178 'back' => __('Back', 'mlsimport'),
162 - 'skip' => __('Skip', 'mlsimport'),
163 179 'connecting' => __('Connecting...', 'mlsimport'),
164 180 'testing' => __('Testing...', 'mlsimport'),
165 181 'importing' => __('Importing...', 'mlsimport'),
166 182 'success' => __('Success!', 'mlsimport'),
@@ -168,17 +184,31 @@
168 184 )
169 185 )
170 186 );
171 187
172 - if ( $hook === 'admin_page_mlsimport-onboarding' &&
173 - isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding' &&
174 - isset($_GET['step']) && $_GET['step'] === 'account') {
175 -
176 - if (!empty($mls_import_list)) {
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 ) ) {
177 207
178 - $inline_script = 'jQuery(document).ready(function($){ var autofill=' . wp_kses_post($mls_import_list) . '; mlsimport_autocomplte_mls_selection(autofill); });';
179 - wp_add_inline_script('mlsimport-onboarding-script', $inline_script);
180 - }
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);
181 211 }
182 212
183 213
184 214
@@ -286,11 +316,16 @@
286 316 function mlsimport_save_step_data($step, $data) {
287 317 $user_data = get_option('mlsimport_onboarding_user_data', array());
288 318
289 319 // Sanitize data
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).
290 323 $sanitized_data = array();
291 324 foreach ($data as $key => $value) {
292 - if (is_array($value)) {
325 + if (mlsimport_is_credential_key($key)) {
326 + $sanitized_data[$key] = trim((string) $value);
327 + } elseif (is_array($value)) {
293 328 $sanitized_data[$key] = array_map('sanitize_text_field', $value);
294 329 } else {
295 330 $sanitized_data[$key] = sanitize_text_field($value);
296 331 }
@@ -296,8 +331,9 @@
296 331 }
297 332 }
298 333
299 334 // Update user data
335 + // Store this step's sanitized data under its step key in the aggregate option.
300 336 $user_data[$step] = $sanitized_data;
301 337
302 338 // Record onboarding-step completion (lifecycle telemetry).
303 339 mlsimport_telemetry_mark_onboarding_step( $step );
@@ -402,8 +438,9 @@
402 438 // Get current step
403 439 $current_step = mlsimport_get_current_step();
404 440
405 441 // Process based on step
442 + // Each wizard step persists its own fields, then redirects to the next step.
406 443 switch ($current_step) {
407 444 case 'welcome':
408 445 // Nothing to save, just redirect to next step
409 446 mlsimport_redirect_to_next_step($current_step);
@@ -409,36 +446,81 @@
409 446 mlsimport_redirect_to_next_step($current_step);
410 447 break;
411 448
412 449 case 'account':
413 - // Save account information only if all required fields are filled
414 - $username = isset($_POST['mlsimport_username']) ? trim($_POST['mlsimport_username']) : '';
415 - $password = isset($_POST['mlsimport_password']) ? trim($_POST['mlsimport_password']) : '';
416 - $mls_id = isset($_POST['mlsimport_mls_name']) ? trim($_POST['mlsimport_mls_name']) : '';
417 - $token = isset($_POST['mlsimport_mls_token']) ? trim($_POST['mlsimport_mls_token']) : '';
418 -
419 - // Only save if all fields are non-empty
420 - if ($username !== '' && $password !== '' && $mls_id !== '' && $token !== '') {
421 - $account_data = array(
422 - 'username' => $username,
423 - 'password' => $password,
424 - 'mls_id' => $mls_id,
425 - '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'
426 500 );
427 -
428 - mlsimport_save_step_data($current_step, $account_data);
429 -
430 - // Save to plugin options
431 - $options = get_option('mlsimport_admin_options', array());
432 - $options['mlsimport_username'] = $username;
433 - $options['mlsimport_password'] = $password;
434 - $options['mlsimport_mls_name'] = $mls_id;
435 - $options['mlsimport_mls_token'] = $token;
436 - update_option('mlsimport_admin_options', $options);
437 -
438 - // Redirect to next step
439 - mlsimport_redirect_to_next_step($current_step);
501 + break;
440 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);
441 523 break;
442 524
443 525
444 526 case 'field-mapping':
@@ -565,10 +647,15 @@
565 647 'post_type' => 'mlsimport_item',
566 648 );
567 649
568 650 $post_id = wp_insert_post($post_data);
569 -
651 +
570 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);
571 658 // Set up import item defaults
572 659 mlsimport_setup_import_item_defaults($post_id, $import_data);
573 660 }
574 661
@@ -637,206 +724,199 @@
637 724 // Write to plugin logs
638 725 mlsimport_saas_single_write_import_custom_logs($formatted_message, 'onboarding');
639 726 }
640 727
728 +add_action('wp_ajax_mlsimport_save_account', 'mlsimport_save_account_callback');
641 729 /**
642 - * Display admin notice for incomplete onboarding
730 + * AJAX handler: save the MLSImport username or email/password and test login.
643 731 *
644 - * @since 6.1.0
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().
738 + *
739 + * Defined in the onboarding module so the callback and its credential-handling
740 + * dependencies are loadable by the pure-PHP credentials regression harness.
741 + *
742 + * @return void
645 743 */
646 -function mlsimport_onboarding_admin_notice() {
647 - // Allow disabling the notice via constant
648 - if (defined('MLSIMPORT_HIDE_SETUP_NOTICE') && MLSIMPORT_HIDE_SETUP_NOTICE) {
649 - return;
650 - }
651 - // Only show on plugin pages
652 - $screen = get_current_screen();
653 - if (!$screen || strpos($screen->id, 'mlsimport') === false) {
654 - return;
655 - }
656 -
657 - // Don't show on onboarding page
658 - if (isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding') {
659 - return;
660 - }
661 -
662 - // Check if onboarding is complete
663 - $onboarding_completed = get_option('mlsimport_onboarding_completed', false);
664 - if ($onboarding_completed) {
665 - return;
666 - }
667 -
668 - // Get current step
669 - $current_step = get_option('mlsimport_onboarding_current_step', 'welcome');
670 - $steps = mlsimport_get_steps();
671 -
672 - // Display notice
673 - ?>
674 -
675 - <?php
676 -}
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 + }
677 750
678 -/**
679 - * Handle AJAX test account connection
680 - *
681 - * @since 6.1.0
682 - */
683 -function mlsimport_ajax_test_account_connection() {
684 - // Check nonce
685 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mlsimport_onboarding_nonce')) {
686 - wp_send_json_error(array('message' => __('Security check failed', 'mlsimport')));
687 - }
688 -
689 - // Get credentials
690 - $username = isset($_POST['username']) ? sanitize_text_field($_POST['username']) : '';
691 - $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : '';
692 -
693 - if (empty($username) || empty($password)) {
694 - wp_send_json_error(array('message' => __('Username and password are required', 'mlsimport')));
695 - }
696 -
697 - // Save to temporary storage for test
698 - $options = get_option('mlsimport_admin_options', array());
699 - $options['mlsimport_username'] = $username;
700 - $options['mlsimport_password'] = $password;
701 - update_option('mlsimport_admin_options', $options);
702 -
703 - // Delete token to force fresh request
704 - delete_transient('mlsimport_saas_token');
705 -
706 - // Test connection using existing methods
707 - global $mlsimport;
708 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
709 -
710 - if (empty($token)) {
711 - wp_send_json_error(array('message' => __('Unable to connect to MLS Import. Please check your credentials.', 'mlsimport')));
712 - }
713 -
714 - // Log success
715 - mlsimport_log_onboarding_event('Successfully connected to MLS Import account', 'info');
716 -
717 - wp_send_json_success(array('message' => __('Successfully connected to MLS Import', 'mlsimport')));
718 -}
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 + }
719 769
720 -/**
721 - * Handle AJAX test MLS connection
722 - *
723 - * @since 6.1.0
724 - */
725 -function mlsimport_ajax_test_mls_connection() {
726 - // Check nonce
727 - if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mlsimport_onboarding_nonce')) {
728 - wp_send_json_error(array('message' => __('Security check failed', 'mlsimport')));
729 - }
730 -
731 - // Get MLS info
732 - $mls_id = isset($_POST['mls_id']) ? sanitize_text_field($_POST['mls_id']) : '';
733 - $mls_token = isset($_POST['mls_token']) ? sanitize_text_field($_POST['mls_token']) : '';
734 -
735 - if (empty($mls_id)) {
736 - wp_send_json_error(array('message' => __('MLS selection is required', 'mlsimport')));
737 - }
738 -
739 - // Save to temporary storage for test
740 - $options = get_option('mlsimport_admin_options', array());
741 - $options['mlsimport_mls_name'] = $mls_id;
742 - $options['mlsimport_mls_token'] = $mls_token;
743 - update_option('mlsimport_admin_options', $options);
744 -
745 - // Test connection using existing methods
746 - global $mlsimport;
747 - $connection_result = $mlsimport->admin->mlsimport_saas_check_mls_connection();
748 - $is_connected = get_option('mlsimport_connection_test', '');
749 -
750 - if ($is_connected !== 'yes') {
751 - wp_send_json_error(array('message' => __('Unable to connect to MLS. Please check your credentials.', 'mlsimport')));
752 - }
753 -
754 - // Log success
755 - mlsimport_log_onboarding_event('Successfully connected to MLS provider', 'info');
756 -
757 - wp_send_json_success(array('message' => __('Successfully connected to MLS', 'mlsimport')));
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 + );
783 +
784 + wp_send_json_error(array('message' => $message));
785 + }
786 +
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);
794 +
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 + }
758 848 }
759 849
760 850 /**
761 851 * Handle AJAX run test import
762 852 *
853 + * Verifies the 'mlsimport_onboarding_nonce' nonce; performs no capability
854 + * check. Caps the configured import item at 5 listings, builds the import
855 + * request set, and enqueues the background async action that performs the
856 + * actual import.
857 + *
763 858 * @since 6.1.0
764 859 */
765 860 function mlsimport_ajax_run_test_import() {
766 - // Check nonce
861 + // Prove request intent before resolving and authorizing the saved Import Task.
767 862 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mlsimport_onboarding_nonce')) {
768 863 wp_send_json_error(array('message' => __('Security check failed', 'mlsimport')));
769 864 }
770 865
771 866 // Get import ID
867 + // The import item id was stashed during the import-config step.
772 868 $user_data = get_option('mlsimport_onboarding_user_data', array());
773 869 $import_id = isset($user_data['import_id']) ? $user_data['import_id'] : 0;
774 -
870 +
871 + // Without an import item there is nothing to run.
775 872 if (empty($import_id)) {
776 873 wp_send_json_error(array('message' => __('No import configuration found', 'mlsimport')));
777 874 }
778 875
779 - // Set a lower limit for test import
780 - update_post_meta($import_id, 'mlsimport_item_how_many', 5);
781 -
782 - // 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 );
783 882 global $mlsimport;
784 -
883 +
785 884 try {
786 - // Set up import parameters
787 - $item_id_array = array(
788 - 'item_id' => $import_id,
789 - 'how_many' => 5,
790 - 'max_number' => 5,
791 - '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 + )
792 900 );
793 -
794 - // Make sure we're starting clean
795 - update_option('mlsimport_force_stop_' . $import_id, 'no', false);
796 - update_post_meta($import_id, 'mlsimport_attach_to_move_' . $import_id, '');
797 -
798 - // Get listings
799 - $mlsrequest = $mlsimport->admin->mlsimport_make_listing_requests($import_id);
800 -
801 - if (!isset($mlsrequest['results']) || $mlsrequest['results'] == 0) {
802 - wp_send_json_error(array('message' => __('No listings found with current configuration', 'mlsimport')));
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 + );
803 905 }
804 -
805 - $found_items = intval($mlsrequest['results']);
806 - if ($found_items > 5) {
807 - $found_items = 5;
808 - }
809 -
810 - $item_id_array['max_number'] = $found_items;
811 -
812 - // Generate import requests
813 - $attachments_to_move = (array)$mlsimport->admin->mlsimport_saas_generate_import_requests_per_item($item_id_array);
814 - update_post_meta($import_id, 'mlsimport_attach_to_move_' . $import_id, $attachments_to_move);
815 -
816 - // Prepare background process arguments
817 - $attachments_to_send = array(
818 - 'args' => array(
819 - 'attachments_to_move' => $import_id,
820 - 'item_id_array' => $item_id_array,
821 - ),
822 - );
823 -
824 - // Start background process
825 -update_post_meta($import_id, 'mlsimport_spawn_status', 'started');
826 -mlsimport_log_onboarding_event('Starting test import of 5 properties', 'info');
827 906
828 -// Use the async action system instead of direct execution
829 -as_enqueue_async_action('mlsimport_background_process_per_item', $attachments_to_send);
830 -spawn_cron();
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'] );
831 911
832 -// Return success data without checking import count
833 -wp_send_json_success(array(
834 - 'message' => __('Import process started', 'mlsimport'),
835 - 'import_id' => $import_id
836 -));
837 -
838 -
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 + )
918 + );
839 919 } catch (Exception $e) {
840 920 mlsimport_log_onboarding_event('Test import failed: ' . $e->getMessage(), 'error');
841 921 wp_send_json_error(array('message' => __('Import failed: ', 'mlsimport') . $e->getMessage()));
842 922 }
@@ -844,20 +924,40 @@
844 924
845 925 /**
846 926 * Handle AJAX save step data
847 927 *
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.
933 + *
848 934 * @since 6.1.0
849 935 */
850 936 function mlsimport_ajax_save_step_data() {
851 - // Check nonce
937 + // First prove that the request originated from the onboarding screen.
852 938 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mlsimport_onboarding_nonce')) {
853 939 wp_send_json_error(array('message' => __('Security check failed', 'mlsimport')));
854 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 + }
855 953
856 954 // Get step and data
955 + // Step id is sanitized; the raw data array is sanitized inside save_step_data().
857 956 $step = isset($_POST['step']) ? sanitize_text_field($_POST['step']) : '';
858 957 $data = isset($_POST['data']) ? $_POST['data'] : array();
859 -
958 +
959 + // A step id is required to know where to store the data.
860 960 if (empty($step)) {
861 961 wp_send_json_error(array('message' => __('No step specified', 'mlsimport')));
862 962 }
863 963
@@ -862,9 +962,10 @@
862 962 }
863 963
864 964 // Save step data
865 965 $result = mlsimport_save_step_data($step, $data);
866 -
966 +
967 + // update_option returns false when the write fails (or value is unchanged).
867 968 if (!$result) {
868 969 wp_send_json_error(array('message' => __('Failed to save data', 'mlsimport')));
869 970 }
870 971
@@ -933,101 +1034,8 @@
933 1034 exit;
934 1035 }
935 1036 }
936 1037 add_action('admin_init', 'mlsimport_maybe_restart_wizard');
937 -/**
938 - * Get a template configuration based on MLS provider
939 - *
940 - * @since 6.1.0
941 - * @param int $mls_id The MLS provider ID
942 - * @return array Default settings for the specified MLS
943 - */
944 -function mlsimport_get_import_item_template($mls_id) {
945 - // Default template
946 - $template = array(
947 - 'title_format' => '{Address}, {City}, {CountyOrParish}, {PropertyType}',
948 - 'property_status' => 'publish',
949 - 'auto_update' => 1,
950 - 'standard_status' => array('Active', 'Coming Soon'),
951 - 'property_types' => array('Residential', 'Condo/Townhome/Row Home/Co-Op'),
952 - );
953 -
954 - // Customize based on MLS ID if needed
955 - switch ($mls_id) {
956 - // Add MLS-specific customizations here
957 - case '111': // Example - Rae Edmonton
958 - $template['standard_status'] = array('Active');
959 - break;
960 -
961 - default:
962 - // Use defaults
963 - break;
964 - }
965 -
966 - return $template;
967 -}
968 -
969 -/**
970 - * Display a condensed log summary
971 - *
972 - * @since 6.1.0
973 - * @param int $num_entries Number of entries to show
974 - * @return string HTML output of log summary
975 - */
976 -function mlsimport_display_onboarding_log_summary($num_entries = 10) {
977 - $path = WP_PLUGIN_DIR . '/mlsimport/logs/onboarding_logs.log';
978 -
979 - if (!file_exists($path)) {
980 - return '<div class="mlsimport-log-summary empty">' . __('No logs available', 'mlsimport') . '</div>';
981 - }
982 -
983 - // Get the last N lines
984 - $lines = file($path);
985 - $lines = array_slice($lines, -$num_entries);
986 -
987 - $output = '<div class="mlsimport-log-summary">';
988 - $output .= '<h4>' . __('Recent Activity', 'mlsimport') . '</h4>';
989 - $output .= '<ul class="mlsimport-logs">';
990 -
991 - foreach ($lines as $line) {
992 - // Extract log type for styling
993 - if (strpos($line, '[INFO]') !== false) {
994 - $class = 'info';
995 - } elseif (strpos($line, '[WARNING]') !== false) {
996 - $class = 'warning';
997 - } elseif (strpos($line, '[ERROR]') !== false) {
998 - $class = 'error';
999 - } else {
1000 - $class = '';
1001 - }
1002 -
1003 - $output .= '<li class="log-item ' . $class . '">' . esc_html($line) . '</li>';
1004 - }
1005 -
1006 - $output .= '</ul>';
1007 - $output .= '</div>';
1008 -
1009 - return $output;
1010 -}
1011 -
1012 -/**
1013 - * Register onboarding-specific log types with logging system
1014 - *
1015 - * @since 6.1.0
1016 - */
1017 -function mlsimport_register_onboarding_logs() {
1018 - // Create logs directory if it doesn't exist
1019 - $log_dir = WP_PLUGIN_DIR . '/mlsimport/logs';
1020 - if (!file_exists($log_dir)) {
1021 - mkdir($log_dir, 0755, true);
1022 - }
1023 -
1024 - // Create onboarding log file if it doesn't exist
1025 - $log_file = $log_dir . '/onboarding_logs.log';
1026 - if (!file_exists($log_file)) {
1027 - touch($log_file);
1028 - }
1029 -}
1030 1038
1031 1039 // Initialize onboarding
1032 1040 add_action('init', 'mlsimport_init_onboarding');
1033 1041