[WEB-4513] refactor: consolidate password strength meter into shared ui package (#7462)

* refactor: consolidate password strength indicator into shared UI package

* chore: remove old password strength meter implementations

* chore: update package dependencies for password strength refactor

* chore: code refactor

* fix: lock file

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Anmol Singh Bhatia
2025-07-25 16:56:46 +05:30
committed by GitHub
parent 63d025cbf4
commit a5f3bd15b1
25 changed files with 310 additions and 458 deletions

View File

@@ -1,89 +0,0 @@
"use client";
import { FC, useMemo } from "react";
// plane internal packages
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordStrength } from "@plane/utils";
type TPasswordStrengthMeter = {
password: string;
isFocused?: boolean;
};
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
const { password, isFocused = false } = props;
// derived values
const strength = useMemo(() => getPasswordStrength(password), [password]);
const strengthBars = useMemo(() => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password length should me more than 8 characters.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password is weak.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
return {
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
text: "Password is strong.",
textColor: "text-green-500",
};
}
default: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
}
}, [strength]);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if (!isPasswordMeterVisible) return <></>;
return (
<div className="w-full space-y-2 pt-2">
<div className="space-y-1.5">
<div className="relative flex items-center gap-2">
{strengthBars?.bars.map((color, index) => (
<div key={`${color}-${index}`} className={cn("w-full h-1 rounded-full", color)} />
))}
</div>
<div className={cn(`text-xs font-medium text-custom-text-100`, strengthBars?.textColor)}>
{strengthBars?.text}
</div>
</div>
{/* <div className="relative flex flex-wrap gap-x-4 gap-y-2">
{PASSWORD_CRITERIA.map((criteria) => (
<div
key={criteria.key}
className={cn(
"relative flex items-center gap-1 text-xs",
criteria.isCriteriaValid(password) ? `text-green-500/70` : "text-custom-text-300"
)}
>
<CircleCheck width={14} height={14} />
{criteria.label}
</div>
))}
</div> */}
</div>
);
};

View File

@@ -7,11 +7,10 @@ import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Checkbox, Input, Spinner } from "@plane/ui";
import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { Banner } from "@/components/common/banner";
import { PasswordStrengthMeter } from "@/components/common/password-strength-meter";
// service initialization
const authService = new AuthService();
@@ -274,7 +273,7 @@ export const InstanceSetupForm: FC = (props) => {
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
<p className="px-1 text-xs text-red-500">{errorData.message}</p>
)}
<PasswordStrengthMeter password={formData.password} isFocused={isPasswordInputFocused} />
<PasswordStrengthIndicator password={formData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="w-full space-y-1">

View File

@@ -40,8 +40,7 @@
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"swr": "^2.2.4",
"uuid": "^9.0.1",
"zxcvbn": "^4.4.2"
"uuid": "^9.0.1"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -51,7 +50,6 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"typescript": "5.8.3"
}
}

View File

@@ -4,13 +4,10 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Eye, EyeOff, XCircle } from "lucide-react";
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
import { PasswordStrengthMeter } from "@/components/account";
// helpers
import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
import { Button, Input, Spinner, PasswordStrengthIndicator } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
@@ -72,7 +69,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
const passwordSupport = passwordFormData.password.length > 0 &&
mode === EAuthModes.SIGN_UP &&
getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
<PasswordStrengthMeter password={passwordFormData.password} isFocused={isPasswordInputFocused} />
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
);
const isButtonDisabled = useMemo(

View File

@@ -1 +0,0 @@
export * from "./password-strength-meter";

View File

@@ -1,94 +0,0 @@
"use client";
import { FC, useMemo } from "react";
// import { CircleCheck } from "lucide-react";
// helpers
import { cn } from "@plane/utils";
import {
E_PASSWORD_STRENGTH,
// PASSWORD_CRITERIA,
getPasswordStrength,
} from "@/helpers/password.helper";
type TPasswordStrengthMeter = {
password: string;
isFocused?: boolean;
};
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
const { password, isFocused = false } = props;
// derived values
const strength = useMemo(() => getPasswordStrength(password), [password]);
const strengthBars = useMemo(() => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password length should me more than 8 characters.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Password is weak.",
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
return {
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
text: "Password is strong.",
textColor: "text-green-500",
};
}
default: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: "Please enter your password.",
textColor: "text-custom-text-100",
};
}
}
}, [strength]);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if (!isPasswordMeterVisible) return <></>;
return (
<div className="w-full space-y-2 pt-2">
<div className="space-y-1.5">
<div className="relative flex items-center gap-2">
{strengthBars?.bars.map((color, index) => (
<div key={`${color}-${index}`} className={cn("w-full h-1 rounded-full", color)} />
))}
</div>
<div className={cn(`text-xs font-medium text-custom-text-100`, strengthBars?.textColor)}>
{strengthBars?.text}
</div>
</div>
{/* <div className="relative flex flex-wrap gap-x-4 gap-y-2">
{PASSWORD_CRITERIA.map((criteria) => (
<div
key={criteria.key}
className={cn(
"relative flex items-center gap-1 text-xs",
criteria.isCriteriaValid(password) ? `text-green-500/70` : "text-custom-text-300"
)}
>
<CircleCheck width={14} height={14} />
{criteria.label}
</div>
))}
</div> */}
</div>
);
};

View File

@@ -1,5 +1,4 @@
export * from "./auth-forms";
export * from "./oauth";
export * from "./terms-and-conditions";
export * from "./helpers";
export * from "./user-logged-in";

View File

@@ -1,67 +0,0 @@
import zxcvbn from "zxcvbn";
export enum E_PASSWORD_STRENGTH {
EMPTY = "empty",
LENGTH_NOT_VALID = "length_not_valid",
STRENGTH_NOT_VALID = "strength_not_valid",
STRENGTH_VALID = "strength_valid",
}
const PASSWORD_MIN_LENGTH = 8;
// const PASSWORD_NUMBER_REGEX = /\d/;
// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/;
// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/;
export const PASSWORD_CRITERIA = [
{
key: "min_8_char",
label: "Min 8 characters",
isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH,
},
// {
// key: "min_1_upper_case",
// label: "Min 1 upper-case letter",
// isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
// },
// {
// key: "min_1_number",
// label: "Min 1 number",
// isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
// },
// {
// key: "min_1_special_char",
// label: "Min 1 special character",
// isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
// },
];
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
if (!password || password === "" || password.length <= 0) {
return passwordStrength;
}
if (password.length >= PASSWORD_MIN_LENGTH) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
} else {
passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
return passwordStrength;
}
const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
(criterion) => criterion
);
const passwordStrengthScore = zxcvbn(password).score;
if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
return passwordStrength;
}
if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID;
}
return passwordStrength;
};

View File

@@ -49,8 +49,7 @@
"react-popper": "^2.3.0",
"swr": "^2.2.2",
"tailwind-merge": "^2.0.0",
"uuid": "^9.0.0",
"zxcvbn": "^4.4.2"
"uuid": "^9.0.0"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -63,7 +62,6 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.1",
"@types/zxcvbn": "^4.4.4",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"typescript": "5.8.3"
}

View File

@@ -7,10 +7,9 @@ import { Eye, EyeOff } from "lucide-react";
// plane imports
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { PasswordStrengthMeter } from "@/components/account";
import { PageHead } from "@/components/core";
import { ProfileSettingContentHeader } from "@/components/profile";
// helpers
@@ -114,7 +113,7 @@ const SecurityPage = observer(() => {
const passwordSupport = password.length > 0 &&
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
<PasswordStrengthMeter password={password} isFocused={isPasswordInputFocused} />
<PasswordStrengthIndicator password={password} isFocused={isPasswordInputFocused} />
);
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;

View File

@@ -11,10 +11,10 @@ import { Eye, EyeOff } from "lucide-react";
// ui
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, Input } from "@plane/ui";
import { Button, Input, PasswordStrengthIndicator } from "@plane/ui";
// components
import { getPasswordStrength } from "@plane/utils";
import { AuthBanner, PasswordStrengthMeter } from "@/components/account";
import { AuthBanner } from "@/components/account";
// helpers
import {
EAuthenticationErrorCodes,
@@ -192,7 +192,7 @@ const ResetPasswordPage = observer(() => {
/>
)}
</div>
<PasswordStrengthMeter password={resetFormData.password} isFocused={isPasswordInputFocused} />
<PasswordStrengthIndicator password={resetFormData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">

View File

@@ -11,10 +11,9 @@ import { Eye, EyeOff } from "lucide-react";
// plane imports
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { getPasswordStrength } from "@plane/utils";
import { PasswordStrengthMeter } from "@/components/account/password-strength-meter";
// helpers
import { EPageTypes } from "@/helpers/authentication.helper";
// hooks
@@ -193,7 +192,7 @@ const SetPasswordPage = observer(() => {
/>
)}
</div>
<PasswordStrengthMeter password={passwordFormData.password} isFocused={isPasswordInputFocused} />
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
</div>
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">

View File

@@ -7,10 +7,9 @@ import { Eye, EyeOff } from "lucide-react";
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { getPasswordStrength } from "@plane/utils";
import { PasswordStrengthMeter } from "@/components/account/password-strength-meter";
import { PageHead } from "@/components/core/page-title";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// helpers
@@ -110,7 +109,7 @@ const SecurityPage = observer(() => {
const passwordSupport = password.length > 0 &&
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
<PasswordStrengthMeter password={password} isFocused={isPasswordInputFocused} />
<PasswordStrengthIndicator password={password} isFocused={isPasswordInputFocused} />
);
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;

View File

@@ -8,10 +8,10 @@ import { Eye, EyeOff, Info, X, XCircle } from "lucide-react";
// plane imports
import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS, AUTH_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, Input, Spinner } from "@plane/ui";
import { Button, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account";
import { ForgotPasswordPopover } from "@/components/account";
// constants
// helpers
import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper";
@@ -95,7 +95,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
) : (
passwordFormData.password.length > 0 &&
getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
<PasswordStrengthMeter password={passwordFormData.password} isFocused={isPasswordInputFocused} />
<PasswordStrengthIndicator password={passwordFormData.password} isFocused={isPasswordInputFocused} />
)
);

View File

@@ -2,4 +2,3 @@ export * from "./oauth";
export * from "./auth-forms";
export * from "./deactivate-account-modal";
export * from "./terms-and-conditions";
export * from "./password-strength-meter";

View File

@@ -1,91 +0,0 @@
"use client";
import { FC, useMemo } from "react";
// plane imports
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn, getPasswordStrength } from "@plane/utils";
type TPasswordStrengthMeter = {
password: string;
isFocused?: boolean;
};
export const PasswordStrengthMeter: FC<TPasswordStrengthMeter> = (props) => {
const { password, isFocused = false } = props;
const { t } = useTranslation();
// derived values
const strength = useMemo(() => getPasswordStrength(password), [password]);
const strengthBars = useMemo(() => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: t("auth.common.password.errors.empty"),
textColor: "text-custom-text-100",
};
}
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: t("auth.common.password.errors.length"),
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
return {
bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
text: t("auth.common.password.errors.strength.weak"),
textColor: "text-red-500",
};
}
case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
return {
bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
text: t("auth.common.password.errors.strength.strong"),
textColor: "text-green-500",
};
}
default: {
return {
bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
text: t("auth.common.password.errors.empty"),
textColor: "text-custom-text-100",
};
}
}
}, [strength, t]);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if (!isPasswordMeterVisible) return <></>;
return (
<div className="w-full space-y-2 pt-2">
<div className="space-y-1.5">
<div className="relative flex items-center gap-2">
{strengthBars?.bars.map((color, index) => (
<div key={`${color}-${index}`} className={cn("w-full h-1 rounded-full", color)} />
))}
</div>
<div className={cn(`text-xs font-medium text-custom-text-100`, strengthBars?.textColor)}>
{strengthBars?.text}
</div>
</div>
{/* <div className="relative flex flex-wrap gap-x-4 gap-y-2">
{PASSWORD_CRITERIA.map((criteria) => (
<div
key={criteria.key}
className={cn(
"relative flex items-center gap-1 text-xs",
criteria.isCriteriaValid(password) ? `text-green-500/70` : "text-custom-text-300"
)}
>
<CircleCheck width={14} height={14} />
{criteria.label}
</div>
))}
</div> */}
</div>
);
};

View File

@@ -11,10 +11,9 @@ import { E_PASSWORD_STRENGTH, ONBOARDING_TRACKER_ELEMENTS, USER_TRACKER_EVENTS }
import { useTranslation } from "@plane/i18n";
import { IUser, TUserProfile, TOnboardingSteps } from "@plane/types";
// ui
import { Button, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui";
import { Button, Input, PasswordStrengthIndicator, Spinner, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { getFileURL, getPasswordStrength } from "@plane/utils";
import { PasswordStrengthMeter } from "@/components/account";
import { UserImageUploadModal } from "@/components/core";
import { OnboardingHeader, SwitchAccountDropdown } from "@/components/onboarding";
// constants
@@ -454,7 +453,10 @@ export const ProfileSetup: React.FC<Props> = observer((props) => {
</div>
)}
/>
<PasswordStrengthMeter password={watch("password") ?? ""} isFocused={isPasswordInputFocused} />
<PasswordStrengthIndicator
password={watch("password") ?? ""}
isFocused={isPasswordInputFocused}
/>
</div>
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="confirm_password">

View File

@@ -65,8 +65,7 @@
"swr": "^2.1.3",
"tailwind-merge": "^2.0.0",
"use-font-face-observer": "^1.2.2",
"uuid": "^9.0.0",
"zxcvbn": "^4.4.2"
"uuid": "^9.0.0"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -80,7 +79,6 @@
"@types/react-color": "^3.0.6",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^8.3.4",
"@types/zxcvbn": "^4.4.4",
"prettier": "^3.2.5",
"typescript": "5.8.3"
}

View File

@@ -2,3 +2,4 @@ export * from "./input";
export * from "./textarea";
export * from "./input-color-picker";
export * from "./checkbox";
export * from "./password";

View File

@@ -0,0 +1,65 @@
import { E_PASSWORD_STRENGTH } from "@plane/constants";
export interface StrengthInfo {
message: string;
textColor: string;
activeFragments: number;
}
/**
* Get strength information including message, color, and active fragments
*/
export const getStrengthInfo = (strength: E_PASSWORD_STRENGTH): StrengthInfo => {
switch (strength) {
case E_PASSWORD_STRENGTH.EMPTY:
return {
message: "Please enter your password",
textColor: "text-custom-text-100",
activeFragments: 0,
};
case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID:
return {
message: "Password is too short",
textColor: "text-red-500",
activeFragments: 1,
};
case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID:
return {
message: "Password is weak",
textColor: "text-orange-500",
activeFragments: 2,
};
case E_PASSWORD_STRENGTH.STRENGTH_VALID:
return {
message: "Password is strong",
textColor: "text-green-500",
activeFragments: 3,
};
default:
return {
message: "Please enter your password",
textColor: "text-custom-text-100",
activeFragments: 0,
};
}
};
/**
* Get fragment color based on position and active state
*/
export const getFragmentColor = (fragmentIndex: number, activeFragments: number): string => {
if (fragmentIndex >= activeFragments) {
return "bg-custom-background-90";
}
switch (activeFragments) {
case 1:
return "bg-red-500";
case 2:
return "bg-orange-500";
case 3:
return "bg-green-500";
default:
return "bg-custom-background-90";
}
};

View File

@@ -0,0 +1,2 @@
export * from "./indicator";
export * from "./helper";

View File

@@ -0,0 +1,75 @@
import { CircleCheck } from "lucide-react";
import React from "react";
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { cn, getPasswordStrength, getPasswordCriteria } from "@plane/utils";
import { getStrengthInfo, getFragmentColor } from "./helper";
export interface PasswordStrengthIndicatorProps {
password: string;
showCriteria?: boolean;
isFocused?: boolean;
}
export const PasswordStrengthIndicator: React.FC<PasswordStrengthIndicatorProps> = ({
password,
showCriteria = true,
isFocused = false,
}) => {
const strength = getPasswordStrength(password);
const criteria = getPasswordCriteria(password);
const strengthInfo = getStrengthInfo(strength);
const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
if ((!password && !showCriteria) || !isPasswordMeterVisible) {
return null;
}
return (
<div className={cn("space-y-3")}>
{/* Strength Indicator */}
<div className="space-y-2">
<div className="flex gap-1 w-full transition-all duration-300 ease-linear">
{[0, 1, 2].map((fragmentIndex) => (
<div
key={fragmentIndex}
className={cn(
"h-1 flex-1 rounded-sm transition-all duration-300 ease-in-out",
getFragmentColor(fragmentIndex, strengthInfo.activeFragments)
)}
/>
))}
</div>
{/* Strength Message */}
{password && <p className={cn("text-sm font-medium", strengthInfo.textColor)}>{strengthInfo.message}</p>}
</div>
{/* Criteria list */}
{showCriteria && (
<div className="flex flex-wrap gap-2">
{criteria.map((criterion) => (
<div key={criterion.key} className="flex items-center gap-1.5">
<div className="flex items-center justify-center p-0.5">
<CircleCheck
className={cn("h-3 w-3 flex-shrink-0", {
"text-green-500": criterion.isValid,
"text-custom-text-100": !criterion.isValid,
})}
/>
</div>
<span
className={cn("text-xs", {
"text-green-500": criterion.isValid,
"text-custom-text-100": !criterion.isValid,
})}
>
{criterion.label}
</span>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -29,8 +29,7 @@
"lodash": "^4.17.21",
"react": "^18.3.1",
"tailwind-merge": "^2.5.5",
"uuid": "^10.0.0",
"zxcvbn": "^4.4.2"
"uuid": "^10.0.0"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -38,7 +37,6 @@
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.5",
"tsup": "8.4.0",
"typescript": "5.8.3"
}

View File

@@ -1,15 +1,8 @@
"use client";
import { ReactNode } from "react";
import zxcvbn from "zxcvbn";
// plane imports
import {
E_PASSWORD_STRENGTH,
PASSWORD_MIN_LENGTH,
EErrorAlertType,
EAuthErrorCodes,
TAuthErrorInfo,
} from "@plane/constants";
import { E_PASSWORD_STRENGTH, EErrorAlertType, EAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
/**
* @description Password strength levels
@@ -23,71 +16,67 @@ export enum PasswordStrength {
}
/**
* @description Password strength criteria type
* Calculate password strength based on various criteria
*/
export type PasswordCriterion = {
regex: RegExp;
description: string;
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
if (!password || password === "" || password.length <= 0) {
return E_PASSWORD_STRENGTH.EMPTY;
}
if (password.length < 8) {
return E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
}
// Check all criteria
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasDigit = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*()\-_+=\[\]{}|;:'",.<>?/]/.test(password);
if (hasUpperCase && hasLowerCase && hasDigit && hasSpecialChar) {
return E_PASSWORD_STRENGTH.STRENGTH_VALID;
}
return E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
};
export type PasswordCriteria = {
key: string;
label: string;
isValid: boolean;
};
/**
* @description Password strength criteria
* Get password criteria for validation display
*/
export const PASSWORD_CRITERIA = [
export const getPasswordCriteria = (password: string): PasswordCriteria[] => [
{
key: "min_8_char",
key: "length",
label: "Min 8 characters",
isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH,
isValid: password.length >= 8,
},
{
key: "uppercase",
label: "Min 1 upper-case letter",
isValid: /[A-Z]/.test(password),
},
{
key: "lowercase",
label: "Min 1 lower-case letter",
isValid: /[a-z]/.test(password),
},
{
key: "number",
label: "Min 1 number",
isValid: /[0-9]/.test(password),
},
{
key: "special",
label: "Min 1 special character",
isValid: /[!@#$%^&*()\-_+=\[\]{}|;:'",.<>?/]/.test(password),
},
// {
// key: "min_1_upper_case",
// label: "Min 1 upper-case letter",
// isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
// },
// {
// key: "min_1_number",
// label: "Min 1 number",
// isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
// },
// {
// key: "min_1_special_char",
// label: "Min 1 special character",
// isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
// },
];
// Password strength check
export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
if (!password || password === "" || password.length <= 0) {
return passwordStrength;
}
if (password.length >= PASSWORD_MIN_LENGTH) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
} else {
passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
return passwordStrength;
}
const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
(criterion) => criterion
);
const passwordStrengthScore = zxcvbn(password).score;
if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
return passwordStrength;
}
if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) {
passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID;
}
return passwordStrength;
};
// Error code messages
const errorCodeMessages: {
[key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode };

View File

@@ -2764,12 +2764,7 @@
dependencies:
"@types/node" "*"
"@types/zxcvbn@^4.4.4", "@types/zxcvbn@^4.4.5":
version "4.4.5"
resolved "https://registry.yarnpkg.com/@types/zxcvbn/-/zxcvbn-4.4.5.tgz#8ce8623ed7a36e3a76d1c0b539708dfb2e859bc0"
integrity sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==
"@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin@^8.36.0", "@typescript-eslint/eslint-plugin@^8.6.0":
"@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin@^8.6.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz#332392883f936137cd6252c8eb236d298e514e70"
integrity sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==
@@ -2784,6 +2779,21 @@
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/eslint-plugin@^8.36.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz#6e5220d16f2691ab6d983c1737dd5b36e17641b7"
integrity sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.38.0"
"@typescript-eslint/type-utils" "8.38.0"
"@typescript-eslint/utils" "8.38.0"
"@typescript-eslint/visitor-keys" "8.38.0"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser@^8.6.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.37.0.tgz#b87f6b61e25ad5cc5bbf8baf809b8da889c89804"
@@ -2804,6 +2814,15 @@
"@typescript-eslint/types" "^8.37.0"
debug "^4.3.4"
"@typescript-eslint/project-service@8.38.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.38.0.tgz#4900771f943163027fd7d2020a062892056b5e2f"
integrity sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.38.0"
"@typescript-eslint/types" "^8.38.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz#a31a3c80ca2ef4ed58de13742debb692e7d4c0a4"
@@ -2812,11 +2831,24 @@
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/visitor-keys" "8.37.0"
"@typescript-eslint/scope-manager@8.38.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz#5a0efcb5c9cf6e4121b58f87972f567c69529226"
integrity sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==
dependencies:
"@typescript-eslint/types" "8.38.0"
"@typescript-eslint/visitor-keys" "8.38.0"
"@typescript-eslint/tsconfig-utils@8.37.0", "@typescript-eslint/tsconfig-utils@^8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz#47a2760d265c6125f8e7864bc5c8537cad2bd053"
integrity sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==
"@typescript-eslint/tsconfig-utils@8.38.0", "@typescript-eslint/tsconfig-utils@^8.38.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz#6de4ce224a779601a8df667db56527255c42c4d0"
integrity sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==
"@typescript-eslint/type-utils@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz#2a682e4c6ff5886712dad57e9787b5e417124507"
@@ -2828,11 +2860,27 @@
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/type-utils@8.38.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz#a56cd84765fa6ec135fe252b5db61e304403a85b"
integrity sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==
dependencies:
"@typescript-eslint/types" "8.38.0"
"@typescript-eslint/typescript-estree" "8.38.0"
"@typescript-eslint/utils" "8.38.0"
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.37.0", "@typescript-eslint/types@^8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.37.0.tgz#09517aa9625eb3c68941dde3ac8835740587b6ff"
integrity sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==
"@typescript-eslint/types@8.38.0", "@typescript-eslint/types@^8.38.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.38.0.tgz#297351c994976b93c82ac0f0e206c8143aa82529"
integrity sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==
"@typescript-eslint/typescript-estree@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz#a07e4574d8e6e4355a558f61323730c987f5fcbc"
@@ -2849,6 +2897,22 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/typescript-estree@8.38.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz#82262199eb6778bba28a319e25ad05b1158957df"
integrity sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==
dependencies:
"@typescript-eslint/project-service" "8.38.0"
"@typescript-eslint/tsconfig-utils" "8.38.0"
"@typescript-eslint/types" "8.38.0"
"@typescript-eslint/visitor-keys" "8.38.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
minimatch "^9.0.4"
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.37.0.tgz#189ea59b2709f5d898614611f091a776751ee335"
@@ -2859,6 +2923,16 @@
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/typescript-estree" "8.37.0"
"@typescript-eslint/utils@8.38.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.38.0.tgz#5f10159899d30eb92ba70e642ca6f754bddbf15a"
integrity sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.38.0"
"@typescript-eslint/types" "8.38.0"
"@typescript-eslint/typescript-estree" "8.38.0"
"@typescript-eslint/visitor-keys@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz#cdb6a6bd3e8d6dd69bd70c1bdda36e2d18737455"
@@ -2867,6 +2941,14 @@
"@typescript-eslint/types" "8.37.0"
eslint-visitor-keys "^4.2.1"
"@typescript-eslint/visitor-keys@8.38.0":
version "8.38.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz#a9765a527b082cb8fc60fd8a16e47c7ad5b60ea5"
integrity sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==
dependencies:
"@typescript-eslint/types" "8.38.0"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.2.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
@@ -10842,8 +10924,3 @@ zod@^3.22.2:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
zxcvbn@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30"
integrity sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==