From 96e3fd19fed71d120f976de86386802f75fc8fab Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 31 Jan 2025 14:40:05 -0800 Subject: [PATCH 01/29] Add foundation for the custom password strength meter --- .../src/assets/jetpack-logo.svg | 2 + .../src/class-account-protection.php | 55 ++++++++ .../src/class-password-manager.php | 2 + .../src/js/jetpack-password-strength-meter.js | 122 ++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 projects/packages/account-protection/src/js/jetpack-password-strength-meter.js diff --git a/projects/packages/account-protection/src/assets/jetpack-logo.svg b/projects/packages/account-protection/src/assets/jetpack-logo.svg index b91e3c5c216f5..dd6ad79d05a91 100644 --- a/projects/packages/account-protection/src/assets/jetpack-logo.svg +++ b/projects/packages/account-protection/src/assets/jetpack-logo.svg @@ -3,10 +3,12 @@ x="0px" y="0px" height="32px" + viewBox='0 0 118 32' aria-labelledby="jetpack-logo" role="img" > "Jetpack Logo" + password_manager, 'on_profile_update' ), 10, 3 ); add_action( 'after_password_reset', array( $this->password_manager, 'on_password_reset' ), 10, 2 ); + + // TESTING + add_filter( + 'retrieve_password_message', + function ( $message, $key, $user_login, $user_data ) { + + $reset_link = network_site_url( 'wp-login.php?login=' . rawurlencode( $user_login ) . "&key=$key&action=rp", 'login' ); + + // Log or store the reset link for debugging + error_log( 'Generated Reset Link: ' . $reset_link ); + + return $message; // Keep the original email message intact + }, + 10, + 4 + ); + + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_jetpack_password_strength_meter_profile_script' ) ); + add_action( 'login_enqueue_scripts', array( $this, 'enqueue_jetpack_password_strength_meter_reset_script') ); + } + + public function enqueue_jetpack_password_strength_meter_profile_script() { + if ( ! wp_script_is('jetpack-password-strength-meter', 'enqueued') ) { + wp_enqueue_script( + 'jetpack-password-strength-meter', + plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', + array('jquery'), + null, + true + ); + } + + $this->localize_jetpack_branding_data(); + } + + public function enqueue_jetpack_password_strength_meter_reset_script() { + if ( isset( $_GET['action'] ) && ( $_GET['action'] === 'rp' || $_GET['action'] === 'resetpass' ) ) { + if ( ! wp_script_is('jetpack-password-strength-meter', 'enqueued') ) { + wp_enqueue_script( + 'jetpack-password-strength-meter', + plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', + array('jquery'), + null, + true + ); + } + } + + $this->localize_jetpack_branding_data(); + } + + public function localize_jetpack_branding_data() { + wp_localize_script( 'jetpack-password-strength-meter', 'jetpackBrandingData', array( + 'logo' => plugin_dir_url(__FILE__) . 'assets/jetpack-logo.svg' + ) ); } /** diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php index 403ca5a430185..f04e1daf01bf2 100644 --- a/projects/packages/account-protection/src/class-password-manager.php +++ b/projects/packages/account-protection/src/class-password-manager.php @@ -155,6 +155,7 @@ public function validate_password_reset( \WP_Error $errors, $user ): void { * @return void */ public function on_profile_update( int $user_id, \WP_User $old_user_data, array $userdata ): void { + // TODO: Need to verify this is working... seems to happen on reset link send! if ( ! $this->verify_profile_update_nonce( $user_id ) ) { error_log( "Nonce verification failed for profile update: User ID {$user_id}" ); return; @@ -178,6 +179,7 @@ public function on_password_reset( $user, $new_password ) { // return; // } + // TODO: Need to verify this is working... error_log( 'on_password_reset' ); $this->save_recent_password( $user->ID, $user->user_pass ); diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js new file mode 100644 index 0000000000000..4290eddaa2803 --- /dev/null +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -0,0 +1,122 @@ +/* global jQuery, jetpackBrandingData */ + +jQuery( document ).ready( function ( $ ) { + const passwordInput = $( '#pass1' ); + const generatePasswordButton = $( '.wp-generate-pw' ); + const passwordStrengthResult = $( '#pass-strength-result' ); + + // TODO: Enforce password confirmation on custom validation + // const weakPasswordConfirmation = $( '.pw-weak' ); + // const submitButton = $( '#submit' ); + + passwordInput.css( { border: '1px solid #8c8f94', 'border-radius': '4px 4px 0px 0px' } ); + passwordStrengthResult.hide(); + + // Store the last known class to prevent duplicate logs + let lastClass = passwordStrengthResult.attr( 'class' ) || ''; + + /** + * Function to log the updated class only if it changes + */ + function logClassChange() { + const newClass = passwordStrengthResult.attr( 'class' ) || ''; + if ( newClass !== lastClass ) { + lastClass = newClass; // Update last known class + } + } + + // Monitor class changes using MutationObserver + const classObserver = new MutationObserver( function ( mutations ) { + mutations.forEach( function ( mutation ) { + if ( mutation.attributeName === 'class' ) { + logClassChange(); + } + } ); + } ); + + // Start observing class attribute changes + if ( passwordStrengthResult.length ) { + classObserver.observe( passwordStrengthResult[ 0 ], { attributes: true } ); + } + + const strengthMeter = $( '
', { + id: 'custom-password-message', + css: { + display: 'flex', + 'justify-content': 'space-between', + 'align-items': 'center', + padding: '8px 16px', + 'margin-left': '1px', + 'margin-right': '1px', + 'margin-bottom': '16px', + 'background-color': '#9dd977', + 'border-radius': '0px 0px 4px 4px', + }, + } ); + + const strength = $( '

', { + text: 'Strong', + css: { + display: 'flex', + 'align-items': 'center', + 'font-size': '12px', + 'font-weight': 'bold', + margin: '0', + }, + } ); + + const jetpackBranding = $( '

', { + css: { + display: 'flex', + 'align-items': 'center', + gap: '4px', + }, + } ); + + const brandingMessage = $( '

', { + text: 'Powered by ', + css: { + 'font-size': '12px', + margin: '0', + }, + } ); + + const jetpackLogo = $( '', { + src: jetpackBrandingData.logo, + alt: 'Jetpack Logo', + css: { + height: '18px', + }, + } ); + + jetpackBranding.append( brandingMessage ); + jetpackBranding.append( jetpackLogo ); + + strengthMeter.append( strength ); + strengthMeter.append( jetpackBranding ); + + passwordInput.after( strengthMeter ); + + // Run validation on real-time input updates + passwordInput.on( 'input', () => validatePassword( 'input update' ) ); + + // Run validation if input has a initial value + if ( passwordInput.val().length > 0 ) { + validatePassword( 'immediate with initial value' ); + } + + // Run validation on password generation + generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); + + /** + * + * Validate the current password input + * + * @param text - The password to validate + */ + function validatePassword() { + const currentPasswordInput = passwordInput.val(); + // Password validation logic here + console.log( 'Validating password...', currentPasswordInput ); + } +} ); From 78e272b69419c0aaddec7e3ca7098bc93a278edb Mon Sep 17 00:00:00 2001 From: dkmyta Date: Sat, 1 Feb 2025 12:45:29 -0800 Subject: [PATCH 02/29] Add ajax request for password validation --- .../src/class-account-protection.php | 35 +++++++++++-- .../src/class-validation-service.php | 18 +++---- .../src/js/jetpack-password-strength-meter.js | 50 +++++++++++++++---- 3 files changed, 81 insertions(+), 22 deletions(-) diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 816b58a5e06b5..3c185f314ea69 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -137,8 +137,34 @@ function ( $message, $key, $user_login, $user_data ) { 4 ); + // Eneuque password strength meter scripts add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_jetpack_password_strength_meter_profile_script' ) ); add_action( 'login_enqueue_scripts', array( $this, 'enqueue_jetpack_password_strength_meter_reset_script') ); + + add_action('wp_ajax_validate_password_ajax', array( $this, 'validate_password_ajax' ) ); + add_action('wp_ajax_nopriv_validate_password_ajax', array( $this, 'validate_password_ajax' ) ); + + } + + public function validate_password_ajax() { + // Verify password is set in request + if ( ! isset( $_POST['password'] ) ) { + wp_send_json_error( [ 'message' => 'No password provided.' ] ); + } + + $password = sanitize_text_field( $_POST[ 'password' ] ); // ✅ Sanitize input + + error_log( 'Password: ' . $password ); + // Simulating a validation process (replace with actual validation logic) + $errors = []; + $errors[] = __( 'Between 6 and 150 characters', 'jetpack-account-protection' ); + $errors[] = __( 'Doesn\'t contain a backslash (\\) character', 'jetpack-account-protection' ); + + if ( empty( $errors ) ) { + wp_send_json_success( [ 'message' => 'Password is strong!' ] ); + } else { + wp_send_json_error( [ 'errors' => $errors ] ); + } } public function enqueue_jetpack_password_strength_meter_profile_script() { @@ -152,7 +178,7 @@ public function enqueue_jetpack_password_strength_meter_profile_script() { ); } - $this->localize_jetpack_branding_data(); + $this->localize_jetpack_data(); } public function enqueue_jetpack_password_strength_meter_reset_script() { @@ -168,11 +194,12 @@ public function enqueue_jetpack_password_strength_meter_reset_script() { } } - $this->localize_jetpack_branding_data(); + $this->localize_jetpack_data(); } - public function localize_jetpack_branding_data() { - wp_localize_script( 'jetpack-password-strength-meter', 'jetpackBrandingData', array( + public function localize_jetpack_data() { + wp_localize_script( 'jetpack-password-strength-meter', 'jetpackData', array( + 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'logo' => plugin_dir_url(__FILE__) . 'assets/jetpack-logo.svg' ) ); } diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index 69f74598e7d42..6405075b03179 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -68,17 +68,17 @@ public function return_all_validation_errors( $user, string $password ): array { $errors[] = __( 'Between 6 and 150 characters', 'jetpack-account-protection' ); } - if ( $this->matches_user_data( $user, $password ) ) { - $errors[] = __( 'Doesn\'t match user data', 'jetpack-account-protection' ); - } + // if ( $this->matches_user_data( $user, $password ) ) { + // $errors[] = __( 'Doesn\'t match user data', 'jetpack-account-protection' ); + // } - if ( $this->is_recent_password( $user->ID, $password ) ) { - $errors[] = __( 'Not used recently', 'jetpack-account-protection' ); - } + // if ( $this->is_recent_password( $user->ID, $password ) ) { + // $errors[] = __( 'Not used recently', 'jetpack-account-protection' ); + // } - if ( $this->is_weak_password( $password ) ) { - $errors[] = __( 'Not a leaked password.', 'jetpack-account-protection' ); - } + // if ( $this->is_weak_password( $password ) ) { + // $errors[] = __( 'Not a leaked password.', 'jetpack-account-protection' ); + // } return $errors; } diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 4290eddaa2803..65735db5d4555 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -1,4 +1,4 @@ -/* global jQuery, jetpackBrandingData */ +/* global jQuery, jetpackData */ jQuery( document ).ready( function ( $ ) { const passwordInput = $( '#pass1' ); @@ -8,6 +8,7 @@ jQuery( document ).ready( function ( $ ) { // TODO: Enforce password confirmation on custom validation // const weakPasswordConfirmation = $( '.pw-weak' ); // const submitButton = $( '#submit' ); + const passwordValidationStatus = $( '

' ); passwordInput.css( { border: '1px solid #8c8f94', 'border-radius': '4px 4px 0px 0px' } ); passwordStrengthResult.hide(); @@ -46,8 +47,8 @@ jQuery( document ).ready( function ( $ ) { 'justify-content': 'space-between', 'align-items': 'center', padding: '8px 16px', - 'margin-left': '1px', - 'margin-right': '1px', + 'margin-left': '1px', // TODO: Certain styling should only apply to profile or reset UIs - profile only + 'margin-right': '1px', // TODO: Certain styling should only apply to profile or reset UIs - profile only 'margin-bottom': '16px', 'background-color': '#9dd977', 'border-radius': '0px 0px 4px 4px', @@ -82,7 +83,7 @@ jQuery( document ).ready( function ( $ ) { } ); const jetpackLogo = $( '', { - src: jetpackBrandingData.logo, + src: jetpackData.logo, alt: 'Jetpack Logo', css: { height: '18px', @@ -95,15 +96,18 @@ jQuery( document ).ready( function ( $ ) { strengthMeter.append( strength ); strengthMeter.append( jetpackBranding ); + passwordInput.after( passwordValidationStatus ); passwordInput.after( strengthMeter ); // Run validation on real-time input updates passwordInput.on( 'input', () => validatePassword( 'input update' ) ); - // Run validation if input has a initial value - if ( passwordInput.val().length > 0 ) { - validatePassword( 'immediate with initial value' ); - } + // Run validation if input has a initial value - reset form + setTimeout( () => { + if ( passwordInput.val().length > 0 ) { + validatePassword( 'immediate with initial value' ); + } + }, 1000 ); // Run validation on password generation generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); @@ -112,11 +116,39 @@ jQuery( document ).ready( function ( $ ) { * * Validate the current password input * - * @param text - The password to validate */ function validatePassword() { const currentPasswordInput = passwordInput.val(); // Password validation logic here console.log( 'Validating password...', currentPasswordInput ); + + $.ajax( { + url: jetpackData.ajaxurl, // WordPress AJAX handler + type: 'POST', + data: { + action: 'validate_password_ajax', + password: currentPasswordInput, // ✅ Pass the password for validation + }, + beforeSend: function () { + passwordValidationStatus.html( '

Validating...

' ); + }, + success: function ( response ) { + if ( response.success ) { + passwordValidationStatus.html( + `

${ response.data.message }

` + ); + } else { + let errorHtml = '
    '; + response.data.errors.forEach( error => { + errorHtml += `
  • ${ error }
  • `; + } ); + errorHtml += '
'; + passwordValidationStatus.html( errorHtml ); + } + }, + error: function () { + passwordValidationStatus.html( '

Error connecting to server.

' ); + }, + } ); } } ); From eafecc0dafb0b6a1a39a7cf9a7fef1a1c696002b Mon Sep 17 00:00:00 2001 From: dkmyta Date: Mon, 3 Feb 2025 13:08:43 -0800 Subject: [PATCH 03/29] Updates and improvements --- .../account-protection/src/assets/check.svg | 4 ++ .../account-protection/src/assets/cross.svg | 4 ++ .../src/class-account-protection.php | 4 +- .../src/class-password-manager.php | 26 +-------- .../src/class-validation-service.php | 14 ++++- .../src/js/jetpack-password-strength-meter.js | 54 +++++++++++++++---- 6 files changed, 68 insertions(+), 38 deletions(-) create mode 100644 projects/packages/account-protection/src/assets/check.svg create mode 100644 projects/packages/account-protection/src/assets/cross.svg diff --git a/projects/packages/account-protection/src/assets/check.svg b/projects/packages/account-protection/src/assets/check.svg new file mode 100644 index 0000000000000..1edf9dd81adc2 --- /dev/null +++ b/projects/packages/account-protection/src/assets/check.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/cross.svg b/projects/packages/account-protection/src/assets/cross.svg new file mode 100644 index 0000000000000..44c2eb42b0c2f --- /dev/null +++ b/projects/packages/account-protection/src/assets/cross.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 3c185f314ea69..30e5287961028 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -200,7 +200,9 @@ public function enqueue_jetpack_password_strength_meter_reset_script() { public function localize_jetpack_data() { wp_localize_script( 'jetpack-password-strength-meter', 'jetpackData', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), - 'logo' => plugin_dir_url(__FILE__) . 'assets/jetpack-logo.svg' + 'logo' => plugin_dir_url(__FILE__) . 'assets/jetpack-logo.svg', + 'check' => plugin_dir_url(__FILE__) . 'assets/check.svg', + 'cross' => plugin_dir_url(__FILE__) . 'assets/cross.svg', ) ); } diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php index f04e1daf01bf2..b58bc5b1e3c15 100644 --- a/projects/packages/account-protection/src/class-password-manager.php +++ b/projects/packages/account-protection/src/class-password-manager.php @@ -53,17 +53,6 @@ private function verify_profile_update_nonce( $user_id ) { return $this->verify_password_update_nonce( 'update-user_' . $user_id ); } - // /** - // * Verify the nonce for password reset. - // * - // * @param int $user_id The user ID. - // * - // * @return bool True if the nonce is valid, false otherwise. - // */ - // private function verify_password_reset_nonce( $user_id ) { - // return $this->verify_password_update_nonce( 'resetpassword_' . $user_id ); - // } - /** * Validate the profile update. * @@ -116,14 +105,7 @@ public function validate_profile_update( \WP_Error $errors, bool $update, \stdCl * @return void */ public function validate_password_reset( \WP_Error $errors, $user ): void { - // TODO: Does not appear possible to verify the nonce or reset key here, unclear how to approach this safely - // Maybe its fine because we are only handling existing data that has already been screened and erroring or allowing a pass? - // If necessary, we could use the same logic to verify as wp-login.php case 'resetpass'/case 'rp' - // if ( ! $this->verify_password_reset_nonce( $user->ID ) ) { - // $errors->add( 'nonce_error', __( 'Nonce verification failed. Please try again.', 'jetpack-account-protection' ) ); - // return; - // } - + // No nonce verification necessary as the actions hook in after a robust verification process if ( is_wp_error( $user ) ) { return; } @@ -173,11 +155,7 @@ public function on_profile_update( int $user_id, \WP_User $old_user_data, array * @param string $new_password The new password. */ public function on_password_reset( $user, $new_password ) { - // TODO: Does not appear possible to verify the nonce or reset key here, unclear how to approach this safely - // if ( ! $this->verify_password_reset_nonce( $user->ID ) ) { - // error_log( "Nonce verification failed for password reset: User ID {$user->ID}" ); - // return; - // } + // No nonce verification necessary as the actions hook in after a robust verification process // TODO: Need to verify this is working... error_log( 'on_password_reset' ); diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index 6405075b03179..2528037f4484f 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -160,6 +160,11 @@ private function matches_user_data( $user, string $password ): bool { return false; } + $email_parts = explode( '@', $user->user_email ); // test@example.com + $email_username = $email_parts[0]; // 'test' + $email_domain = $email_parts[1]; // 'example.com' + $email_provider = explode( '.', $email_domain )[0]; // 'example' + $user_data = array( $user->user_login, $user->user_nicename, @@ -167,14 +172,19 @@ private function matches_user_data( $user, string $password ): bool { $user->first_name, $user->last_name, $user->user_email, - explode( '@', $user->user_email )[0], // Email username - explode( '@', $user->user_email )[1], // Email domain + $email_username, + $email_provider, $user->nickname, ); $password_lower = strtolower( $password ); foreach ( $user_data as $data ) { + // Skip if $data is 3 characters or less. + if ( strlen( $data ) <= 3 ) { + continue; + } + if ( ! empty( $data ) && strpos( $password_lower, strtolower( $data ) ) !== false ) { return true; } diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 65735db5d4555..2b28fde48729c 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -9,6 +9,39 @@ jQuery( document ).ready( function ( $ ) { // const weakPasswordConfirmation = $( '.pw-weak' ); // const submitButton = $( '#submit' ); const passwordValidationStatus = $( '
' ); + const validationCheckList = $( '
    ' ); + const validationCheckListItem = $( '
  • ', { + css: { + display: 'flex', + 'align-items': 'center', + gap: '4px', + }, + } ); + const jetpackCheck = $( '', { + src: jetpackData.check, + alt: 'Jetpack Check', + css: { + height: '24px', + }, + } ); + const jetpackCross = $( '', { + src: jetpackData.cross, + alt: 'Jetpack Cross', + css: { + height: '24px', + }, + } ); + const validationCheckListItemText = $( '

    Between 6 and 150 characters

    ', { + css: { + margin: '0', + }, + } ); + validationCheckListItem.append( jetpackCross ); + validationCheckListItem.append( validationCheckListItemText ); + + validationCheckList.append( validationCheckListItem ); + + passwordValidationStatus.append( validationCheckList ); passwordInput.css( { border: '1px solid #8c8f94', 'border-radius': '4px 4px 0px 0px' } ); passwordStrengthResult.hide(); @@ -100,17 +133,17 @@ jQuery( document ).ready( function ( $ ) { passwordInput.after( strengthMeter ); // Run validation on real-time input updates - passwordInput.on( 'input', () => validatePassword( 'input update' ) ); + // passwordInput.on( 'input', () => validatePassword( 'input update' ) ); - // Run validation if input has a initial value - reset form - setTimeout( () => { - if ( passwordInput.val().length > 0 ) { - validatePassword( 'immediate with initial value' ); - } - }, 1000 ); + // // Run validation if input has a initial value - reset form + // setTimeout( () => { + // if ( passwordInput.val().length > 0 ) { + // validatePassword( 'immediate with initial value' ); + // } + // }, 1000 ); - // Run validation on password generation - generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); + // // Run validation on password generation + // generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); /** * @@ -119,7 +152,6 @@ jQuery( document ).ready( function ( $ ) { */ function validatePassword() { const currentPasswordInput = passwordInput.val(); - // Password validation logic here console.log( 'Validating password...', currentPasswordInput ); $.ajax( { @@ -127,7 +159,7 @@ jQuery( document ).ready( function ( $ ) { type: 'POST', data: { action: 'validate_password_ajax', - password: currentPasswordInput, // ✅ Pass the password for validation + password: currentPasswordInput, }, beforeSend: function () { passwordValidationStatus.html( '

    Validating...

    ' ); From 108e3e1ccc9a02c42bab1450d4f441dc1ea5dae7 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Tue, 4 Feb 2025 14:14:47 -0800 Subject: [PATCH 04/29] Add password validation status handling and hook up ajax callback --- .../account-protection/src/assets/check.svg | 2 +- .../account-protection/src/assets/cross.svg | 2 +- .../account-protection/src/assets/loading.svg | 12 + .../src/class-account-protection.php | 23 +- .../account-protection/src/class-config.php | 9 +- .../src/class-password-manager.php | 10 +- .../src/class-validation-service.php | 59 ++-- .../src/js/jetpack-password-strength-meter.js | 277 ++++++++++++------ .../tests/php/test-password-manager.php | 8 +- .../tests/php/test-validation-service.php | 4 +- 10 files changed, 261 insertions(+), 145 deletions(-) create mode 100644 projects/packages/account-protection/src/assets/loading.svg diff --git a/projects/packages/account-protection/src/assets/check.svg b/projects/packages/account-protection/src/assets/check.svg index 1edf9dd81adc2..d88457419a8b1 100644 --- a/projects/packages/account-protection/src/assets/check.svg +++ b/projects/packages/account-protection/src/assets/check.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/cross.svg b/projects/packages/account-protection/src/assets/cross.svg index 44c2eb42b0c2f..3c33e4931cada 100644 --- a/projects/packages/account-protection/src/assets/cross.svg +++ b/projects/packages/account-protection/src/assets/cross.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/projects/packages/account-protection/src/assets/loading.svg b/projects/packages/account-protection/src/assets/loading.svg new file mode 100644 index 0000000000000..5ab5dce606fa7 --- /dev/null +++ b/projects/packages/account-protection/src/assets/loading.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 30e5287961028..ac6be5ee17ad1 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -152,19 +152,10 @@ public function validate_password_ajax() { wp_send_json_error( [ 'message' => 'No password provided.' ] ); } - $password = sanitize_text_field( $_POST[ 'password' ] ); // ✅ Sanitize input - - error_log( 'Password: ' . $password ); - // Simulating a validation process (replace with actual validation logic) - $errors = []; - $errors[] = __( 'Between 6 and 150 characters', 'jetpack-account-protection' ); - $errors[] = __( 'Doesn\'t contain a backslash (\\) character', 'jetpack-account-protection' ); - - if ( empty( $errors ) ) { - wp_send_json_success( [ 'message' => 'Password is strong!' ] ); - } else { - wp_send_json_error( [ 'errors' => $errors ] ); - } + $password = sanitize_text_field( $_POST[ 'password' ] ); + $state = ( new Validation_Service() )->get_validation_state( wp_get_current_user(), $password ); + + wp_send_json_success( [ 'status' => $state ] ); } public function enqueue_jetpack_password_strength_meter_profile_script() { @@ -201,8 +192,10 @@ public function localize_jetpack_data() { wp_localize_script( 'jetpack-password-strength-meter', 'jetpackData', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'logo' => plugin_dir_url(__FILE__) . 'assets/jetpack-logo.svg', - 'check' => plugin_dir_url(__FILE__) . 'assets/check.svg', - 'cross' => plugin_dir_url(__FILE__) . 'assets/cross.svg', + 'checkIcon' => plugin_dir_url(__FILE__) . 'assets/check.svg', + 'crossIcon' => plugin_dir_url(__FILE__) . 'assets/cross.svg', + 'loadingIcon' => plugin_dir_url(__FILE__) . 'assets/loading.svg', + 'validationInitialState' => ( new Validation_Service() )->get_validation_initial_state(), ) ); } diff --git a/projects/packages/account-protection/src/class-config.php b/projects/packages/account-protection/src/class-config.php index b4aacb57387ad..59ac338bf4057 100644 --- a/projects/packages/account-protection/src/class-config.php +++ b/projects/packages/account-protection/src/class-config.php @@ -11,11 +11,18 @@ * Class Config */ class Config { + // Password Detection Constants public const PASSWORD_DETECTION_TRANSIENT_PREFIX = 'password_detection'; public const PASSWORD_DETECTION_ERROR_CODE = 'password_detection_validation_error'; public const PASSWORD_DETECTION_ERROR_MESSAGE = 'Password validation failed.'; public const PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION = 600; // 10 minutes public const PASSWORD_DETECTION_MAX_RESEND_ATTEMPTS = 3; - public const VALIDATION_SERVICE_USER_META_KEY = 'jetpack_account_protection_recent_password_hashes'; + // Password Manager Constants + public const PASSWORD_MANAGER_USER_META_KEY = 'jetpack_account_protection_recent_password_hashes'; + public const PASSWORD_MANAGER_RECENT_PASSWORDS_LIMIT = 10; + + // Validation Service Constants + public const VALIDATION_SERVICE_MIN_LENGTH = 6; + public const VALIDATION_SERVICE_MAX_LENGTH = 150; } diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php index 259e872ca4070..5102ba12b9619 100644 --- a/projects/packages/account-protection/src/class-password-manager.php +++ b/projects/packages/account-protection/src/class-password-manager.php @@ -86,7 +86,7 @@ public function validate_profile_update( \WP_Error $errors, bool $update, \stdCl } } - $error = $this->validation_service->return_first_validation_error( $user, $password, 'profile' ); + $error = $this->validation_service->get_first_validation_error( $user, $password, 'profile' ); if ( ! empty( $error ) ) { $errors->add( 'password_error', $error ); return; @@ -129,7 +129,7 @@ public function validate_password_reset( \WP_Error $errors, $user ): void { return; } - $error = $this->validation_service->return_first_validation_error( $user, $password, 'reset' ); + $error = $this->validation_service->get_first_validation_error( $user, $password, 'reset' ); if ( ! empty( $error ) ) { $errors->add( 'password_error', $error ); return; @@ -179,7 +179,7 @@ public function on_password_reset( $user, $new_password ): void { // phpcs:ignor * @return void */ public function save_recent_password( int $user_id, string $password_hash ): void { - $recent_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_USER_META_KEY, true ); + $recent_passwords = get_user_meta( $user_id, Config::PASSWORD_MANAGER_USER_META_KEY, true ); if ( ! is_array( $recent_passwords ) ) { $recent_passwords = array(); @@ -191,8 +191,8 @@ public function save_recent_password( int $user_id, string $password_hash ): voi // Add the new hashed password and keep only the last 10 array_unshift( $recent_passwords, $password_hash ); - $recent_passwords = array_slice( $recent_passwords, 0, 10 ); + $recent_passwords = array_slice( $recent_passwords, 0, Config::PASSWORD_MANAGER_RECENT_PASSWORDS_LIMIT ); - update_user_meta( $user_id, Config::VALIDATION_SERVICE_USER_META_KEY, $recent_passwords ); + update_user_meta( $user_id, Config::PASSWORD_MANAGER_USER_META_KEY, $recent_passwords ); } } diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index 23858000b8523..e1145cb83f95c 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -50,37 +50,38 @@ protected function request_suffixes( string $password_prefix ) { } /** - * Return all validation errors. + * Return validation initial state. + * + * @return array An array of all validation statuses and messages. + */ + public function get_validation_initial_state(): array { + return [ + 'contains_backslash' => [ 'status' => null, 'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ) ], + 'invalid_length' => [ 'status' => null, 'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ) ], + 'matches_user_data' => [ 'status' => null, 'message' => __( "Doesn't match user data", 'jetpack-account-protection' ) ], + // 'recent' => [ 'status' => null, 'message' => __( 'Not used recently', 'jetpack-account-protection' ) ], + // 'weak' => [ 'status' => null, 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ) ], + ]; + } + + /** + * Return validation state. * * @param \WP_User|\stdClass $user The user object or a copy. * @param string $password The password to check. * - * @return array An array of validation errors (if any). + * @return array An array of the status of each check. */ - public function return_all_validation_errors( $user, string $password ): array { - $errors = array(); - - if ( $this->contains_backslash( $password ) ) { - $errors[] = __( 'Doesn\'t contain a backslash (\\) character', 'jetpack-account-protection' ); - } - - if ( $this->is_invalid_length( $password ) ) { - $errors[] = __( 'Between 6 and 150 characters', 'jetpack-account-protection' ); - } - - // if ( $this->matches_user_data( $user, $password ) ) { - // $errors[] = __( 'Doesn\'t match user data', 'jetpack-account-protection' ); - // } - - // if ( $this->is_recent_password( $user->ID, $password ) ) { - // $errors[] = __( 'Not used recently', 'jetpack-account-protection' ); - // } - - // if ( $this->is_weak_password( $password ) ) { - // $errors[] = __( 'Not a leaked password.', 'jetpack-account-protection' ); - // } - - return $errors; + public function get_validation_state( $user, string $password ): array { + $validation_state = $this->get_validation_initial_state(); + + $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password ); + $validation_state['invalid_length']['status'] = $this->is_invalid_length( $password ); + $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); + // $validation_state['recent']['recent'] = $this->is_recent_password( $user->ID, $password ); + // $validation_state['weak']['status'] = $this->is_weak_password( $password ); + + return $validation_state; } /** @@ -92,7 +93,7 @@ public function return_all_validation_errors( $user, string $password ): array { * * @return string The first validation errors (if any). */ - public function return_first_validation_error( $user, string $password, $context ): string { + public function get_first_validation_error( $user, string $password, $context ): string { if ( 'profile' === $context ) { if ( empty( $password ) ) { return __( 'Error: The password cannot be a space or all spaces.', 'jetpack-account-protection' ); @@ -144,7 +145,7 @@ public function contains_backslash( string $password ): bool { */ public function is_invalid_length( string $password ): bool { $length = strlen( $password ); - return $length < 6 || $length > 150; + return $length < Config::VALIDATION_SERVICE_MIN_LENGTH || $length > Config::VALIDATION_SERVICE_MAX_LENGTH; } /** @@ -250,7 +251,7 @@ public function is_current_password( \WP_User $user, string $password ): bool { * @return bool True if the password hash was recently used, false otherwise. */ public function is_recent_password( int $user_id, string $password ): bool { - $recent_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_USER_META_KEY, true ); + $recent_passwords = get_user_meta( $user_id, Config::PASSWORD_MANAGER_USER_META_KEY, true ); if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) { return false; diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 2b28fde48729c..8d3265bbbaafb 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -1,100 +1,101 @@ /* global jQuery, jetpackData */ jQuery( document ).ready( function ( $ ) { - const passwordInput = $( '#pass1' ); + // TODO: Enforce password confirmation on custom validation const generatePasswordButton = $( '.wp-generate-pw' ); + const weakPasswordConfirmation = $( '.pw-weak' ); + const submitButton = $( '#submit' ); + + // Get the password input field, reset styling + const passwordInput = $( '#pass1' ); + passwordInput.css( { 'border-color': '#8c8f94' } ); + + // Hide core password strength meter const passwordStrengthResult = $( '#pass-strength-result' ); + passwordStrengthResult.hide(); - // TODO: Enforce password confirmation on custom validation - // const weakPasswordConfirmation = $( '.pw-weak' ); - // const submitButton = $( '#submit' ); const passwordValidationStatus = $( '
    ' ); - const validationCheckList = $( '
      ' ); - const validationCheckListItem = $( '
    • ', { + + const validationMessages = { + core: { + status: null, + message: 'Passes core validation', + }, + ...jetpackData.validationInitialState, + }; + + const validationCheckList = $( '
        ', { css: { display: 'flex', - 'align-items': 'center', + 'flex-direction': 'column', gap: '4px', }, } ); - const jetpackCheck = $( '', { - src: jetpackData.check, - alt: 'Jetpack Check', - css: { - height: '24px', - }, - } ); - const jetpackCross = $( '', { - src: jetpackData.cross, - alt: 'Jetpack Cross', - css: { - height: '24px', - }, - } ); - const validationCheckListItemText = $( '

        Between 6 and 150 characters

        ', { - css: { - margin: '0', - }, - } ); - validationCheckListItem.append( jetpackCross ); - validationCheckListItem.append( validationCheckListItemText ); - validationCheckList.append( validationCheckListItem ); + const validationItems = {}; - passwordValidationStatus.append( validationCheckList ); + Object.entries( validationMessages ).forEach( ( [ key, value ] ) => { + const listItem = $( '
      • ', { + css: { + display: 'contains_backslash' === key ? 'none' : 'flex', + 'align-items': 'center', + gap: '8px', + }, + 'data-key': key, + } ); - passwordInput.css( { border: '1px solid #8c8f94', 'border-radius': '4px 4px 0px 0px' } ); - passwordStrengthResult.hide(); + const validationIcon = $( '', { + src: jetpackData.loadingIcon, + alt: 'Validating...', + css: { + height: '24px', + }, + } ); - // Store the last known class to prevent duplicate logs - let lastClass = passwordStrengthResult.attr( 'class' ) || ''; + const validationCheckListItemText = $( '

        ', { + text: value.message, + css: { + 'margin-top': '0', + }, + } ); - /** - * Function to log the updated class only if it changes - */ - function logClassChange() { - const newClass = passwordStrengthResult.attr( 'class' ) || ''; - if ( newClass !== lastClass ) { - lastClass = newClass; // Update last known class - } - } + listItem.append( validationIcon ); + listItem.append( validationCheckListItemText ); + validationCheckList.append( listItem ); - // Monitor class changes using MutationObserver - const classObserver = new MutationObserver( function ( mutations ) { - mutations.forEach( function ( mutation ) { - if ( mutation.attributeName === 'class' ) { - logClassChange(); - } - } ); + // Store references to update later + validationItems[ key ] = { + icon: validationIcon, + text: validationCheckListItemText, + item: listItem, + }; } ); - // Start observing class attribute changes - if ( passwordStrengthResult.length ) { - classObserver.observe( passwordStrengthResult[ 0 ], { attributes: true } ); - } + passwordValidationStatus.append( validationCheckList ); + passwordInput.after( passwordValidationStatus ); const strengthMeter = $( '

        ', { - id: 'custom-password-message', css: { display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', - padding: '8px 16px', + height: '30px', + padding: '0px 16px', 'margin-left': '1px', // TODO: Certain styling should only apply to profile or reset UIs - profile only 'margin-right': '1px', // TODO: Certain styling should only apply to profile or reset UIs - profile only 'margin-bottom': '16px', - 'background-color': '#9dd977', 'border-radius': '0px 0px 4px 4px', }, } ); const strength = $( '

        ', { - text: 'Strong', + text: '', css: { display: 'flex', 'align-items': 'center', 'font-size': '12px', 'font-weight': 'bold', + color: '#1d2327', margin: '0', }, } ); @@ -111,6 +112,7 @@ jQuery( document ).ready( function ( $ ) { text: 'Powered by ', css: { 'font-size': '12px', + color: '#1d2327', margin: '0', }, } ); @@ -126,24 +128,18 @@ jQuery( document ).ready( function ( $ ) { jetpackBranding.append( brandingMessage ); jetpackBranding.append( jetpackLogo ); - strengthMeter.append( strength ); - strengthMeter.append( jetpackBranding ); - - passwordInput.after( passwordValidationStatus ); - passwordInput.after( strengthMeter ); - // Run validation on real-time input updates - // passwordInput.on( 'input', () => validatePassword( 'input update' ) ); + passwordInput.on( 'input', () => validatePassword() ); - // // Run validation if input has a initial value - reset form - // setTimeout( () => { - // if ( passwordInput.val().length > 0 ) { - // validatePassword( 'immediate with initial value' ); - // } - // }, 1000 ); + // Run validation if input has a initial value - reset form + setTimeout( () => { + if ( passwordInput.val().length > 0 ) { + validatePassword(); + } + }, 1500 ); - // // Run validation on password generation - // generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); + // Run validation on password generation + generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); /** * @@ -152,35 +148,142 @@ jQuery( document ).ready( function ( $ ) { */ function validatePassword() { const currentPasswordInput = passwordInput.val(); - console.log( 'Validating password...', currentPasswordInput ); + const failedValidationConditions = {}; + + if ( ! currentPasswordInput || 0 === currentPasswordInput.length ) { + applyStyling( failedValidationConditions, true ); + return; + } + + Object.values( validationItems ).forEach( ( { icon } ) => { + icon.attr( 'src', jetpackData.loading ); + icon.attr( 'alt', 'Validating...' ); + } ); + + const passwordStrengthResultClass = passwordStrengthResult.attr( 'class' ) || ''; + + const coreValidationFailed = + passwordStrengthResultClass !== 'strong' && passwordStrengthResultClass !== 'good'; + const coreItem = validationCheckList.find( `li[data-key="core"]` ); + const coreValidationIcon = coreItem.find( 'img' ); + const coreValidationText = coreItem.find( 'p' ); + + coreValidationIcon.attr( + 'src', + coreValidationFailed ? jetpackData.crossIcon : jetpackData.checkIcon + ); + coreValidationIcon.attr( 'alt', coreValidationFailed ? 'Jetpack Cross' : 'Jetpack Check' ); + coreValidationText.css( 'color', coreValidationFailed ? '#E65054' : '#008710' ); + + if ( coreValidationFailed ) { + failedValidationConditions.core = coreValidationFailed; + } $.ajax( { - url: jetpackData.ajaxurl, // WordPress AJAX handler + url: jetpackData.ajaxurl, type: 'POST', data: { action: 'validate_password_ajax', password: currentPasswordInput, }, - beforeSend: function () { - passwordValidationStatus.html( '

        Validating...

        ' ); - }, success: function ( response ) { if ( response.success ) { + Object.entries( response.data.status ).forEach( ( [ key, item ] ) => { + const isInvalid = item.status; + const { icon, text, item: listItem } = validationItems[ key ] || {}; + + if ( ! icon || ! text ) return; + + if ( key === 'contains_backslash' ) { + listItem.css( 'display', isInvalid ? 'flex' : 'none' ); + } + + icon.attr( 'src', isInvalid ? jetpackData.crossIcon : jetpackData.checkIcon ); + icon.attr( 'alt', isInvalid ? 'Jetpack Cross' : 'Jetpack Check' ); + text.css( 'color', isInvalid ? '#E65054' : '#008710' ); + + if ( isInvalid ) { + failedValidationConditions[ key ] = isInvalid; + } + } ); + + applyStyling( failedValidationConditions ); + } else { passwordValidationStatus.html( - `

        ${ response.data.message }

        ` + '

        Error: Unable to validate password.

        ' ); - } else { - let errorHtml = '
          '; - response.data.errors.forEach( error => { - errorHtml += `
        • ${ error }
        • `; - } ); - errorHtml += '
        '; - passwordValidationStatus.html( errorHtml ); } }, error: function () { - passwordValidationStatus.html( '

        Error connecting to server.

        ' ); + passwordValidationStatus.html( + '

        Error connecting to server.

        ' + ); }, } ); } + + /** + * + * Apply styling based on validation results + * + * @param {object} failedValidationConditions + * @param {boolean} passwordIsEmpty + */ + function applyStyling( failedValidationConditions, passwordIsEmpty = false ) { + let finalColor = '#8c8f94'; + let finalStrengthText = ''; + + if ( passwordIsEmpty ) { + strengthMeter.hide(); + passwordValidationStatus.hide(); + passwordInput.css( { 'border-color': '#8c8f94', 'border-radius': '4px' } ); + return; + } + + if ( 0 === Object.keys( failedValidationConditions ).length ) { + finalColor = '#64CA43'; + finalStrengthText = 'Strong'; + + if ( submitButton.prop( 'disabled' ) ) { + submitButton.prop( 'disabled', false ); // Enable only if currently disabled + } + + if ( weakPasswordConfirmation.is( ':visible' ) ) { + weakPasswordConfirmation.css( 'display', 'none' ); // Hide only if visible + } + } else { + finalColor = '#E65054'; + finalStrengthText = 'Weak'; + + if ( ! submitButton.prop( 'disabled' ) ) { + submitButton.prop( 'disabled', true ); // Disable only if currently enabled + } + + if ( weakPasswordConfirmation.css( 'display' ) !== 'table-row' ) { + weakPasswordConfirmation.css( 'display', 'table-row' ); // Show as table row only if hidden + } + } + + strength.text( finalStrengthText ); + strengthMeter.css( 'background-color', finalColor ); + passwordInput.css( { 'border-color': finalColor, 'border-radius': '4px 4px 0px 0px' } ); + + // TODO: Smoother transition? + if ( ! strengthMeter.find( strength ).length ) { + strengthMeter.append( strength ); + } + if ( ! strengthMeter.find( jetpackBranding ).length ) { + strengthMeter.append( jetpackBranding ); + } + if ( ! strengthMeter.parent().length ) { + passwordInput.after( strengthMeter ); + } + + if ( strengthMeter.is( ':hidden' ) ) { + strengthMeter.show(); + } + if ( passwordValidationStatus.is( ':hidden' ) ) { + passwordValidationStatus.show(); + } + } } ); diff --git a/projects/packages/account-protection/tests/php/test-password-manager.php b/projects/packages/account-protection/tests/php/test-password-manager.php index 5cb25e18df2cd..35acd33edf9f9 100644 --- a/projects/packages/account-protection/tests/php/test-password-manager.php +++ b/projects/packages/account-protection/tests/php/test-password-manager.php @@ -36,7 +36,7 @@ public function test_validate_profile_update_success() { $validation_service_mock = $this->createMock( Validation_Service::class ); $validation_service_mock->expects( $this->once() ) - ->method( 'return_first_validation_error' ) + ->method( 'get_first_validation_error' ) ->willReturn( '' ); $password_manager_mock = $this->getMockBuilder( Password_Manager::class ) @@ -78,7 +78,7 @@ public function test_validate_password_reset_with_valid_user() { $validation_service_mock = $this->createMock( Validation_Service::class ); $validation_service_mock->expects( $this->once() ) - ->method( 'return_first_validation_error' ) + ->method( 'get_first_validation_error' ) ->willReturn( '' ); $password_manager_mock = new Password_Manager( $validation_service_mock ); @@ -146,13 +146,13 @@ public function test_save_recent_password_stores_last_10_passwords() { 'hash10', ); - update_user_meta( $user_id, Config::VALIDATION_SERVICE_USER_META_KEY, $password_hashes ); + update_user_meta( $user_id, Config::PASSWORD_MANAGER_USER_META_KEY, $password_hashes ); $validation_service_mock = $this->createMock( Validation_Service::class ); $password_manager_mock = new Password_Manager( $validation_service_mock ); $password_manager_mock->save_recent_password( $user_id, 'new_hash' ); - $stored_passwords = get_user_meta( $user_id, Config::VALIDATION_SERVICE_USER_META_KEY, true ); + $stored_passwords = get_user_meta( $user_id, Config::PASSWORD_MANAGER_USER_META_KEY, true ); $this->assertCount( 10, $stored_passwords ); $this->assertEquals( 'new_hash', $stored_passwords[0] ); } diff --git a/projects/packages/account-protection/tests/php/test-validation-service.php b/projects/packages/account-protection/tests/php/test-validation-service.php index bf22f78739404..073b326650f03 100644 --- a/projects/packages/account-protection/tests/php/test-validation-service.php +++ b/projects/packages/account-protection/tests/php/test-validation-service.php @@ -189,7 +189,7 @@ public function test_returns_true_if_password_was_recently_used() { $user_id = 1; $password_hash = wp_hash_password( 'somepassword' ); - update_user_meta( $user_id, Config::VALIDATION_SERVICE_USER_META_KEY, array( $password_hash ) ); + update_user_meta( $user_id, Config::PASSWORD_MANAGER_USER_META_KEY, array( $password_hash ) ); $validation_service = new Validation_Service( $this->get_connection_manager() ); $this->assertTrue( $validation_service->is_recent_password( $user_id, 'somepassword' ) ); @@ -199,7 +199,7 @@ public function test_returns_false_if_password_was_not_recently_used() { $user_id = 1; $password_hash = wp_hash_password( 'somepassword' ); - update_user_meta( $user_id, Config::VALIDATION_SERVICE_USER_META_KEY, array( $password_hash ) ); + update_user_meta( $user_id, Config::PASSWORD_MANAGER_USER_META_KEY, array( $password_hash ) ); $validation_service = new Validation_Service( $this->get_connection_manager() ); $this->assertFalse( $validation_service->is_recent_password( $user_id, 'anotherpassword' ) ); From 529005b557f6cf00ee7e9872b80eb24171a44402 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Tue, 4 Feb 2025 14:16:37 -0800 Subject: [PATCH 05/29] Update variables names --- .../src/js/jetpack-password-strength-meter.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 8d3265bbbaafb..9ef30761e8b56 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -11,8 +11,8 @@ jQuery( document ).ready( function ( $ ) { passwordInput.css( { 'border-color': '#8c8f94' } ); // Hide core password strength meter - const passwordStrengthResult = $( '#pass-strength-result' ); - passwordStrengthResult.hide(); + const corePasswordStrengthMeter = $( '#pass-strength-result' ); + corePasswordStrengthMeter.hide(); const passwordValidationStatus = $( '
        ' ); @@ -160,10 +160,10 @@ jQuery( document ).ready( function ( $ ) { icon.attr( 'alt', 'Validating...' ); } ); - const passwordStrengthResultClass = passwordStrengthResult.attr( 'class' ) || ''; + const corePasswordStrengthMeterClass = corePasswordStrengthMeter.attr( 'class' ) || ''; const coreValidationFailed = - passwordStrengthResultClass !== 'strong' && passwordStrengthResultClass !== 'good'; + corePasswordStrengthMeterClass !== 'strong' && corePasswordStrengthMeterClass !== 'good'; const coreItem = validationCheckList.find( `li[data-key="core"]` ); const coreValidationIcon = coreItem.find( 'img' ); const coreValidationText = coreItem.find( 'p' ); From d156dcf21600fd137c4399e977ccf2a71c519d03 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Tue, 4 Feb 2025 15:30:47 -0800 Subject: [PATCH 06/29] Add loading state --- .../src/js/jetpack-password-strength-meter.js | 93 +++++++++++-------- 1 file changed, 54 insertions(+), 39 deletions(-) diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 9ef30761e8b56..543f0811c239b 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -1,16 +1,13 @@ /* global jQuery, jetpackData */ jQuery( document ).ready( function ( $ ) { - // TODO: Enforce password confirmation on custom validation const generatePasswordButton = $( '.wp-generate-pw' ); const weakPasswordConfirmation = $( '.pw-weak' ); const submitButton = $( '#submit' ); - // Get the password input field, reset styling const passwordInput = $( '#pass1' ); - passwordInput.css( { 'border-color': '#8c8f94' } ); + passwordInput.css( { 'border-color': '#8C8F94' } ); - // Hide core password strength meter const corePasswordStrengthMeter = $( '#pass-strength-result' ); corePasswordStrengthMeter.hide(); @@ -63,7 +60,6 @@ jQuery( document ).ready( function ( $ ) { listItem.append( validationCheckListItemText ); validationCheckList.append( listItem ); - // Store references to update later validationItems[ key ] = { icon: validationIcon, text: validationCheckListItemText, @@ -85,17 +81,18 @@ jQuery( document ).ready( function ( $ ) { 'margin-right': '1px', // TODO: Certain styling should only apply to profile or reset UIs - profile only 'margin-bottom': '16px', 'border-radius': '0px 0px 4px 4px', + 'background-color': '#8C8F94', }, } ); const strength = $( '

        ', { - text: '', + text: 'Validating...', css: { display: 'flex', 'align-items': 'center', 'font-size': '12px', 'font-weight': 'bold', - color: '#1d2327', + color: '#1D2327', margin: '0', }, } ); @@ -112,7 +109,7 @@ jQuery( document ).ready( function ( $ ) { text: 'Powered by ', css: { 'font-size': '12px', - color: '#1d2327', + color: '#1D2327', margin: '0', }, } ); @@ -127,18 +124,18 @@ jQuery( document ).ready( function ( $ ) { jetpackBranding.append( brandingMessage ); jetpackBranding.append( jetpackLogo ); + strengthMeter.append( strength ); + strengthMeter.append( jetpackBranding ); + passwordInput.after( strengthMeter ); - // Run validation on real-time input updates passwordInput.on( 'input', () => validatePassword() ); - // Run validation if input has a initial value - reset form setTimeout( () => { if ( passwordInput.val().length > 0 ) { validatePassword(); } }, 1500 ); - // Run validation on password generation generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); /** @@ -150,34 +147,40 @@ jQuery( document ).ready( function ( $ ) { const currentPasswordInput = passwordInput.val(); const failedValidationConditions = {}; - if ( ! currentPasswordInput || 0 === currentPasswordInput.length ) { + if ( ! currentPasswordInput || currentPasswordInput.length === 0 ) { applyStyling( failedValidationConditions, true ); return; } - Object.values( validationItems ).forEach( ( { icon } ) => { - icon.attr( 'src', jetpackData.loading ); + // strengthMeter loading state + strength.text( 'Validating...' ); + strengthMeter.css( 'background-color', '#8C8F94' ); + passwordInput.css( { 'border-color': '#8C8F94' } ); + + // passwordValidationStatus loading state + Object.values( validationItems ).forEach( ( { icon, text } ) => { + icon.attr( 'src', jetpackData.loadingIcon ); icon.attr( 'alt', 'Validating...' ); + text.css( { color: '#3C434A', transition: 'color 0.2s ease-in-out' } ); } ); const corePasswordStrengthMeterClass = corePasswordStrengthMeter.attr( 'class' ) || ''; - const coreValidationFailed = corePasswordStrengthMeterClass !== 'strong' && corePasswordStrengthMeterClass !== 'good'; - const coreItem = validationCheckList.find( `li[data-key="core"]` ); - const coreValidationIcon = coreItem.find( 'img' ); - const coreValidationText = coreItem.find( 'p' ); - - coreValidationIcon.attr( - 'src', - coreValidationFailed ? jetpackData.crossIcon : jetpackData.checkIcon - ); - coreValidationIcon.attr( 'alt', coreValidationFailed ? 'Jetpack Cross' : 'Jetpack Check' ); - coreValidationText.css( 'color', coreValidationFailed ? '#E65054' : '#008710' ); - - if ( coreValidationFailed ) { - failedValidationConditions.core = coreValidationFailed; - } + + const uiUpdates = []; + + uiUpdates.push( () => { + const { icon, text } = validationItems.core; + + icon.attr( 'src', coreValidationFailed ? jetpackData.crossIcon : jetpackData.checkIcon ); + icon.attr( 'alt', coreValidationFailed ? 'Jetpack Cross' : 'Jetpack Check' ); + text.css( 'color', coreValidationFailed ? '#E65054' : '#008710' ); + + if ( coreValidationFailed ) { + failedValidationConditions.core = true; + } + } ); $.ajax( { url: jetpackData.ajaxurl, @@ -198,16 +201,28 @@ jQuery( document ).ready( function ( $ ) { listItem.css( 'display', isInvalid ? 'flex' : 'none' ); } - icon.attr( 'src', isInvalid ? jetpackData.crossIcon : jetpackData.checkIcon ); - icon.attr( 'alt', isInvalid ? 'Jetpack Cross' : 'Jetpack Check' ); - text.css( 'color', isInvalid ? '#E65054' : '#008710' ); + uiUpdates.push( () => { + icon.attr( 'src', isInvalid ? jetpackData.crossIcon : jetpackData.checkIcon ); + icon.attr( 'alt', isInvalid ? 'Jetpack Cross' : 'Jetpack Check' ); + text.css( { + color: isInvalid ? '#E65054' : '#008710', + transition: 'color 0.2s ease-in-out', + } ); + } ); if ( isInvalid ) { failedValidationConditions[ key ] = isInvalid; } } ); - applyStyling( failedValidationConditions ); + requestAnimationFrame( () => { + uiUpdates.forEach( update => update() ); + + validationCheckList.css( 'opacity', 0.99 ); + setTimeout( () => validationCheckList.css( 'opacity', 1 ), 1 ); + + applyStyling( failedValidationConditions ); + } ); } else { passwordValidationStatus.html( '

        Error: Unable to validate password.

        ' @@ -226,8 +241,8 @@ jQuery( document ).ready( function ( $ ) { * * Apply styling based on validation results * - * @param {object} failedValidationConditions - * @param {boolean} passwordIsEmpty + * @param {object} failedValidationConditions - Object containing failed validation conditions + * @param {boolean} passwordIsEmpty - Whether the password input is empty */ function applyStyling( failedValidationConditions, passwordIsEmpty = false ) { let finalColor = '#8c8f94'; @@ -245,22 +260,22 @@ jQuery( document ).ready( function ( $ ) { finalStrengthText = 'Strong'; if ( submitButton.prop( 'disabled' ) ) { - submitButton.prop( 'disabled', false ); // Enable only if currently disabled + submitButton.prop( 'disabled', false ); } if ( weakPasswordConfirmation.is( ':visible' ) ) { - weakPasswordConfirmation.css( 'display', 'none' ); // Hide only if visible + weakPasswordConfirmation.css( 'display', 'none' ); } } else { finalColor = '#E65054'; finalStrengthText = 'Weak'; if ( ! submitButton.prop( 'disabled' ) ) { - submitButton.prop( 'disabled', true ); // Disable only if currently enabled + submitButton.prop( 'disabled', true ); } if ( weakPasswordConfirmation.css( 'display' ) !== 'table-row' ) { - weakPasswordConfirmation.css( 'display', 'table-row' ); // Show as table row only if hidden + weakPasswordConfirmation.css( 'display', 'table-row' ); } } From 84c5ecbfb9066a4b30beb1699ceda6bc8950a005 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Tue, 4 Feb 2025 15:33:30 -0800 Subject: [PATCH 07/29] Remove todos --- .../src/js/jetpack-password-strength-meter.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 543f0811c239b..a7c2c67696fb5 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -77,8 +77,8 @@ jQuery( document ).ready( function ( $ ) { 'align-items': 'center', height: '30px', padding: '0px 16px', - 'margin-left': '1px', // TODO: Certain styling should only apply to profile or reset UIs - profile only - 'margin-right': '1px', // TODO: Certain styling should only apply to profile or reset UIs - profile only + 'margin-left': '1px', + 'margin-right': '1px', 'margin-bottom': '16px', 'border-radius': '0px 0px 4px 4px', 'background-color': '#8C8F94', @@ -283,7 +283,6 @@ jQuery( document ).ready( function ( $ ) { strengthMeter.css( 'background-color', finalColor ); passwordInput.css( { 'border-color': finalColor, 'border-radius': '4px 4px 0px 0px' } ); - // TODO: Smoother transition? if ( ! strengthMeter.find( strength ).length ) { strengthMeter.append( strength ); } From 523b195ab46768548bc73750c04e21d26fd20658 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Tue, 4 Feb 2025 19:06:08 -0800 Subject: [PATCH 08/29] Add nonce to ajax request --- .../src/class-account-protection.php | 102 ++++------------ .../src/class-password-strength-meter.php | 114 ++++++++++++++++++ .../src/class-validation-service.php | 40 ++++-- .../src/js/jetpack-password-strength-meter.js | 3 + .../client/security/account-protection.jsx | 2 +- 5 files changed, 167 insertions(+), 94 deletions(-) create mode 100644 projects/packages/account-protection/src/class-password-strength-meter.php diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index ac6be5ee17ad1..f53373940e89e 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -44,17 +44,26 @@ class Account_Protection { */ private $password_manager; + /** + * Password_Strength_Meter instance + * + * @var Password_Strength_Meter + */ + private $password_strength_meter; + /** * Account_Protection constructor. * - * @param ?Modules $modules Modules instance. - * @param ?Password_Detection $password_detection Password detection instance. - * @param ?Password_Manager $password_manager Validation service instance. + * @param ?Modules $modules Modules instance. + * @param ?Password_Detection $password_detection Password detection instance. + * @param ?Password_Manager $password_manager Password manager instance. + * @param ?Password_Strength_Meter $password_strength_meter Password strength meter instance. */ - public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null, ?Password_Manager $password_manager = null ) { - $this->modules = $modules ?? new Modules(); - $this->password_detection = $password_detection ?? new Password_Detection(); - $this->password_manager = $password_manager ?? new Password_Manager(); + public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null, ?Password_Manager $password_manager = null, ?Password_Strength_Meter $password_strength_meter = null ) { + $this->modules = $modules ?? new Modules(); + $this->password_detection = $password_detection ?? new Password_Detection(); + $this->password_manager = $password_manager ?? new Password_Manager(); + $this->password_strength_meter = $password_strength_meter ?? new Password_Strength_Meter(); } /** @@ -121,82 +130,13 @@ function () { add_action( 'profile_update', array( $this->password_manager, 'on_profile_update' ), 10, 3 ); add_action( 'after_password_reset', array( $this->password_manager, 'on_password_reset' ), 10, 2 ); - // TESTING - add_filter( - 'retrieve_password_message', - function ( $message, $key, $user_login, $user_data ) { - - $reset_link = network_site_url( 'wp-login.php?login=' . rawurlencode( $user_login ) . "&key=$key&action=rp", 'login' ); - - // Log or store the reset link for debugging - error_log( 'Generated Reset Link: ' . $reset_link ); - - return $message; // Keep the original email message intact - }, - 10, - 4 - ); - // Eneuque password strength meter scripts - add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_jetpack_password_strength_meter_profile_script' ) ); - add_action( 'login_enqueue_scripts', array( $this, 'enqueue_jetpack_password_strength_meter_reset_script') ); - - add_action('wp_ajax_validate_password_ajax', array( $this, 'validate_password_ajax' ) ); - add_action('wp_ajax_nopriv_validate_password_ajax', array( $this, 'validate_password_ajax' ) ); - - } - - public function validate_password_ajax() { - // Verify password is set in request - if ( ! isset( $_POST['password'] ) ) { - wp_send_json_error( [ 'message' => 'No password provided.' ] ); - } - - $password = sanitize_text_field( $_POST[ 'password' ] ); - $state = ( new Validation_Service() )->get_validation_state( wp_get_current_user(), $password ); - - wp_send_json_success( [ 'status' => $state ] ); - } - - public function enqueue_jetpack_password_strength_meter_profile_script() { - if ( ! wp_script_is('jetpack-password-strength-meter', 'enqueued') ) { - wp_enqueue_script( - 'jetpack-password-strength-meter', - plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', - array('jquery'), - null, - true - ); - } - - $this->localize_jetpack_data(); - } - - public function enqueue_jetpack_password_strength_meter_reset_script() { - if ( isset( $_GET['action'] ) && ( $_GET['action'] === 'rp' || $_GET['action'] === 'resetpass' ) ) { - if ( ! wp_script_is('jetpack-password-strength-meter', 'enqueued') ) { - wp_enqueue_script( - 'jetpack-password-strength-meter', - plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', - array('jquery'), - null, - true - ); - } - } - - $this->localize_jetpack_data(); - } + add_action( 'admin_enqueue_scripts', array( $this->password_strength_meter, 'enqueue_jetpack_password_strength_meter_profile_script' ) ); + add_action( 'login_enqueue_scripts', array( $this->password_strength_meter, 'enqueue_jetpack_password_strength_meter_reset_script' ) ); - public function localize_jetpack_data() { - wp_localize_script( 'jetpack-password-strength-meter', 'jetpackData', array( - 'ajaxurl' => admin_url( 'admin-ajax.php' ), - 'logo' => plugin_dir_url(__FILE__) . 'assets/jetpack-logo.svg', - 'checkIcon' => plugin_dir_url(__FILE__) . 'assets/check.svg', - 'crossIcon' => plugin_dir_url(__FILE__) . 'assets/cross.svg', - 'loadingIcon' => plugin_dir_url(__FILE__) . 'assets/loading.svg', - 'validationInitialState' => ( new Validation_Service() )->get_validation_initial_state(), - ) ); + // AJAX endpoint for password validation + add_action( 'wp_ajax_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); + add_action( 'wp_ajax_nopriv_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); } /** diff --git a/projects/packages/account-protection/src/class-password-strength-meter.php b/projects/packages/account-protection/src/class-password-strength-meter.php new file mode 100644 index 0000000000000..29da677e0b707 --- /dev/null +++ b/projects/packages/account-protection/src/class-password-strength-meter.php @@ -0,0 +1,114 @@ +validation_service = $validation_service ?? new Validation_Service(); + } + + /** + * AJAX endpoint for password validation. + * + * @return void + */ + public function validate_password_ajax(): void { + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'validate_password_nonce' ) ) { + wp_send_json_error( array( 'message' => 'Invalid nonce.' ) ); + } + + if ( ! isset( $_POST['password'] ) ) { + wp_send_json_error( array( 'message' => 'No password provided.' ) ); + } + + // TODO: May need to skip user specific validation in pass reset unless we can retreive the user object + + $password = sanitize_text_field( wp_unslash( $_POST['password'] ) ); + $state = $this->validation_service->get_validation_state( wp_get_current_user(), $password ); + + wp_send_json_success( array( 'status' => $state ) ); + } + + /** + * Enqueue the password strength meter script on the profile page. + * + * @return void + */ + public function enqueue_jetpack_password_strength_meter_profile_script(): void { + if ( ! wp_script_is( 'jetpack-password-strength-meter', 'enqueued' ) ) { + wp_enqueue_script( + 'jetpack-password-strength-meter', + plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', + array( 'jquery' ), + Account_Protection::PACKAGE_VERSION, + true + ); + } + + $this->localize_jetpack_data(); + } + + /** + * Enqueue the password strength meter script on the reset password page. + * + * @return void + */ + public function enqueue_jetpack_password_strength_meter_reset_script(): void { + // No nonce verification necessary as the action includes a robust verification process + // phpcs:disable WordPress.Security.NonceVerification + if ( isset( $_GET['action'] ) && ( $_GET['action'] === 'rp' || $_GET['action'] === 'resetpass' ) ) { + if ( ! wp_script_is( 'jetpack-password-strength-meter', 'enqueued' ) ) { + wp_enqueue_script( + 'jetpack-password-strength-meter', + plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', + array( 'jquery' ), + Account_Protection::PACKAGE_VERSION, + true + ); + } + } + + $this->localize_jetpack_data(); + } + + /** + * Localize the Jetpack data for the password strength meter. + * + * @return void + */ + public function localize_jetpack_data(): void { + wp_localize_script( + 'jetpack-password-strength-meter', + 'jetpackData', + array( + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'validate_password_nonce' ), + 'logo' => plugin_dir_url( __FILE__ ) . 'assets/jetpack-logo.svg', + 'checkIcon' => plugin_dir_url( __FILE__ ) . 'assets/check.svg', + 'crossIcon' => plugin_dir_url( __FILE__ ) . 'assets/cross.svg', + 'loadingIcon' => plugin_dir_url( __FILE__ ) . 'assets/loading.svg', + 'validationInitialState' => $this->validation_service->get_validation_initial_state(), + ) + ); + } +} diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index e1145cb83f95c..939a1759b06ba 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -55,13 +55,28 @@ protected function request_suffixes( string $password_prefix ) { * @return array An array of all validation statuses and messages. */ public function get_validation_initial_state(): array { - return [ - 'contains_backslash' => [ 'status' => null, 'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ) ], - 'invalid_length' => [ 'status' => null, 'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ) ], - 'matches_user_data' => [ 'status' => null, 'message' => __( "Doesn't match user data", 'jetpack-account-protection' ) ], - // 'recent' => [ 'status' => null, 'message' => __( 'Not used recently', 'jetpack-account-protection' ) ], - // 'weak' => [ 'status' => null, 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ) ], - ]; + return array( + 'contains_backslash' => array( + 'status' => null, + 'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ), + ), + 'invalid_length' => array( + 'status' => null, + 'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ), + ), + 'matches_user_data' => array( + 'status' => null, + 'message' => __( "Doesn't match user data", 'jetpack-account-protection' ), + ), + 'recent' => array( + 'status' => null, + 'message' => __( 'Not used recently', 'jetpack-account-protection' ), + ), + 'weak' => array( + 'status' => null, + 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), + ), + ); } /** @@ -74,13 +89,14 @@ public function get_validation_initial_state(): array { */ public function get_validation_state( $user, string $password ): array { $validation_state = $this->get_validation_initial_state(); + // TODO: We maybe need skip certain user specific checks on reset, unless we can confidently retrieve the user object. - $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password ); + $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password ); $validation_state['invalid_length']['status'] = $this->is_invalid_length( $password ); - $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); - // $validation_state['recent']['recent'] = $this->is_recent_password( $user->ID, $password ); - // $validation_state['weak']['status'] = $this->is_weak_password( $password ); - + $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); + $validation_state['recent']['status'] = $this->is_recent_password( $user->ID, $password ); + $validation_state['weak']['status'] = $this->is_weak_password( $password ); + return $validation_state; } diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index a7c2c67696fb5..c87f3afd3f1bb 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -187,6 +187,7 @@ jQuery( document ).ready( function ( $ ) { type: 'POST', data: { action: 'validate_password_ajax', + nonce: jetpackData.nonce, password: currentPasswordInput, }, success: function ( response ) { @@ -224,12 +225,14 @@ jQuery( document ).ready( function ( $ ) { applyStyling( failedValidationConditions ); } ); } else { + // TODO: Test this passwordValidationStatus.html( '

        Error: Unable to validate password.

        ' ); } }, error: function () { + // TODO: Test this passwordValidationStatus.html( '

        Error connecting to server.

        ' ); diff --git a/projects/plugins/jetpack/_inc/client/security/account-protection.jsx b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx index 62cafbe901792..2365fd31dc6c0 100644 --- a/projects/plugins/jetpack/_inc/client/security/account-protection.jsx +++ b/projects/plugins/jetpack/_inc/client/security/account-protection.jsx @@ -25,7 +25,7 @@ const AccountProtectionComponent = class extends Component { module={ this.props.getModule( 'account-protection' ) } support={ { text: __( - 'Jetpack recommends enabling this feature. Please be mindful of the risks', + 'Jetpack recommends enabling this feature. Please be mindful of the risks.', 'jetpack' ), link: '#', // TODO: Update link once doc is avaiable From 1157e6cb80166c7d8d28aba80ad865d3fa2bf2d1 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Wed, 5 Feb 2025 18:50:53 -0800 Subject: [PATCH 09/29] Improve logic --- .../src/class-account-protection.php | 2 +- .../src/class-password-strength-meter.php | 17 +-- .../src/class-validation-service.php | 36 +++--- .../src/js/jetpack-password-strength-meter.js | 103 +++++++++++++----- 4 files changed, 104 insertions(+), 54 deletions(-) diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index f53373940e89e..3360b9ce8c62e 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -130,7 +130,7 @@ function () { add_action( 'profile_update', array( $this->password_manager, 'on_profile_update' ), 10, 3 ); add_action( 'after_password_reset', array( $this->password_manager, 'on_password_reset' ), 10, 2 ); - // Eneuque password strength meter scripts + // Enqueue password strength meter scripts add_action( 'admin_enqueue_scripts', array( $this->password_strength_meter, 'enqueue_jetpack_password_strength_meter_profile_script' ) ); add_action( 'login_enqueue_scripts', array( $this->password_strength_meter, 'enqueue_jetpack_password_strength_meter_reset_script' ) ); diff --git a/projects/packages/account-protection/src/class-password-strength-meter.php b/projects/packages/account-protection/src/class-password-strength-meter.php index 29da677e0b707..00cd328ea67e1 100644 --- a/projects/packages/account-protection/src/class-password-strength-meter.php +++ b/projects/packages/account-protection/src/class-password-strength-meter.php @@ -41,10 +41,10 @@ public function validate_password_ajax(): void { wp_send_json_error( array( 'message' => 'No password provided.' ) ); } - // TODO: May need to skip user specific validation in pass reset unless we can retreive the user object + // TODO: Find user object when logged out $password = sanitize_text_field( wp_unslash( $_POST['password'] ) ); - $state = $this->validation_service->get_validation_state( wp_get_current_user(), $password ); + $state = $this->validation_service->get_validation_state( $password ); wp_send_json_success( array( 'status' => $state ) ); } @@ -63,9 +63,9 @@ public function enqueue_jetpack_password_strength_meter_profile_script(): void { Account_Protection::PACKAGE_VERSION, true ); - } - $this->localize_jetpack_data(); + $this->localize_jetpack_data( 'profile' ); + } } /** @@ -85,24 +85,27 @@ public function enqueue_jetpack_password_strength_meter_reset_script(): void { Account_Protection::PACKAGE_VERSION, true ); + + $this->localize_jetpack_data( 'reset' ); } } - - $this->localize_jetpack_data(); } /** * Localize the Jetpack data for the password strength meter. * + * @param string $context The context of the password strength meter. + * * @return void */ - public function localize_jetpack_data(): void { + public function localize_jetpack_data( $context ): void { wp_localize_script( 'jetpack-password-strength-meter', 'jetpackData', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'validate_password_nonce' ), + 'context' => $context, 'logo' => plugin_dir_url( __FILE__ ) . 'assets/jetpack-logo.svg', 'checkIcon' => plugin_dir_url( __FILE__ ) . 'assets/check.svg', 'crossIcon' => plugin_dir_url( __FILE__ ) . 'assets/cross.svg', diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index 939a1759b06ba..20214f5b25c4b 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -64,38 +64,38 @@ public function get_validation_initial_state(): array { 'status' => null, 'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ), ), - 'matches_user_data' => array( - 'status' => null, - 'message' => __( "Doesn't match user data", 'jetpack-account-protection' ), - ), - 'recent' => array( - 'status' => null, - 'message' => __( 'Not used recently', 'jetpack-account-protection' ), - ), - 'weak' => array( - 'status' => null, - 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), - ), + // 'matches_user_data' => array( + // 'status' => null, + // 'message' => __( "Doesn't match user data", 'jetpack-account-protection' ), + // ), + // 'recent' => array( + // 'status' => null, + // 'message' => __( 'Not used recently', 'jetpack-account-protection' ), + // ), + // 'weak' => array( + // 'status' => null, + // 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), + // ), ); } /** * Return validation state. * - * @param \WP_User|\stdClass $user The user object or a copy. - * @param string $password The password to check. + * @param string $password The password to check. * * @return array An array of the status of each check. */ - public function get_validation_state( $user, string $password ): array { + public function get_validation_state( string $password ): array { $validation_state = $this->get_validation_initial_state(); // TODO: We maybe need skip certain user specific checks on reset, unless we can confidently retrieve the user object. $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password ); $validation_state['invalid_length']['status'] = $this->is_invalid_length( $password ); - $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); - $validation_state['recent']['status'] = $this->is_recent_password( $user->ID, $password ); - $validation_state['weak']['status'] = $this->is_weak_password( $password ); + // $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); + // $validation_state['recent']['status'] = $this->is_recent_password( $user->ID, $password ); // TODO: Skip on create-user. + // $validation_state['weak']['status'] = $this->is_weak_password( $password ); + // TODO: Do we need ot include a check for current password also? return $validation_state; } diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index c87f3afd3f1bb..d86781951dfb6 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -3,7 +3,10 @@ jQuery( document ).ready( function ( $ ) { const generatePasswordButton = $( '.wp-generate-pw' ); const weakPasswordConfirmation = $( '.pw-weak' ); - const submitButton = $( '#submit' ); + const updateProfileFormSubmitButton = $( '#submit' ); + const resetPasswordFormSaveButton = $( '#wp-submit' ); + + // Non JS form flashes momentarily, we should hide it initially to avoid UI awkwardness const passwordInput = $( '#pass1' ); passwordInput.css( { 'border-color': '#8C8F94' } ); @@ -26,6 +29,7 @@ jQuery( document ).ready( function ( $ ) { display: 'flex', 'flex-direction': 'column', gap: '4px', + 'margin-bottom': '16px', }, } ); @@ -77,14 +81,16 @@ jQuery( document ).ready( function ( $ ) { 'align-items': 'center', height: '30px', padding: '0px 16px', - 'margin-left': '1px', - 'margin-right': '1px', 'margin-bottom': '16px', - 'border-radius': '0px 0px 4px 4px', + 'border-radius': '0px 0px 4px 4px', // TODO: Radius is off initially, we also flash the non JS form quickly... 'background-color': '#8C8F94', }, } ); + if ( 'profile' === jetpackData.context ) { + strengthMeter.css( { 'margin-left': '1px', 'margin-right': '1px' } ); + } + const strength = $( '

        ', { text: 'Validating...', css: { @@ -138,6 +144,8 @@ jQuery( document ).ready( function ( $ ) { generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); + let currentAjaxRequest = null; + /** * * Validate the current password input @@ -147,16 +155,16 @@ jQuery( document ).ready( function ( $ ) { const currentPasswordInput = passwordInput.val(); const failedValidationConditions = {}; - if ( ! currentPasswordInput || currentPasswordInput.length === 0 ) { + if ( currentAjaxRequest ) { + currentAjaxRequest.abort(); + currentAjaxRequest = null; + } + + if ( ! currentPasswordInput || currentPasswordInput.trim().length === 0 ) { applyStyling( failedValidationConditions, true ); return; } - // strengthMeter loading state - strength.text( 'Validating...' ); - strengthMeter.css( 'background-color', '#8C8F94' ); - passwordInput.css( { 'border-color': '#8C8F94' } ); - // passwordValidationStatus loading state Object.values( validationItems ).forEach( ( { icon, text } ) => { icon.attr( 'src', jetpackData.loadingIcon ); @@ -164,25 +172,44 @@ jQuery( document ).ready( function ( $ ) { text.css( { color: '#3C434A', transition: 'color 0.2s ease-in-out' } ); } ); + // strengthMeter loading state + strength.text( 'Validating...' ); + jetpackBranding.show(); + strengthMeter.css( 'background-color', '#8C8F94' ); + passwordValidationStatus.show(); // TODO: Reset to validating state AND text renders before icon is ready PLUS better transitions + passwordInput.css( { 'border-color': '#8C8F94', 'border-radius': '4px 4px 0px 0px' } ); + + if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', true ); + } + + if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', true ); + } + const corePasswordStrengthMeterClass = corePasswordStrengthMeter.attr( 'class' ) || ''; - const coreValidationFailed = - corePasswordStrengthMeterClass !== 'strong' && corePasswordStrengthMeterClass !== 'good'; + + const coreValidationFailed = ! ( + corePasswordStrengthMeterClass.includes( 'strong' ) || + corePasswordStrengthMeterClass.includes( 'good' ) + ); const uiUpdates = []; uiUpdates.push( () => { + // TODO: Could this be improved? Better transitions from initial-loading/validation-results const { icon, text } = validationItems.core; icon.attr( 'src', coreValidationFailed ? jetpackData.crossIcon : jetpackData.checkIcon ); icon.attr( 'alt', coreValidationFailed ? 'Jetpack Cross' : 'Jetpack Check' ); text.css( 'color', coreValidationFailed ? '#E65054' : '#008710' ); - - if ( coreValidationFailed ) { - failedValidationConditions.core = true; - } } ); - $.ajax( { + if ( coreValidationFailed ) { + failedValidationConditions.core = true; + } + + currentAjaxRequest = $.ajax( { url: jetpackData.ajaxurl, type: 'POST', data: { @@ -191,6 +218,8 @@ jQuery( document ).ready( function ( $ ) { password: currentPasswordInput, }, success: function ( response ) { + currentAjaxRequest = null; + if ( response.success ) { Object.entries( response.data.status ).forEach( ( [ key, item ] ) => { const isInvalid = item.status; @@ -231,11 +260,14 @@ jQuery( document ).ready( function ( $ ) { ); } }, - error: function () { + error: function ( jqXHR, textStatus ) { // TODO: Test this - passwordValidationStatus.html( - '

        Error connecting to server.

        ' - ); + if ( textStatus !== 'abort' ) { + // Ignore aborted requests + passwordValidationStatus.html( + '

        Error connecting to server.

        ' + ); + } }, } ); } @@ -252,9 +284,13 @@ jQuery( document ).ready( function ( $ ) { let finalStrengthText = ''; if ( passwordIsEmpty ) { - strengthMeter.hide(); + // strengthMeter.hide(); + strength.text( '' ); + jetpackBranding.hide(); + strengthMeter.css( 'background-color', 'transparent' ); passwordValidationStatus.hide(); passwordInput.css( { 'border-color': '#8c8f94', 'border-radius': '4px' } ); + return; } @@ -262,8 +298,12 @@ jQuery( document ).ready( function ( $ ) { finalColor = '#64CA43'; finalStrengthText = 'Strong'; - if ( submitButton.prop( 'disabled' ) ) { - submitButton.prop( 'disabled', false ); + if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', false ); + } + + if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', false ); } if ( weakPasswordConfirmation.is( ':visible' ) ) { @@ -273,12 +313,19 @@ jQuery( document ).ready( function ( $ ) { finalColor = '#E65054'; finalStrengthText = 'Weak'; - if ( ! submitButton.prop( 'disabled' ) ) { - submitButton.prop( 'disabled', true ); + if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', true ); + } + + if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', true ); } - if ( weakPasswordConfirmation.css( 'display' ) !== 'table-row' ) { - weakPasswordConfirmation.css( 'display', 'table-row' ); + if ( weakPasswordConfirmation.css( 'display' ) === 'none' ) { + weakPasswordConfirmation.css( + 'display', + 'reset' === jetpackData.context ? 'block' : 'table-row' + ); } } From e093fabf9936add0654f5e8dec6340af183c7d5f Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 10:35:06 -0800 Subject: [PATCH 10/29] Improvements and reorg --- .../src/class-password-manager.php | 29 +--- .../src/class-password-strength-meter.php | 29 ++-- .../src/class-validation-service.php | 134 +++++++++++------- .../src/js/jetpack-password-strength-meter.js | 47 +++--- 4 files changed, 129 insertions(+), 110 deletions(-) diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php index 744f7aece3fa1..d205db196199b 100644 --- a/projects/packages/account-protection/src/class-password-manager.php +++ b/projects/packages/account-protection/src/class-password-manager.php @@ -80,16 +80,7 @@ public function validate_profile_update( \WP_Error $errors, bool $update, \stdCl $password = sanitize_text_field( wp_unslash( $_POST['pass1'] ) ); - if ( $update ) { - $old_user_data = $this->get_old_user_data( $user->ID ); - if ( $this->validation_service->is_current_password( $old_user_data, $password ) ) { - $errors->add( 'password_error', __( 'Error: The password was used recently.', 'jetpack-account-protection' ) ); - return; - } - } - - $context = $update ? 'update' : 'create-user'; - $error = $this->validation_service->get_first_validation_error( $user, $password, $context ); + $error = $this->validation_service->get_first_validation_error( $password, true, $user ); if ( ! empty( $error ) ) { $errors->add( 'password_error', $error ); @@ -97,17 +88,6 @@ public function validate_profile_update( \WP_Error $errors, bool $update, \stdCl } } - /** - * Get the old user data. - * - * @param int $user_id The user ID. - * - * @return \WP_User|false The old user data, or false if the user does not exist. - */ - public function get_old_user_data( $user_id ) { - return get_userdata( $user_id ); - } - /** * Validate the password reset. * @@ -135,12 +115,7 @@ public function validate_password_reset( \WP_Error $errors, $user ): void { } $password = sanitize_text_field( wp_unslash( $_POST['pass1'] ) ); - if ( $this->validation_service->is_current_password( $user, $password ) ) { - $errors->add( 'password_error', __( 'Error: The password was used recently.', 'jetpack-account-protection' ) ); - return; - } - - $error = $this->validation_service->get_first_validation_error( $user, $password, 'reset' ); + $error = $this->validation_service->get_first_validation_error( $password ); if ( ! empty( $error ) ) { $errors->add( 'password_error', $error ); return; diff --git a/projects/packages/account-protection/src/class-password-strength-meter.php b/projects/packages/account-protection/src/class-password-strength-meter.php index 00cd328ea67e1..60d0a5941cf44 100644 --- a/projects/packages/account-protection/src/class-password-strength-meter.php +++ b/projects/packages/account-protection/src/class-password-strength-meter.php @@ -33,20 +33,23 @@ public function __construct( ?Validation_Service $validation_service = null ) { * @return void */ public function validate_password_ajax(): void { - if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'validate_password_nonce' ) ) { - wp_send_json_error( array( 'message' => 'Invalid nonce.' ) ); + if ( ! isset( $_POST['password'] ) ) { + wp_send_json_error( array( 'message' => __( 'No password provided.', 'jetpack-account-protection' ) ) ); } - if ( ! isset( $_POST['password'] ) ) { - wp_send_json_error( array( 'message' => 'No password provided.' ) ); + if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'validate_password_nonce' ) ) { + wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'jetpack-account-protection' ) ) ); } - // TODO: Find user object when logged out + $user_specific = false; + if ( isset( $_POST['user_specific'] ) ) { + $user_specific = filter_var( sanitize_text_field( wp_unslash( $_POST['user_specific'] ) ), FILTER_VALIDATE_BOOLEAN ); + } $password = sanitize_text_field( wp_unslash( $_POST['password'] ) ); - $state = $this->validation_service->get_validation_state( $password ); + $state = $this->validation_service->get_validation_state( $password, $user_specific ); - wp_send_json_success( array( 'status' => $state ) ); + wp_send_json_success( array( 'state' => $state ) ); } /** @@ -64,7 +67,7 @@ public function enqueue_jetpack_password_strength_meter_profile_script(): void { true ); - $this->localize_jetpack_data( 'profile' ); + $this->localize_jetpack_data( true ); } } @@ -86,7 +89,7 @@ public function enqueue_jetpack_password_strength_meter_reset_script(): void { true ); - $this->localize_jetpack_data( 'reset' ); + $this->localize_jetpack_data(); } } } @@ -94,23 +97,23 @@ public function enqueue_jetpack_password_strength_meter_reset_script(): void { /** * Localize the Jetpack data for the password strength meter. * - * @param string $context The context of the password strength meter. + * @param bool $user_specific Whether or not to run user specific checks. * * @return void */ - public function localize_jetpack_data( $context ): void { + public function localize_jetpack_data( bool $user_specific = false ): void { wp_localize_script( 'jetpack-password-strength-meter', 'jetpackData', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'validate_password_nonce' ), - 'context' => $context, + 'userSpecific' => $user_specific, 'logo' => plugin_dir_url( __FILE__ ) . 'assets/jetpack-logo.svg', 'checkIcon' => plugin_dir_url( __FILE__ ) . 'assets/check.svg', 'crossIcon' => plugin_dir_url( __FILE__ ) . 'assets/cross.svg', 'loadingIcon' => plugin_dir_url( __FILE__ ) . 'assets/loading.svg', - 'validationInitialState' => $this->validation_service->get_validation_initial_state(), + 'validationInitialState' => $this->validation_service->get_validation_initial_state( $user_specific ), ) ); } diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index 3202b1d7680ef..a8eef98dd16df 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -52,10 +52,12 @@ protected function request_suffixes( string $password_prefix ) { /** * Return validation initial state. * + * @param bool $user_specific Whether or not to include user specific checks. + * * @return array An array of all validation statuses and messages. */ - public function get_validation_initial_state(): array { - return array( + public function get_validation_initial_state( $user_specific ): array { + $base_conditions = array( 'contains_backslash' => array( 'status' => null, 'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ), @@ -64,61 +66,69 @@ public function get_validation_initial_state(): array { 'status' => null, 'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ), ), - // 'matches_user_data' => array( - // 'status' => null, - // 'message' => __( "Doesn't match user data", 'jetpack-account-protection' ), - // ), - // 'recent' => array( - // 'status' => null, - // 'message' => __( 'Not used recently', 'jetpack-account-protection' ), - // ), - // 'weak' => array( - // 'status' => null, - // 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), - // ), + 'weak' => array( + 'status' => null, + 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), + ), + ); + + if ( ! $user_specific ) { + return $base_conditions; + } + + $user_specific_conditions = array( + 'matches_user_data' => array( + 'status' => null, + 'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ), + ), + 'recent' => array( + 'status' => null, + 'message' => __( 'Not used recently', 'jetpack-account-protection' ), + ), ); + + return array_merge( $base_conditions, $user_specific_conditions ); } /** - * Return validation state. + * Return validation state - client-side. * * @param string $password The password to check. + * @param bool $user_specific Whether or not to run user specific checks. * * @return array An array of the status of each check. */ - public function get_validation_state( string $password ): array { - $validation_state = $this->get_validation_initial_state(); - // TODO: We maybe need skip certain user specific checks on reset, unless we can confidently retrieve the user object. + public function get_validation_state( string $password, $user_specific ): array { + $validation_state = $this->get_validation_initial_state( $user_specific ); $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password ); $validation_state['invalid_length']['status'] = $this->is_invalid_length( $password ); - // $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); - // $validation_state['recent']['status'] = $this->is_recent_password( $user->ID, $password ); // TODO: Skip on create-user. - // $validation_state['weak']['status'] = $this->is_weak_password( $password ); - // TODO: Do we need ot include a check for current password also? + $validation_state['weak']['status'] = $this->is_weak_password( $password ); + + if ( ! $user_specific ) { + return $validation_state; + } + + // Run checks on existing user data + $user = wp_get_current_user(); + $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password ); + $validation_state['recent']['status'] = $this->is_recent_password( $user, $password ); return $validation_state; } /** - * Return first validation error. + * Return first validation error - server-side. * - * @param \WP_User|\stdClass $user The user object or a copy. - * @param string $password The password to check. - * @param 'create-user'|'update'|'reset' $context The context the validation is run in. + * @param string $password The password to check. + * @param bool $user_specific Whether or not to run user specific checks. + * @param \stdClass|null $user The user data or null. * * @return string The first validation errors (if any). */ - public function get_first_validation_error( $user, string $password, $context ): string { - // Reset form includes this validation in core - if ( 'reset' !== $context ) { - if ( empty( $password ) ) { - return __( 'Error: The password cannot be a space or all spaces.', 'jetpack-account-protection' ); - } - } - - // Update and create-user forms include this validation in core - if ( 'reset' === $context ) { + public function get_first_validation_error( string $password, $user_specific = false, $user = null ): string { + // Update and create-user forms include backlash validation + if ( ! $user_specific ) { if ( $this->contains_backslash( $password ) ) { return __( 'Error: The password cannot contain a backslash (\\) character.', 'jetpack-account-protection' ); } @@ -128,18 +138,24 @@ public function get_first_validation_error( $user, string $password, $context ): return __( 'Error: The password must be between 6 and 150 characters.', 'jetpack-account-protection' ); } - if ( $this->matches_user_data( $user, $password ) ) { - return __( 'Error: The password matches user data.', 'jetpack-account-protection' ); + if ( $this->is_weak_password( $password ) ) { + return __( 'Error: The password was found in a public leak.', 'jetpack-account-protection' ); } - if ( 'create-user' !== $context ) { - if ( $this->is_recent_password( $user->ID, $password ) ) { - return __( 'Error: The password was used recently.', 'jetpack-account-protection' ); + // Skip user-specific checks during password reset + if ( $user_specific ) { + // Reset form includes empty validation + if ( empty( $password ) ) { + return __( 'Error: The password cannot be a space or all spaces.', 'jetpack-account-protection' ); } - } - if ( $this->is_weak_password( $password ) ) { - return __( 'Error: The password was found in a public leak.', 'jetpack-account-protection' ); + // Run checks on new user data + if ( $this->matches_user_data( $user, $password ) ) { + return __( 'Error: The password matches new user data.', 'jetpack-account-protection' ); + } + if ( $this->is_recent_password( $user, $password ) ) { + return __( 'Error: The password was used recently.', 'jetpack-account-protection' ); + } } return ''; @@ -265,14 +281,23 @@ public function is_current_password( \WP_User $user, string $password ): bool { /** * Check if the password has been used recently by the user. * - * @param int $user_id The user ID. - * @param string $password The password to check. + * @param \WP_User|\stdClass $user The user data. + * @param string $password The password to check. * - * @return bool True if the password hash was recently used, false otherwise. + * @return bool True if the password was recently used, false otherwise. */ - public function is_recent_password( int $user_id, string $password ): bool { - $recent_passwords = get_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); + public function is_recent_password( $user, string $password ): bool { + // Skip on create-user validation - no user ID yet + if ( empty( $user->ID ) ) { + return false; + } + + $user_data = $user instanceof \WP_User ? $user : $this->get_old_user_data( $user->ID ); + if ( $this->is_current_password( $user_data, $password ) ) { + return true; + } + $recent_passwords = get_user_meta( $user->ID, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, true ); if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) { return false; } @@ -285,4 +310,15 @@ public function is_recent_password( int $user_id, string $password ): bool { return false; } + + /** + * Get the old user data. + * + * @param int $user_id The user ID. + * + * @return \WP_User|false The old user data, or false if the user does not exist. + */ + public function get_old_user_data( $user_id ) { + return get_userdata( $user_id ); + } } diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index d86781951dfb6..15d73a56a88dd 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -11,8 +11,8 @@ jQuery( document ).ready( function ( $ ) { const passwordInput = $( '#pass1' ); passwordInput.css( { 'border-color': '#8C8F94' } ); - const corePasswordStrengthMeter = $( '#pass-strength-result' ); - corePasswordStrengthMeter.hide(); + const coreStrengthMeter = $( '#pass-strength-result' ); + coreStrengthMeter.hide(); const passwordValidationStatus = $( '
        ' ); @@ -82,12 +82,12 @@ jQuery( document ).ready( function ( $ ) { height: '30px', padding: '0px 16px', 'margin-bottom': '16px', - 'border-radius': '0px 0px 4px 4px', // TODO: Radius is off initially, we also flash the non JS form quickly... + 'border-radius': '0px 0px 4px 4px', 'background-color': '#8C8F94', }, } ); - if ( 'profile' === jetpackData.context ) { + if ( '1' === jetpackData.userSpecific ) { strengthMeter.css( { 'margin-left': '1px', 'margin-right': '1px' } ); } @@ -137,7 +137,7 @@ jQuery( document ).ready( function ( $ ) { passwordInput.on( 'input', () => validatePassword() ); setTimeout( () => { - if ( passwordInput.val().length > 0 ) { + if ( passwordInput && passwordInput.val() && passwordInput.val().length > 0 ) { validatePassword(); } }, 1500 ); @@ -165,6 +165,10 @@ jQuery( document ).ready( function ( $ ) { return; } + if ( coreStrengthMeter.is( ':visible' ) ) { + coreStrengthMeter.hide(); + } + // passwordValidationStatus loading state Object.values( validationItems ).forEach( ( { icon, text } ) => { icon.attr( 'src', jetpackData.loadingIcon ); @@ -176,8 +180,9 @@ jQuery( document ).ready( function ( $ ) { strength.text( 'Validating...' ); jetpackBranding.show(); strengthMeter.css( 'background-color', '#8C8F94' ); - passwordValidationStatus.show(); // TODO: Reset to validating state AND text renders before icon is ready PLUS better transitions passwordInput.css( { 'border-color': '#8C8F94', 'border-radius': '4px 4px 0px 0px' } ); + strengthMeter.show(); + passwordValidationStatus.show(); if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { updateProfileFormSubmitButton.prop( 'disabled', true ); @@ -187,17 +192,15 @@ jQuery( document ).ready( function ( $ ) { resetPasswordFormSaveButton.prop( 'disabled', true ); } - const corePasswordStrengthMeterClass = corePasswordStrengthMeter.attr( 'class' ) || ''; + const coreStrengthMeterClass = coreStrengthMeter.attr( 'class' ) || ''; const coreValidationFailed = ! ( - corePasswordStrengthMeterClass.includes( 'strong' ) || - corePasswordStrengthMeterClass.includes( 'good' ) + coreStrengthMeterClass.includes( 'strong' ) || coreStrengthMeterClass.includes( 'good' ) ); const uiUpdates = []; uiUpdates.push( () => { - // TODO: Could this be improved? Better transitions from initial-loading/validation-results const { icon, text } = validationItems.core; icon.attr( 'src', coreValidationFailed ? jetpackData.crossIcon : jetpackData.checkIcon ); @@ -216,12 +219,13 @@ jQuery( document ).ready( function ( $ ) { action: 'validate_password_ajax', nonce: jetpackData.nonce, password: currentPasswordInput, + user_specific: jetpackData.userSpecific, }, success: function ( response ) { currentAjaxRequest = null; if ( response.success ) { - Object.entries( response.data.status ).forEach( ( [ key, item ] ) => { + Object.entries( response.data.state ).forEach( ( [ key, item ] ) => { const isInvalid = item.status; const { icon, text, item: listItem } = validationItems[ key ] || {}; @@ -254,19 +258,20 @@ jQuery( document ).ready( function ( $ ) { applyStyling( failedValidationConditions ); } ); } else { - // TODO: Test this - passwordValidationStatus.html( - '

        Error: Unable to validate password.

        ' - ); + // TODO: Restore core strength meter state, show error? + strengthMeter.hide(); + passwordValidationStatus.hide(); + passwordInput.removeAttr( 'style' ); + coreStrengthMeter.show(); } }, error: function ( jqXHR, textStatus ) { - // TODO: Test this if ( textStatus !== 'abort' ) { - // Ignore aborted requests - passwordValidationStatus.html( - '

        Error connecting to server.

        ' - ); + // TODO: Restore core strength meter state, show error? + strengthMeter.hide(); + passwordValidationStatus.hide(); + passwordInput.removeAttr( 'style' ); + coreStrengthMeter.show(); } }, } ); @@ -324,7 +329,7 @@ jQuery( document ).ready( function ( $ ) { if ( weakPasswordConfirmation.css( 'display' ) === 'none' ) { weakPasswordConfirmation.css( 'display', - 'reset' === jetpackData.context ? 'block' : 'table-row' + '' === jetpackData.userSpecific ? 'block' : 'table-row' ); } } From f0448ef56bfe286d46d1efa8e15336d4cd0c53c3 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 11:34:23 -0800 Subject: [PATCH 11/29] Add info popovers --- .../account-protection/src/assets/info.svg | 3 + .../src/class-password-strength-meter.php | 1 + .../src/class-validation-service.php | 5 ++ .../src/js/jetpack-password-strength-meter.js | 75 ++++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 projects/packages/account-protection/src/assets/info.svg diff --git a/projects/packages/account-protection/src/assets/info.svg b/projects/packages/account-protection/src/assets/info.svg new file mode 100644 index 0000000000000..67e27b83571a6 --- /dev/null +++ b/projects/packages/account-protection/src/assets/info.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/projects/packages/account-protection/src/class-password-strength-meter.php b/projects/packages/account-protection/src/class-password-strength-meter.php index 60d0a5941cf44..519fcf408436b 100644 --- a/projects/packages/account-protection/src/class-password-strength-meter.php +++ b/projects/packages/account-protection/src/class-password-strength-meter.php @@ -110,6 +110,7 @@ public function localize_jetpack_data( bool $user_specific = false ): void { 'nonce' => wp_create_nonce( 'validate_password_nonce' ), 'userSpecific' => $user_specific, 'logo' => plugin_dir_url( __FILE__ ) . 'assets/jetpack-logo.svg', + 'infoIcon' => plugin_dir_url( __FILE__ ) . 'assets/info.svg', 'checkIcon' => plugin_dir_url( __FILE__ ) . 'assets/check.svg', 'crossIcon' => plugin_dir_url( __FILE__ ) . 'assets/cross.svg', 'loadingIcon' => plugin_dir_url( __FILE__ ) . 'assets/loading.svg', diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index a8eef98dd16df..454937bab7639 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -61,14 +61,17 @@ public function get_validation_initial_state( $user_specific ): array { 'contains_backslash' => array( 'status' => null, 'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ), + 'info' => null, ), 'invalid_length' => array( 'status' => null, 'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ), + 'info' => null, ), 'weak' => array( 'status' => null, 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), + 'info' => __( 'If found in a public breach, this password may already be known to attackers. Using a unique password improves security.', 'jetpack-account-protection' ), ), ); @@ -80,10 +83,12 @@ public function get_validation_initial_state( $user_specific ): array { 'matches_user_data' => array( 'status' => null, 'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ), + 'info' => __( 'Using a password similar to your username or email makes it easier to guess. A unique password is more secure.', 'jetpack-account-protection' ), ), 'recent' => array( 'status' => null, 'message' => __( 'Not used recently', 'jetpack-account-protection' ), + 'info' => __( 'Reusing old passwords may increase security risks. A fresh password improves protection.', 'jetpack-account-protection' ), ), ); diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 15d73a56a88dd..63605abf4e3b3 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -16,13 +16,14 @@ jQuery( document ).ready( function ( $ ) { const passwordValidationStatus = $( '
        ' ); - const validationMessages = { + const validationItemData = { core: { status: null, message: 'Passes core validation', + info: null, }, ...jetpackData.validationInitialState, - }; + }; // TODO: Add info icon popovers const validationCheckList = $( '
          ', { css: { @@ -35,7 +36,7 @@ jQuery( document ).ready( function ( $ ) { const validationItems = {}; - Object.entries( validationMessages ).forEach( ( [ key, value ] ) => { + Object.entries( validationItemData ).forEach( ( [ key, value ] ) => { const listItem = $( '
        • ', { css: { display: 'contains_backslash' === key ? 'none' : 'flex', @@ -60,8 +61,76 @@ jQuery( document ).ready( function ( $ ) { }, } ); + let infoIconPopover = null; + if ( value.info ) { + infoIconPopover = $( '
          ', { + css: { + position: 'relative', + display: 'inline-block', + }, + } ); + const infoIcon = $( '', { + src: jetpackData.infoIcon, + alt: 'Info', + css: { + height: '20px', + cursor: 'pointer', + }, + } ); + + const popover = $( '
          ', { + text: value.info, + css: { + display: 'none', + position: 'absolute', + bottom: '30px', // Position it directly above the icon + left: '50%', + transform: 'translateX(-50%)', + background: '#333', + color: '#fff', + padding: '6px 10px', + 'border-radius': '4px', + 'white-space': 'normal', + width: '200px', + 'font-size': '12px', + 'box-shadow': '0px 4px 6px rgba(0, 0, 0, 0.1)', + 'z-index': 10, + 'text-align': 'center', + }, + } ); + + const popoverArrow = $( '
          ', { + css: { + position: 'absolute', + bottom: '-6px', + left: '50%', + transform: 'translateX(-50%)', + 'border-left': '6px solid transparent', + 'border-right': '6px solid transparent', + 'border-top': '6px solid #333', + }, + } ); + + popover.append( popoverArrow ); + + infoIcon.hover( + function () { + popover.fadeIn( 200 ); + }, + function () { + popover.fadeOut( 200 ); + } + ); + + infoIconPopover.append( infoIcon ); + infoIconPopover.append( popover ); + } + listItem.append( validationIcon ); listItem.append( validationCheckListItemText ); + if ( infoIconPopover ) { + listItem.append( infoIconPopover ); + } validationCheckList.append( listItem ); validationItems[ key ] = { From 8381d5ded77c0e37d52dfcfe8fb66a3e966ea0cc Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 11:39:48 -0800 Subject: [PATCH 12/29] Add core req to initial validation state --- .../src/class-validation-service.php | 5 +++++ .../src/js/jetpack-password-strength-meter.js | 13 ++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index 454937bab7639..c50e573329142 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -58,6 +58,11 @@ protected function request_suffixes( string $password_prefix ) { */ public function get_validation_initial_state( $user_specific ): array { $base_conditions = array( + 'core' => array( + 'status' => null, + 'message' => __( 'Passes core validation', 'jetpack-account-protection' ), + 'info' => __( 'This password meets the minimum WordPress core requirements.', 'jetpack-account-protection' ), + ), 'contains_backslash' => array( 'status' => null, 'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ), diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 63605abf4e3b3..3734e3f52949c 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -16,15 +16,6 @@ jQuery( document ).ready( function ( $ ) { const passwordValidationStatus = $( '
          ' ); - const validationItemData = { - core: { - status: null, - message: 'Passes core validation', - info: null, - }, - ...jetpackData.validationInitialState, - }; // TODO: Add info icon popovers - const validationCheckList = $( '
            ', { css: { display: 'flex', @@ -36,7 +27,7 @@ jQuery( document ).ready( function ( $ ) { const validationItems = {}; - Object.entries( validationItemData ).forEach( ( [ key, value ] ) => { + Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { const listItem = $( '
          • ', { css: { display: 'contains_backslash' === key ? 'none' : 'flex', @@ -83,7 +74,7 @@ jQuery( document ).ready( function ( $ ) { css: { display: 'none', position: 'absolute', - bottom: '30px', // Position it directly above the icon + bottom: '30px', left: '50%', transform: 'translateX(-50%)', background: '#333', From 73c3db274d4fcb4023ad29892e3d9d7ec5aa3ae4 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 11:41:14 -0800 Subject: [PATCH 13/29] Generalize core info popover message --- .../account-protection/src/class-validation-service.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index c50e573329142..88c3d2d9b1753 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -61,7 +61,7 @@ public function get_validation_initial_state( $user_specific ): array { 'core' => array( 'status' => null, 'message' => __( 'Passes core validation', 'jetpack-account-protection' ), - 'info' => __( 'This password meets the minimum WordPress core requirements.', 'jetpack-account-protection' ), + 'info' => __( 'Passwords should meet WordPress core security requirements to enhance account protection.', 'jetpack-account-protection' ), ), 'contains_backslash' => array( 'status' => null, From b76ee4b236e8d5d07fa09f09bbf78158280bf7f7 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 11:59:20 -0800 Subject: [PATCH 14/29] Fix core strength meter status --- .../src/class-account-protection.php | 16 +++++++++ .../src/js/jetpack-password-strength-meter.js | 36 ++++++------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index a1c713848de94..0ec79f2b0bd7f 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -132,6 +132,22 @@ protected function register_runtime_hooks(): void { // AJAX endpoint for password validation add_action( 'wp_ajax_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); add_action( 'wp_ajax_nopriv_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); + + // TESTING + add_filter( + 'retrieve_password_message', + function ( $message, $key, $user_login, $user_data ) { + + $reset_link = network_site_url( 'wp-login.php?login=' . rawurlencode( $user_login ) . "&key=$key&action=rp", 'login' ); + + // Log or store the reset link for debugging + error_log( 'Generated Reset Link: ' . $reset_link ); + + return $message; // Keep the original email message intact + }, + 10, + 4 + ); } /** diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 3734e3f52949c..adc6f225f5cc2 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -16,6 +16,8 @@ jQuery( document ).ready( function ( $ ) { const passwordValidationStatus = $( '
            ' ); + const userSpecific = Boolean( jetpackData.userSpecific ); + const validationCheckList = $( '
              ', { css: { display: 'flex', @@ -53,7 +55,7 @@ jQuery( document ).ready( function ( $ ) { } ); let infoIconPopover = null; - if ( value.info ) { + if ( userSpecific && value.info ) { infoIconPopover = $( '
              ', { css: { position: 'relative', @@ -147,7 +149,7 @@ jQuery( document ).ready( function ( $ ) { }, } ); - if ( '1' === jetpackData.userSpecific ) { + if ( userSpecific ) { strengthMeter.css( { 'margin-left': '1px', 'margin-right': '1px' } ); } @@ -252,26 +254,8 @@ jQuery( document ).ready( function ( $ ) { resetPasswordFormSaveButton.prop( 'disabled', true ); } - const coreStrengthMeterClass = coreStrengthMeter.attr( 'class' ) || ''; - - const coreValidationFailed = ! ( - coreStrengthMeterClass.includes( 'strong' ) || coreStrengthMeterClass.includes( 'good' ) - ); - const uiUpdates = []; - uiUpdates.push( () => { - const { icon, text } = validationItems.core; - - icon.attr( 'src', coreValidationFailed ? jetpackData.crossIcon : jetpackData.checkIcon ); - icon.attr( 'alt', coreValidationFailed ? 'Jetpack Cross' : 'Jetpack Check' ); - text.css( 'color', coreValidationFailed ? '#E65054' : '#008710' ); - } ); - - if ( coreValidationFailed ) { - failedValidationConditions.core = true; - } - currentAjaxRequest = $.ajax( { url: jetpackData.ajaxurl, type: 'POST', @@ -285,6 +269,12 @@ jQuery( document ).ready( function ( $ ) { currentAjaxRequest = null; if ( response.success ) { + // Manually update core strength meter status + const coreStrengthMeterClass = coreStrengthMeter.attr( 'class' ) || ''; + response.data.state.core.status = ! ( + coreStrengthMeterClass.includes( 'strong' ) || coreStrengthMeterClass.includes( 'good' ) + ); + Object.entries( response.data.state ).forEach( ( [ key, item ] ) => { const isInvalid = item.status; const { icon, text, item: listItem } = validationItems[ key ] || {}; @@ -349,7 +339,6 @@ jQuery( document ).ready( function ( $ ) { let finalStrengthText = ''; if ( passwordIsEmpty ) { - // strengthMeter.hide(); strength.text( '' ); jetpackBranding.hide(); strengthMeter.css( 'background-color', 'transparent' ); @@ -387,10 +376,7 @@ jQuery( document ).ready( function ( $ ) { } if ( weakPasswordConfirmation.css( 'display' ) === 'none' ) { - weakPasswordConfirmation.css( - 'display', - '' === jetpackData.userSpecific ? 'block' : 'table-row' - ); + weakPasswordConfirmation.css( 'display', userSpecific ? 'table-row' : 'block' ); } } From ab15e8df561bcfeee78aca742a14a4e622504717 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 11:59:36 -0800 Subject: [PATCH 15/29] Remove testing code --- .../src/class-account-protection.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/projects/packages/account-protection/src/class-account-protection.php b/projects/packages/account-protection/src/class-account-protection.php index 0ec79f2b0bd7f..a1c713848de94 100644 --- a/projects/packages/account-protection/src/class-account-protection.php +++ b/projects/packages/account-protection/src/class-account-protection.php @@ -132,22 +132,6 @@ protected function register_runtime_hooks(): void { // AJAX endpoint for password validation add_action( 'wp_ajax_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); add_action( 'wp_ajax_nopriv_validate_password_ajax', array( $this->password_strength_meter, 'validate_password_ajax' ) ); - - // TESTING - add_filter( - 'retrieve_password_message', - function ( $message, $key, $user_login, $user_data ) { - - $reset_link = network_site_url( 'wp-login.php?login=' . rawurlencode( $user_login ) . "&key=$key&action=rp", 'login' ); - - // Log or store the reset link for debugging - error_log( 'Generated Reset Link: ' . $reset_link ); - - return $message; // Keep the original email message intact - }, - 10, - 4 - ); } /** From 20ac0331557c7e1ee8680bf8b222ff9b0b26c9ca Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 13:00:42 -0800 Subject: [PATCH 16/29] Ensure save enabled when appropriate --- .../src/js/jetpack-password-strength-meter.js | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index adc6f225f5cc2..9552b1b3a3971 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -3,6 +3,8 @@ jQuery( document ).ready( function ( $ ) { const generatePasswordButton = $( '.wp-generate-pw' ); const weakPasswordConfirmation = $( '.pw-weak' ); + const weakPasswordConfirmationCheckbox = + weakPasswordConfirmation.find( 'input[type="checkbox"]' ); const updateProfileFormSubmitButton = $( '#submit' ); const resetPasswordFormSaveButton = $( '#wp-submit' ); @@ -246,6 +248,7 @@ jQuery( document ).ready( function ( $ ) { strengthMeter.show(); passwordValidationStatus.show(); + // Disable submit buttons while validating if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { updateProfileFormSubmitButton.prop( 'disabled', true ); } @@ -352,6 +355,10 @@ jQuery( document ).ready( function ( $ ) { finalColor = '#64CA43'; finalStrengthText = 'Strong'; + if ( weakPasswordConfirmation.is( ':visible' ) ) { + weakPasswordConfirmation.css( 'display', 'none' ); + } + if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { updateProfileFormSubmitButton.prop( 'disabled', false ); } @@ -359,24 +366,30 @@ jQuery( document ).ready( function ( $ ) { if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { resetPasswordFormSaveButton.prop( 'disabled', false ); } - - if ( weakPasswordConfirmation.is( ':visible' ) ) { - weakPasswordConfirmation.css( 'display', 'none' ); - } } else { finalColor = '#E65054'; finalStrengthText = 'Weak'; - if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', true ); + if ( weakPasswordConfirmation.css( 'display' ) === 'none' ) { + weakPasswordConfirmation.css( 'display', userSpecific ? 'table-row' : 'block' ); } - if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', true ); - } + if ( weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { + if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', false ); + } - if ( weakPasswordConfirmation.css( 'display' ) === 'none' ) { - weakPasswordConfirmation.css( 'display', userSpecific ? 'table-row' : 'block' ); + if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', false ); + } + } else { + if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', true ); + } + + if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', true ); + } } } From 70b18a67b18e5f66b825b338e287cd1a53d61007 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 13:01:25 -0800 Subject: [PATCH 17/29] Update todos --- .../src/js/jetpack-password-strength-meter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 9552b1b3a3971..aef54cfa06808 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -8,7 +8,7 @@ jQuery( document ).ready( function ( $ ) { const updateProfileFormSubmitButton = $( '#submit' ); const resetPasswordFormSaveButton = $( '#wp-submit' ); - // Non JS form flashes momentarily, we should hide it initially to avoid UI awkwardness + // TODO: Non JS form flashes momentarily, we should hide it initially to avoid UI awkwardness const passwordInput = $( '#pass1' ); passwordInput.css( { 'border-color': '#8C8F94' } ); From e82172c4ec9a96bc87c5773d0f47ed9f48c8686a Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 13:07:51 -0800 Subject: [PATCH 18/29] Center validation items --- .../src/js/jetpack-password-strength-meter.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index aef54cfa06808..e251005116162 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -16,7 +16,13 @@ jQuery( document ).ready( function ( $ ) { const coreStrengthMeter = $( '#pass-strength-result' ); coreStrengthMeter.hide(); - const passwordValidationStatus = $( '
              ' ); + const passwordValidationStatus = $( '
              ', { + id: 'password-validation-status', + css: { + width: 'fit-content', + margin: 'auto', + }, + } ); const userSpecific = Boolean( jetpackData.userSpecific ); From fe525c747a71064142e58bed403cfdfc00c40bb3 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 13:32:44 -0800 Subject: [PATCH 19/29] Fix tests --- .../account-protection/src/class-password-manager.php | 10 +++++++--- .../src/js/jetpack-password-strength-meter.js | 1 + .../tests/php/test-password-manager.php | 9 +++------ .../tests/php/test-validation-service.php | 9 +++++---- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/projects/packages/account-protection/src/class-password-manager.php b/projects/packages/account-protection/src/class-password-manager.php index d205db196199b..9e07970629cb1 100644 --- a/projects/packages/account-protection/src/class-password-manager.php +++ b/projects/packages/account-protection/src/class-password-manager.php @@ -72,9 +72,13 @@ public function validate_profile_update( \WP_Error $errors, bool $update, \stdCl return; } - if ( ( ! $update && ( ! isset( $_POST['_wpnonce_create-user'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_create-user'] ) ), 'create-user' ) ) ) - || ( $update && ! $this->verify_profile_update_nonce( $user->ID ) ) ) { - $errors->add( 'nonce_error', __( 'Error: Nonce verification failed.', 'jetpack-account-protection' ) ); + if ( ! $update && ( ! isset( $_POST['_wpnonce_create-user'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_create-user'] ) ), 'create-user' ) ) ) { + $errors->add( 'nonce_error', __( 'Error: Create user nonce verification failed.', 'jetpack-account-protection' ) ); + return; + } + + if ( $update && ! $this->verify_profile_update_nonce( $user->ID ) ) { + $errors->add( 'nonce_error', __( 'Error: Update user nonce verification failed.', 'jetpack-account-protection' ) ); return; } diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index e251005116162..4fe5b19802d8a 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -68,6 +68,7 @@ jQuery( document ).ready( function ( $ ) { css: { position: 'relative', display: 'inline-block', + height: '20px', }, } ); const infoIcon = $( '', { diff --git a/projects/packages/account-protection/tests/php/test-password-manager.php b/projects/packages/account-protection/tests/php/test-password-manager.php index 0b445aac16fd1..c385cf8f7223a 100644 --- a/projects/packages/account-protection/tests/php/test-password-manager.php +++ b/projects/packages/account-protection/tests/php/test-password-manager.php @@ -10,6 +10,7 @@ class Password_Manager_Test extends BaseTestCase { public function test_validate_profile_update_nonce_failure() { $_POST['_wpnonce'] = 'invalid_nonce'; + $_POST['pass1'] = 'newpassword'; $errors = new \WP_Error(); $user = (object) array( 'ID' => 1 ); @@ -41,17 +42,13 @@ public function test_validate_profile_update_success() { $password_manager_mock = $this->getMockBuilder( Password_Manager::class ) ->setConstructorArgs( array( $validation_service_mock ) ) - ->onlyMethods( array( 'verify_profile_update_nonce', 'get_old_user_data' ) ) + ->onlyMethods( array( 'verify_profile_update_nonce' ) ) ->getMock(); $password_manager_mock->expects( $this->once() ) ->method( 'verify_profile_update_nonce' ) ->willReturn( true ); - $password_manager_mock->expects( $this->once() ) - ->method( 'get_old_user_data' ) - ->willReturn( $fake_user ); - $password_manager_mock->validate_profile_update( $errors, true, $user ); $this->assertFalse( $errors->has_errors() ); @@ -146,7 +143,7 @@ public function test_save_recent_password_stores_last_10_passwords() { 'hash10', ); - update_user_meta( $user_id, Config::PASSWORD_MANAGER__RECENT_PASSWORD_HASHES_USER_META_KEY, $password_hashes ); + update_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, $password_hashes ); $validation_service_mock = $this->createMock( Validation_Service::class ); $password_manager_mock = new Password_Manager( $validation_service_mock ); diff --git a/projects/packages/account-protection/tests/php/test-validation-service.php b/projects/packages/account-protection/tests/php/test-validation-service.php index 5e6921c73def8..782be5cb6cd2a 100644 --- a/projects/packages/account-protection/tests/php/test-validation-service.php +++ b/projects/packages/account-protection/tests/php/test-validation-service.php @@ -186,13 +186,14 @@ public function test_returns_false_if_password_is_not_current_password() { } public function test_returns_true_if_password_was_recently_used() { - $user_id = 1; - $password_hash = wp_hash_password( 'somepassword' ); + $user = new \WP_User(); + $user->user_pass = wp_hash_password( 'somepassword' ); + $user->ID = 1; - update_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $password_hash ) ); + update_user_meta( $user->ID, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $user->user_pass ) ); $validation_service = new Validation_Service( $this->get_connection_manager() ); - $this->assertTrue( $validation_service->is_recent_password( $user_id, 'somepassword' ) ); + $this->assertTrue( $validation_service->is_recent_password( $user, 'somepassword' ) ); } public function test_returns_false_if_password_was_not_recently_used() { From e96a8ddb11ed87f90be16e68b590300dafd40d86 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 14:42:58 -0800 Subject: [PATCH 20/29] Save alt approach --- .../src/class-password-strength-meter.php | 49 +- .../src/css/strength-meter.css | 108 +++++ ...etpack-password-strength-meter-original.js | 424 ++++++++++++++++++ .../src/js/jetpack-password-strength-meter.js | 149 +----- 4 files changed, 587 insertions(+), 143 deletions(-) create mode 100644 projects/packages/account-protection/src/css/strength-meter.css create mode 100644 projects/packages/account-protection/src/js/jetpack-password-strength-meter-original.js diff --git a/projects/packages/account-protection/src/class-password-strength-meter.php b/projects/packages/account-protection/src/class-password-strength-meter.php index 519fcf408436b..1878bd06f6cff 100644 --- a/projects/packages/account-protection/src/class-password-strength-meter.php +++ b/projects/packages/account-protection/src/class-password-strength-meter.php @@ -59,14 +59,8 @@ public function validate_password_ajax(): void { */ public function enqueue_jetpack_password_strength_meter_profile_script(): void { if ( ! wp_script_is( 'jetpack-password-strength-meter', 'enqueued' ) ) { - wp_enqueue_script( - 'jetpack-password-strength-meter', - plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', - array( 'jquery' ), - Account_Protection::PACKAGE_VERSION, - true - ); - + $this->enqueue_script(); + $this->enqueue_styles(); $this->localize_jetpack_data( true ); } } @@ -81,14 +75,8 @@ public function enqueue_jetpack_password_strength_meter_reset_script(): void { // phpcs:disable WordPress.Security.NonceVerification if ( isset( $_GET['action'] ) && ( $_GET['action'] === 'rp' || $_GET['action'] === 'resetpass' ) ) { if ( ! wp_script_is( 'jetpack-password-strength-meter', 'enqueued' ) ) { - wp_enqueue_script( - 'jetpack-password-strength-meter', - plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', - array( 'jquery' ), - Account_Protection::PACKAGE_VERSION, - true - ); - + $this->enqueue_script(); + $this->enqueue_styles(); $this->localize_jetpack_data(); } } @@ -118,4 +106,33 @@ public function localize_jetpack_data( bool $user_specific = false ): void { ) ); } + + /** + * Enqueue the password strength meter script. + * + * @return void + */ + public function enqueue_script(): void { + wp_enqueue_script( + 'jetpack-password-strength-meter', + plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js', + array( 'jquery' ), + Account_Protection::PACKAGE_VERSION, + true + ); + } + + /** + * Enqueue the password strength meter styles. + * + * @return void + */ + public function enqueue_styles(): void { + wp_enqueue_style( + 'strength-meter-styles', + plugin_dir_url( __FILE__ ) . 'css/strength-meter.css', + array(), + Account_Protection::PACKAGE_VERSION + ); + } } diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css new file mode 100644 index 0000000000000..57b4c6f1f7d4c --- /dev/null +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -0,0 +1,108 @@ +/* General Styling */ +#password-validation-status { + width: fit-content; + margin: auto; +} + +.validation-checklist { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +} + +.validation-item { + display: flex; + align-items: center; + gap: 8px; +} + +.validation-icon { + height: 24px; +} + +.validation-text { + margin-top: 0; +} + +/* Info Popover */ +.info-popover { + position: relative; + display: inline-block; + height: 20px; +} + +.info-icon { + height: 20px; + cursor: pointer; +} + +.popover { + display: none; + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 6px 10px; + border-radius: 4px; + white-space: normal; + width: 200px; + font-size: 12px; + box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); + z-index: 10; + text-align: center; +} + +.popover-arrow { + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid #333; +} + +/* Strength Meter */ +.strength-meter { + display: flex; + justify-content: space-between; + align-items: center; + height: 30px; + padding: 0 16px; + margin-bottom: 16px; + border-radius: 0 0 4px 4px; + background-color: #8C8F94; +} + +.strength-meter .strength { + display: flex; + align-items: center; + font-size: 12px; + font-weight: bold; + color: #1D2327; + margin: 0; +} + +.user-specific { + margin-left: 1px; + margin-right: 1px; +} + +.branding { + display: flex; + align-items: center; + gap: 4px; +} + +.branding .powered-by { + font-size: 12px; + color: #1D2327; + margin: 0; +} + +.branding img { + height: 18px; +} diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter-original.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter-original.js new file mode 100644 index 0000000000000..4fe5b19802d8a --- /dev/null +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter-original.js @@ -0,0 +1,424 @@ +/* global jQuery, jetpackData */ + +jQuery( document ).ready( function ( $ ) { + const generatePasswordButton = $( '.wp-generate-pw' ); + const weakPasswordConfirmation = $( '.pw-weak' ); + const weakPasswordConfirmationCheckbox = + weakPasswordConfirmation.find( 'input[type="checkbox"]' ); + const updateProfileFormSubmitButton = $( '#submit' ); + const resetPasswordFormSaveButton = $( '#wp-submit' ); + + // TODO: Non JS form flashes momentarily, we should hide it initially to avoid UI awkwardness + + const passwordInput = $( '#pass1' ); + passwordInput.css( { 'border-color': '#8C8F94' } ); + + const coreStrengthMeter = $( '#pass-strength-result' ); + coreStrengthMeter.hide(); + + const passwordValidationStatus = $( '
              ', { + id: 'password-validation-status', + css: { + width: 'fit-content', + margin: 'auto', + }, + } ); + + const userSpecific = Boolean( jetpackData.userSpecific ); + + const validationCheckList = $( '
                ', { + css: { + display: 'flex', + 'flex-direction': 'column', + gap: '4px', + 'margin-bottom': '16px', + }, + } ); + + const validationItems = {}; + + Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { + const listItem = $( '
              • ', { + css: { + display: 'contains_backslash' === key ? 'none' : 'flex', + 'align-items': 'center', + gap: '8px', + }, + 'data-key': key, + } ); + + const validationIcon = $( '', { + src: jetpackData.loadingIcon, + alt: 'Validating...', + css: { + height: '24px', + }, + } ); + + const validationCheckListItemText = $( '

                ', { + text: value.message, + css: { + 'margin-top': '0', + }, + } ); + + let infoIconPopover = null; + if ( userSpecific && value.info ) { + infoIconPopover = $( '

                ', { + css: { + position: 'relative', + display: 'inline-block', + height: '20px', + }, + } ); + const infoIcon = $( '', { + src: jetpackData.infoIcon, + alt: 'Info', + css: { + height: '20px', + cursor: 'pointer', + }, + } ); + + const popover = $( '
                ', { + text: value.info, + css: { + display: 'none', + position: 'absolute', + bottom: '30px', + left: '50%', + transform: 'translateX(-50%)', + background: '#333', + color: '#fff', + padding: '6px 10px', + 'border-radius': '4px', + 'white-space': 'normal', + width: '200px', + 'font-size': '12px', + 'box-shadow': '0px 4px 6px rgba(0, 0, 0, 0.1)', + 'z-index': 10, + 'text-align': 'center', + }, + } ); + + const popoverArrow = $( '
                ', { + css: { + position: 'absolute', + bottom: '-6px', + left: '50%', + transform: 'translateX(-50%)', + 'border-left': '6px solid transparent', + 'border-right': '6px solid transparent', + 'border-top': '6px solid #333', + }, + } ); + + popover.append( popoverArrow ); + + infoIcon.hover( + function () { + popover.fadeIn( 200 ); + }, + function () { + popover.fadeOut( 200 ); + } + ); + + infoIconPopover.append( infoIcon ); + infoIconPopover.append( popover ); + } + + listItem.append( validationIcon ); + listItem.append( validationCheckListItemText ); + if ( infoIconPopover ) { + listItem.append( infoIconPopover ); + } + validationCheckList.append( listItem ); + + validationItems[ key ] = { + icon: validationIcon, + text: validationCheckListItemText, + item: listItem, + }; + } ); + + passwordValidationStatus.append( validationCheckList ); + passwordInput.after( passwordValidationStatus ); + + const strengthMeter = $( '
                ', { + css: { + display: 'flex', + 'justify-content': 'space-between', + 'align-items': 'center', + height: '30px', + padding: '0px 16px', + 'margin-bottom': '16px', + 'border-radius': '0px 0px 4px 4px', + 'background-color': '#8C8F94', + }, + } ); + + if ( userSpecific ) { + strengthMeter.css( { 'margin-left': '1px', 'margin-right': '1px' } ); + } + + const strength = $( '

                ', { + text: 'Validating...', + css: { + display: 'flex', + 'align-items': 'center', + 'font-size': '12px', + 'font-weight': 'bold', + color: '#1D2327', + margin: '0', + }, + } ); + + const jetpackBranding = $( '

                ', { + css: { + display: 'flex', + 'align-items': 'center', + gap: '4px', + }, + } ); + + const brandingMessage = $( '

                ', { + text: 'Powered by ', + css: { + 'font-size': '12px', + color: '#1D2327', + margin: '0', + }, + } ); + + const jetpackLogo = $( '', { + src: jetpackData.logo, + alt: 'Jetpack Logo', + css: { + height: '18px', + }, + } ); + + jetpackBranding.append( brandingMessage ); + jetpackBranding.append( jetpackLogo ); + strengthMeter.append( strength ); + strengthMeter.append( jetpackBranding ); + passwordInput.after( strengthMeter ); + + passwordInput.on( 'input', () => validatePassword() ); + + setTimeout( () => { + if ( passwordInput && passwordInput.val() && passwordInput.val().length > 0 ) { + validatePassword(); + } + }, 1500 ); + + generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); + + let currentAjaxRequest = null; + + /** + * + * Validate the current password input + * + */ + function validatePassword() { + const currentPasswordInput = passwordInput.val(); + const failedValidationConditions = {}; + + if ( currentAjaxRequest ) { + currentAjaxRequest.abort(); + currentAjaxRequest = null; + } + + if ( ! currentPasswordInput || currentPasswordInput.trim().length === 0 ) { + applyStyling( failedValidationConditions, true ); + return; + } + + if ( coreStrengthMeter.is( ':visible' ) ) { + coreStrengthMeter.hide(); + } + + // passwordValidationStatus loading state + Object.values( validationItems ).forEach( ( { icon, text } ) => { + icon.attr( 'src', jetpackData.loadingIcon ); + icon.attr( 'alt', 'Validating...' ); + text.css( { color: '#3C434A', transition: 'color 0.2s ease-in-out' } ); + } ); + + // strengthMeter loading state + strength.text( 'Validating...' ); + jetpackBranding.show(); + strengthMeter.css( 'background-color', '#8C8F94' ); + passwordInput.css( { 'border-color': '#8C8F94', 'border-radius': '4px 4px 0px 0px' } ); + strengthMeter.show(); + passwordValidationStatus.show(); + + // Disable submit buttons while validating + if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', true ); + } + + if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', true ); + } + + const uiUpdates = []; + + currentAjaxRequest = $.ajax( { + url: jetpackData.ajaxurl, + type: 'POST', + data: { + action: 'validate_password_ajax', + nonce: jetpackData.nonce, + password: currentPasswordInput, + user_specific: jetpackData.userSpecific, + }, + success: function ( response ) { + currentAjaxRequest = null; + + if ( response.success ) { + // Manually update core strength meter status + const coreStrengthMeterClass = coreStrengthMeter.attr( 'class' ) || ''; + response.data.state.core.status = ! ( + coreStrengthMeterClass.includes( 'strong' ) || coreStrengthMeterClass.includes( 'good' ) + ); + + Object.entries( response.data.state ).forEach( ( [ key, item ] ) => { + const isInvalid = item.status; + const { icon, text, item: listItem } = validationItems[ key ] || {}; + + if ( ! icon || ! text ) return; + + if ( key === 'contains_backslash' ) { + listItem.css( 'display', isInvalid ? 'flex' : 'none' ); + } + + uiUpdates.push( () => { + icon.attr( 'src', isInvalid ? jetpackData.crossIcon : jetpackData.checkIcon ); + icon.attr( 'alt', isInvalid ? 'Jetpack Cross' : 'Jetpack Check' ); + text.css( { + color: isInvalid ? '#E65054' : '#008710', + transition: 'color 0.2s ease-in-out', + } ); + } ); + + if ( isInvalid ) { + failedValidationConditions[ key ] = isInvalid; + } + } ); + + requestAnimationFrame( () => { + uiUpdates.forEach( update => update() ); + + validationCheckList.css( 'opacity', 0.99 ); + setTimeout( () => validationCheckList.css( 'opacity', 1 ), 1 ); + + applyStyling( failedValidationConditions ); + } ); + } else { + // TODO: Restore core strength meter state, show error? + strengthMeter.hide(); + passwordValidationStatus.hide(); + passwordInput.removeAttr( 'style' ); + coreStrengthMeter.show(); + } + }, + error: function ( jqXHR, textStatus ) { + if ( textStatus !== 'abort' ) { + // TODO: Restore core strength meter state, show error? + strengthMeter.hide(); + passwordValidationStatus.hide(); + passwordInput.removeAttr( 'style' ); + coreStrengthMeter.show(); + } + }, + } ); + } + + /** + * + * Apply styling based on validation results + * + * @param {object} failedValidationConditions - Object containing failed validation conditions + * @param {boolean} passwordIsEmpty - Whether the password input is empty + */ + function applyStyling( failedValidationConditions, passwordIsEmpty = false ) { + let finalColor = '#8c8f94'; + let finalStrengthText = ''; + + if ( passwordIsEmpty ) { + strength.text( '' ); + jetpackBranding.hide(); + strengthMeter.css( 'background-color', 'transparent' ); + passwordValidationStatus.hide(); + passwordInput.css( { 'border-color': '#8c8f94', 'border-radius': '4px' } ); + + return; + } + + if ( 0 === Object.keys( failedValidationConditions ).length ) { + finalColor = '#64CA43'; + finalStrengthText = 'Strong'; + + if ( weakPasswordConfirmation.is( ':visible' ) ) { + weakPasswordConfirmation.css( 'display', 'none' ); + } + + if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', false ); + } + + if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', false ); + } + } else { + finalColor = '#E65054'; + finalStrengthText = 'Weak'; + + if ( weakPasswordConfirmation.css( 'display' ) === 'none' ) { + weakPasswordConfirmation.css( 'display', userSpecific ? 'table-row' : 'block' ); + } + + if ( weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { + if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', false ); + } + + if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', false ); + } + } else { + if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { + updateProfileFormSubmitButton.prop( 'disabled', true ); + } + + if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { + resetPasswordFormSaveButton.prop( 'disabled', true ); + } + } + } + + strength.text( finalStrengthText ); + strengthMeter.css( 'background-color', finalColor ); + passwordInput.css( { 'border-color': finalColor, 'border-radius': '4px 4px 0px 0px' } ); + + if ( ! strengthMeter.find( strength ).length ) { + strengthMeter.append( strength ); + } + if ( ! strengthMeter.find( jetpackBranding ).length ) { + strengthMeter.append( jetpackBranding ); + } + if ( ! strengthMeter.parent().length ) { + passwordInput.after( strengthMeter ); + } + + if ( strengthMeter.is( ':hidden' ) ) { + strengthMeter.show(); + } + if ( passwordValidationStatus.is( ':hidden' ) ) { + passwordValidationStatus.show(); + } + } +} ); diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 4fe5b19802d8a..9d2a831c843f4 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -16,104 +16,46 @@ jQuery( document ).ready( function ( $ ) { const coreStrengthMeter = $( '#pass-strength-result' ); coreStrengthMeter.hide(); - const passwordValidationStatus = $( '

                ', { + const passwordValidationStatus = $( '
                ', { id: 'password-validation-status', - css: { - width: 'fit-content', - margin: 'auto', - }, } ); const userSpecific = Boolean( jetpackData.userSpecific ); - const validationCheckList = $( '
                  ', { - css: { - display: 'flex', - 'flex-direction': 'column', - gap: '4px', - 'margin-bottom': '16px', - }, - } ); + const validationCheckList = $( '
                    ', { class: 'validation-checklist' } ); const validationItems = {}; Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { - const listItem = $( '
                  • ', { - css: { - display: 'contains_backslash' === key ? 'none' : 'flex', - 'align-items': 'center', - gap: '8px', - }, + const listItem = $( '
                  • ', { + class: 'validation-item', 'data-key': key, } ); const validationIcon = $( '', { src: jetpackData.loadingIcon, alt: 'Validating...', - css: { - height: '24px', - }, + class: 'validation-icon', } ); const validationCheckListItemText = $( '

                    ', { text: value.message, - css: { - 'margin-top': '0', - }, + class: 'validation-text', } ); let infoIconPopover = null; if ( userSpecific && value.info ) { - infoIconPopover = $( '

                    ', { - css: { - position: 'relative', - display: 'inline-block', - height: '20px', - }, - } ); + infoIconPopover = $( '
                    ', { class: 'info-popover' } ); const infoIcon = $( '', { src: jetpackData.infoIcon, alt: 'Info', - css: { - height: '20px', - cursor: 'pointer', - }, + class: 'info-icon', } ); - const popover = $( '
                    ', { + const popover = $( '
                    ', { text: value.info, - css: { - display: 'none', - position: 'absolute', - bottom: '30px', - left: '50%', - transform: 'translateX(-50%)', - background: '#333', - color: '#fff', - padding: '6px 10px', - 'border-radius': '4px', - 'white-space': 'normal', - width: '200px', - 'font-size': '12px', - 'box-shadow': '0px 4px 6px rgba(0, 0, 0, 0.1)', - 'z-index': 10, - 'text-align': 'center', - }, - } ); - - const popoverArrow = $( '
                    ', { - css: { - position: 'absolute', - bottom: '-6px', - left: '50%', - transform: 'translateX(-50%)', - 'border-left': '6px solid transparent', - 'border-right': '6px solid transparent', - 'border-top': '6px solid #333', - }, - } ); - - popover.append( popoverArrow ); + class: 'popover', + } ).append( $( '
                    ', { class: 'popover-arrow' } ) ); infoIcon.hover( function () { @@ -124,15 +66,10 @@ jQuery( document ).ready( function ( $ ) { } ); - infoIconPopover.append( infoIcon ); - infoIconPopover.append( popover ); + infoIconPopover.append( infoIcon, popover ); } - listItem.append( validationIcon ); - listItem.append( validationCheckListItemText ); - if ( infoIconPopover ) { - listItem.append( infoIconPopover ); - } + listItem.append( validationIcon, validationCheckListItemText, infoIconPopover ); validationCheckList.append( listItem ); validationItems[ key ] = { @@ -146,65 +83,23 @@ jQuery( document ).ready( function ( $ ) { passwordInput.after( passwordValidationStatus ); const strengthMeter = $( '
                    ', { - css: { - display: 'flex', - 'justify-content': 'space-between', - 'align-items': 'center', - height: '30px', - padding: '0px 16px', - 'margin-bottom': '16px', - 'border-radius': '0px 0px 4px 4px', - 'background-color': '#8C8F94', - }, + class: 'strength-meter' + ( userSpecific ? ' user-specific' : null ), } ); - if ( userSpecific ) { - strengthMeter.css( { 'margin-left': '1px', 'margin-right': '1px' } ); - } - const strength = $( '

                    ', { + class: 'strength', text: 'Validating...', - css: { - display: 'flex', - 'align-items': 'center', - 'font-size': '12px', - 'font-weight': 'bold', - color: '#1D2327', - margin: '0', - }, } ); - const jetpackBranding = $( '

                    ', { - css: { - display: 'flex', - 'align-items': 'center', - gap: '4px', - }, - } ); - - const brandingMessage = $( '

                    ', { - text: 'Powered by ', - css: { - 'font-size': '12px', - color: '#1D2327', - margin: '0', - }, - } ); - - const jetpackLogo = $( '', { - src: jetpackData.logo, - alt: 'Jetpack Logo', - css: { - height: '18px', - }, - } ); + const jetpackBranding = $( '

                    ', { class: 'branding' } ).append( + $( '

                    ', { class: 'powered-by', text: 'Powered by ' } ), + $( '', { src: jetpackData.logo, alt: 'Jetpack Logo' } ) + ); - jetpackBranding.append( brandingMessage ); - jetpackBranding.append( jetpackLogo ); - strengthMeter.append( strength ); - strengthMeter.append( jetpackBranding ); + strengthMeter.append( strength, jetpackBranding ); passwordInput.after( strengthMeter ); + // Event listeners passwordInput.on( 'input', () => validatePassword() ); setTimeout( () => { @@ -213,7 +108,7 @@ jQuery( document ).ready( function ( $ ) { } }, 1500 ); - generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); + generatePasswordButton.on( 'click', () => validatePassword() ); let currentAjaxRequest = null; From 9d4da9625202b830dec4c6defdbf06b3725e7f22 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 18:51:14 -0800 Subject: [PATCH 21/29] Fix styling, centralize core references --- .../src/css/strength-meter.css | 16 +-- .../src/js/jetpack-password-strength-meter.js | 111 ++++++++++-------- 2 files changed, 71 insertions(+), 56 deletions(-) diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css index 57b4c6f1f7d4c..d5c50a7706cc5 100644 --- a/projects/packages/account-protection/src/css/strength-meter.css +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -7,7 +7,7 @@ .validation-checklist { display: flex; flex-direction: column; - gap: 4px; + gap: 8px; margin-bottom: 16px; } @@ -15,14 +15,16 @@ display: flex; align-items: center; gap: 8px; -} + margin-bottom: 0; -.validation-icon { - height: 24px; -} + .validation-icon { + height: 24px; + } + + .validation-text { + margin-top: 0; + } -.validation-text { - margin-top: 0; } /* Info Popover */ diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 9d2a831c843f4..45b0ef4b96caa 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -1,20 +1,20 @@ /* global jQuery, jetpackData */ jQuery( document ).ready( function ( $ ) { - const generatePasswordButton = $( '.wp-generate-pw' ); - const weakPasswordConfirmation = $( '.pw-weak' ); - const weakPasswordConfirmationCheckbox = - weakPasswordConfirmation.find( 'input[type="checkbox"]' ); - const updateProfileFormSubmitButton = $( '#submit' ); - const resetPasswordFormSaveButton = $( '#wp-submit' ); + const coreElements = { + generatePasswordButton: $( '.wp-generate-pw' ), + passwordInput: $( '#pass1' ), + strengthMeter: $( '#pass-strength-result' ), + weakPasswordConfirmation: $( '.pw-weak' ), + weakPasswordConfirmationCheckbox: $( '.pw-weak input[type="checkbox"]' ), + updateFormSubmitButton: $( '#submit' ), + resetFormSaveButton: $( '#wp-submit' ), + }; // TODO: Non JS form flashes momentarily, we should hide it initially to avoid UI awkwardness - const passwordInput = $( '#pass1' ); - passwordInput.css( { 'border-color': '#8C8F94' } ); - - const coreStrengthMeter = $( '#pass-strength-result' ); - coreStrengthMeter.hide(); + coreElements.passwordInput.css( { 'border-color': '#8C8F94' } ); + coreElements.strengthMeter.hide(); const passwordValidationStatus = $( '

                    ', { id: 'password-validation-status', @@ -80,7 +80,7 @@ jQuery( document ).ready( function ( $ ) { } ); passwordValidationStatus.append( validationCheckList ); - passwordInput.after( passwordValidationStatus ); + coreElements.passwordInput.after( passwordValidationStatus ); const strengthMeter = $( '
                    ', { class: 'strength-meter' + ( userSpecific ? ' user-specific' : null ), @@ -97,18 +97,22 @@ jQuery( document ).ready( function ( $ ) { ); strengthMeter.append( strength, jetpackBranding ); - passwordInput.after( strengthMeter ); + coreElements.passwordInput.after( strengthMeter ); // Event listeners - passwordInput.on( 'input', () => validatePassword() ); + coreElements.passwordInput.on( 'input', () => validatePassword() ); setTimeout( () => { - if ( passwordInput && passwordInput.val() && passwordInput.val().length > 0 ) { + if ( + coreElements.passwordInput && + coreElements.passwordInput.val() && + coreElements.passwordInput.val().length > 0 + ) { validatePassword(); } }, 1500 ); - generatePasswordButton.on( 'click', () => validatePassword() ); + coreElements.generatePasswordButton.on( 'click', () => validatePassword() ); let currentAjaxRequest = null; @@ -118,7 +122,7 @@ jQuery( document ).ready( function ( $ ) { * */ function validatePassword() { - const currentPasswordInput = passwordInput.val(); + const currentPasswordInput = coreElements.passwordInput.val(); const failedValidationConditions = {}; if ( currentAjaxRequest ) { @@ -131,8 +135,8 @@ jQuery( document ).ready( function ( $ ) { return; } - if ( coreStrengthMeter.is( ':visible' ) ) { - coreStrengthMeter.hide(); + if ( coreElements.strengthMeter.is( ':visible' ) ) { + coreElements.strengthMeter.hide(); } // passwordValidationStatus loading state @@ -146,17 +150,20 @@ jQuery( document ).ready( function ( $ ) { strength.text( 'Validating...' ); jetpackBranding.show(); strengthMeter.css( 'background-color', '#8C8F94' ); - passwordInput.css( { 'border-color': '#8C8F94', 'border-radius': '4px 4px 0px 0px' } ); + coreElements.passwordInput.css( { + 'border-color': '#8C8F94', + 'border-radius': '4px 4px 0px 0px', + } ); strengthMeter.show(); passwordValidationStatus.show(); // Disable submit buttons while validating - if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', true ); + if ( ! coreElements.updateFormSubmitButton.prop( 'disabled' ) ) { + coreElements.updateFormSubmitButton.prop( 'disabled', true ); } - if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', true ); + if ( ! coreElements.resetFormSaveButton.prop( 'disabled' ) ) { + coreElements.resetFormSaveButton.prop( 'disabled', true ); } const uiUpdates = []; @@ -175,7 +182,7 @@ jQuery( document ).ready( function ( $ ) { if ( response.success ) { // Manually update core strength meter status - const coreStrengthMeterClass = coreStrengthMeter.attr( 'class' ) || ''; + const coreStrengthMeterClass = coreElements.strengthMeter.attr( 'class' ) || ''; response.data.state.core.status = ! ( coreStrengthMeterClass.includes( 'strong' ) || coreStrengthMeterClass.includes( 'good' ) ); @@ -216,8 +223,8 @@ jQuery( document ).ready( function ( $ ) { // TODO: Restore core strength meter state, show error? strengthMeter.hide(); passwordValidationStatus.hide(); - passwordInput.removeAttr( 'style' ); - coreStrengthMeter.show(); + coreElements.passwordInput.removeAttr( 'style' ); + coreElements.strengthMeter.show(); } }, error: function ( jqXHR, textStatus ) { @@ -225,8 +232,8 @@ jQuery( document ).ready( function ( $ ) { // TODO: Restore core strength meter state, show error? strengthMeter.hide(); passwordValidationStatus.hide(); - passwordInput.removeAttr( 'style' ); - coreStrengthMeter.show(); + coreElements.passwordInput.removeAttr( 'style' ); + coreElements.strengthMeter.show(); } }, } ); @@ -248,7 +255,7 @@ jQuery( document ).ready( function ( $ ) { jetpackBranding.hide(); strengthMeter.css( 'background-color', 'transparent' ); passwordValidationStatus.hide(); - passwordInput.css( { 'border-color': '#8c8f94', 'border-radius': '4px' } ); + coreElements.passwordInput.css( { 'border-color': '#8c8f94', 'border-radius': '4px' } ); return; } @@ -257,47 +264,53 @@ jQuery( document ).ready( function ( $ ) { finalColor = '#64CA43'; finalStrengthText = 'Strong'; - if ( weakPasswordConfirmation.is( ':visible' ) ) { - weakPasswordConfirmation.css( 'display', 'none' ); + if ( coreElements.weakPasswordConfirmation.is( ':visible' ) ) { + coreElements.weakPasswordConfirmation.css( 'display', 'none' ); } - if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', false ); + if ( coreElements.updateFormSubmitButton.prop( 'disabled' ) ) { + coreElements.updateFormSubmitButton.prop( 'disabled', false ); } - if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', false ); + if ( coreElements.resetFormSaveButton.prop( 'disabled' ) ) { + coreElements.resetFormSaveButton.prop( 'disabled', false ); } } else { finalColor = '#E65054'; finalStrengthText = 'Weak'; - if ( weakPasswordConfirmation.css( 'display' ) === 'none' ) { - weakPasswordConfirmation.css( 'display', userSpecific ? 'table-row' : 'block' ); + if ( coreElements.weakPasswordConfirmation.css( 'display' ) === 'none' ) { + coreElements.weakPasswordConfirmation.css( + 'display', + userSpecific ? 'table-row' : 'block' + ); } - if ( weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { - if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', false ); + if ( coreElements.weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { + if ( coreElements.updateFormSubmitButton.prop( 'disabled' ) ) { + coreElements.updateFormSubmitButton.prop( 'disabled', false ); } - if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', false ); + if ( coreElements.resetFormSaveButton.prop( 'disabled' ) ) { + coreElements.resetFormSaveButton.prop( 'disabled', false ); } } else { - if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', true ); + if ( ! coreElements.updateFormSubmitButton.prop( 'disabled' ) ) { + coreElements.updateFormSubmitButton.prop( 'disabled', true ); } - if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', true ); + if ( ! coreElements.resetFormSaveButton.prop( 'disabled' ) ) { + coreElements.resetFormSaveButton.prop( 'disabled', true ); } } } strength.text( finalStrengthText ); strengthMeter.css( 'background-color', finalColor ); - passwordInput.css( { 'border-color': finalColor, 'border-radius': '4px 4px 0px 0px' } ); + coreElements.passwordInput.css( { + 'border-color': finalColor, + 'border-radius': '4px 4px 0px 0px', + } ); if ( ! strengthMeter.find( strength ).length ) { strengthMeter.append( strength ); @@ -306,7 +319,7 @@ jQuery( document ).ready( function ( $ ) { strengthMeter.append( jetpackBranding ); } if ( ! strengthMeter.parent().length ) { - passwordInput.after( strengthMeter ); + coreElements.passwordInput.after( strengthMeter ); } if ( strengthMeter.is( ':hidden' ) ) { From ec88c3964e70496bcad48c7f58dfcb38a7b0f7a1 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Thu, 6 Feb 2025 19:06:04 -0800 Subject: [PATCH 22/29] Reorg --- .../src/js/jetpack-password-strength-meter.js | 63 +++++-------------- 1 file changed, 15 insertions(+), 48 deletions(-) diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 45b0ef4b96caa..b0e5e35d2e506 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -16,21 +16,13 @@ jQuery( document ).ready( function ( $ ) { coreElements.passwordInput.css( { 'border-color': '#8C8F94' } ); coreElements.strengthMeter.hide(); - const passwordValidationStatus = $( '
                    ', { - id: 'password-validation-status', - } ); - - const userSpecific = Boolean( jetpackData.userSpecific ); - + const passwordValidationStatus = $( '
                    ', { id: 'password-validation-status' } ); const validationCheckList = $( '
                      ', { class: 'validation-checklist' } ); - const validationItems = {}; + const userSpecific = Boolean( jetpackData.userSpecific ); Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { - const listItem = $( '
                    • ', { - class: 'validation-item', - 'data-key': key, - } ); + const listItem = $( '
                    • ', { class: 'validation-item', 'data-key': key } ); const validationIcon = $( '', { src: jetpackData.loadingIcon, @@ -58,12 +50,8 @@ jQuery( document ).ready( function ( $ ) { } ).append( $( '
                      ', { class: 'popover-arrow' } ) ); infoIcon.hover( - function () { - popover.fadeIn( 200 ); - }, - function () { - popover.fadeOut( 200 ); - } + () => popover.fadeIn( 200 ), + () => popover.fadeOut( 200 ) ); infoIconPopover.append( infoIcon, popover ); @@ -101,6 +89,7 @@ jQuery( document ).ready( function ( $ ) { // Event listeners coreElements.passwordInput.on( 'input', () => validatePassword() ); + coreElements.generatePasswordButton.on( 'click', () => validatePassword() ); setTimeout( () => { if ( @@ -112,8 +101,6 @@ jQuery( document ).ready( function ( $ ) { } }, 1500 ); - coreElements.generatePasswordButton.on( 'click', () => validatePassword() ); - let currentAjaxRequest = null; /** @@ -130,14 +117,12 @@ jQuery( document ).ready( function ( $ ) { currentAjaxRequest = null; } - if ( ! currentPasswordInput || currentPasswordInput.trim().length === 0 ) { + if ( ! currentPasswordInput.trim() ) { applyStyling( failedValidationConditions, true ); return; } - if ( coreElements.strengthMeter.is( ':visible' ) ) { - coreElements.strengthMeter.hide(); - } + coreElements.strengthMeter.hide(); // passwordValidationStatus loading state Object.values( validationItems ).forEach( ( { icon, text } ) => { @@ -264,17 +249,9 @@ jQuery( document ).ready( function ( $ ) { finalColor = '#64CA43'; finalStrengthText = 'Strong'; - if ( coreElements.weakPasswordConfirmation.is( ':visible' ) ) { - coreElements.weakPasswordConfirmation.css( 'display', 'none' ); - } - - if ( coreElements.updateFormSubmitButton.prop( 'disabled' ) ) { - coreElements.updateFormSubmitButton.prop( 'disabled', false ); - } - - if ( coreElements.resetFormSaveButton.prop( 'disabled' ) ) { - coreElements.resetFormSaveButton.prop( 'disabled', false ); - } + coreElements.weakPasswordConfirmation.css( 'display', 'none' ); + coreElements.updateFormSubmitButton.prop( 'disabled', false ); + coreElements.resetFormSaveButton.prop( 'disabled', false ); } else { finalColor = '#E65054'; finalStrengthText = 'Weak'; @@ -287,21 +264,11 @@ jQuery( document ).ready( function ( $ ) { } if ( coreElements.weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { - if ( coreElements.updateFormSubmitButton.prop( 'disabled' ) ) { - coreElements.updateFormSubmitButton.prop( 'disabled', false ); - } - - if ( coreElements.resetFormSaveButton.prop( 'disabled' ) ) { - coreElements.resetFormSaveButton.prop( 'disabled', false ); - } + coreElements.updateFormSubmitButton.prop( 'disabled', false ); + coreElements.resetFormSaveButton.prop( 'disabled', false ); } else { - if ( ! coreElements.updateFormSubmitButton.prop( 'disabled' ) ) { - coreElements.updateFormSubmitButton.prop( 'disabled', true ); - } - - if ( ! coreElements.resetFormSaveButton.prop( 'disabled' ) ) { - coreElements.resetFormSaveButton.prop( 'disabled', true ); - } + coreElements.updateFormSubmitButton.prop( 'disabled', true ); + coreElements.resetFormSaveButton.prop( 'disabled', true ); } } From 79997c68a5ee959b80bdc09bf9ee4884ede83e82 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 7 Feb 2025 06:28:16 -0800 Subject: [PATCH 23/29] Use global pagenow for context, restrict user specific check to profile updates --- .../src/class-password-detection.php | 18 +- .../src/class-password-strength-meter.php | 24 +-- .../src/css/strength-meter.css | 13 +- .../src/js/jetpack-password-strength-meter.js | 164 ++++++++++-------- 4 files changed, 115 insertions(+), 104 deletions(-) diff --git a/projects/packages/account-protection/src/class-password-detection.php b/projects/packages/account-protection/src/class-password-detection.php index 3e5457191b28f..9ad4d68692389 100644 --- a/projects/packages/account-protection/src/class-password-detection.php +++ b/projects/packages/account-protection/src/class-password-detection.php @@ -356,15 +356,19 @@ private function set_transient_error( int $user_id, string $message, int $expira * @return void */ public function enqueue_styles(): void { + global $pagenow; + if ( ! isset( $pagenow ) || $pagenow !== 'wp-login.php' ) { + return; + } // No nonce verification necessary - reading only // phpcs:disable WordPress.Security.NonceVerification - if ( ( isset( $GLOBALS['pagenow'] ) && $GLOBALS['pagenow'] === 'wp-login.php' ) && ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) ) { - wp_enqueue_style( - 'password-detection-styles', - plugin_dir_url( __FILE__ ) . 'css/password-detection.css', - array(), - Account_Protection::PACKAGE_VERSION - ); + if ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) { + wp_enqueue_style( + 'password-detection-styles', + plugin_dir_url( __FILE__ ) . 'css/password-detection.css', + array(), + Account_Protection::PACKAGE_VERSION + ); } } } diff --git a/projects/packages/account-protection/src/class-password-strength-meter.php b/projects/packages/account-protection/src/class-password-strength-meter.php index 1878bd06f6cff..fbeda04429e37 100644 --- a/projects/packages/account-protection/src/class-password-strength-meter.php +++ b/projects/packages/account-protection/src/class-password-strength-meter.php @@ -58,11 +58,17 @@ public function validate_password_ajax(): void { * @return void */ public function enqueue_jetpack_password_strength_meter_profile_script(): void { - if ( ! wp_script_is( 'jetpack-password-strength-meter', 'enqueued' ) ) { - $this->enqueue_script(); - $this->enqueue_styles(); - $this->localize_jetpack_data( true ); + global $pagenow; + + if ( ! isset( $pagenow ) || ! in_array( $pagenow, array( 'profile.php', 'user-new.php', 'user-edit.php' ), true ) ) { + return; } + + $this->enqueue_script(); + $this->enqueue_styles(); + + // Only profile page should run user specific checks. + $this->localize_jetpack_data( 'profile.php' === $pagenow ); } /** @@ -73,12 +79,10 @@ public function enqueue_jetpack_password_strength_meter_profile_script(): void { public function enqueue_jetpack_password_strength_meter_reset_script(): void { // No nonce verification necessary as the action includes a robust verification process // phpcs:disable WordPress.Security.NonceVerification - if ( isset( $_GET['action'] ) && ( $_GET['action'] === 'rp' || $_GET['action'] === 'resetpass' ) ) { - if ( ! wp_script_is( 'jetpack-password-strength-meter', 'enqueued' ) ) { - $this->enqueue_script(); - $this->enqueue_styles(); - $this->localize_jetpack_data(); - } + if ( isset( $_GET['action'] ) && ( 'rp' === $_GET['action'] || 'resetpass' === $_GET['action'] ) ) { + $this->enqueue_script(); + $this->enqueue_styles(); + $this->localize_jetpack_data(); } } diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css index d5c50a7706cc5..8e89800c8fcfb 100644 --- a/projects/packages/account-protection/src/css/strength-meter.css +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -1,14 +1,10 @@ /* General Styling */ -#password-validation-status { - width: fit-content; - margin: auto; -} - .validation-checklist { display: flex; flex-direction: column; gap: 8px; - margin-bottom: 16px; + width: fit-content; + margin: 16px auto; } .validation-item { @@ -88,11 +84,6 @@ margin: 0; } -.user-specific { - margin-left: 1px; - margin-right: 1px; -} - .branding { display: flex; align-items: center; diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index b0e5e35d2e506..c2e8504879045 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -3,75 +3,39 @@ jQuery( document ).ready( function ( $ ) { const coreElements = { generatePasswordButton: $( '.wp-generate-pw' ), + passwordInputWrapper: $( '.user-pass1-wrap' ), passwordInput: $( '#pass1' ), strengthMeter: $( '#pass-strength-result' ), weakPasswordConfirmation: $( '.pw-weak' ), weakPasswordConfirmationCheckbox: $( '.pw-weak input[type="checkbox"]' ), updateFormSubmitButton: $( '#submit' ), + createUserFormSubmitButton: $( '#createusersub' ), resetFormSaveButton: $( '#wp-submit' ), }; // TODO: Non JS form flashes momentarily, we should hide it initially to avoid UI awkwardness - coreElements.passwordInput.css( { 'border-color': '#8C8F94' } ); + coreElements.passwordInputWrapper.css( { + 'margin-bottom': '16px', + } ); + coreElements.passwordInput.css( { + 'border-color': '#8C8F94', + 'border-radius': '4px 4px 0 0', + margin: '0', + } ); coreElements.strengthMeter.hide(); const passwordValidationStatus = $( '
                      ', { id: 'password-validation-status' } ); + coreElements.passwordInput.after( passwordValidationStatus ); const validationCheckList = $( '
                        ', { class: 'validation-checklist' } ); + passwordValidationStatus.append( validationCheckList ); + const validationItems = {}; const userSpecific = Boolean( jetpackData.userSpecific ); - Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { - const listItem = $( '
                      • ', { class: 'validation-item', 'data-key': key } ); - - const validationIcon = $( '', { - src: jetpackData.loadingIcon, - alt: 'Validating...', - class: 'validation-icon', - } ); - - const validationCheckListItemText = $( '

                        ', { - text: value.message, - class: 'validation-text', - } ); - - let infoIconPopover = null; - if ( userSpecific && value.info ) { - infoIconPopover = $( '

                        ', { class: 'info-popover' } ); - const infoIcon = $( '', { - src: jetpackData.infoIcon, - alt: 'Info', - class: 'info-icon', - } ); - - const popover = $( '
                        ', { - text: value.info, - class: 'popover', - } ).append( $( '
                        ', { class: 'popover-arrow' } ) ); - - infoIcon.hover( - () => popover.fadeIn( 200 ), - () => popover.fadeOut( 200 ) - ); - - infoIconPopover.append( infoIcon, popover ); - } - - listItem.append( validationIcon, validationCheckListItemText, infoIconPopover ); - validationCheckList.append( listItem ); - - validationItems[ key ] = { - icon: validationIcon, - text: validationCheckListItemText, - item: listItem, - }; - } ); - - passwordValidationStatus.append( validationCheckList ); - coreElements.passwordInput.after( passwordValidationStatus ); - + // Strength meter initial state const strengthMeter = $( '
                        ', { - class: 'strength-meter' + ( userSpecific ? ' user-specific' : null ), + class: 'strength-meter', } ); const strength = $( '

                        ', { @@ -85,21 +49,20 @@ jQuery( document ).ready( function ( $ ) { ); strengthMeter.append( strength, jetpackBranding ); - coreElements.passwordInput.after( strengthMeter ); + validationCheckList.before( strengthMeter ); + + // Validation initial state + generateAndAppendValidationInitialState(); // Event listeners coreElements.passwordInput.on( 'input', () => validatePassword() ); coreElements.generatePasswordButton.on( 'click', () => validatePassword() ); setTimeout( () => { - if ( - coreElements.passwordInput && - coreElements.passwordInput.val() && - coreElements.passwordInput.val().length > 0 - ) { + if ( coreElements.passwordInput?.val()?.length ) { validatePassword(); } - }, 1500 ); + }, 1000 ); // TODO: This might not always catching the initial password value let currentAjaxRequest = null; @@ -117,7 +80,7 @@ jQuery( document ).ready( function ( $ ) { currentAjaxRequest = null; } - if ( ! currentPasswordInput.trim() ) { + if ( ! currentPasswordInput?.trim() ) { applyStyling( failedValidationConditions, true ); return; } @@ -143,13 +106,9 @@ jQuery( document ).ready( function ( $ ) { passwordValidationStatus.show(); // Disable submit buttons while validating - if ( ! coreElements.updateFormSubmitButton.prop( 'disabled' ) ) { - coreElements.updateFormSubmitButton.prop( 'disabled', true ); - } - - if ( ! coreElements.resetFormSaveButton.prop( 'disabled' ) ) { - coreElements.resetFormSaveButton.prop( 'disabled', true ); - } + coreElements.updateFormSubmitButton.prop( 'disabled', true ); + coreElements.createUserFormSubmitButton.prop( 'disabled', true ); + coreElements.resetFormSaveButton.prop( 'disabled', true ); const uiUpdates = []; @@ -251,23 +210,25 @@ jQuery( document ).ready( function ( $ ) { coreElements.weakPasswordConfirmation.css( 'display', 'none' ); coreElements.updateFormSubmitButton.prop( 'disabled', false ); + coreElements.createUserFormSubmitButton.prop( 'disabled', false ); coreElements.resetFormSaveButton.prop( 'disabled', false ); } else { finalColor = '#E65054'; finalStrengthText = 'Weak'; if ( coreElements.weakPasswordConfirmation.css( 'display' ) === 'none' ) { - coreElements.weakPasswordConfirmation.css( - 'display', - userSpecific ? 'table-row' : 'block' - ); + coreElements.weakPasswordConfirmation.css( { + display: 'table-row', + } ); } if ( coreElements.weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { coreElements.updateFormSubmitButton.prop( 'disabled', false ); + coreElements.createUserFormSubmitButton.prop( 'disabled', false ); coreElements.resetFormSaveButton.prop( 'disabled', false ); } else { coreElements.updateFormSubmitButton.prop( 'disabled', true ); + coreElements.createUserFormSubmitButton.prop( 'disabled', true ); coreElements.resetFormSaveButton.prop( 'disabled', true ); } } @@ -289,11 +250,62 @@ jQuery( document ).ready( function ( $ ) { coreElements.passwordInput.after( strengthMeter ); } - if ( strengthMeter.is( ':hidden' ) ) { - strengthMeter.show(); - } - if ( passwordValidationStatus.is( ':hidden' ) ) { - passwordValidationStatus.show(); - } + strengthMeter.show(); + passwordValidationStatus.show(); + } + + /** + * Generate and append the initial validation state + */ + function generateAndAppendValidationInitialState() { + Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { + const listItem = $( '

                      • ', { class: 'validation-item', 'data-key': key } ); + + if ( key === 'contains_backslash' ) { + listItem.hide(); + } + + const validationIcon = $( '', { + src: jetpackData.loadingIcon, + alt: 'Validating...', + class: 'validation-icon', + } ); + + const validationCheckListItemText = $( '

                        ', { + text: value.message, + class: 'validation-text', + } ); + + let infoIconPopover = null; + if ( userSpecific && value.info ) { + infoIconPopover = $( '

                        ', { class: 'info-popover' } ); + const infoIcon = $( '', { + src: jetpackData.infoIcon, + alt: 'Info', + class: 'info-icon', + } ); + + const popover = $( '
                        ', { + text: value.info, + class: 'popover', + } ).append( $( '
                        ', { class: 'popover-arrow' } ) ); + + infoIcon.hover( + () => popover.fadeIn( 200 ), + () => popover.fadeOut( 200 ) + ); + + infoIconPopover.append( infoIcon, popover ); + } + + listItem.append( validationIcon, validationCheckListItemText, infoIconPopover ); + validationCheckList.append( listItem ); + + validationItems[ key ] = { + icon: validationIcon, + text: validationCheckListItemText, + item: listItem, + }; + } ); } } ); From 67237998577583bdb149ff8f91bc8c2abf8b3640 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 7 Feb 2025 06:49:17 -0800 Subject: [PATCH 24/29] Compartmentalize generating and appending validation meter and status initial states --- .../src/css/strength-meter.css | 3 +- .../src/js/jetpack-password-strength-meter.js | 112 ++++++++++-------- 2 files changed, 62 insertions(+), 53 deletions(-) diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css index 8e89800c8fcfb..79663f45139b8 100644 --- a/projects/packages/account-protection/src/css/strength-meter.css +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -3,8 +3,7 @@ display: flex; flex-direction: column; gap: 8px; - width: fit-content; - margin: 16px auto; + margin: 16px 0; } .validation-item { diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index c2e8504879045..bb9442da7107a 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -30,41 +30,23 @@ jQuery( document ).ready( function ( $ ) { const validationCheckList = $( '
                          ', { class: 'validation-checklist' } ); passwordValidationStatus.append( validationCheckList ); - const validationItems = {}; - const userSpecific = Boolean( jetpackData.userSpecific ); - - // Strength meter initial state - const strengthMeter = $( '
                          ', { - class: 'strength-meter', - } ); - - const strength = $( '

                          ', { - class: 'strength', - text: 'Validating...', - } ); - - const jetpackBranding = $( '

                          ', { class: 'branding' } ).append( - $( '

                          ', { class: 'powered-by', text: 'Powered by ' } ), - $( '', { src: jetpackData.logo, alt: 'Jetpack Logo' } ) - ); - - strengthMeter.append( strength, jetpackBranding ); - validationCheckList.before( strengthMeter ); - - // Validation initial state - generateAndAppendValidationInitialState(); + let currentAjaxRequest = null; + const strengthMeterItems = {}; + const validationChecklistItems = {}; - // Event listeners - coreElements.passwordInput.on( 'input', () => validatePassword() ); - coreElements.generatePasswordButton.on( 'click', () => validatePassword() ); + generateAndAppendStrengthMeterInitialState(); + generateAndAppendValidationChecklistInitialState(); + // Initial password auto-population check setTimeout( () => { if ( coreElements.passwordInput?.val()?.length ) { validatePassword(); } }, 1000 ); // TODO: This might not always catching the initial password value - let currentAjaxRequest = null; + // Event listeners + coreElements.passwordInput.on( 'input', () => validatePassword() ); + coreElements.generatePasswordButton.on( 'click', () => validatePassword() ); /** * @@ -88,21 +70,21 @@ jQuery( document ).ready( function ( $ ) { coreElements.strengthMeter.hide(); // passwordValidationStatus loading state - Object.values( validationItems ).forEach( ( { icon, text } ) => { + Object.values( validationChecklistItems ).forEach( ( { icon, text } ) => { icon.attr( 'src', jetpackData.loadingIcon ); icon.attr( 'alt', 'Validating...' ); text.css( { color: '#3C434A', transition: 'color 0.2s ease-in-out' } ); } ); // strengthMeter loading state - strength.text( 'Validating...' ); - jetpackBranding.show(); - strengthMeter.css( 'background-color', '#8C8F94' ); + strengthMeterItems.strength.text( 'Validating...' ); + strengthMeterItems.jetpackBranding.show(); + strengthMeterItems.strengthMeterWrapper.css( 'background-color', '#8C8F94' ); coreElements.passwordInput.css( { 'border-color': '#8C8F94', 'border-radius': '4px 4px 0px 0px', } ); - strengthMeter.show(); + strengthMeterItems.strengthMeterWrapper.show(); passwordValidationStatus.show(); // Disable submit buttons while validating @@ -133,7 +115,7 @@ jQuery( document ).ready( function ( $ ) { Object.entries( response.data.state ).forEach( ( [ key, item ] ) => { const isInvalid = item.status; - const { icon, text, item: listItem } = validationItems[ key ] || {}; + const { icon, text, item: listItem } = validationChecklistItems[ key ] || {}; if ( ! icon || ! text ) return; @@ -165,7 +147,7 @@ jQuery( document ).ready( function ( $ ) { } ); } else { // TODO: Restore core strength meter state, show error? - strengthMeter.hide(); + strengthMeterItems.strengthMeterWrapper.hide(); passwordValidationStatus.hide(); coreElements.passwordInput.removeAttr( 'style' ); coreElements.strengthMeter.show(); @@ -174,7 +156,7 @@ jQuery( document ).ready( function ( $ ) { error: function ( jqXHR, textStatus ) { if ( textStatus !== 'abort' ) { // TODO: Restore core strength meter state, show error? - strengthMeter.hide(); + strengthMeterItems.strengthMeterWrapper.hide(); passwordValidationStatus.hide(); coreElements.passwordInput.removeAttr( 'style' ); coreElements.strengthMeter.show(); @@ -195,9 +177,9 @@ jQuery( document ).ready( function ( $ ) { let finalStrengthText = ''; if ( passwordIsEmpty ) { - strength.text( '' ); - jetpackBranding.hide(); - strengthMeter.css( 'background-color', 'transparent' ); + strengthMeterItems.strength.text( '' ); + strengthMeterItems.jetpackBranding.hide(); + strengthMeterItems.strengthMeterWrapper.css( 'background-color', 'transparent' ); passwordValidationStatus.hide(); coreElements.passwordInput.css( { 'border-color': '#8c8f94', 'border-radius': '4px' } ); @@ -233,31 +215,33 @@ jQuery( document ).ready( function ( $ ) { } } - strength.text( finalStrengthText ); - strengthMeter.css( 'background-color', finalColor ); + strengthMeterItems.strength.text( finalStrengthText ); + strengthMeterItems.strengthMeterWrapper.css( 'background-color', finalColor ); coreElements.passwordInput.css( { 'border-color': finalColor, 'border-radius': '4px 4px 0px 0px', } ); - if ( ! strengthMeter.find( strength ).length ) { - strengthMeter.append( strength ); + if ( ! strengthMeterItems.strengthMeterWrapper.find( strengthMeterItems.strength ).length ) { + strengthMeterItems.strengthMeterWrapper.append( strengthMeterItems.strength ); } - if ( ! strengthMeter.find( jetpackBranding ).length ) { - strengthMeter.append( jetpackBranding ); + if ( + ! strengthMeterItems.strengthMeterWrapper.find( strengthMeterItems.jetpackBranding ).length + ) { + strengthMeterItems.strengthMeterWrapper.append( strengthMeterItems.jetpackBranding ); } - if ( ! strengthMeter.parent().length ) { - coreElements.passwordInput.after( strengthMeter ); + if ( ! strengthMeterItems.strengthMeterWrapper.parent().length ) { + coreElements.passwordInput.after( strengthMeterItems.strengthMeterWrapper ); } - strengthMeter.show(); + strengthMeterItems.strengthMeterWrapper.show(); passwordValidationStatus.show(); } /** - * Generate and append the initial validation state + * Generate and append the initial validation checklist state */ - function generateAndAppendValidationInitialState() { + function generateAndAppendValidationChecklistInitialState() { Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { const listItem = $( '

                        • ', { class: 'validation-item', 'data-key': key } ); @@ -277,7 +261,7 @@ jQuery( document ).ready( function ( $ ) { } ); let infoIconPopover = null; - if ( userSpecific && value.info ) { + if ( Boolean( jetpackData.userSpecific ) && value.info ) { infoIconPopover = $( '
                          ', { class: 'info-popover' } ); const infoIcon = $( '', { src: jetpackData.infoIcon, @@ -301,11 +285,37 @@ jQuery( document ).ready( function ( $ ) { listItem.append( validationIcon, validationCheckListItemText, infoIconPopover ); validationCheckList.append( listItem ); - validationItems[ key ] = { + validationChecklistItems[ key ] = { icon: validationIcon, text: validationCheckListItemText, item: listItem, }; } ); } + + /** + * Generate and append the initial strength meter state + */ + function generateAndAppendStrengthMeterInitialState() { + const strengthMeterWrapper = $( '
                          ', { + class: 'strength-meter', + } ); + + const strength = $( '

                          ', { + class: 'strength', + text: 'Validating...', + } ); + + const jetpackBranding = $( '

                          ', { class: 'branding' } ).append( + $( '

                          ', { class: 'powered-by', text: 'Powered by ' } ), + $( '', { src: jetpackData.logo, alt: 'Jetpack Logo' } ) + ); + + strengthMeterWrapper.append( strength, jetpackBranding ); + validationCheckList.before( strengthMeterWrapper ); + + strengthMeterItems.strengthMeterWrapper = strengthMeterWrapper; + strengthMeterItems.strength = strength; + strengthMeterItems.jetpackBranding = jetpackBranding; + } } ); From 5db6af0033dfc685693d34009cb531ba86b1d19f Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 7 Feb 2025 12:23:24 -0800 Subject: [PATCH 25/29] Optimization and reorg improvements --- .../src/css/strength-meter.css | 2 +- ...etpack-password-strength-meter-original.js | 424 --------------- .../src/js/jetpack-password-strength-meter.js | 506 +++++++++--------- 3 files changed, 261 insertions(+), 671 deletions(-) delete mode 100644 projects/packages/account-protection/src/js/jetpack-password-strength-meter-original.js diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css index 79663f45139b8..6e97317c628e2 100644 --- a/projects/packages/account-protection/src/css/strength-meter.css +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -16,7 +16,7 @@ height: 24px; } - .validation-text { + .validation-message { margin-top: 0; } diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter-original.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter-original.js deleted file mode 100644 index 4fe5b19802d8a..0000000000000 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter-original.js +++ /dev/null @@ -1,424 +0,0 @@ -/* global jQuery, jetpackData */ - -jQuery( document ).ready( function ( $ ) { - const generatePasswordButton = $( '.wp-generate-pw' ); - const weakPasswordConfirmation = $( '.pw-weak' ); - const weakPasswordConfirmationCheckbox = - weakPasswordConfirmation.find( 'input[type="checkbox"]' ); - const updateProfileFormSubmitButton = $( '#submit' ); - const resetPasswordFormSaveButton = $( '#wp-submit' ); - - // TODO: Non JS form flashes momentarily, we should hide it initially to avoid UI awkwardness - - const passwordInput = $( '#pass1' ); - passwordInput.css( { 'border-color': '#8C8F94' } ); - - const coreStrengthMeter = $( '#pass-strength-result' ); - coreStrengthMeter.hide(); - - const passwordValidationStatus = $( '

                          ', { - id: 'password-validation-status', - css: { - width: 'fit-content', - margin: 'auto', - }, - } ); - - const userSpecific = Boolean( jetpackData.userSpecific ); - - const validationCheckList = $( '
                            ', { - css: { - display: 'flex', - 'flex-direction': 'column', - gap: '4px', - 'margin-bottom': '16px', - }, - } ); - - const validationItems = {}; - - Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { - const listItem = $( '
                          • ', { - css: { - display: 'contains_backslash' === key ? 'none' : 'flex', - 'align-items': 'center', - gap: '8px', - }, - 'data-key': key, - } ); - - const validationIcon = $( '', { - src: jetpackData.loadingIcon, - alt: 'Validating...', - css: { - height: '24px', - }, - } ); - - const validationCheckListItemText = $( '

                            ', { - text: value.message, - css: { - 'margin-top': '0', - }, - } ); - - let infoIconPopover = null; - if ( userSpecific && value.info ) { - infoIconPopover = $( '

                            ', { - css: { - position: 'relative', - display: 'inline-block', - height: '20px', - }, - } ); - const infoIcon = $( '', { - src: jetpackData.infoIcon, - alt: 'Info', - css: { - height: '20px', - cursor: 'pointer', - }, - } ); - - const popover = $( '
                            ', { - text: value.info, - css: { - display: 'none', - position: 'absolute', - bottom: '30px', - left: '50%', - transform: 'translateX(-50%)', - background: '#333', - color: '#fff', - padding: '6px 10px', - 'border-radius': '4px', - 'white-space': 'normal', - width: '200px', - 'font-size': '12px', - 'box-shadow': '0px 4px 6px rgba(0, 0, 0, 0.1)', - 'z-index': 10, - 'text-align': 'center', - }, - } ); - - const popoverArrow = $( '
                            ', { - css: { - position: 'absolute', - bottom: '-6px', - left: '50%', - transform: 'translateX(-50%)', - 'border-left': '6px solid transparent', - 'border-right': '6px solid transparent', - 'border-top': '6px solid #333', - }, - } ); - - popover.append( popoverArrow ); - - infoIcon.hover( - function () { - popover.fadeIn( 200 ); - }, - function () { - popover.fadeOut( 200 ); - } - ); - - infoIconPopover.append( infoIcon ); - infoIconPopover.append( popover ); - } - - listItem.append( validationIcon ); - listItem.append( validationCheckListItemText ); - if ( infoIconPopover ) { - listItem.append( infoIconPopover ); - } - validationCheckList.append( listItem ); - - validationItems[ key ] = { - icon: validationIcon, - text: validationCheckListItemText, - item: listItem, - }; - } ); - - passwordValidationStatus.append( validationCheckList ); - passwordInput.after( passwordValidationStatus ); - - const strengthMeter = $( '
                            ', { - css: { - display: 'flex', - 'justify-content': 'space-between', - 'align-items': 'center', - height: '30px', - padding: '0px 16px', - 'margin-bottom': '16px', - 'border-radius': '0px 0px 4px 4px', - 'background-color': '#8C8F94', - }, - } ); - - if ( userSpecific ) { - strengthMeter.css( { 'margin-left': '1px', 'margin-right': '1px' } ); - } - - const strength = $( '

                            ', { - text: 'Validating...', - css: { - display: 'flex', - 'align-items': 'center', - 'font-size': '12px', - 'font-weight': 'bold', - color: '#1D2327', - margin: '0', - }, - } ); - - const jetpackBranding = $( '

                            ', { - css: { - display: 'flex', - 'align-items': 'center', - gap: '4px', - }, - } ); - - const brandingMessage = $( '

                            ', { - text: 'Powered by ', - css: { - 'font-size': '12px', - color: '#1D2327', - margin: '0', - }, - } ); - - const jetpackLogo = $( '', { - src: jetpackData.logo, - alt: 'Jetpack Logo', - css: { - height: '18px', - }, - } ); - - jetpackBranding.append( brandingMessage ); - jetpackBranding.append( jetpackLogo ); - strengthMeter.append( strength ); - strengthMeter.append( jetpackBranding ); - passwordInput.after( strengthMeter ); - - passwordInput.on( 'input', () => validatePassword() ); - - setTimeout( () => { - if ( passwordInput && passwordInput.val() && passwordInput.val().length > 0 ) { - validatePassword(); - } - }, 1500 ); - - generatePasswordButton.on( 'click', () => validatePassword( 'on password generation' ) ); - - let currentAjaxRequest = null; - - /** - * - * Validate the current password input - * - */ - function validatePassword() { - const currentPasswordInput = passwordInput.val(); - const failedValidationConditions = {}; - - if ( currentAjaxRequest ) { - currentAjaxRequest.abort(); - currentAjaxRequest = null; - } - - if ( ! currentPasswordInput || currentPasswordInput.trim().length === 0 ) { - applyStyling( failedValidationConditions, true ); - return; - } - - if ( coreStrengthMeter.is( ':visible' ) ) { - coreStrengthMeter.hide(); - } - - // passwordValidationStatus loading state - Object.values( validationItems ).forEach( ( { icon, text } ) => { - icon.attr( 'src', jetpackData.loadingIcon ); - icon.attr( 'alt', 'Validating...' ); - text.css( { color: '#3C434A', transition: 'color 0.2s ease-in-out' } ); - } ); - - // strengthMeter loading state - strength.text( 'Validating...' ); - jetpackBranding.show(); - strengthMeter.css( 'background-color', '#8C8F94' ); - passwordInput.css( { 'border-color': '#8C8F94', 'border-radius': '4px 4px 0px 0px' } ); - strengthMeter.show(); - passwordValidationStatus.show(); - - // Disable submit buttons while validating - if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', true ); - } - - if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', true ); - } - - const uiUpdates = []; - - currentAjaxRequest = $.ajax( { - url: jetpackData.ajaxurl, - type: 'POST', - data: { - action: 'validate_password_ajax', - nonce: jetpackData.nonce, - password: currentPasswordInput, - user_specific: jetpackData.userSpecific, - }, - success: function ( response ) { - currentAjaxRequest = null; - - if ( response.success ) { - // Manually update core strength meter status - const coreStrengthMeterClass = coreStrengthMeter.attr( 'class' ) || ''; - response.data.state.core.status = ! ( - coreStrengthMeterClass.includes( 'strong' ) || coreStrengthMeterClass.includes( 'good' ) - ); - - Object.entries( response.data.state ).forEach( ( [ key, item ] ) => { - const isInvalid = item.status; - const { icon, text, item: listItem } = validationItems[ key ] || {}; - - if ( ! icon || ! text ) return; - - if ( key === 'contains_backslash' ) { - listItem.css( 'display', isInvalid ? 'flex' : 'none' ); - } - - uiUpdates.push( () => { - icon.attr( 'src', isInvalid ? jetpackData.crossIcon : jetpackData.checkIcon ); - icon.attr( 'alt', isInvalid ? 'Jetpack Cross' : 'Jetpack Check' ); - text.css( { - color: isInvalid ? '#E65054' : '#008710', - transition: 'color 0.2s ease-in-out', - } ); - } ); - - if ( isInvalid ) { - failedValidationConditions[ key ] = isInvalid; - } - } ); - - requestAnimationFrame( () => { - uiUpdates.forEach( update => update() ); - - validationCheckList.css( 'opacity', 0.99 ); - setTimeout( () => validationCheckList.css( 'opacity', 1 ), 1 ); - - applyStyling( failedValidationConditions ); - } ); - } else { - // TODO: Restore core strength meter state, show error? - strengthMeter.hide(); - passwordValidationStatus.hide(); - passwordInput.removeAttr( 'style' ); - coreStrengthMeter.show(); - } - }, - error: function ( jqXHR, textStatus ) { - if ( textStatus !== 'abort' ) { - // TODO: Restore core strength meter state, show error? - strengthMeter.hide(); - passwordValidationStatus.hide(); - passwordInput.removeAttr( 'style' ); - coreStrengthMeter.show(); - } - }, - } ); - } - - /** - * - * Apply styling based on validation results - * - * @param {object} failedValidationConditions - Object containing failed validation conditions - * @param {boolean} passwordIsEmpty - Whether the password input is empty - */ - function applyStyling( failedValidationConditions, passwordIsEmpty = false ) { - let finalColor = '#8c8f94'; - let finalStrengthText = ''; - - if ( passwordIsEmpty ) { - strength.text( '' ); - jetpackBranding.hide(); - strengthMeter.css( 'background-color', 'transparent' ); - passwordValidationStatus.hide(); - passwordInput.css( { 'border-color': '#8c8f94', 'border-radius': '4px' } ); - - return; - } - - if ( 0 === Object.keys( failedValidationConditions ).length ) { - finalColor = '#64CA43'; - finalStrengthText = 'Strong'; - - if ( weakPasswordConfirmation.is( ':visible' ) ) { - weakPasswordConfirmation.css( 'display', 'none' ); - } - - if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', false ); - } - - if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', false ); - } - } else { - finalColor = '#E65054'; - finalStrengthText = 'Weak'; - - if ( weakPasswordConfirmation.css( 'display' ) === 'none' ) { - weakPasswordConfirmation.css( 'display', userSpecific ? 'table-row' : 'block' ); - } - - if ( weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { - if ( updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', false ); - } - - if ( resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', false ); - } - } else { - if ( ! updateProfileFormSubmitButton.prop( 'disabled' ) ) { - updateProfileFormSubmitButton.prop( 'disabled', true ); - } - - if ( ! resetPasswordFormSaveButton.prop( 'disabled' ) ) { - resetPasswordFormSaveButton.prop( 'disabled', true ); - } - } - } - - strength.text( finalStrengthText ); - strengthMeter.css( 'background-color', finalColor ); - passwordInput.css( { 'border-color': finalColor, 'border-radius': '4px 4px 0px 0px' } ); - - if ( ! strengthMeter.find( strength ).length ) { - strengthMeter.append( strength ); - } - if ( ! strengthMeter.find( jetpackBranding ).length ) { - strengthMeter.append( jetpackBranding ); - } - if ( ! strengthMeter.parent().length ) { - passwordInput.after( strengthMeter ); - } - - if ( strengthMeter.is( ':hidden' ) ) { - strengthMeter.show(); - } - if ( passwordValidationStatus.is( ':hidden' ) ) { - passwordValidationStatus.show(); - } - } -} ); diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index bb9442da7107a..75fc294306e8a 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -1,98 +1,153 @@ /* global jQuery, jetpackData */ jQuery( document ).ready( function ( $ ) { - const coreElements = { - generatePasswordButton: $( '.wp-generate-pw' ), - passwordInputWrapper: $( '.user-pass1-wrap' ), - passwordInput: $( '#pass1' ), - strengthMeter: $( '#pass-strength-result' ), - weakPasswordConfirmation: $( '.pw-weak' ), - weakPasswordConfirmationCheckbox: $( '.pw-weak input[type="checkbox"]' ), - updateFormSubmitButton: $( '#submit' ), - createUserFormSubmitButton: $( '#createusersub' ), - resetFormSaveButton: $( '#wp-submit' ), + const UIComponents = { + core: { + passwordInputWrapper: $( '.user-pass1-wrap' ), + passwordInput: $( '#pass1' ), + passwordStrengthResults: $( '#pass-strength-result' ), + weakPasswordConfirmation: $( '.pw-weak' ), + weakPasswordConfirmationCheckbox: $( '.pw-weak input[type="checkbox"]' ), + submitButtons: $( '#submit, #createusersub, #wp-submit' ), + }, + passwordValidationStatus: $( '

                            ', { id: 'password-validation-status' } ), + validationCheckList: $( '
                              ', { class: 'validation-checklist' } ), + strengthMeter: {}, + validationChecklistItems: {}, }; - // TODO: Non JS form flashes momentarily, we should hide it initially to avoid UI awkwardness + let currentAjaxRequest = null; - coreElements.passwordInputWrapper.css( { - 'margin-bottom': '16px', - } ); - coreElements.passwordInput.css( { - 'border-color': '#8C8F94', - 'border-radius': '4px 4px 0 0', - margin: '0', - } ); - coreElements.strengthMeter.hide(); + // TODO: Investigate UI awkwardness + initializeUI(); + bindEvents(); - const passwordValidationStatus = $( '
                              ', { id: 'password-validation-status' } ); - coreElements.passwordInput.after( passwordValidationStatus ); - const validationCheckList = $( '
                                ', { class: 'validation-checklist' } ); - passwordValidationStatus.append( validationCheckList ); + /** + * Apply initial UI structure and styling + */ + function initializeUI() { + const { passwordInputWrapper, passwordInput, passwordStrengthResults } = UIComponents.core; - let currentAjaxRequest = null; - const strengthMeterItems = {}; - const validationChecklistItems = {}; + passwordInputWrapper.css( { + 'margin-bottom': '16px', + } ); + passwordInput.css( { + 'border-color': '#8C8F94', + 'border-radius': '4px 4px 0 0', + margin: '0', + } ); + passwordStrengthResults.hide(); + passwordInput.after( UIComponents.passwordValidationStatus ); + UIComponents.passwordValidationStatus.append( UIComponents.validationCheckList ); - generateAndAppendStrengthMeterInitialState(); - generateAndAppendValidationChecklistInitialState(); + initializeStrengthMeter(); + initializeValidationChecklist(); + } - // Initial password auto-population check - setTimeout( () => { - if ( coreElements.passwordInput?.val()?.length ) { - validatePassword(); - } - }, 1000 ); // TODO: This might not always catching the initial password value + /** + * Generate and append the initial strength meter state + */ + function initializeStrengthMeter() { + const strengthMeterWrapper = $( '
                                ', { + class: 'strength-meter', + } ); + + const strengthText = $( '

                                ', { + class: 'strength', + text: 'Validating...', + } ); + + const branding = $( '

                                ', { class: 'branding' } ).append( + $( '

                                ', { class: 'powered-by', text: 'Powered by ' } ), + $( '', { src: jetpackData.logo, alt: 'Jetpack Logo' } ) + ); - // Event listeners - coreElements.passwordInput.on( 'input', () => validatePassword() ); - coreElements.generatePasswordButton.on( 'click', () => validatePassword() ); + strengthMeterWrapper.append( strengthText, branding ); + UIComponents.validationCheckList.before( strengthMeterWrapper ); + + UIComponents.strengthMeter = { + wrapper: strengthMeterWrapper, + text: strengthText, + branding, + }; + } + + /** + * Generate and append the initial validation checklist state + */ + function initializeValidationChecklist() { + Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { + const listItem = $( '

                              • ', { class: 'validation-item', 'data-key': key } ); + + if ( key === 'contains_backslash' ) { + listItem.hide(); + } + + const validationIcon = $( '', { + src: jetpackData.loadingIcon, + alt: 'Validating...', + class: 'validation-icon', + } ); + + const validationMessage = $( '

                                ', { + text: value.message, + class: 'validation-message', + } ); + + let infoIconPopover = null; + if ( value.info ) { + infoIconPopover = createInfoIconPopover( value.info ); + } + + listItem.append( validationIcon, validationMessage, infoIconPopover ); + UIComponents.validationCheckList.append( listItem ); + + UIComponents.validationChecklistItems[ key ] = { + icon: validationIcon, + text: validationMessage, + item: listItem, + }; + } ); + } + + /** + * Bind events to the UI components + */ + function bindEvents() { + const { passwordInput } = UIComponents.core; + + passwordInput.on( 'input', () => validatePassword() ); + $( '.wp-generate-pw' ).on( 'click', validatePassword ); + + // TODO: Ensure this captures auto-population every time + setTimeout( () => { + if ( passwordInput.val() && passwordInput.val().length ) validatePassword(); + }, 1500 ); + } /** - * * Validate the current password input - * */ function validatePassword() { - const currentPasswordInput = coreElements.passwordInput.val(); - const failedValidationConditions = {}; + const { passwordInput, passwordStrengthResults } = UIComponents.core; + + const password = passwordInput.val(); if ( currentAjaxRequest ) { - currentAjaxRequest.abort(); + const oldRequest = currentAjaxRequest; currentAjaxRequest = null; + oldRequest.abort(); } - if ( ! currentPasswordInput?.trim() ) { - applyStyling( failedValidationConditions, true ); + if ( ! password?.trim() ) { + applyStyling( [], true ); return; } - coreElements.strengthMeter.hide(); - - // passwordValidationStatus loading state - Object.values( validationChecklistItems ).forEach( ( { icon, text } ) => { - icon.attr( 'src', jetpackData.loadingIcon ); - icon.attr( 'alt', 'Validating...' ); - text.css( { color: '#3C434A', transition: 'color 0.2s ease-in-out' } ); - } ); - - // strengthMeter loading state - strengthMeterItems.strength.text( 'Validating...' ); - strengthMeterItems.jetpackBranding.show(); - strengthMeterItems.strengthMeterWrapper.css( 'background-color', '#8C8F94' ); - coreElements.passwordInput.css( { - 'border-color': '#8C8F94', - 'border-radius': '4px 4px 0px 0px', - } ); - strengthMeterItems.strengthMeterWrapper.show(); - passwordValidationStatus.show(); - - // Disable submit buttons while validating - coreElements.updateFormSubmitButton.prop( 'disabled', true ); - coreElements.createUserFormSubmitButton.prop( 'disabled', true ); - coreElements.resetFormSaveButton.prop( 'disabled', true ); + // Ensure core strength meter is hidden + passwordStrengthResults.hide(); - const uiUpdates = []; + renderLoadingState(); currentAjaxRequest = $.ajax( { url: jetpackData.ajaxurl, @@ -100,222 +155,181 @@ jQuery( document ).ready( function ( $ ) { data: { action: 'validate_password_ajax', nonce: jetpackData.nonce, - password: currentPasswordInput, + password: password, user_specific: jetpackData.userSpecific, }, - success: function ( response ) { - currentAjaxRequest = null; - - if ( response.success ) { - // Manually update core strength meter status - const coreStrengthMeterClass = coreElements.strengthMeter.attr( 'class' ) || ''; - response.data.state.core.status = ! ( - coreStrengthMeterClass.includes( 'strong' ) || coreStrengthMeterClass.includes( 'good' ) - ); - - Object.entries( response.data.state ).forEach( ( [ key, item ] ) => { - const isInvalid = item.status; - const { icon, text, item: listItem } = validationChecklistItems[ key ] || {}; - - if ( ! icon || ! text ) return; - - if ( key === 'contains_backslash' ) { - listItem.css( 'display', isInvalid ? 'flex' : 'none' ); - } - - uiUpdates.push( () => { - icon.attr( 'src', isInvalid ? jetpackData.crossIcon : jetpackData.checkIcon ); - icon.attr( 'alt', isInvalid ? 'Jetpack Cross' : 'Jetpack Check' ); - text.css( { - color: isInvalid ? '#E65054' : '#008710', - transition: 'color 0.2s ease-in-out', - } ); - } ); - - if ( isInvalid ) { - failedValidationConditions[ key ] = isInvalid; - } - } ); - - requestAnimationFrame( () => { - uiUpdates.forEach( update => update() ); - - validationCheckList.css( 'opacity', 0.99 ); - setTimeout( () => validationCheckList.css( 'opacity', 1 ), 1 ); - - applyStyling( failedValidationConditions ); - } ); - } else { - // TODO: Restore core strength meter state, show error? - strengthMeterItems.strengthMeterWrapper.hide(); - passwordValidationStatus.hide(); - coreElements.passwordInput.removeAttr( 'style' ); - coreElements.strengthMeter.show(); - } - }, - error: function ( jqXHR, textStatus ) { - if ( textStatus !== 'abort' ) { - // TODO: Restore core strength meter state, show error? - strengthMeterItems.strengthMeterWrapper.hide(); - passwordValidationStatus.hide(); - coreElements.passwordInput.removeAttr( 'style' ); - coreElements.strengthMeter.show(); - } - }, + success: handleValidationResponse, + error: handleValidationError, } ); } /** - * - * Apply styling based on validation results - * - * @param {object} failedValidationConditions - Object containing failed validation conditions - * @param {boolean} passwordIsEmpty - Whether the password input is empty + * Handles the password validation response. + * @param {object} response - The response object. */ - function applyStyling( failedValidationConditions, passwordIsEmpty = false ) { - let finalColor = '#8c8f94'; - let finalStrengthText = ''; + function handleValidationResponse( response ) { + currentAjaxRequest = null; - if ( passwordIsEmpty ) { - strengthMeterItems.strength.text( '' ); - strengthMeterItems.jetpackBranding.hide(); - strengthMeterItems.strengthMeterWrapper.css( 'background-color', 'transparent' ); - passwordValidationStatus.hide(); - coreElements.passwordInput.css( { 'border-color': '#8c8f94', 'border-radius': '4px' } ); + if ( response.success ) { + const failedValidationConditions = updateValidationChecklist( response.data.state ); + applyStyling( failedValidationConditions ); + } else { + restoreCoreStrengthMeter(); + } + } - return; + /** + * Handles validation errors. + * @param {object} jqXHR - The jqXHR object. + * @param {any} textStatus - The status of the request. + */ + function handleValidationError( jqXHR, textStatus ) { + if ( textStatus !== 'abort' ) { + restoreCoreStrengthMeter(); } + } - if ( 0 === Object.keys( failedValidationConditions ).length ) { - finalColor = '#64CA43'; - finalStrengthText = 'Strong'; + /** + * Updates the validation checklist based on the response data. + * + * @param {object} state - The validation state. + * @return {object} - The failed conditions. + */ + function updateValidationChecklist( state ) { + const failedConditions = []; + + // Manually update core strength meter status + const corePasswordStrengthResultsClass = + UIComponents.core.passwordStrengthResults.attr( 'class' ) || ''; + const coreValidationFailed = ! ( + corePasswordStrengthResultsClass.includes( 'strong' ) || + corePasswordStrengthResultsClass.includes( 'good' ) + ); - coreElements.weakPasswordConfirmation.css( 'display', 'none' ); - coreElements.updateFormSubmitButton.prop( 'disabled', false ); - coreElements.createUserFormSubmitButton.prop( 'disabled', false ); - coreElements.resetFormSaveButton.prop( 'disabled', false ); - } else { - finalColor = '#E65054'; - finalStrengthText = 'Weak'; + Object.entries( state ).forEach( ( [ key, item ] ) => { + const validationFailed = key === 'core' ? coreValidationFailed : item.status; + const checklistItem = UIComponents.validationChecklistItems[ key ]; - if ( coreElements.weakPasswordConfirmation.css( 'display' ) === 'none' ) { - coreElements.weakPasswordConfirmation.css( { - display: 'table-row', - } ); + if ( key === 'contains_backslash' ) { + checklistItem.item.css( 'display', validationFailed ? 'flex' : 'none' ); } - if ( coreElements.weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { - coreElements.updateFormSubmitButton.prop( 'disabled', false ); - coreElements.createUserFormSubmitButton.prop( 'disabled', false ); - coreElements.resetFormSaveButton.prop( 'disabled', false ); - } else { - coreElements.updateFormSubmitButton.prop( 'disabled', true ); - coreElements.createUserFormSubmitButton.prop( 'disabled', true ); - coreElements.resetFormSaveButton.prop( 'disabled', true ); + if ( checklistItem ) { + checklistItem.icon.attr( + 'src', + validationFailed ? jetpackData.crossIcon : jetpackData.checkIcon + ); + checklistItem.icon.attr( 'alt', validationFailed ? 'Invalid' : 'Valid' ); + checklistItem.text.css( { color: validationFailed ? '#E65054' : '#008710' } ); } - } - strengthMeterItems.strength.text( finalStrengthText ); - strengthMeterItems.strengthMeterWrapper.css( 'background-color', finalColor ); - coreElements.passwordInput.css( { - 'border-color': finalColor, - 'border-radius': '4px 4px 0px 0px', + if ( validationFailed ) failedConditions.push( key ); } ); - if ( ! strengthMeterItems.strengthMeterWrapper.find( strengthMeterItems.strength ).length ) { - strengthMeterItems.strengthMeterWrapper.append( strengthMeterItems.strength ); - } - if ( - ! strengthMeterItems.strengthMeterWrapper.find( strengthMeterItems.jetpackBranding ).length - ) { - strengthMeterItems.strengthMeterWrapper.append( strengthMeterItems.jetpackBranding ); - } - if ( ! strengthMeterItems.strengthMeterWrapper.parent().length ) { - coreElements.passwordInput.after( strengthMeterItems.strengthMeterWrapper ); - } - - strengthMeterItems.strengthMeterWrapper.show(); - passwordValidationStatus.show(); + return failedConditions; } /** - * Generate and append the initial validation checklist state + * + * Apply styling based on validation results + * + * @param {Array} failedValidationConditions - Array containing failed validation conditions keys + * @param {boolean} passwordIsEmpty - Whether the password input is empty */ - function generateAndAppendValidationChecklistInitialState() { - Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => { - const listItem = $( '

                              • ', { class: 'validation-item', 'data-key': key } ); - - if ( key === 'contains_backslash' ) { - listItem.hide(); - } + function applyStyling( failedValidationConditions, passwordIsEmpty = false ) { + if ( passwordIsEmpty ) { + renderEmptyInputState(); + return; + } - const validationIcon = $( '', { - src: jetpackData.loadingIcon, - alt: 'Validating...', - class: 'validation-icon', - } ); + const isPasswordStrong = failedValidationConditions.length === 0; + const color = isPasswordStrong ? '#64CA43' : '#E65054'; + const strengthText = isPasswordStrong ? 'Strong' : 'Weak'; - const validationCheckListItemText = $( '

                                ', { - text: value.message, - class: 'validation-text', - } ); + const { + weakPasswordConfirmation, + weakPasswordConfirmationCheckbox, + submitButtons, + passwordInput, + } = UIComponents.core; + const { wrapper, text } = UIComponents.strengthMeter; - let infoIconPopover = null; - if ( Boolean( jetpackData.userSpecific ) && value.info ) { - infoIconPopover = $( '

                                ', { class: 'info-popover' } ); - const infoIcon = $( '', { - src: jetpackData.infoIcon, - alt: 'Info', - class: 'info-icon', - } ); - - const popover = $( '
                                ', { - text: value.info, - class: 'popover', - } ).append( $( '
                                ', { class: 'popover-arrow' } ) ); - - infoIcon.hover( - () => popover.fadeIn( 200 ), - () => popover.fadeOut( 200 ) - ); + if ( isPasswordStrong || weakPasswordConfirmationCheckbox.prop( 'checked' ) ) { + submitButtons.prop( 'disabled', false ); + } else { + submitButtons.prop( 'disabled', true ); + } - infoIconPopover.append( infoIcon, popover ); - } + weakPasswordConfirmation.css( 'display', isPasswordStrong ? 'none' : 'table-row' ); - listItem.append( validationIcon, validationCheckListItemText, infoIconPopover ); - validationCheckList.append( listItem ); + text.text( strengthText ); + wrapper.css( 'background-color', color ); - validationChecklistItems[ key ] = { - icon: validationIcon, - text: validationCheckListItemText, - item: listItem, - }; + passwordInput.css( { + 'border-color': color, + 'border-radius': '4px 4px 0px 0px', } ); + + UIComponents.passwordValidationStatus.show(); } /** - * Generate and append the initial strength meter state + * Render the empty input state */ - function generateAndAppendStrengthMeterInitialState() { - const strengthMeterWrapper = $( '
                                ', { - class: 'strength-meter', + function renderEmptyInputState() { + UIComponents.passwordValidationStatus.hide(); + UIComponents.core.passwordInput.removeAttr( 'style' ); + } + + /** + * Render the loading state + */ + function renderLoadingState() { + const { passwordInput, weakPasswordConfirmation, submitButtons } = UIComponents.core; + submitButtons.prop( 'disabled', true ); + weakPasswordConfirmation.hide(); + + Object.values( UIComponents.validationChecklistItems ).forEach( ( { icon, text } ) => { + icon.attr( 'src', jetpackData.loadingIcon ); + icon.attr( 'alt', 'Validating...' ); + text.css( { color: '#3C434A', transition: 'color 0.2s ease-in-out' } ); } ); - const strength = $( '

                                ', { - class: 'strength', - text: 'Validating...', + UIComponents.strengthMeter.text.text( 'Validating...' ); + UIComponents.strengthMeter.wrapper.css( 'background-color', '#8C8F94' ); + passwordInput.css( { + 'border-color': '#8C8F94', + 'border-radius': '4px 4px 0px 0px', } ); - const jetpackBranding = $( '

                                ', { class: 'branding' } ).append( - $( '

                                ', { class: 'powered-by', text: 'Powered by ' } ), - $( '', { src: jetpackData.logo, alt: 'Jetpack Logo' } ) + UIComponents.passwordValidationStatus.show(); + } + + /** + * Resets UI to core strength meter. + */ + function restoreCoreStrengthMeter() { + renderEmptyInputState(); + UIComponents.core.passwordStrengthResults.show(); + } + + /** + * Creates an info popover element. + * + * @param {string} infoText - The text to display in the popover. + * @return {jQuery} - The info popover element. + */ + function createInfoIconPopover( infoText ) { + const infoIcon = $( '', { src: jetpackData.infoIcon, alt: 'Info', class: 'info-icon' } ); + const popover = $( '

                                ', { text: infoText, class: 'popover' } ).append( + $( '
                                ', { class: 'popover-arrow' } ) ); - strengthMeterWrapper.append( strength, jetpackBranding ); - validationCheckList.before( strengthMeterWrapper ); + infoIcon.hover( + () => popover.fadeIn( 200 ), + () => popover.fadeOut( 200 ) + ); - strengthMeterItems.strengthMeterWrapper = strengthMeterWrapper; - strengthMeterItems.strength = strength; - strengthMeterItems.jetpackBranding = jetpackBranding; + return $( '
                                ', { class: 'info-popover' } ).append( infoIcon, popover ); } } ); From f3d5c462d08710ee48391908a347bbc96a423f3f Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 7 Feb 2025 12:27:32 -0800 Subject: [PATCH 26/29] Remove todos --- .../account-protection/src/js/jetpack-password-strength-meter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js index 75fc294306e8a..c8dda2223e479 100644 --- a/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js +++ b/projects/packages/account-protection/src/js/jetpack-password-strength-meter.js @@ -18,7 +18,6 @@ jQuery( document ).ready( function ( $ ) { let currentAjaxRequest = null; - // TODO: Investigate UI awkwardness initializeUI(); bindEvents(); From 33fcc0b025e64eaa4c0e00aaf032eeb9c78fdc40 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 7 Feb 2025 12:29:23 -0800 Subject: [PATCH 27/29] Remove unneeded comments --- .../packages/account-protection/src/css/strength-meter.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css index 6e97317c628e2..e89d5c6282ed9 100644 --- a/projects/packages/account-protection/src/css/strength-meter.css +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -1,4 +1,3 @@ -/* General Styling */ .validation-checklist { display: flex; flex-direction: column; @@ -19,10 +18,8 @@ .validation-message { margin-top: 0; } - } -/* Info Popover */ .info-popover { position: relative; display: inline-block; @@ -62,7 +59,6 @@ border-top: 6px solid #333; } -/* Strength Meter */ .strength-meter { display: flex; justify-content: space-between; From e0c08b88f83b8cb7cf144f6968cc083058ee58e7 Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 7 Feb 2025 12:35:22 -0800 Subject: [PATCH 28/29] Ensure info popover fits in all form views --- .../account-protection/src/class-validation-service.php | 4 ++-- .../packages/account-protection/src/css/strength-meter.css | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/packages/account-protection/src/class-validation-service.php b/projects/packages/account-protection/src/class-validation-service.php index 88c3d2d9b1753..b6068d775ea06 100644 --- a/projects/packages/account-protection/src/class-validation-service.php +++ b/projects/packages/account-protection/src/class-validation-service.php @@ -76,7 +76,7 @@ public function get_validation_initial_state( $user_specific ): array { 'weak' => array( 'status' => null, 'message' => __( 'Not a leaked password', 'jetpack-account-protection' ), - 'info' => __( 'If found in a public breach, this password may already be known to attackers. Using a unique password improves security.', 'jetpack-account-protection' ), + 'info' => __( 'If found in a public breach, this password may already be known to attackers.', 'jetpack-account-protection' ), ), ); @@ -88,7 +88,7 @@ public function get_validation_initial_state( $user_specific ): array { 'matches_user_data' => array( 'status' => null, 'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ), - 'info' => __( 'Using a password similar to your username or email makes it easier to guess. A unique password is more secure.', 'jetpack-account-protection' ), + 'info' => __( 'Using a password similar to your username or email makes it easier to guess.', 'jetpack-account-protection' ), ), 'recent' => array( 'status' => null, diff --git a/projects/packages/account-protection/src/css/strength-meter.css b/projects/packages/account-protection/src/css/strength-meter.css index e89d5c6282ed9..69d6d7be46a0d 100644 --- a/projects/packages/account-protection/src/css/strength-meter.css +++ b/projects/packages/account-protection/src/css/strength-meter.css @@ -42,7 +42,7 @@ padding: 6px 10px; border-radius: 4px; white-space: normal; - width: 200px; + width: 150px; font-size: 12px; box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); z-index: 10; From c55e5d238d5e4cf5c05e773f70334678400882cb Mon Sep 17 00:00:00 2001 From: dkmyta Date: Fri, 7 Feb 2025 13:07:55 -0800 Subject: [PATCH 29/29] Fix test --- .../tests/php/test-validation-service.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/projects/packages/account-protection/tests/php/test-validation-service.php b/projects/packages/account-protection/tests/php/test-validation-service.php index 782be5cb6cd2a..007156659634d 100644 --- a/projects/packages/account-protection/tests/php/test-validation-service.php +++ b/projects/packages/account-protection/tests/php/test-validation-service.php @@ -197,13 +197,14 @@ public function test_returns_true_if_password_was_recently_used() { } public function test_returns_false_if_password_was_not_recently_used() { - $user_id = 1; - $password_hash = wp_hash_password( 'somepassword' ); + $user = new \WP_User(); + $user->user_pass = wp_hash_password( 'somepassword' ); + $user->ID = 1; - update_user_meta( $user_id, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $password_hash ) ); + update_user_meta( $user->ID, Config::PASSWORD_MANAGER_RECENT_PASSWORD_HASHES_USER_META_KEY, array( $user->user_pass ) ); $validation_service = new Validation_Service( $this->get_connection_manager() ); - $this->assertFalse( $validation_service->is_recent_password( $user_id, 'anotherpassword' ) ); + $this->assertFalse( $validation_service->is_recent_password( $user, 'anotherpassword' ) ); } public function test_returns_true_if_password_matches_user_data() {