diff --git a/lib/components/Input/HelperMessage.scss b/lib/components/Input/HelperMessage.scss new file mode 100644 index 00000000..1833fbfd --- /dev/null +++ b/lib/components/Input/HelperMessage.scss @@ -0,0 +1,23 @@ +$inactive_color: #999999; +$success_color: #4bb4b3; +$warning_color: #ffad3a; +$error_field: #ec3f3f; + +.deriv-helper-message { + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; + &--general { + color: $inactive_color; + } + &--success { + color: $success_color; + } + &--warning { + color: $warning_color; + } + &--error { + color: $error_field; + } +} diff --git a/lib/components/Input/HelperMessage.tsx b/lib/components/Input/HelperMessage.tsx index 7f03ecc0..68099585 100644 --- a/lib/components/Input/HelperMessage.tsx +++ b/lib/components/Input/HelperMessage.tsx @@ -1,7 +1,7 @@ import React, { ReactNode } from "react"; import clsx from "clsx"; import { InputVariants } from "."; -import "./Input.scss"; +import "./HelperMessage.scss"; export interface HelperMessageProps { error?: boolean; @@ -9,11 +9,12 @@ export interface HelperMessageProps { variant?: InputVariants; disabled?: boolean; } - -const MessageVariant: Record = { - general: "deriv-helper-message__general", - success: "deriv-helper-message__success", - error: "deriv-helper-message__error", +type TMessageVariant = Exclude; +const MessageVariant: Record = { + general: "deriv-helper-message--general", + success: "deriv-helper-message--success", + warning: "deriv-helper-message--warning", + error: "deriv-helper-message--error", }; const HelperMessage = ({ @@ -25,7 +26,8 @@ const HelperMessage = ({

{message} diff --git a/lib/components/Input/Input.scss b/lib/components/Input/Input.scss index ce714132..403261d6 100644 --- a/lib/components/Input/Input.scss +++ b/lib/components/Input/Input.scss @@ -2,27 +2,44 @@ $disabled_color: #d6d6d6; $inactive_color: #999999; $active_color: #85acb0; $success_color: #4bb4b3; +$warning_color: #ffad3a; $error_field: #ec3f3f; -$inactive_color: #999999; $border: 1px solid; .deriv-input { - display: inline-block; + display: inline-flex; position: relative; + border-radius: 4px; + width: 328px; + box-sizing: border-box; + text-align: left; + padding: 10px 16px; + border: $border; + + &--general { + border-color: $inactive_color; + &:focus-within { + border-color: $active_color; + } + } + &--error { + border-color: $error_field; + } + &--success { + border-color: $success_color; + } + + &--active { + border-color: $active_color; + } - &--field { + &--disabled { + border-color: $disabled_color; + } + + &__field { width: 100%; - padding: 10px 16px; outline: none; - border-radius: 4px; - width: 328px; - box-sizing: border-box; - outline: none; - text-align: left; - &:disabled { - border: $border $disabled_color; - color: $inactive_color; - } &::placeholder { visibility: hidden; } @@ -36,27 +53,9 @@ $border: 1px solid; height: fit-content; } } - &__general { - border: $border $inactive_color; - &:focus { - border: $border $active_color; - } - } - &__error { - border: $border $error_field; - &:focus { - border: $border $error_field; - } - } - &__success { - border: $border $success_color; - &:focus { - border: $border $success_color; - } - } } - &--label { + &__label { display: inline-block; position: absolute; top: 0; @@ -68,61 +67,44 @@ $border: 1px solid; text-transform: capitalize; transition: all 0.15s ease-out; padding: 0; - &__general { + &--general { color: $inactive_color; } - &__error { + &--error { color: $error_field; } - &__success { + &--success { color: $success_color; } + &--active { + color: $active_color; + } + + &--disabled { + color: $disabled_color; + } } - &--helper-message { + &__helper-message { + top: calc(100% + 2px); position: absolute; left: 16px; - margin-top: 2px; + line-height: 1; } - &--right-content { - position: absolute; - right: 16px; - bottom: 25%; + &__right-content { + margin-left: 16px; } } -.deriv-input--field__general:disabled + .deriv-input--label, -.deriv-input--field__error:disabled + .deriv-input--label, -.deriv-input--field__success:disabled + .deriv-input--label { - color: $inactive_color; - cursor: not-allowed; -} - -.deriv-input--field:focus + .deriv-input--label { +.deriv-input--general .deriv-input__field:focus + .deriv-input__label { color: $active_color; } -.deriv-input--field__error:focus + .deriv-input--label { +.deriv-input--error .deriv-input__field:focus + .deriv-input__label { color: $error_field; } -.deriv-input--field__success:focus + .deriv-input--label { +.deriv-input--success .deriv-input__field:focus + .deriv-input__label { color: $success_color; } - -.deriv-helper-message { - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 18px; - &__general { - color: $inactive_color; - } - &__success { - color: $success_color; - } - &__error { - color: $error_field; - } -} diff --git a/lib/components/Input/index.tsx b/lib/components/Input/index.tsx index f3303023..8bd188fd 100644 --- a/lib/components/Input/index.tsx +++ b/lib/components/Input/index.tsx @@ -3,8 +3,13 @@ import clsx from "clsx"; import HelperMessage from "./HelperMessage"; import "./Input.scss"; -export type InputVariants = "general" | "success" | "error"; - interface InputProps +export type InputVariants = + | "general" + | "success" + | "error" + | "warning" + | "disabled"; +interface InputProps extends Omit, "style" | "placeholder"> { label?: string; leftPlaceholder?: ReactNode; @@ -15,15 +20,19 @@ export type InputVariants = "general" | "success" | "error"; } const InputVariant: Record = { - general: "deriv-input--field__general", - success: "deriv-input--field__success", - error: "deriv-input--field__error", + general: "deriv-input--general", + success: "deriv-input--success", + warning: "deriv-input--general", + error: "deriv-input--error", + disabled: "deriv-input--disabled", }; const LabelVariant: Record = { - general: "deriv-input--label__general", - success: "deriv-input--label__success", - error: "deriv-input--label__error", + general: "deriv-input__label--general", + success: "deriv-input__label--success", + warning: "deriv-input__label--general", + error: "deriv-input__label--error", + disabled: "deriv-input__label--disabled", }; export const Input = ({ @@ -39,33 +48,42 @@ export const Input = ({ ...rest }: InputProps) => { return ( -

+
{leftPlaceholder && ( -
{leftPlaceholder}
+
{leftPlaceholder}
)} {rightPlaceholder && ( -
{rightPlaceholder}
+
{rightPlaceholder}
)} -
+
{message && ( = { + common: "This is a very common password.", + commonNames: "Common names and surnames are easy to guess.", + dates: "Dates are easy to guess.", + extendedRepeat: + 'Repeated character patterns like "abcabcabc" are easy to guess.', + keyPattern: "Short keyboard patterns are easy to guess.", + namesByThemselves: "Single names or surnames are easy to guess.", + pwned: "Your password was exposed by a data breach on the Internet.", + recentYears: "Recent years are easy to guess.", + sequences: 'Common character sequences like "abc" are easy to guess.', + similarToCommon: "This is similar to a commonly used password.", + simpleRepeat: 'Repeated characters like "aaa" are easy to guess.', + straightRow: "Straight rows of keys on your keyboard are easy to guess.", + topHundred: "This is a frequently used password.", + topTen: "This is a heavily used password.", + userInputs: "There should not be any personal or page related data.", + wordByItself: "Single words are easy to guess.", +}; diff --git a/lib/components/PasswordInput/PasswordIcon.tsx b/lib/components/PasswordInput/PasswordIcon.tsx new file mode 100644 index 00000000..6f90be2a --- /dev/null +++ b/lib/components/PasswordInput/PasswordIcon.tsx @@ -0,0 +1,27 @@ +export const EyeIcon = () => ( + + + +); + +export const EyeIconSlash = () => ( + + + +); diff --git a/lib/components/PasswordInput/PasswordInput.scss b/lib/components/PasswordInput/PasswordInput.scss new file mode 100644 index 00000000..1238d002 --- /dev/null +++ b/lib/components/PasswordInput/PasswordInput.scss @@ -0,0 +1,24 @@ +$NEUTRAL: #e6e9e9; +$SUCCESS: #4bb4b3; +$ERROR: #ec3f3f; + +.deriv-password { + width: fit-content; + position: relative; + + &__meter { + z-index: -1; + bottom: 1px; + border-radius: 0px 0px 4px 4px; + position: absolute; + width: 100%; + height: 4px; + background-color: $NEUTRAL; + } + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + } +} diff --git a/lib/components/PasswordInput/PasswordMeter.scss b/lib/components/PasswordInput/PasswordMeter.scss new file mode 100644 index 00000000..9ae96820 --- /dev/null +++ b/lib/components/PasswordInput/PasswordMeter.scss @@ -0,0 +1,45 @@ +$NEUTRAL: #e6e9e9; +$SUCCESS: #4bb4b3; +$ERROR: #ec3f3f; + +.deriv-password__meter__bar { + height: 100%; + border-radius: 0px 0px 4px 4px; + transition: "width 0.25s ease-in-out"; + + &__initial { + background-color: $NEUTRAL; + width: 0%; + border-radius: 0px 0px 0px 4px; + } + + &--error { + background-color: $ERROR; + width: 0%; + border-radius: 0px 0px 0px 4px; + } + + &--weak { + background-color: $ERROR; + width: 25%; + border-radius: 0px 0px 0px 4px; + } + + &--moderate { + background-color: $ERROR; + width: 50%; + border-radius: 0px 0px 0px 4px; + } + + &--strong { + background-color: $SUCCESS; + width: 75%; + border-radius: 0px 0px 0px 4px; + } + + &--complete { + background-color: $SUCCESS; + width: 100%; + border-radius: 0px 0px 4px 4px; + } +} diff --git a/lib/components/PasswordInput/PasswordMeter.tsx b/lib/components/PasswordInput/PasswordMeter.tsx new file mode 100644 index 00000000..b7a19582 --- /dev/null +++ b/lib/components/PasswordInput/PasswordMeter.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import clsx from "clsx"; +import { TScore } from "./PasswordConstants"; +import "./PasswordMeter.scss"; + +type PasswordMeterProps = { + score: TScore; +}; + +const PasswordStrengthClass: Record = { + 0: "deriv-password__meter__bar--error", + 1: "deriv-password__meter__bar--weak", + 2: "deriv-password__meter__bar--moderate", + 3: "deriv-password__meter__bar--strong", + 4: "deriv-password__meter__bar--complete", +}; + +export const PasswordMeter = ({ score }: PasswordMeterProps) => ( +
+
+
+); diff --git a/lib/components/PasswordInput/PasswordUtils.ts b/lib/components/PasswordInput/PasswordUtils.ts new file mode 100644 index 00000000..f88b0481 --- /dev/null +++ b/lib/components/PasswordInput/PasswordUtils.ts @@ -0,0 +1,50 @@ +import { passwordRegex, passwordValues } from "./PasswordConstants"; + +export const validPassword = (value: string) => + passwordRegex.isPasswordValid.test(value); + +export const isPasswordValid = (password: string) => { + return ( + passwordRegex.isPasswordValid.test(password) && + passwordRegex.isLengthValid.test(password) + ); +}; + +export const isPasswordModerate = (password: string) => { + const hasMoreThanOneSymbol = (password.match(/\W/g) ?? []).length > 1; + return ( + isPasswordValid(password) && + hasMoreThanOneSymbol && + password.length >= passwordValues.minLength && + password.length < passwordValues.longPassword && + passwordRegex.isLengthValid + ); +}; + +export const isPasswordStrong = (password: string) => { + const hasMoreThanOneSymbol = (password.match(/\W/g) ?? []).length > 1; + return ( + isPasswordValid(password) && + hasMoreThanOneSymbol && + password.length >= passwordValues.longPassword && + passwordRegex.isLengthValid + ); +}; + +export const calculateScore = (password: string) => { + if (password.length === 0) return 0; + if (!isPasswordValid(password)) return 1; + if ( + !isPasswordStrong(password) && + isPasswordValid(password) && + !isPasswordModerate(password) + ) + return 2; + if ( + !isPasswordStrong(password) && + isPasswordValid(password) && + isPasswordModerate(password) + ) + return 3; + if (isPasswordStrong(password)) return 4; +}; diff --git a/lib/components/PasswordInput/index.tsx b/lib/components/PasswordInput/index.tsx new file mode 100644 index 00000000..f0fe5764 --- /dev/null +++ b/lib/components/PasswordInput/index.tsx @@ -0,0 +1,117 @@ +import React, { + ChangeEvent, + ComponentProps, + useCallback, + useMemo, + useState, +} from "react"; +import { zxcvbn, zxcvbnOptions } from "@zxcvbn-ts/core"; +import { dictionary } from "@zxcvbn-ts/language-common"; +import { Input } from "../Input"; +import { calculateScore, isPasswordValid } from "./PasswordUtils"; +import { + TScore, + passwordErrorMessage, + passwordKeys, + passwordRegex, + warningMessages, +} from "./PasswordConstants"; +import { EyeIcon, EyeIconSlash } from "./PasswordIcon"; +import { PasswordMeter } from "./PasswordMeter"; +import "./PasswordInput.scss"; + +export const validatePassword = (password: string) => { + const score = calculateScore(password); + let errorMessage = ""; + + const options = { dictionary: { ...dictionary } }; + zxcvbnOptions.setOptions(options); + + const { feedback } = zxcvbn(password); + if (!passwordRegex.isLengthValid.test(password)) { + errorMessage = passwordErrorMessage.invalidLength; + } else if (!isPasswordValid(password)) { + errorMessage = passwordErrorMessage.missingCharacter; + } else { + errorMessage = warningMessages[feedback.warning as passwordKeys] ?? ""; + } + return { errorMessage, score }; +}; + +type InputProps = ComponentProps; + +interface PasswordInputProps + extends Pick< + InputProps, + "value" | "onChange" | "label" | "id" | "autoComplete" + > { + hidePasswordMeter?: boolean; + hint?: string; +} + +const PasswordVariant: Record = { + 0: "error", + 1: "error", + 2: "warning", + 3: "success", + 4: "success", +}; + +export const PasswordInput = ({ + autoComplete, + id, + label, + value, + onChange, + hint, + hidePasswordMeter, +}: PasswordInputProps) => { + const [isTouched, setIsTouched] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const { errorMessage, score } = useMemo( + () => validatePassword(value as string), + [value] + ); + + const handleChange = useCallback( + (e: ChangeEvent) => { + onChange?.(e); + if (!isTouched) { + setIsTouched(true); + } + }, + [isTouched, onChange] + ); + + const handleBlur = useCallback(() => { + if (!isTouched) { + setIsTouched(true); + } + }, [isTouched]); + + return ( +
+ setShowPassword(!showPassword)} + > + {showPassword ? : } + + } + /> + {!hidePasswordMeter && } +
+ ); +}; diff --git a/lib/main.ts b/lib/main.ts index b8ab0ddc..7818aaff 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -5,7 +5,8 @@ export { Loader } from "./components/Loader"; export { PageLayout } from "./components/Layout"; export { Tab, Tabs } from "./components/Tabs"; export { Text } from "./components/Text"; -export { ToggleSwitch } from "./components/ToggleSwitch" +export { ToggleSwitch } from "./components/ToggleSwitch"; export { Tooltip } from "./components/Tooltip"; export { useDevice } from "./hooks/useDevice"; export { useOnClickOutside } from "./hooks/useOnClickOutside"; +export { PasswordInput } from "./components/PasswordInput"; diff --git a/package-lock.json b/package-lock.json index 87327f1b..15b42d31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", "clsx": "^2.1.0", "downshift": "^8.3.1", "eslint": "^8.55.0", @@ -7113,6 +7115,21 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/@zxcvbn-ts/core": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-3.0.4.tgz", + "integrity": "sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==", + "dev": true, + "dependencies": { + "fastest-levenshtein": "1.0.16" + } + }, + "node_modules/@zxcvbn-ts/language-common": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-common/-/language-common-3.0.4.tgz", + "integrity": "sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw==", + "dev": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -10062,6 +10079,15 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.0.tgz", diff --git a/package.json b/package.json index bcb47a95..e4c6b375 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", "clsx": "^2.1.0", "downshift": "^8.3.1", "eslint": "^8.55.0", diff --git a/src/stories/PasswordInput.stories.ts b/src/stories/PasswordInput.stories.ts new file mode 100644 index 00000000..9910c84c --- /dev/null +++ b/src/stories/PasswordInput.stories.ts @@ -0,0 +1,53 @@ +import { StoryObj, Meta } from "@storybook/react"; +import { PasswordInput } from "../../lib/main"; + +const meta = { + title: "Components/PasswordInput", + component: PasswordInput, + parameters: { layout: "centered" }, + tags: ["autodocs"], + args: { + autoComplete: "password", + id: "password", + label: "Enter Password", + value: "", + onChange: () => {}, + hidePasswordMeter: false, + hint: "Password should have lower and uppercase English letters with numbers.", + }, + argTypes: { + autoComplete: { + control: { + disable: true, + }, + }, + id: { + control: { + disable: true, + }, + }, + onChange: { + control: { + disable: true, + }, + }, + hidePasswordMeter: { + control: { + type: "boolean", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: "Default Password Input", + args: { + label: "Enter Password", + value: "", + onChange: () => {}, + hidePasswordMeter: false, + }, +};