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:
@@ -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';
|
||||
|
||||
@@ -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}}个字符"
|
||||
}
|
||||
Reference in New Issue
Block a user