From 77428b973ddc0f613f341e8568f8d8043a0c1c1b Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Mon, 15 Jul 2024 16:22:42 +0100 Subject: [PATCH] Checkout: Add password strength meter to new field (#49164) * Add validation message and fix rendering when account is required * Adjust validation so we can change the label in messages with custom callback * Add and style password meter * Add validation feedback and zxcvbn support * changelog * Accessibility fixes * Update lock file * Fix initial state in safari * Announce strength as you type * Use React.ReactElement * update lock file * Update lock * Downgrade local pnpm and recreate lock * Feedback should only be shown when `showError` is true, not `hasError` * Feedback should default to null --- .../block.tsx | 36 ++---- .../create-password.tsx | 68 ++++++++++ .../password-strength-meter/index.tsx | 119 ++++++++++++++++++ .../password-strength-meter/style.scss | 73 +++++++++++ plugins/woocommerce-blocks/package.json | 1 + .../get-validity-message-for-input.ts | 2 +- .../packages/components/text-input/types.ts | 6 +- .../text-input/validated-text-input.tsx | 7 +- .../add-password-field-component-48628 | 4 + .../src/Blocks/BlockTypes/Checkout.php | 9 +- pnpm-lock.yaml | 10 +- 11 files changed, 298 insertions(+), 37 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/create-password.tsx create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/checkout/password-strength-meter/index.tsx create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/checkout/password-strength-meter/style.scss create mode 100644 plugins/woocommerce/changelog/add-password-field-component-48628 diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx index 3202c8a0368..7bd211e3415 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx @@ -11,18 +11,21 @@ import { ContactFormValues, getSetting } from '@woocommerce/settings'; import { StoreNoticesContainer, CheckboxControl, - ValidatedTextInput, } from '@woocommerce/blocks-components'; import { useDispatch, useSelect } from '@wordpress/data'; import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; import { CONTACT_FORM_KEYS } from '@woocommerce/block-settings'; import { Form } from '@woocommerce/base-components/cart-checkout'; +/** + * Internal dependencies + */ +import CreatePassword from './create-password'; + const CreateAccountUI = (): React.ReactElement | null => { - const { customerPassword, shouldCreateAccount } = useSelect( ( select ) => { + const { shouldCreateAccount } = useSelect( ( select ) => { const store = select( CHECKOUT_STORE_KEY ); return { - customerPassword: store.getCustomerPassword(), shouldCreateAccount: store.getShouldCreateAccount(), }; } ); @@ -72,32 +75,7 @@ const CreateAccountUI = (): React.ReactElement | null => { } } /> ) } - { showCreateAccountPassword && ( - { - if ( - validity.valueMissing || - validity.badInput || - validity.typeMismatch - ) { - return __( - 'Please enter a valid password', - 'woocommerce' - ); - } - } } - onChange={ ( value: string ) => - __internalSetCustomerPassword( value ) - } - /> - ) } + { showCreateAccountPassword && } ); }; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/create-password.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/create-password.tsx new file mode 100644 index 00000000000..68e4b2b85df --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/create-password.tsx @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { ValidatedTextInput } from '@woocommerce/blocks-components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; + +/** + * Internal dependencies + */ +import PasswordStrengthMeter from '../../password-strength-meter'; + +const CreatePassword = () => { + const [ passwordStrength, setPasswordStrength ] = useState( 0 ); + const { customerPassword } = useSelect( ( select ) => { + const store = select( CHECKOUT_STORE_KEY ); + return { + customerPassword: store.getCustomerPassword(), + }; + } ); + const { __internalSetCustomerPassword } = useDispatch( CHECKOUT_STORE_KEY ); + + return ( + { + if ( + validity.valueMissing || + validity.badInput || + validity.typeMismatch + ) { + return __( 'Please enter a valid password', 'woocommerce' ); + } + } } + customValidation={ ( inputObject ) => { + if ( passwordStrength < 2 ) { + inputObject.setCustomValidity( + __( 'Please create a stronger password', 'woocommerce' ) + ); + return false; + } + return true; + } } + onChange={ ( value: string ) => + __internalSetCustomerPassword( value ) + } + feedback={ + + setPasswordStrength( strength ) + } + /> + } + /> + ); +}; + +export default CreatePassword; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/password-strength-meter/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/password-strength-meter/index.tsx new file mode 100644 index 00000000000..e37d7b4cd55 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/password-strength-meter/index.tsx @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import { sprintf, __ } from '@wordpress/i18n'; +import { useInstanceId } from '@wordpress/compose'; +import { passwordStrength } from 'check-password-strength'; +import { usePrevious } from '@woocommerce/base-hooks'; +import { useEffect } from '@wordpress/element'; +import clsx from 'clsx'; + +/** + * Internal dependencies + */ +import './style.scss'; + +declare global { + interface Window { + zxcvbn: ( password: string ) => { + score: number; + }; + } +} + +const scoreDescriptions = [ + __( 'Too weak', 'woocommerce' ), + __( 'Weak', 'woocommerce' ), + __( 'Medium', 'woocommerce' ), + __( 'Strong', 'woocommerce' ), + __( 'Very strong', 'woocommerce' ), +]; + +/** + * Renders a password strength meter. + * + * Uses zxcvbn to calculate the password strength if available, otherwise falls back to check-password-strength which + * does not include dictionaries of common passwords. + */ +const PasswordStrengthMeter = ( { + password = '', + onChange, +}: { + password: string; + onChange?: ( strength: number ) => void; +} ): React.ReactElement | null => { + const instanceId = useInstanceId( + PasswordStrengthMeter, + 'woocommerce-password-strength-meter' + ) as string; + + let strength = -1; + + if ( password.length > 0 ) { + if ( typeof window.zxcvbn === 'undefined' ) { + const result = passwordStrength( password ); + strength = result.id; + } else { + const result = window.zxcvbn( password ); + strength = result.score; + } + } + + const previousStrength = usePrevious( strength ); + + useEffect( () => { + if ( strength !== previousStrength && onChange ) { + onChange( strength ); + } + }, [ strength, previousStrength, onChange ] ); + + return ( +
+ + -1 ? strength : 0 } + > + { scoreDescriptions[ strength ] ?? '' } + +
+ { !! scoreDescriptions[ strength ] && ( + <> + + { sprintf( + /* translators: %s: Password strength */ + __( + 'Password strength: %1$s (%2$d characters long)', + 'woocommerce' + ), + scoreDescriptions[ strength ], + password.length + ) } + { ' ' } + + { scoreDescriptions[ strength ] } + + + ) } +
+
+ ); +}; + +export default PasswordStrengthMeter; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/password-strength-meter/style.scss b/plugins/woocommerce-blocks/assets/js/blocks/checkout/password-strength-meter/style.scss new file mode 100644 index 00000000000..ffa5f7652d7 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/password-strength-meter/style.scss @@ -0,0 +1,73 @@ +.wc-block-components-password-strength { + &.hidden { + opacity: 0; + } + .wc-block-components-password-strength__meter { + margin: $gap-small 0 0; + width: 100%; + display: block; + height: 6px; + border-radius: 4px; + border: 0; + background-color: $universal-border-light; + color: $alert-red; + + &::-webkit-meter-bar, + &::-webkit-meter-inner-element { + background: none; + border: 0; + height: 6px; + vertical-align: middle; + } + &::-webkit-meter-optimum-value, + &::-webkit-meter-even-less-good-value, + &::-webkit-meter-suboptimum-value { + background: none; + background-color: currentColor; + transition: 0.2s ease; + border-radius: 3px; + border: 0; + height: 6px; + vertical-align: middle; + } + + &:-moz-meter-optimum::-moz-meter-bar, + &:-moz-meter-sub-optimum::-moz-meter-bar, + &:-moz-meter-sub-sub-optimum::-moz-meter-bar { + background: none; + background-color: currentColor; + transition: 0.2s ease; + border-radius: 3px; + border: 0; + height: 6px; + vertical-align: middle; + } + } + .wc-block-components-password-strength__result { + @include font-size(smaller); + display: block; + text-align: right; + margin: $gap-smallest 0 0; + color: $alert-red; + + &::after { + content: "\00a0 "; + } + } + .wc-block-components-password-strength__meter[value="1"], + .wc-block-components-password-strength__meter[value="1"] + .wc-block-components-password-strength__result { + color: $alert-red; + } + .wc-block-components-password-strength__meter[value="2"], + .wc-block-components-password-strength__meter[value="2"] + .wc-block-components-password-strength__result { + color: #ff6f00; + } + .wc-block-components-password-strength__meter[value="3"], + .wc-block-components-password-strength__meter[value="3"] + .wc-block-components-password-strength__result { + color: $alert-yellow; + } + .wc-block-components-password-strength__meter[value="4"], + .wc-block-components-password-strength__meter[value="4"] + .wc-block-components-password-strength__result { + color: $alert-green; + } +} diff --git a/plugins/woocommerce-blocks/package.json b/plugins/woocommerce-blocks/package.json index 9c2ff5fdc8f..d627512f2ff 100644 --- a/plugins/woocommerce-blocks/package.json +++ b/plugins/woocommerce-blocks/package.json @@ -275,6 +275,7 @@ "@wordpress/url": "3.13.0", "@wordpress/wordcount": "3.47.0", "change-case": "^4.1.2", + "check-password-strength": "^2.0.10", "clsx": "^2.1.1", "compare-versions": "4.1.3", "config": "3.3.7", diff --git a/plugins/woocommerce-blocks/packages/checkout/utils/validation/get-validity-message-for-input.ts b/plugins/woocommerce-blocks/packages/checkout/utils/validation/get-validity-message-for-input.ts index b8314b3a620..fda88d7e26b 100644 --- a/plugins/woocommerce-blocks/packages/checkout/utils/validation/get-validity-message-for-input.ts +++ b/plugins/woocommerce-blocks/packages/checkout/utils/validation/get-validity-message-for-input.ts @@ -34,7 +34,7 @@ const defaultValidityMessage = const getValidityMessageForInput = ( label: string | undefined, inputElement: HTMLInputElement, - customValidityMessage?: ( validity: ValidityState ) => string + customValidityMessage?: ( validity: ValidityState ) => string | undefined ): string => { // No errors, or custom error - return early. if ( inputElement.validity.valid || inputElement.validity.customError ) { diff --git a/plugins/woocommerce-blocks/packages/components/text-input/types.ts b/plugins/woocommerce-blocks/packages/components/text-input/types.ts index e7313171851..cfea0a0923b 100644 --- a/plugins/woocommerce-blocks/packages/components/text-input/types.ts +++ b/plugins/woocommerce-blocks/packages/components/text-input/types.ts @@ -20,6 +20,8 @@ export interface ValidatedTextInputProps ariaDescribedBy?: string | undefined; // id to use for the error message. If not provided, an id will be generated. errorId?: string; + // Feedback to display alongside the input. May be hidden when validation errors are displayed. + feedback?: JSX.Element | null; // if true, the input will be focused on mount. focusOnMount?: boolean; // Callback to run on change which is passed the updated value. @@ -37,9 +39,7 @@ export interface ValidatedTextInputProps | ( ( inputObject: HTMLInputElement ) => boolean ) | undefined; // Custom validation message to display when validity is false. Given the input element. Expected to use inputObject.validity. - customValidityMessage?: - | ( ( validity: ValidityState ) => undefined | string ) - | undefined; + customValidityMessage?: ( validity: ValidityState ) => undefined | string; // Custom formatted to format values as they are typed. customFormatter?: ( value: string ) => string; // Whether validation should run when focused - only has an effect when focusOnMount is also true. diff --git a/plugins/woocommerce-blocks/packages/components/text-input/validated-text-input.tsx b/plugins/woocommerce-blocks/packages/components/text-input/validated-text-input.tsx index d13bb6b4266..583690fa340 100644 --- a/plugins/woocommerce-blocks/packages/components/text-input/validated-text-input.tsx +++ b/plugins/woocommerce-blocks/packages/components/text-input/validated-text-input.tsx @@ -50,6 +50,7 @@ const ValidatedTextInput = forwardRef< value = '', customValidation = () => true, customValidityMessage, + feedback = null, customFormatter = ( newValue: string ) => newValue, label, validateOnMount = true, @@ -241,12 +242,14 @@ const ValidatedTextInput = forwardRef< id={ textInputId } type={ type } feedback={ - showError ? ( + showError && hasError ? ( - ) : null + ) : ( + feedback + ) } ref={ inputRef } onChange={ ( newValue ) => { diff --git a/plugins/woocommerce/changelog/add-password-field-component-48628 b/plugins/woocommerce/changelog/add-password-field-component-48628 new file mode 100644 index 00000000000..497941d8689 --- /dev/null +++ b/plugins/woocommerce/changelog/add-password-field-component-48628 @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Add strength meter to block checkout password field. diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Checkout.php b/plugins/woocommerce/src/Blocks/BlockTypes/Checkout.php index dc18934ed18..60737382791 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/Checkout.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/Checkout.php @@ -94,10 +94,17 @@ class Checkout extends AbstractBlock { * @return array|string */ protected function get_block_type_script( $key = null ) { + $dependencies = []; + + // Load password strength meter script asynchronously if needed. + if ( ! is_user_logged_in() && 'no' === get_option( 'woocommerce_registration_generate_password' ) ) { + $dependencies[] = 'zxcvbn-async'; + } + $script = [ 'handle' => 'wc-' . $this->block_name . '-block-frontend', 'path' => $this->asset_api->get_block_asset_build_path( $this->block_name . '-frontend' ), - 'dependencies' => [], + 'dependencies' => $dependencies, ]; return $key ? $script[ $key ] : $script; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97f235e13a2..617a16a3e27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3928,6 +3928,9 @@ importers: change-case: specifier: ^4.1.2 version: 4.1.2 + check-password-strength: + specifier: ^2.0.10 + version: 2.0.10 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -13254,6 +13257,9 @@ packages: engines: {node: '>=8.3.0'} hasBin: true + check-password-strength@2.0.10: + resolution: {integrity: sha512-HRM5ICPmtnNtLnTv2QrfVkq1IxI9z3bzYpDJ1k5ixwD9HtJGHuv265R6JmHOV6r8wLhQMlULnIUVpkrC2yaiCw==} + check-types@8.0.3: resolution: {integrity: sha512-YpeKZngUmG65rLudJ4taU7VLkOCTMhNl/u4ctNC56LQS/zJTyNH0Lrtwm1tfTsbLlwvlfsA2d1c8vCf/Kh2KwQ==} @@ -43968,7 +43974,7 @@ snapshots: '@babel/code-frame': 7.23.5 '@babel/parser': 7.23.5 '@babel/traverse': 7.23.5 - '@babel/types': 7.24.7 + '@babel/types': 7.23.5 eslint: 7.32.0 eslint-visitor-keys: 1.3.0 resolve: 1.22.8 @@ -45585,6 +45591,8 @@ snapshots: run-parallel: 1.2.0 semver: 6.3.1 + check-password-strength@2.0.10: {} + check-types@8.0.3: {} checkstyle-formatter@1.1.0: