feat: add password strength validation (#1561)

* feat: add password strength validation

* refactor: rename password state variables for consistency

* chore: enforce only minimum length check for password(create account)

* chore: add translation for enforced password suggestion text
This commit is contained in:
rajk93
2025-06-26 11:26:55 +05:30
committed by GitHub
parent 3d73f688c3
commit 26b2eebb92
15 changed files with 222 additions and 20 deletions
+3
View File
@@ -40,6 +40,9 @@
"@polkadot/ui-settings": "^3.15.1",
"@polkadot/util": "^13.5.2",
"@polkadot/util-crypto": "^13.5.2",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"file-saver": "^2.0.5",
"i18next": "^23.7.11",
"react": "^18.2.0",
@@ -18,6 +18,7 @@ interface Props {
isReadOnly?: boolean;
label: string;
onBlur?: () => void;
onFocus?: () => void;
onChange?: (value: string) => void;
onEnter?: () => void;
placeholder?: string;
@@ -26,7 +27,7 @@ interface Props {
withoutMargin?: boolean;
}
function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement<Props> {
function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, onFocus, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement<Props> {
const [isCapsLock, setIsCapsLock] = useState(false);
const { t } = useTranslation();
@@ -65,6 +66,7 @@ function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused
disabled={disabled}
onBlur={onBlur}
onChange={_onChange}
onFocus={onFocus}
onKeyPress={_checkKey}
placeholder={placeholder}
readOnly={isReadOnly}
@@ -0,0 +1,74 @@
// Copyright 2019-2025 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PasswordStrength } from '../util/passwordValidation.js';
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
passwordStrength: PasswordStrength;
}
function PasswordStrengthIndicator ({ className, passwordStrength }: Props): React.ReactElement<Props> {
const { feedback, score } = passwordStrength;
const strengthColors = [
'#ff4444', // Very Weak - Red
'#ffa700', // Weak - Orange
'#ffeb3b', // Medium - Yellow
'#00C853', // Strong - Light Green
'#00695C' // Very Strong - Dark Green
];
return (
<div className={className}>
<div className='strength-meter'>
{[0, 1, 2, 3, 4].map((index) => (
<div
className={`strength-segment ${index <= score ? 'active' : ''}`}
key={index}
style={{
backgroundColor: index <= score ? strengthColors[score] : '#e0e0e0'
}}
/>
))}
</div>
{feedback.suggestions.length > 0 && (
<ul className='suggestions'>
{feedback.suggestions.map((suggestion, index) => (
<li key={index}>{suggestion}</li>
))}
</ul>
)}
</div>
);
}
export default styled(PasswordStrengthIndicator)`
.strength-meter {
margin-top: 0.5rem;
display: flex;
margin-bottom: 0.5rem;
}
.strength-segment {
flex: 1;
height: 4px;
margin: 0 2px;
}
.strength-label {
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.suggestions {
margin: 0.5rem 0;
padding-left: 1.5rem;
font-size: 0.85rem;
color: var(--warning-color);
}
`;
@@ -13,6 +13,8 @@ interface BasicProps {
isError?: boolean;
value?: string | null;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
}
type Props<T extends BasicProps> = T & {
@@ -29,6 +29,7 @@ export { default as MenuDivider } from './MenuDivider.js';
export { default as MenuItem } from './MenuItem.js';
export { default as MnemonicSeed } from './MnemonicSeed.js';
export { default as NextStepButton } from './NextStepButton.js';
export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicator.js';
export { default as RemoveAuth } from './RemoveAuth.js';
export { default as Spinner } from './Spinner.js';
export { default as Svg } from './Svg.js';
+37 -12
View File
@@ -3,11 +3,12 @@
import type { Validator } from '../util/validators.js';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { InputWithLabel, ValidatedInput } from '../components/index.js';
import { InputWithLabel, PasswordStrengthIndicator, ValidatedInput } from '../components/index.js';
import { useTranslation } from '../hooks/index.js';
import { allOf, isNotShorterThan, isSameAs } from '../util/validators.js';
import { validatePasswordStrength } from '../util/passwordValidation.js';
import { allOf, isNotShorterThan, isSameAs, Result } from '../util/validators.js';
interface Props {
isFocussed?: boolean;
@@ -18,17 +19,38 @@ const MIN_LENGTH = 6;
export default function Password ({ isFocussed, onChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const [pass1, setPass1] = useState<string | null>(null);
const [pass2, setPass2] = useState<string | null>(null);
const isFirstPasswordValid = useMemo(() => isNotShorterThan(MIN_LENGTH, t('Password is too short')), [t]);
const [password, setPassword] = useState<string | null>(null);
const [confirmPassword, setConfirmPassword] = useState<string | null>(null);
const [passwordStrength, setPasswordStrength] = useState(validatePasswordStrength('', t));
const [isPasswordFocused, setIsPasswordFocused] = useState(false);
const [isPasswordResultInit, setIsPasswordResultInit] = useState(false);
const handlePasswordFocus = useCallback((): void => setIsPasswordFocused(true), []);
const handlePasswordBlur = useCallback((): void => setIsPasswordFocused(false), []);
// Primary password validation using zxcvbn
const isFirstPasswordValid = useCallback<Validator<string>>((password) => {
setIsPasswordResultInit(true);
const strength = validatePasswordStrength(password, t);
setPasswordStrength(strength);
if (password.length < MIN_LENGTH) {
return Promise.resolve(Result.error(t('Password is too short')));
}
return Promise.resolve(Result.ok<string>(password));
}, [t]);
const isSecondPasswordValid = useCallback((firstPassword: string): Validator<string> => allOf(
isNotShorterThan(MIN_LENGTH, t('Password is too short')),
isSameAs(firstPassword, t('Passwords do not match'))
), [t]);
useEffect((): void => {
onChange(pass1 && pass2 ? pass1 : null);
}, [onChange, pass1, pass2]);
onChange(password && confirmPassword ? password : null);
}, [onChange, password, confirmPassword]);
return (
<>
@@ -37,18 +59,21 @@ export default function Password ({ isFocussed, onChange }: Props): React.ReactE
data-input-password
isFocused={isFocussed}
label={t('A new password for this account')}
onValidatedChange={setPass1}
onBlur={handlePasswordBlur}
onFocus={handlePasswordFocus}
onValidatedChange={setPassword}
type='password'
validator={isFirstPasswordValid}
/>
{pass1 && (
{isPasswordFocused && isPasswordResultInit && <PasswordStrengthIndicator passwordStrength={passwordStrength} />}
{password && (
<ValidatedInput
component={InputWithLabel}
data-input-repeat-password
label={t('Repeat password for verification')}
onValidatedChange={setPass2}
onValidatedChange={setConfirmPassword}
type='password'
validator={isSecondPasswordValid(pass1)}
validator={isSecondPasswordValid(password)}
/>
)}
</>
@@ -0,0 +1,54 @@
// Copyright 2019-2025 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { TFunction } from '../hooks/useTranslation.js';
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
// Configure zxcvbn with English dictionary and common patterns
zxcvbnOptions.setOptions({
dictionary: {
...zxcvbnCommonPackage.dictionary, // common words across languages
...zxcvbnEnPackage.dictionary // english-specific dictionary
},
graphs: zxcvbnCommonPackage.adjacencyGraphs, // keyboard patterns
translations: zxcvbnEnPackage.translations // language translations for feedback messages
});
export interface PasswordStrength {
feedback: {
warning: string;
suggestions: string[];
};
score: number; // 0-4 (0: very weak, 4: very strong)
}
const MIN_LENGTH = 6; // Minimum password length requirement
export function validatePasswordStrength (password: string, t: TFunction): PasswordStrength {
const result = zxcvbn(password);
// First check: Minimum length requirement
if (password.length < MIN_LENGTH) {
return {
feedback: {
suggestions: [t('Password must be at least {{length}} characters long', { replace: { length: MIN_LENGTH } })],
warning: t('Password is too short')
},
score: 0
};
}
// Combine zxcvbn suggestions with our custom ones
const suggestions = (result.feedback.suggestions || []).map((suggestion) => t(suggestion));
return {
feedback: {
suggestions,
warning: result.feedback.warning ? t(result.feedback.warning) : ''
},
score: result.score
};
}
@@ -158,5 +158,6 @@
"Export all accounts": "সমস্ত অ্যাকাউন্ট এক্সপোর্ট করুন",
"All account": "সমস্ত অ্যাকাউন্ট",
"password for encrypting all accounts": "সকল অ্যাকাউন্ট এনক্রিপ্ট করার জন্য পাসওয়ার্ড",
"I want to export all my accounts": "আমি আমার সকল অ্যাকাউন্ট এক্সপোর্ট করতে চাই"
"I want to export all my accounts": "আমি আমার সকল অ্যাকাউন্ট এক্সপোর্ট করতে চাই",
"Password must be at least {{length}} characters long": "পাসওয়ার্ড কমপক্ষে {{length}} অক্ষরের হতে হবে"
}
@@ -182,5 +182,7 @@
"Ledger App": "",
"Don't ask again": "",
"Previous": "",
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": ""
"It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "",
"Password is not strong enough": "",
"Password must be at least {{length}} characters long": ""
}
@@ -153,5 +153,6 @@
"Import Account": "Importer",
"Sign on Ledger": "Signer sur Ledger",
"Allow use on any chain": "Toutes les blockchains",
"Select network": "Sélectionner un réseau"
"Select network": "Sélectionner un réseau",
"Password must be at least {{length}} characters long": "Le mot de passe doit contenir au moins {{length}} caractères"
}
@@ -158,5 +158,6 @@
"Export all accounts": "Eksportuj wszystkie konta",
"All account": "Wszystkie konta",
"password for encrypting all accounts": "hasło do szyfrowania wszystkich kont",
"I want to export all my accounts": "Chcę wyeksportować wszystkie moje konta"
"I want to export all my accounts": "Chcę wyeksportować wszystkie moje konta",
"Password must be at least {{length}} characters long": "Hasło musi mieć co najmniej {{length}} znaków"
}
@@ -158,5 +158,6 @@
"Export all accounts": "ส่งออกทุกบัญชี",
"All account": "ทุกบัญชี",
"password for encrypting all accounts": "รหัสผ่านสำหรับการเข้ารหัสทุกบัญชี",
"I want to export all my accounts": "ฉันอยากจะนำออกทุกบัญชี"
"I want to export all my accounts": "ฉันอยากจะนำออกทุกบัญชี",
"Password must be at least {{length}} characters long": "รหัสผ่านต้องมีอย่างน้อย {{length}} ตัวอักษร"
}
@@ -158,7 +158,8 @@
"Export all accounts": "تماماکاؤنٹسبرآمدکریں۔",
"All account": "تماماکاؤنٹ",
"password for encrypting all accounts": "تماماکاؤنٹسکوخفیہکرنےکےلیےپاسورڈ۔",
"I want to export all my accounts": "میںاپنےتماماکاؤنٹسبرآمدکرناچاہتاہوں۔"
"I want to export all my accounts": "میںاپنےتماماکاؤنٹسبرآمدکرناچاہتاہوں۔",
"Password must be at least {{length}} characters long": "پاس ورڈ کم از کم {{length}} حروف کا ہونا چاہیے۔"
}
@@ -103,5 +103,6 @@
"Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.": "请写下你的钱包助记词,放在安全的地方。这个助记词可以用来恢复你的钱包。小心保管,以免失去你的资产。",
"You are exporting your account. Keep it safe and don't share it with anyone.": "您正在导出帐户。保管好它,不要与任何人分享。",
"Manage Website Access": "管理网站访问",
"Notifications": "通知"
"Notifications": "通知",
"Password must be at least {{length}} characters long": "密码必须至少{{length}}个字符"
}