diff --git a/assets/js/checkout.js b/assets/js/checkout.js index 46f45d2b2..441368294 100644 --- a/assets/js/checkout.js +++ b/assets/js/checkout.js @@ -1,4 +1,4 @@ -/* global Vue, moment, _, wu_checkout, pwsL10n, wu_checkout_form, wu_create_cookie, wu_listen_to_cookie_change */ +/* global Vue, moment, _, wu_checkout, wu_checkout_form, wu_create_cookie, wu_listen_to_cookie_change */ (function ($, hooks, _) { /* @@ -748,73 +748,33 @@ }); }, - check_pass_strength() { + init_password_strength() { - const pass1_el = '#field-password'; + const that = this; + const pass1_el = jQuery('#field-password'); - if (!jQuery('#pass-strength-result').length) { + if (!pass1_el.length) { return; } // end if; - jQuery('#pass-strength-result') - .attr('class', 'wu-py-2 wu-px-4 wu-bg-gray-100 wu-block wu-text-sm wu-border-solid wu-border wu-border-gray-200'); - - const pass1 = jQuery(pass1_el).val(); + // Use the shared WU_PasswordStrength utility + if (typeof window.WU_PasswordStrength !== 'undefined') { - if (!pass1) { + this.password_strength_checker = new window.WU_PasswordStrength({ + pass1: pass1_el, + result: jQuery('#pass-strength-result'), + minStrength: 3, + onValidityChange: function(isValid) { - jQuery('#pass-strength-result').addClass('empty').html('Enter Password'); + that.valid_password = isValid; - return; + } + }); } // end if; - this.valid_password = false; - - const disallowed_list = typeof wp.passwordStrength.userInputDisallowedList === 'undefined' - ? wp.passwordStrength.userInputBlacklist() - : wp.passwordStrength.userInputDisallowedList(); - - const strength = wp.passwordStrength.meter(pass1, disallowed_list, pass1); - - switch (strength) { - - case -1: - jQuery('#pass-strength-result').addClass('wu-bg-red-200 wu-border-red-300').html(pwsL10n.unknown); - - break; - - case 2: - jQuery('#pass-strength-result').addClass('wu-bg-red-200 wu-border-red-300').html(pwsL10n.bad); - - break; - - case 3: - jQuery('#pass-strength-result').addClass('wu-bg-green-200 wu-border-green-300').html(pwsL10n.good); - - this.valid_password = true; - - break; - - case 4: - jQuery('#pass-strength-result').addClass('wu-bg-green-200 wu-border-green-300').html(pwsL10n.strong); - - this.valid_password = true; - - break; - - case 5: - jQuery('#pass-strength-result').addClass('wu-bg-yellow-200 wu-border-yellow-300').html(pwsL10n.mismatch); - - break; - - default: - jQuery('#pass-strength-result').addClass('wu-bg-yellow-200 wu-border-yellow-300').html(pwsL10n.short); - - } // end switch; - }, check_user_exists_debounced: _.debounce(function(field_type, value) { @@ -1162,11 +1122,8 @@ hooks.doAction('wu_on_change_gateway', this.gateway, this.gateway); - jQuery('#field-password').on('input pwupdate', function () { - - that.check_pass_strength(); - - }); + // Initialize password strength checker using the shared utility + this.init_password_strength(); wu_initialize_tooltip(); diff --git a/assets/js/wu-password-reset.js b/assets/js/wu-password-reset.js new file mode 100644 index 000000000..425889d27 --- /dev/null +++ b/assets/js/wu-password-reset.js @@ -0,0 +1,45 @@ +/* global jQuery, WU_PasswordStrength */ +/** + * Password strength meter for the password reset form. + * + * Uses the shared WU_PasswordStrength utility to check password strength + * and enforces minimum strength requirements. + * + * @since 2.3.0 + */ +(function($) { + 'use strict'; + + var passwordStrength; + + /** + * Initialize the password strength meter. + */ + $(document).ready(function() { + var $pass1 = $('#field-pass1'); + var $pass2 = $('#field-pass2'); + var $submit = $('#wp-submit'); + var $form = $pass1.closest('form'); + + if (!$pass1.length || typeof WU_PasswordStrength === 'undefined') { + return; + } + + // Initialize the password strength checker using the shared utility + passwordStrength = new WU_PasswordStrength({ + pass1: $pass1, + pass2: $pass2, + submit: $submit, + minStrength: 3 // Require at least medium strength + }); + + // Prevent form submission if password is too weak + $form.on('submit', function(e) { + if (!passwordStrength.isValid()) { + e.preventDefault(); + return false; + } + }); + }); + +})(jQuery); diff --git a/assets/js/wu-password-strength.js b/assets/js/wu-password-strength.js new file mode 100644 index 000000000..f60215d29 --- /dev/null +++ b/assets/js/wu-password-strength.js @@ -0,0 +1,241 @@ +/* global jQuery, wp, pwsL10n */ +/** + * Shared password strength utility for WP Ultimo. + * + * This module provides reusable password strength checking functionality + * that can be used across different forms (checkout, password reset, etc.) + * + * @since 2.3.0 + */ +(function($) { + 'use strict'; + + /** + * Password strength checker utility. + * + * @param {Object} options Configuration options + * @param {jQuery} options.pass1 First password field element + * @param {jQuery} options.pass2 Second password field element (optional) + * @param {jQuery} options.result Strength result display element + * @param {jQuery} options.submit Submit button element (optional) + * @param {number} options.minStrength Minimum required strength level (default: 3) + * @param {Function} options.onValidityChange Callback when password validity changes + */ + window.WU_PasswordStrength = function(options) { + this.options = $.extend({ + pass1: null, + pass2: null, + result: null, + submit: null, + minStrength: 3, + onValidityChange: null + }, options); + + this.isPasswordValid = false; + + this.init(); + }; + + WU_PasswordStrength.prototype = { + /** + * Initialize the password strength checker. + */ + init: function() { + var self = this; + + if (!this.options.pass1 || !this.options.pass1.length) { + return; + } + + // Create or find strength meter element + if (!this.options.result || !this.options.result.length) { + this.options.result = $('#pass-strength-result'); + + if (!this.options.result.length) { + this.options.result = $('
'); + this.options.pass1.after(this.options.result); + } + } + + // Set initial message + this.options.result.html(this.getStrengthLabel('empty')); + + // Bind events + this.options.pass1.on('keyup input', function() { + self.checkStrength(); + }); + + if (this.options.pass2 && this.options.pass2.length) { + this.options.pass2.on('keyup input', function() { + self.checkStrength(); + }); + } + + // Disable submit initially if provided + if (this.options.submit && this.options.submit.length) { + this.options.submit.prop('disabled', true); + } + + // Initial check + this.checkStrength(); + }, + + /** + * Check password strength and update the UI. + */ + checkStrength: function() { + var pass1 = this.options.pass1.val(); + var pass2 = this.options.pass2 ? this.options.pass2.val() : ''; + + // Reset classes + this.options.result.attr('class', 'wu-py-2 wu-px-4 wu-block wu-text-sm wu-border-solid wu-border wu-mt-2'); + + if (!pass1) { + this.options.result.addClass('wu-bg-gray-100 wu-border-gray-200').html(this.getStrengthLabel('empty')); + this.setValid(false); + return; + } + + // Get disallowed list from WordPress + var disallowedList = this.getDisallowedList(); + + var strength = wp.passwordStrength.meter(pass1, disallowedList, pass2); + + this.updateUI(strength); + this.updateValidity(strength); + }, + + /** + * Get the disallowed list for password checking. + * + * @return {Array} The disallowed list + */ + getDisallowedList: function() { + if (typeof wp === 'undefined' || typeof wp.passwordStrength === 'undefined') { + return []; + } + + // Support both old and new WordPress naming + return typeof wp.passwordStrength.userInputDisallowedList === 'undefined' + ? wp.passwordStrength.userInputBlacklist() + : wp.passwordStrength.userInputDisallowedList(); + }, + + /** + * Get the appropriate label for a given strength level. + * + * @param {string|number} strength The strength level + * @return {string} The label text + */ + getStrengthLabel: function(strength) { + // Use WordPress's built-in localized strings + if (typeof pwsL10n === 'undefined') { + // Fallback labels if pwsL10n is not available + var fallbackLabels = { + 'empty': 'Enter a password', + '-1': 'Unknown', + '0': 'Very weak', + '1': 'Very weak', + '2': 'Weak', + '3': 'Medium', + '4': 'Strong', + '5': 'Mismatch' + }; + return fallbackLabels[strength] || fallbackLabels['0']; + } + + switch (strength) { + case 'empty': + return pwsL10n.empty || 'Strength indicator'; + case -1: + return pwsL10n.unknown || 'Unknown'; + case 0: + case 1: + return pwsL10n.short || 'Very weak'; + case 2: + return pwsL10n.bad || 'Weak'; + case 3: + return pwsL10n.good || 'Medium'; + case 4: + return pwsL10n.strong || 'Strong'; + case 5: + return pwsL10n.mismatch || 'Mismatch'; + default: + return pwsL10n.short || 'Very weak'; + } + }, + + /** + * Update the UI based on password strength. + * + * @param {number} strength The password strength level + */ + updateUI: function(strength) { + switch (strength) { + case -1: + case 0: + case 1: + this.options.result.addClass('wu-bg-red-200 wu-border-red-300').html(this.getStrengthLabel(strength)); + break; + case 2: + this.options.result.addClass('wu-bg-red-200 wu-border-red-300').html(this.getStrengthLabel(2)); + break; + case 3: + this.options.result.addClass('wu-bg-yellow-200 wu-border-yellow-300').html(this.getStrengthLabel(3)); + break; + case 4: + this.options.result.addClass('wu-bg-green-200 wu-border-green-300').html(this.getStrengthLabel(4)); + break; + case 5: + this.options.result.addClass('wu-bg-red-200 wu-border-red-300').html(this.getStrengthLabel(5)); + break; + default: + this.options.result.addClass('wu-bg-red-200 wu-border-red-300').html(this.getStrengthLabel(0)); + } + }, + + /** + * Update password validity based on strength. + * + * @param {number} strength The password strength level + */ + updateValidity: function(strength) { + var isValid = false; + + if (strength >= this.options.minStrength && strength !== 5) { + isValid = true; + } + + this.setValid(isValid); + }, + + /** + * Set password validity and update submit button. + * + * @param {boolean} isValid Whether the password is valid + */ + setValid: function(isValid) { + var wasValid = this.isPasswordValid; + this.isPasswordValid = isValid; + + if (this.options.submit && this.options.submit.length) { + this.options.submit.prop('disabled', !isValid); + } + + // Trigger callback if validity changed + if (wasValid !== isValid && typeof this.options.onValidityChange === 'function') { + this.options.onValidityChange(isValid); + } + }, + + /** + * Get the current password validity. + * + * @return {boolean} Whether the password is valid + */ + isValid: function() { + return this.isPasswordValid; + } + }; + +})(jQuery); diff --git a/assets/js/wu-password-toggle.js b/assets/js/wu-password-toggle.js new file mode 100644 index 000000000..b8b2ae94d --- /dev/null +++ b/assets/js/wu-password-toggle.js @@ -0,0 +1,57 @@ +/** + * Password visibility toggle functionality. + * + * Adds show/hide functionality to password fields using the WordPress + * core approach with dashicons. + * + * @since 2.4.0 + * @output assets/js/wu-password-toggle.js + */ + +(function() { + 'use strict'; + + var __ = wp.i18n.__; + + /** + * Toggle password visibility. + * + * @param {Event} event Click event. + */ + function togglePassword(event) { + var toggle = event.target.closest('.wu-pwd-toggle'); + + if (!toggle) { + return; + } + + event.preventDefault(); + + var status = toggle.getAttribute('data-toggle'); + var input = toggle.parentElement.querySelector('input[type="password"], input[type="text"]'); + var icon = toggle.querySelector('.dashicons'); + + if (!input || !icon) { + return; + } + + if ('0' === status) { + // Show password + toggle.setAttribute('data-toggle', '1'); + toggle.setAttribute('aria-label', __('Hide password', 'ultimate-multisite')); + input.setAttribute('type', 'text'); + icon.classList.remove('dashicons-visibility'); + icon.classList.add('dashicons-hidden'); + } else { + // Hide password + toggle.setAttribute('data-toggle', '0'); + toggle.setAttribute('aria-label', __('Show password', 'ultimate-multisite')); + input.setAttribute('type', 'password'); + icon.classList.remove('dashicons-hidden'); + icon.classList.add('dashicons-visibility'); + } + } + + // Use event delegation to handle dynamically added elements (Vue, etc.) + document.addEventListener('click', togglePassword); +})(); diff --git a/inc/checkout/class-checkout-pages.php b/inc/checkout/class-checkout-pages.php index 3c902aa60..66c1e47c7 100644 --- a/inc/checkout/class-checkout-pages.php +++ b/inc/checkout/class-checkout-pages.php @@ -204,6 +204,8 @@ public function get_error_message($error_code, $username = '') { 'password_reset_mismatch' => __('Error: The passwords do not match.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'invalidkey' => __('Error: Your password reset link appears to be invalid. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'expiredkey' => __('Error: Your password reset link has expired. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + 'invalid_key' => __('Error: Your password reset link appears to be invalid. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + 'expired_key' => __('Error: Your password reset link has expired. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain ]; /** diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 43035cf1a..412df8ac0 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -2564,7 +2564,12 @@ public function register_scripts(): void { wp_enqueue_style('wu-admin'); - wp_register_script('wu-checkout', wu_get_asset('checkout.js', 'js'), ['jquery-core', 'wu-vue', 'moment', 'wu-block-ui', 'wu-functions', 'password-strength-meter', 'underscore', 'wp-polyfill', 'wp-hooks', 'wu-cookie-helpers'], wu_get_version(), true); + // Enqueue dashicons for password toggle. + wp_enqueue_style('dashicons'); + + wp_register_script('wu-checkout', wu_get_asset('checkout.js', 'js'), ['jquery-core', 'wu-vue', 'moment', 'wu-block-ui', 'wu-functions', 'password-strength-meter', 'wu-password-strength', 'underscore', 'wp-polyfill', 'wp-hooks', 'wu-cookie-helpers', 'wu-password-toggle'], wu_get_version(), true); + + wp_set_script_translations('wu-password-toggle', 'ultimate-multisite'); wp_localize_script('wu-checkout', 'wu_checkout', $this->get_checkout_variables()); diff --git a/inc/class-scripts.php b/inc/class-scripts.php index 45b6ffe04..d48452e40 100644 --- a/inc/class-scripts.php +++ b/inc/class-scripts.php @@ -150,6 +150,16 @@ public function register_default_scripts(): void { */ $this->register_script('wu-cookie-helpers', wu_get_asset('cookie-helpers.js', 'js'), ['jquery-core']); + /* + * Adds Password Toggle + */ + $this->register_script('wu-password-toggle', wu_get_asset('wu-password-toggle.js', 'js'), ['wp-i18n']); + + /* + * Adds Password Strength Checker + */ + $this->register_script('wu-password-strength', wu_get_asset('wu-password-strength.js', 'js'), ['jquery', 'password-strength-meter']); + /* * Adds Input Masking */ diff --git a/inc/integrations/host-providers/class-closte-host-provider.php b/inc/integrations/host-providers/class-closte-host-provider.php index 8397f04e5..7e3598c93 100644 --- a/inc/integrations/host-providers/class-closte-host-provider.php +++ b/inc/integrations/host-providers/class-closte-host-provider.php @@ -67,6 +67,44 @@ class Closte_Host_Provider extends Base_Host_Provider { 'CLOSTE_CLIENT_API_KEY', ]; + /** + * Runs on singleton instantiation. + * + * @since 2.1.1 + * @return void + */ + public function init(): void { + + parent::init(); + + /** + * Add filter to increase the number of tries to get the SSL certificate. + * This is needed because, from our tests, Closte hosting takes a while to get the SSL certificate. + */ + add_filter('wu_async_process_domain_stage_max_tries', [$this, 'ssl_tries'], 10, 2); + } + + /** + * Increases the number of tries to get the SSL certificate. + * + * @since 2.4.10 + * @param int $max_tries The number of tries to get the SSL certificate. + * @param \WP_Ultimo\Models\Domain $domain The domain object. + * @return int + */ + public function ssl_tries($max_tries, $domain) { + + if ( ! $this->is_enabled()) { + return $max_tries; + } + + if ('checking-ssl-cert' === $domain->get_stage()) { + $max_tries = 20; + } + + return $max_tries; + } + /** * Picks up on tips that a given host provider is being used. * @@ -105,7 +143,7 @@ public function on_add_domain($domain, $site_id): void { if (wu_get_isset($domain_response, 'success', false)) { wu_log_add('integration-closte', sprintf('Domain %s added successfully, requesting SSL certificate', $domain)); $this->request_ssl_certificate($domain); - } elseif (isset($domain_response['error']) && $domain_response['error'] === 'Invalid or empty domain: ' . $domain) { + } elseif (isset($domain_response['error']) && 'Invalid or empty domain: ' . $domain === $domain_response['error'] ) { wu_log_add('integration-closte', sprintf('Domain %s rejected by Closte API as invalid. This may be expected for Closte subdomains or internal domains.', $domain)); } else { wu_log_add('integration-closte', sprintf('Failed to add domain %s. Response: %s', $domain, wp_json_encode($domain_response))); diff --git a/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index bc3a35747..9536d8381 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -301,6 +301,35 @@ public function fields() { public function register_scripts(): void { wp_enqueue_style('wu-admin'); + + // Enqueue dashicons for password toggle. + wp_enqueue_style('dashicons'); + + // Enqueue password toggle script. + wp_enqueue_script( + 'wu-password-toggle', + wu_get_asset('wu-password-toggle.js', 'js'), + ['wp-i18n'], + wu_get_version(), + true + ); + + wp_set_script_translations('wu-password-toggle', 'ultimate-multisite'); + + // Enqueue password strength scripts for reset password page. + if ($this->is_reset_password_page()) { + // wu-password-strength is globally registered with password-strength-meter as dependency. + wp_enqueue_script('wu-password-strength'); + + // Enqueue password reset script. + wp_enqueue_script( + 'wu-password-reset', + wu_get_asset('wu-password-reset.js', 'js'), + ['wu-password-strength'], + wu_get_version(), + true + ); + } } /** @@ -605,7 +634,36 @@ public function output($atts, $content = null): void { $user = check_password_reset_key($rp_key, $rp_login); - if (isset($_POST['pass1']) && isset($_POST['rp_key']) && ! hash_equals(wp_unslash($_POST['rp_key']), wp_unslash($_POST['rp_key']))) { // phpcs:ignore WordPress.Security.NonceVerification + // If the reset key is invalid or expired, redirect with appropriate error. + if (is_wp_error($user)) { + $error_code = $user->get_error_code(); + $redirect_to = add_query_arg( + [ + 'action' => 'lostpassword', + 'error' => $error_code, + ], + remove_query_arg(['action', 'key', 'login']) + ); + + // Clear the invalid cookie. + setcookie( + $rp_cookie, + ' ', + [ + 'expires' => time() - YEAR_IN_SECONDS, + 'path' => '/', + 'domain' => (string) COOKIE_DOMAIN, + 'secure' => is_ssl(), + 'httponly' => true, + ] + ); + + wp_safe_redirect($redirect_to); + + exit; + } + + if (isset($_POST['pass1']) && isset($_POST['rp_key']) && ! hash_equals($rp_key, wp_unslash($_POST['rp_key']))) { // phpcs:ignore WordPress.Security.NonceVerification $user = false; } } else { @@ -615,17 +673,19 @@ public function output($atts, $content = null): void { $redirect_to = add_query_arg('password-reset', 'success', remove_query_arg(['action', 'error'])); $fields = [ - 'pass1' => [ + 'pass1' => [ 'type' => 'password', 'title' => __('New password'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'placeholder' => '', 'value' => '', + 'meter' => true, 'html_attr' => [ 'size' => 24, 'autocapitalize' => 'off', + 'autocomplete' => 'new-password', ], ], - 'pass2' => [ + 'pass2' => [ 'type' => 'password', 'title' => __('Confirm new password'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'placeholder' => '', @@ -633,30 +693,26 @@ public function output($atts, $content = null): void { 'html_attr' => [ 'size' => 24, 'autocapitalize' => 'off', + 'autocomplete' => 'new-password', ], ], - 'lost-password-instructions' => [ - 'type' => 'note', - 'desc' => wp_get_password_hint(), - 'tooltip' => '', - ], - 'action' => [ + 'action' => [ 'type' => 'hidden', 'value' => 'resetpass', ], - 'rp_key' => [ + 'rp_key' => [ 'type' => 'hidden', 'value' => $rp_key, ], - 'user_login' => [ + 'user_login' => [ 'type' => 'hidden', 'value' => $rp_login, ], - 'redirect_to' => [ + 'redirect_to' => [ 'type' => 'hidden', 'value' => $redirect_to, ], - 'wp-submit' => [ + 'wp-submit' => [ 'type' => 'submit', 'title' => __('Save Password'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'value' => __('Save Password'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain diff --git a/views/admin-pages/fields/field-password.php b/views/admin-pages/fields/field-password.php new file mode 100644 index 000000000..a107a5caa --- /dev/null +++ b/views/admin-pages/fields/field-password.php @@ -0,0 +1,74 @@ + +