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), 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/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 && ( + + )} +
+ ); +} + +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 b1cd27b7..3caff558 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -71,6 +71,7 @@ const { const { loginSchema, signupSchema, + registerSchema, changePasswordSchema, deleteAccountSchema, onlyEmailSchema, @@ -114,6 +115,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"); const { dispatchWebhooks } = require("./utils/webhookDispatcher"); const { getDayKey, getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("./utils/usageCounter"); @@ -141,6 +143,7 @@ module.exports = { sendProRequestConfirmationEmail, loginSchema, signupSchema, + registerSchema, changePasswordSchema, deleteAccountSchema, onlyEmailSchema, @@ -217,6 +220,7 @@ module.exports = { enqueueCollectionCleanup, syncCollectionCleanup, initTrashCleanupWorker, + validatePasswordStrength, dispatchWebhooks, getDayKey, getMonthKey, diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index 67b9f14b..05b592c0 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 @@ -17,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() @@ -33,11 +55,29 @@ 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({ 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 +100,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({ @@ -508,6 +557,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 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 };