From 3542751f070e117b27f41309277eb3f5fe0d3b2a Mon Sep 17 00:00:00 2001 From: NAYAMATVISION Date: Mon, 25 May 2026 19:17:23 +0530 Subject: [PATCH 1/4] feat(auth): add frontend and backend password strength validation --- apps/web-dashboard/package.json | 3 +- .../src/components/PasswordStrengthMeter.jsx | 101 ++++++++++++++++++ .../src/pages/ForgotPassword.jsx | 25 ++++- apps/web-dashboard/src/pages/Settings.jsx | 25 ++++- apps/web-dashboard/src/pages/Signup.jsx | 55 ++++------ package-lock.json | 12 ++- packages/common/package.json | 3 +- packages/common/src/index.js | 3 +- packages/common/src/utils/input.validation.js | 28 +++++ packages/common/src/utils/passwordStrength.js | 26 +++++ 10 files changed, 237 insertions(+), 44 deletions(-) create mode 100644 apps/web-dashboard/src/components/PasswordStrengthMeter.jsx create mode 100644 packages/common/src/utils/passwordStrength.js diff --git a/apps/web-dashboard/package.json b/apps/web-dashboard/package.json index feba4a21..999616c1 100644 --- a/apps/web-dashboard/package.json +++ b/apps/web-dashboard/package.json @@ -27,7 +27,8 @@ "react-router-dom": "^7.9.6", "recharts": "^3.5.1", "remark-gfm": "^4.0.1", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/apps/web-dashboard/src/components/PasswordStrengthMeter.jsx b/apps/web-dashboard/src/components/PasswordStrengthMeter.jsx new file mode 100644 index 00000000..d66ecda9 --- /dev/null +++ b/apps/web-dashboard/src/components/PasswordStrengthMeter.jsx @@ -0,0 +1,101 @@ +import { useEffect, useMemo } from 'react'; +import zxcvbn from 'zxcvbn'; + +const MIN_SCORE = 2; + +const SCORE_CONFIG = [ + { label: 'Very weak', color: 'var(--color-danger)' }, + { label: 'Weak', color: '#f97316' }, + { label: 'Fair', color: '#facc15' }, + { label: 'Strong', color: 'var(--color-primary)' }, + { label: 'Very strong', color: 'var(--color-primary)' }, +]; + +function buildUserInputs(userInputs) { + const values = (userInputs || []).filter(Boolean).map((v) => String(v)); + const tokens = values.flatMap((v) => v.split(/[\s@.+_-]+/)); + return [...new Set([...values, ...tokens])].filter((v) => v.length > 2); +} + +function PasswordStrengthMeter({ password, userInputs, onStrengthChange }) { + const result = useMemo(() => { + if (!password) return null; + return zxcvbn(password, buildUserInputs(userInputs)); + }, [password, userInputs]); + + useEffect(() => { + if (!onStrengthChange) return; + if (!result) { + onStrengthChange({ score: 0, isStrongEnough: false }); + return; + } + onStrengthChange({ + score: result.score, + isStrongEnough: result.score >= MIN_SCORE, + }); + }, [result, onStrengthChange]); + + if (!password) return null; + + const { score, feedback } = result; + const config = SCORE_CONFIG[score]; + const barWidth = ((score + 1) / 5) * 100; + + return ( +
+ {/* Strength bar */} +
+
+
+ + {/* Score label */} +
+ + {config.label} + + {score < MIN_SCORE && ( + + Too weak to submit + + )} +
+ + {/* Warning */} + {feedback.warning && ( +

+ {feedback.warning} +

+ )} + + {/* Suggestions */} + {feedback.suggestions?.length > 0 && ( +
    + {feedback.suggestions.map((s) => ( +
  • + {s} +
  • + ))} +
+ )} +
+ ); +} + +export default PasswordStrengthMeter; diff --git a/apps/web-dashboard/src/pages/ForgotPassword.jsx b/apps/web-dashboard/src/pages/ForgotPassword.jsx index 68219050..b3f8bafd 100644 --- a/apps/web-dashboard/src/pages/ForgotPassword.jsx +++ b/apps/web-dashboard/src/pages/ForgotPassword.jsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { Eye, EyeOff, KeyRound, Lock, Mail } from 'lucide-react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import AuthShell from '../components/AuthShell'; +import PasswordStrengthMeter from '../components/PasswordStrengthMeter'; import { useAuth } from '../context/AuthContext'; import api from '../utils/api'; @@ -16,6 +17,7 @@ function ForgotPassword() { const [isSubmitting, setIsSubmitting] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [passwordStrength, setPasswordStrength] = useState({ score: 0, isStrongEnough: false }); const [formData, setFormData] = useState({ email: location.state?.email || '', otp: '', @@ -23,6 +25,11 @@ function ForgotPassword() { confirmPassword: '', }); + const userInputs = useMemo( + () => [formData.email], + [formData.email] + ); + useEffect(() => { if (!authLoading && isAuthenticated) { navigate('/dashboard', { replace: true }); @@ -92,6 +99,11 @@ function ForgotPassword() { return; } + if (!passwordStrength.isStrongEnough) { + toast.error('Please choose a stronger password.'); + return; + } + setIsSubmitting(true); const loadingToast = toast.loading('Resetting password...'); @@ -203,6 +215,11 @@ function ForgotPassword() { {showNewPassword ? : }
+
@@ -232,7 +249,11 @@ function ForgotPassword() {
- diff --git a/apps/web-dashboard/src/pages/Settings.jsx b/apps/web-dashboard/src/pages/Settings.jsx index 7395ad6c..75842ee0 100644 --- a/apps/web-dashboard/src/pages/Settings.jsx +++ b/apps/web-dashboard/src/pages/Settings.jsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import api from '../utils/api'; import { useAuth } from '../context/AuthContext'; import toast from 'react-hot-toast'; import { Lock, Trash2, AlertTriangle, Save, CheckCircle } from 'lucide-react'; import { API_URL } from '../config'; import ConfirmationModal from './ConfirmationModal'; +import PasswordStrengthMeter from '../components/PasswordStrengthMeter'; export default function Settings() { const { logout, user, isLoading } = useAuth(); @@ -12,6 +13,9 @@ export default function Settings() { // Password State const [passData, setPassData] = useState({ currentPassword: '', newPassword: '' }); const [loadingPass, setLoadingPass] = useState(false); + const [passwordStrength, setPasswordStrength] = useState({ score: 0, isStrongEnough: false }); + + const userInputs = useMemo(() => [user?.email], [user?.email]); // Delete Account State const [deletePass, setDeletePass] = useState(''); @@ -22,13 +26,21 @@ export default function Settings() { // Handle Password Change const handlePasswordChange = async (e) => { e.preventDefault(); + if (!passwordStrength.isStrongEnough) { + toast.error('Please choose a stronger password.'); + return; + } setLoadingPass(true); try { await api.put(`/api/auth/change-password`, passData); - toast.success("Password updated!"); + toast.success('Password updated!'); setPassData({ currentPassword: '', newPassword: '' }); } catch (err) { - toast.error(err.response?.data || "Failed to update password"); + const data = err.response?.data; + let message = 'Failed to update password'; + if (typeof data?.error === 'string') message = data.error; + else if (Array.isArray(data?.error)) message = data.error[0]?.message || message; + toast.error(message); } finally { setLoadingPass(false); } @@ -146,10 +158,15 @@ if (pageLoading) return ; minLength={6} style={{ width: '100%', padding: '12px', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '8px', color: '#fff' }} /> +
-
diff --git a/apps/web-dashboard/src/pages/Signup.jsx b/apps/web-dashboard/src/pages/Signup.jsx index 1df0fec9..b3deb88d 100644 --- a/apps/web-dashboard/src/pages/Signup.jsx +++ b/apps/web-dashboard/src/pages/Signup.jsx @@ -4,6 +4,7 @@ import { Eye, EyeOff, Github, Lock, Mail, UserRound } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import AuthShell from '../components/AuthShell'; +import PasswordStrengthMeter from '../components/PasswordStrengthMeter'; import { useAuth } from '../context/AuthContext'; import api from '../utils/api'; import { API_URL } from '../config'; @@ -19,6 +20,7 @@ function Signup() { }); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [passwordStrength, setPasswordStrength] = useState({ score: 0, isStrongEnough: false }); useEffect(() => { if (!authLoading && isAuthenticated) { @@ -26,33 +28,18 @@ function Signup() { } }, [authLoading, isAuthenticated, navigate]); - const passwordChecks = useMemo( - () => [ - { - label: 'At least 6 characters', - passed: formData.password.length >= 6, - }, - { - label: 'Contains a letter', - passed: /[A-Za-z]/.test(formData.password), - }, - { - label: 'Contains a number', - passed: /\d/.test(formData.password), - }, - ], - [formData.password] + const userInputs = useMemo( + () => [formData.name, formData.email], + [formData.name, formData.email] ); - if (authLoading) { - return null; - } - const handleChange = (event) => { const { name, value } = event.target; setFormData((current) => ({ ...current, [name]: value })); }; + if (authLoading) return null; + const handleSubmit = async (event) => { event.preventDefault(); @@ -61,6 +48,11 @@ function Signup() { return; } + if (!passwordStrength.isStrongEnough) { + toast.error('Please choose a stronger password.'); + return; + } + setIsSubmitting(true); const loadingToast = toast.loading('Creating your account...'); @@ -179,21 +171,18 @@ function Signup() { {showPassword ? : } + -
- {passwordChecks.map((check) => ( -
- - {check.label} -
- ))} -
- - diff --git a/package-lock.json b/package-lock.json index 7f68c65d..23f22751 100644 --- a/package-lock.json +++ b/package-lock.json @@ -107,7 +107,8 @@ "react-router-dom": "^7.9.6", "recharts": "^3.5.1", "remark-gfm": "^4.0.1", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -14312,6 +14313,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" + }, "packages/common": { "name": "@urbackend/common", "version": "0.10.0", @@ -14334,7 +14341,8 @@ "multer": "^2.0.2", "resend": "^6.6.0", "uuid": "^9.0.1", - "zod": "^4.1.13" + "zod": "^4.1.13", + "zxcvbn": "^4.4.2" } }, "sdks/urbackend-sdk": { diff --git a/packages/common/package.json b/packages/common/package.json index 2affd6e1..c557c499 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -22,6 +22,7 @@ "multer": "^2.0.2", "resend": "^6.6.0", "uuid": "^9.0.1", - "zod": "^4.1.13" + "zod": "^4.1.13", + "zxcvbn": "^4.4.2" } } diff --git a/packages/common/src/index.js b/packages/common/src/index.js index b9627f3f..88755560 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -113,7 +113,7 @@ const sessionManager = require("./utils/session.manager"); const planLimits = require("./utils/planLimits"); const AppError = require("./utils/AppError"); const { checkLockout, recordFailedAttempt, clearLockout } = require("./utils/loginLockout"); - +const { validatePasswordStrength } = require("./utils/passwordStrength"); module.exports = { connectDB, redis, @@ -213,4 +213,5 @@ module.exports = { trashCleanupQueue, enqueueCollectionCleanup, initTrashCleanupWorker, + validatePasswordStrength, }; diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index a44ffb2f..1d58b645 100755 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -4,6 +4,7 @@ const { MAX_FIELD_DEPTH, UNIQUE_SUPPORTED_TYPES, } = require("./schema.constants"); +const { validatePasswordStrength } = require("./passwordStrength"); module.exports.loginSchema = z.object({ email: z @@ -15,6 +16,15 @@ module.exports.loginSchema = z.object({ .string() .min(6, { message: "Password must be at least 6 characters" }) .max(100, { message: "Password is too long." }), +}).superRefine((data, ctx) => { + const result = validatePasswordStrength(data.password); + if (result) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["password"], + message: result.message, + }); + } }); module.exports.signupSchema = z.object({ @@ -38,6 +48,15 @@ module.exports.signupSchema = z.object({ module.exports.changePasswordSchema = z.object({ currentPassword: z.string().min(1, "Current password is required"), newPassword: z.string().min(6, "New password must be at least 6 characters"), +}).superRefine((data, ctx) => { + const result = validatePasswordStrength(data.newPassword); + if (result) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["newPassword"], + message: result.message, + }); + } }); module.exports.deleteAccountSchema = z.object({ @@ -60,6 +79,15 @@ module.exports.resetPasswordSchema = z.object({ .string() .min(6, "Password must be at least 6 characters") .max(100, "Password is too long."), +}).superRefine((data, ctx) => { + const result = validatePasswordStrength(data.newPassword); + if (result) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["newPassword"], + message: result.message, + }); + } }); module.exports.createProjectSchema = z.object({ diff --git a/packages/common/src/utils/passwordStrength.js b/packages/common/src/utils/passwordStrength.js new file mode 100644 index 00000000..78228d86 --- /dev/null +++ b/packages/common/src/utils/passwordStrength.js @@ -0,0 +1,26 @@ +const zxcvbn = require("zxcvbn"); + +const MIN_SCORE = 2; +const WEAK_PASSWORD_MESSAGE = + "Password is too weak. Try adding numbers, symbols, or more characters."; + +/** + * Validates password strength using zxcvbn. + * @param {string} password + * @returns {{ message: string } | null} + */ +function validatePasswordStrength(password) { + if (typeof password !== "string" || password.length === 0) { + return { message: WEAK_PASSWORD_MESSAGE }; + } + + const result = zxcvbn(password); + + if (result.score < MIN_SCORE) { + return { message: WEAK_PASSWORD_MESSAGE }; + } + + return null; +} + +module.exports = { validatePasswordStrength }; From 18064674d5a9b70b4b8bb0f24046f9dfa9e010ce Mon Sep 17 00:00:00 2001 From: NAYAMATVISION Date: Mon, 25 May 2026 20:02:40 +0530 Subject: [PATCH 2/4] fix(auth): enforce password strength on signup flows only --- packages/common/src/utils/input.validation.js | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index 1d58b645..fbd06e47 100755 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -16,15 +16,6 @@ module.exports.loginSchema = z.object({ .string() .min(6, { message: "Password must be at least 6 characters" }) .max(100, { message: "Password is too long." }), -}).superRefine((data, ctx) => { - const result = validatePasswordStrength(data.password); - if (result) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["password"], - message: result.message, - }); - } }); module.exports.signupSchema = z.object({ @@ -43,6 +34,15 @@ module.exports.signupSchema = z.object({ .string() .min(6, { message: "Password must be at least 6 characters." }) .max(100, { message: "Password is too long." }), +}).superRefine((data, ctx) => { + const result = validatePasswordStrength(data.password); + if (result) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["password"], + message: result.message, + }); + } }); module.exports.changePasswordSchema = z.object({ @@ -536,6 +536,15 @@ module.exports.userSignupSchema = z.object({ .string() .min(6, { message: "Password must be at least 6 characters." }) .max(100, { message: "Password is too long." }), +}).superRefine((data, ctx) => { + const result = validatePasswordStrength(data.password); + if (result) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["password"], + message: result.message, + }); + } }); // Webhook event config schema for per-collection events From 94973ff1dbdab89071143ed29c26452bf054c404 Mon Sep 17 00:00:00 2001 From: NAYAMATVISION Date: Tue, 26 May 2026 12:32:55 +0530 Subject: [PATCH 3/4] fix(auth): enforce password strength for dashboard registration --- .../src/controllers/auth.controller.js | 3 ++- packages/common/src/index.js | 2 ++ packages/common/src/utils/input.validation.js | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/dashboard-api/src/controllers/auth.controller.js b/apps/dashboard-api/src/controllers/auth.controller.js index 79811469..d4d2566d 100644 --- a/apps/dashboard-api/src/controllers/auth.controller.js +++ b/apps/dashboard-api/src/controllers/auth.controller.js @@ -8,6 +8,7 @@ const { sendOtp } = require("@urbackend/common"); const crypto = require("crypto"); const { loginSchema, + registerSchema, changePasswordSchema, deleteAccountSchema, onlyEmailSchema, @@ -274,7 +275,7 @@ async function checkOtpCooldown(userId) { module.exports.register = async (req, res) => { try { - const { email, password } = loginSchema.parse(req.body); + const { email, password } = registerSchema.parse(req.body); const existingUser = await Developer.findOne({ email }); if (existingUser) return res.status(400).json({ error: "Email already exists" }); diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 88755560..d1fdca85 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -70,6 +70,7 @@ const { const { loginSchema, signupSchema, + registerSchema, changePasswordSchema, deleteAccountSchema, onlyEmailSchema, @@ -138,6 +139,7 @@ module.exports = { sendProRequestConfirmationEmail, loginSchema, signupSchema, + registerSchema, changePasswordSchema, deleteAccountSchema, onlyEmailSchema, diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index fbd06e47..68b47aa6 100755 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -18,6 +18,27 @@ module.exports.loginSchema = z.object({ .max(100, { message: "Password is too long." }), }); +module.exports.registerSchema = z.object({ + email: z + .string() + .min(1, { message: "Email is required." }) + .email({ message: "Invalid email format." }) + .max(100, { message: "Email is too long." }), + password: z + .string() + .min(6, { message: "Password must be at least 6 characters" }) + .max(100, { message: "Password is too long." }), +}).superRefine((data, ctx) => { + const result = validatePasswordStrength(data.password); + if (result) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["password"], + message: result.message, + }); + } +}); + module.exports.signupSchema = z.object({ username: z .string() From a37f5887afef6eb2421b8122e5fc4891d0542069 Mon Sep 17 00:00:00 2001 From: NAYAMATVISION Date: Tue, 26 May 2026 12:39:32 +0530 Subject: [PATCH 4/4] test(auth): add registerSchema to dashboard auth mock --- apps/dashboard-api/src/__tests__/auth.controller.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/dashboard-api/src/__tests__/auth.controller.test.js b/apps/dashboard-api/src/__tests__/auth.controller.test.js index 40c70c5b..c6e09a62 100644 --- a/apps/dashboard-api/src/__tests__/auth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/auth.controller.test.js @@ -70,6 +70,10 @@ jest.mock('@urbackend/common', () => { email: z.string().email(), password: z.string().min(1), }), + registerSchema: z.object({ + email: z.string().email(), + password: z.string().min(1), + }), changePasswordSchema: z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(6),