From e7842a866768f7fd8cfba0cb832e918145c65da9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 30 Dec 2025 19:23:54 -0700 Subject: [PATCH 1/6] fix(login): show proper error for invalid password reset links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using the Ultimate Multisite login element for custom login pages, invalid or expired password reset links now display proper error messages instead of silently redirecting. Changes: - Add WP_Error check after check_password_reset_key() in Login_Form_Element - Redirect to lost password page with appropriate error code when key is invalid - Clear the invalid reset cookie before redirecting - Fix hash_equals bug that compared $_POST['rp_key'] to itself - Add 'invalid_key' and 'expired_key' error codes to match WordPress core 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- inc/checkout/class-checkout-pages.php | 2 ++ inc/ui/class-login-form-element.php | 31 ++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) 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/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index bc3a35747..be23da3c7 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -605,7 +605,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 { From 83831651fa209e725e94b94277e7818caf2469b2 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 31 Dec 2025 13:17:36 -0700 Subject: [PATCH 2/6] feat(login): add password strength indicator to password reset form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a password strength meter that enforces minimum medium strength when resetting passwords via the custom login page. Changes: - Add wu-password-reset.js for client-side strength checking - Dynamically create strength meter element below password field - Disable submit button until password meets minimum strength - Add server-side password strength validation - Remove password hint text (replaced by strength meter) - Add field-password.php template with meter support Strength levels: - Very weak/Weak (red): Not allowed - Medium (yellow): Minimum required - Strong (green): Full strength 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- assets/js/wu-password-reset.js | 121 ++++++++++++++++++++ inc/checkout/class-checkout-pages.php | 92 +++++++++++++++ inc/ui/class-login-form-element.php | 49 ++++++-- views/admin-pages/fields/field-password.php | 64 +++++++++++ 4 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 assets/js/wu-password-reset.js create mode 100644 views/admin-pages/fields/field-password.php diff --git a/assets/js/wu-password-reset.js b/assets/js/wu-password-reset.js new file mode 100644 index 000000000..94de86e91 --- /dev/null +++ b/assets/js/wu-password-reset.js @@ -0,0 +1,121 @@ +/* global jQuery, wp, wu_password_reset */ +/** + * Password strength meter for the password reset form. + * + * @since 2.3.0 + */ +(function($) { + 'use strict'; + + var i18n = typeof wu_password_reset !== 'undefined' ? wu_password_reset : { + enter_password: 'Enter a password', + short: 'Very weak', + weak: 'Weak', + medium: 'Medium', + strong: 'Strong', + mismatch: 'Passwords do not match' + }; + + var isPasswordValid = false; + var minStrength = typeof wu_password_reset !== 'undefined' ? parseInt(wu_password_reset.min_strength, 10) : 3; + + /** + * Check password strength and update the UI. + */ + function checkPasswordStrength($pass1, $pass2, $result, $submit) { + var pass1 = $pass1.val(); + var pass2 = $pass2.val(); + + // Reset classes + $result.attr('class', 'wu-py-2 wu-px-4 wu-block wu-text-sm wu-border-solid wu-border wu-mt-2'); + + if (!pass1) { + $result.addClass('wu-bg-gray-100 wu-border-gray-200').html(i18n.enter_password); + isPasswordValid = false; + $submit.prop('disabled', true); + return; + } + + // Get disallowed list from WordPress + var disallowedList = ''; + if (typeof wp !== 'undefined' && typeof wp.passwordStrength !== 'undefined') { + disallowedList = typeof wp.passwordStrength.userInputDisallowedList === 'undefined' + ? wp.passwordStrength.userInputBlacklist() + : wp.passwordStrength.userInputDisallowedList(); + } + + var strength = wp.passwordStrength.meter(pass1, disallowedList, pass2); + + isPasswordValid = false; + + switch (strength) { + case 0: + case 1: + $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.short); + break; + case 2: + $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.weak); + break; + case 3: + $result.addClass('wu-bg-yellow-200 wu-border-yellow-300').html(i18n.medium); + if (minStrength <= 3) { + isPasswordValid = true; + } + break; + case 4: + $result.addClass('wu-bg-green-200 wu-border-green-300').html(i18n.strong); + isPasswordValid = true; + break; + case 5: + $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.mismatch); + break; + default: + $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.short); + } + + $submit.prop('disabled', !isPasswordValid); + } + + /** + * Initialize the password strength meter. + */ + $(document).ready(function() { + var $pass1 = $('#field-pass1'); + var $pass2 = $('#field-pass2'); + var $submit = $('#wp-submit'); + + if (!$pass1.length) { + return; + } + + // Create strength meter element if it doesn't exist + var $result = $('#pass-strength-result'); + if (!$result.length) { + $result = $('
' + i18n.enter_password + '
'); + $pass1.after($result); + } + + // Bind events + $pass1.on('keyup input', function() { + checkPasswordStrength($pass1, $pass2, $result, $submit); + }); + $pass2.on('keyup input', function() { + checkPasswordStrength($pass1, $pass2, $result, $submit); + }); + + // Disable submit initially + $submit.prop('disabled', true); + + // Prevent form submission if password is too weak + $pass1.closest('form').on('submit', function(e) { + if (!isPasswordValid) { + e.preventDefault(); + return false; + } + }); + + // Initial check + checkPasswordStrength($pass1, $pass2, $result, $submit); + }); + +})(jQuery); diff --git a/inc/checkout/class-checkout-pages.php b/inc/checkout/class-checkout-pages.php index 66c1e47c7..709b54b6d 100644 --- a/inc/checkout/class-checkout-pages.php +++ b/inc/checkout/class-checkout-pages.php @@ -62,6 +62,8 @@ public function init(): void { add_action('lost_password', [$this, 'maybe_handle_password_reset_errors']); + add_action('validate_password_reset', [$this, 'validate_password_strength'], 5, 2); + add_action('validate_password_reset', [$this, 'maybe_handle_password_reset_errors']); /** @@ -206,6 +208,7 @@ public function get_error_message($error_code, $username = '') { '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 + 'password_too_weak' => __('Error: Please choose a stronger password. The password must be at least medium strength.', 'ultimate-multisite'), ]; /** @@ -249,6 +252,95 @@ public function maybe_handle_password_reset_errors($errors): void { } } + /** + * Validate password strength during password reset. + * + * Enforces a minimum password strength of "medium" (strength level 3). + * + * @since 2.3.0 + * + * @param \WP_Error $errors The error object. + * @param \WP_User $user The user object. + * @return void + */ + public function validate_password_strength($errors, $user): void { + + $password = wu_request('pass1', ''); + + if (empty($password)) { + return; + } + + // Use WordPress password strength meter. + // Strength levels: 0-1 = very weak, 2 = weak, 3 = medium, 4 = strong. + // We require at least medium (3). + $strength = $this->calculate_password_strength($password); + + if ($strength < 3) { + $errors->add('password_too_weak', __('Error: Please choose a stronger password. The password must be at least medium strength.', 'ultimate-multisite')); + } + } + + /** + * Calculate password strength using similar logic to WordPress. + * + * @since 2.3.0 + * + * @param string $password The password to check. + * @return int Strength level: 0-1 = very weak, 2 = weak, 3 = medium, 4 = strong. + */ + protected function calculate_password_strength(string $password): int { + + $strength = 0; + $length = strlen($password); + + // Minimum length check + if ($length < 6) { + return 0; + } + + // Length scoring + if ($length >= 8) { + ++$strength; + } + + if ($length >= 12) { + ++$strength; + } + + // Character variety scoring + if (preg_match('/[a-z]/', $password)) { + ++$strength; + } + + if (preg_match('/[A-Z]/', $password)) { + ++$strength; + } + + if (preg_match('/[0-9]/', $password)) { + ++$strength; + } + + if (preg_match('/[^a-zA-Z0-9]/', $password)) { + ++$strength; + } + + // Map to WordPress strength levels (0-4) + if ($strength <= 1) { + return 1; // Very weak + } + + if ($strength <= 2) { + return 2; // Weak + } + + if ($strength <= 4) { + return 3; // Medium + } + + return 4; // Strong + } + /** * Maybe redirects users to the confirm screen. * diff --git a/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index be23da3c7..dedbeb710 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -301,6 +301,33 @@ public function fields() { public function register_scripts(): void { wp_enqueue_style('wu-admin'); + + // Enqueue password strength meter for reset password page. + if ($this->is_reset_password_page()) { + wp_enqueue_script('password-strength-meter'); + + wp_enqueue_script( + 'wu-password-reset', + wu_get_asset('wu-password-reset.js', 'js'), + ['jquery', 'password-strength-meter'], + wu_get_version(), + true + ); + + wp_localize_script( + 'wu-password-reset', + 'wu_password_reset', + [ + 'enter_password' => __('Enter a password', 'ultimate-multisite'), + 'short' => __('Very weak', 'ultimate-multisite'), + 'weak' => __('Weak', 'ultimate-multisite'), + 'medium' => __('Medium', 'ultimate-multisite'), + 'strong' => __('Strong', 'ultimate-multisite'), + 'mismatch' => __('Passwords do not match', 'ultimate-multisite'), + 'min_strength' => 3, // Minimum strength level required (3 = medium). + ] + ); + } } /** @@ -644,17 +671,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' => '', @@ -662,30 +691,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..40d9f9641 --- /dev/null +++ b/views/admin-pages/fields/field-password.php @@ -0,0 +1,64 @@ + +
  • print_wrapper_html_attributes(); ?>> + +
    + + $field, + ] + ); + + ?> + + print_html_attributes(); ?>> + + meter)) : ?> + + + + + + + + $field, + ] + ); + + ?> + +
    + +
  • From fab8e9110452b8594bfebedf5022b424032364a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 00:02:04 +0000 Subject: [PATCH 3/6] refactor(password): extract shared password strength utility Extract password strength checking logic into a reusable utility to eliminate duplication between checkout and password reset forms. Changes: - Create shared wu-password-strength.js utility for password validation - Update wu-password-reset.js to use shared utility (reduced from 122 to 46 lines) - Remove hardcoded password strength strings in favor of WordPress's pwsL10n - Remove custom localization in class-login-form-element.php - Update script dependencies to include the shared utility This refactoring ensures consistency across forms and leverages WordPress's built-in localized password strength strings instead of duplicating them. --- assets/js/wu-password-reset.js | 106 ++---------- assets/js/wu-password-strength.js | 241 ++++++++++++++++++++++++++++ inc/ui/class-login-form-element.php | 22 ++- 3 files changed, 265 insertions(+), 104 deletions(-) create mode 100644 assets/js/wu-password-strength.js diff --git a/assets/js/wu-password-reset.js b/assets/js/wu-password-reset.js index 94de86e91..425889d27 100644 --- a/assets/js/wu-password-reset.js +++ b/assets/js/wu-password-reset.js @@ -1,80 +1,16 @@ -/* global jQuery, wp, wu_password_reset */ +/* 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 i18n = typeof wu_password_reset !== 'undefined' ? wu_password_reset : { - enter_password: 'Enter a password', - short: 'Very weak', - weak: 'Weak', - medium: 'Medium', - strong: 'Strong', - mismatch: 'Passwords do not match' - }; - - var isPasswordValid = false; - var minStrength = typeof wu_password_reset !== 'undefined' ? parseInt(wu_password_reset.min_strength, 10) : 3; - - /** - * Check password strength and update the UI. - */ - function checkPasswordStrength($pass1, $pass2, $result, $submit) { - var pass1 = $pass1.val(); - var pass2 = $pass2.val(); - - // Reset classes - $result.attr('class', 'wu-py-2 wu-px-4 wu-block wu-text-sm wu-border-solid wu-border wu-mt-2'); - - if (!pass1) { - $result.addClass('wu-bg-gray-100 wu-border-gray-200').html(i18n.enter_password); - isPasswordValid = false; - $submit.prop('disabled', true); - return; - } - - // Get disallowed list from WordPress - var disallowedList = ''; - if (typeof wp !== 'undefined' && typeof wp.passwordStrength !== 'undefined') { - disallowedList = typeof wp.passwordStrength.userInputDisallowedList === 'undefined' - ? wp.passwordStrength.userInputBlacklist() - : wp.passwordStrength.userInputDisallowedList(); - } - - var strength = wp.passwordStrength.meter(pass1, disallowedList, pass2); - - isPasswordValid = false; - - switch (strength) { - case 0: - case 1: - $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.short); - break; - case 2: - $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.weak); - break; - case 3: - $result.addClass('wu-bg-yellow-200 wu-border-yellow-300').html(i18n.medium); - if (minStrength <= 3) { - isPasswordValid = true; - } - break; - case 4: - $result.addClass('wu-bg-green-200 wu-border-green-300').html(i18n.strong); - isPasswordValid = true; - break; - case 5: - $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.mismatch); - break; - default: - $result.addClass('wu-bg-red-200 wu-border-red-300').html(i18n.short); - } - - $submit.prop('disabled', !isPasswordValid); - } + var passwordStrength; /** * Initialize the password strength meter. @@ -83,39 +19,27 @@ var $pass1 = $('#field-pass1'); var $pass2 = $('#field-pass2'); var $submit = $('#wp-submit'); + var $form = $pass1.closest('form'); - if (!$pass1.length) { + if (!$pass1.length || typeof WU_PasswordStrength === 'undefined') { return; } - // Create strength meter element if it doesn't exist - var $result = $('#pass-strength-result'); - if (!$result.length) { - $result = $('
    ' + i18n.enter_password + '
    '); - $pass1.after($result); - } - - // Bind events - $pass1.on('keyup input', function() { - checkPasswordStrength($pass1, $pass2, $result, $submit); - }); - $pass2.on('keyup input', function() { - checkPasswordStrength($pass1, $pass2, $result, $submit); + // 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 }); - // Disable submit initially - $submit.prop('disabled', true); - // Prevent form submission if password is too weak - $pass1.closest('form').on('submit', function(e) { - if (!isPasswordValid) { + $form.on('submit', function(e) { + if (!passwordStrength.isValid()) { e.preventDefault(); return false; } }); - - // Initial check - checkPasswordStrength($pass1, $pass2, $result, $submit); }); })(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/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index dedbeb710..a84791938 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -306,26 +306,22 @@ public function register_scripts(): void { if ($this->is_reset_password_page()) { wp_enqueue_script('password-strength-meter'); + // Enqueue shared password strength utility. wp_enqueue_script( - 'wu-password-reset', - wu_get_asset('wu-password-reset.js', 'js'), + 'wu-password-strength', + wu_get_asset('wu-password-strength.js', 'js'), ['jquery', 'password-strength-meter'], wu_get_version(), true ); - wp_localize_script( + // Enqueue password reset script. + wp_enqueue_script( 'wu-password-reset', - 'wu_password_reset', - [ - 'enter_password' => __('Enter a password', 'ultimate-multisite'), - 'short' => __('Very weak', 'ultimate-multisite'), - 'weak' => __('Weak', 'ultimate-multisite'), - 'medium' => __('Medium', 'ultimate-multisite'), - 'strong' => __('Strong', 'ultimate-multisite'), - 'mismatch' => __('Passwords do not match', 'ultimate-multisite'), - 'min_strength' => 3, // Minimum strength level required (3 = medium). - ] + wu_get_asset('wu-password-reset.js', 'js'), + ['jquery', 'password-strength-meter', 'wu-password-strength'], + wu_get_version(), + true ); } } From 0730d3f862aae903f932773c1853307a41c0a88c Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 1 Jan 2026 13:40:45 -0700 Subject: [PATCH 4/6] feat(password): add visibility toggle to password fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add eye icon to show/hide password on all password fields: - Checkout signup form (password + password confirmation) - Login form - Password reset page (new password + confirm password) Uses WordPress core approach with dashicons (visibility/hidden icons) and accessible aria-label attributes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- assets/js/wu-password-toggle.js | 68 +++++++++++++++++++++ inc/checkout/class-checkout.php | 7 ++- inc/class-scripts.php | 5 ++ inc/ui/class-login-form-element.php | 14 +++++ views/admin-pages/fields/field-password.php | 22 ++++--- views/checkout/fields/field-password.php | 20 ++++-- 6 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 assets/js/wu-password-toggle.js diff --git a/assets/js/wu-password-toggle.js b/assets/js/wu-password-toggle.js new file mode 100644 index 000000000..194007d16 --- /dev/null +++ b/assets/js/wu-password-toggle.js @@ -0,0 +1,68 @@ +/** + * 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.__; + + /** + * Initialize password toggle functionality. + */ + function init() { + var toggleElements = document.querySelectorAll('.wu-pwd-toggle'); + + toggleElements.forEach(function(toggle) { + toggle.classList.remove('hide-if-no-js'); + toggle.addEventListener('click', togglePassword); + }); + } + + /** + * Toggle password visibility. + * + * @param {Event} event Click event. + */ + function togglePassword(event) { + event.preventDefault(); + + var toggle = this; + 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'); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 43035cf1a..61aac5cc7 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', '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..cff5a6b05 100644 --- a/inc/class-scripts.php +++ b/inc/class-scripts.php @@ -150,6 +150,11 @@ 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 Input Masking */ diff --git a/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index a84791938..8e8b55738 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -302,6 +302,20 @@ 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 meter for reset password page. if ($this->is_reset_password_page()) { wp_enqueue_script('password-strength-meter'); diff --git a/views/admin-pages/fields/field-password.php b/views/admin-pages/fields/field-password.php index 40d9f9641..f0fb77158 100644 --- a/views/admin-pages/fields/field-password.php +++ b/views/admin-pages/fields/field-password.php @@ -27,13 +27,21 @@ ?> - print_html_attributes(); ?>> +
    + print_html_attributes(); ?>> + +
    meter)) : ?> diff --git a/views/checkout/fields/field-password.php b/views/checkout/fields/field-password.php index 8d871b2d0..a6d2eb95b 100644 --- a/views/checkout/fields/field-password.php +++ b/views/checkout/fields/field-password.php @@ -23,12 +23,20 @@ ); ?> - print_html_attributes(); ?>> +
    + print_html_attributes(); ?>> + +
    meter) : ?> From c5b8472624543fbe7b9565d3eb64f1e27a871736 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 1 Jan 2026 18:51:32 -0700 Subject: [PATCH 5/6] fix(password-toggle): fix positioning and dynamic element support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use event delegation for click handling to support Vue-rendered fields - Fix button positioning with inline styles (wu-right-2 class not available) - Position toggle button at right edge of password input - Add padding-right to input to prevent text overlap with button 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- assets/js/wu-password-toggle.js | 27 ++++++--------------- views/admin-pages/fields/field-password.php | 8 +++--- views/checkout/fields/field-password.php | 11 ++++++--- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/assets/js/wu-password-toggle.js b/assets/js/wu-password-toggle.js index 194007d16..b8b2ae94d 100644 --- a/assets/js/wu-password-toggle.js +++ b/assets/js/wu-password-toggle.js @@ -13,27 +13,20 @@ var __ = wp.i18n.__; - /** - * Initialize password toggle functionality. - */ - function init() { - var toggleElements = document.querySelectorAll('.wu-pwd-toggle'); - - toggleElements.forEach(function(toggle) { - toggle.classList.remove('hide-if-no-js'); - toggle.addEventListener('click', togglePassword); - }); - } - /** * 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 toggle = this; var status = toggle.getAttribute('data-toggle'); var input = toggle.parentElement.querySelector('input[type="password"], input[type="text"]'); var icon = toggle.querySelector('.dashicons'); @@ -59,10 +52,6 @@ } } - // Initialize when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } + // Use event delegation to handle dynamically added elements (Vue, etc.) + document.addEventListener('click', togglePassword); })(); diff --git a/views/admin-pages/fields/field-password.php b/views/admin-pages/fields/field-password.php index f0fb77158..a107a5caa 100644 --- a/views/admin-pages/fields/field-password.php +++ b/views/admin-pages/fields/field-password.php @@ -27,16 +27,18 @@ ?> -
    - + print_html_attributes(); ?>>