-
Notifications
You must be signed in to change notification settings - Fork 50
feat(auth): add zxcvbn-based password strength validation across auth flows #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3542751
1806467
94973ff
a37f588
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div style={{ marginTop: '0.5rem' }}> | ||
| {/* Strength bar */} | ||
| <div style={{ | ||
| height: '4px', | ||
| borderRadius: '2px', | ||
| background: 'var(--color-border)', | ||
| overflow: 'hidden', | ||
| marginBottom: '0.4rem', | ||
| }}> | ||
| <div style={{ | ||
| height: '100%', | ||
| width: `${barWidth}%`, | ||
| background: config.color, | ||
| borderRadius: '2px', | ||
| transition: 'width 0.3s ease, background 0.3s ease', | ||
| }} /> | ||
| </div> | ||
|
|
||
| {/* Score label */} | ||
| <div style={{ | ||
| display: 'flex', | ||
| justifyContent: 'space-between', | ||
| alignItems: 'center', | ||
| marginBottom: feedback.warning || feedback.suggestions?.length ? '0.4rem' : 0, | ||
| }}> | ||
| <span style={{ fontSize: '0.78rem', color: config.color, fontWeight: 600 }}> | ||
| {config.label} | ||
| </span> | ||
| {score < MIN_SCORE && ( | ||
| <span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}> | ||
| Too weak to submit | ||
| </span> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* Warning */} | ||
| {feedback.warning && ( | ||
| <p style={{ fontSize: '0.78rem', color: 'var(--color-text-muted)', margin: '0 0 0.25rem' }}> | ||
| {feedback.warning} | ||
| </p> | ||
| )} | ||
|
|
||
| {/* Suggestions */} | ||
| {feedback.suggestions?.length > 0 && ( | ||
| <ul style={{ margin: 0, padding: '0 0 0 1rem' }}> | ||
| {feedback.suggestions.map((s) => ( | ||
| <li key={s} style={{ fontSize: '0.78rem', color: 'var(--color-text-muted)', marginBottom: '0.15rem' }}> | ||
| {s} | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| )} | ||
|
Comment on lines
+45
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add ARIA semantics for live password-strength feedback. The meter and “Too weak to submit” feedback are currently visual-only. Add progress semantics and a polite live region so assistive tech announces strength changes. ♿ Suggested patch- return (
- <div style={{ marginTop: '0.5rem' }}>
+ return (
+ <div style={{ marginTop: '0.5rem' }} aria-live="polite">
{/* Strength bar */}
- <div style={{
+ <div
+ role="progressbar"
+ aria-label="Password strength"
+ aria-valuemin={0}
+ aria-valuemax={4}
+ aria-valuenow={score}
+ aria-valuetext={config.label}
+ style={{
height: '4px',
borderRadius: '2px',
background: 'var(--color-border)',
overflow: 'hidden',
marginBottom: '0.4rem',
- }}>
+ }}>
<div style={{
height: '100%',
width: `${barWidth}%`,
background: config.color,
borderRadius: '2px',
transition: 'width 0.3s ease, background 0.3s ease',
}} />
</div>🤖 Prompt for AI Agents |
||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default PasswordStrengthMeter; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,13 +17,19 @@ 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: '', | ||
| newPassword: '', | ||
| 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; | ||
| } | ||
|
Comment on lines
+102
to
+105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Short answer: No—React doesn’t let a child component’s passive effect (useEffect) “race” with the parent’s render/paint such that a parent event handler (like an onSubmit) observes the previous state for one render. However, state updates scheduled from effects are not guaranteed to be applied before paint in all circumstances, so you also shouldn’t rely on useEffect timing for “next render” ordering unless you structure code around explicit user events or use layout effects when you must block painting. What the React docs guarantee / imply: - useEffect callbacks are deferred relative to the browser paint in the general case, and are run after layout and paint via a deferred event [1]. - React guarantees that even when effects are deferred until after the browser has painted, React will flush a previous render’s effects before starting a new update (so updates from the effect can’t be missed or applied “after” a newer render has begun) [2]. What that means for your scenario (child useEffect updates state used by a parent submit guard): - If the submit guard logic runs in response to a user interaction (e.g., the onSubmit handler), React will process updates such that the “next” UI/event system view isn’t allowed to “skip” the update that was scheduled earlier; useEffect timing is about when the effect runs, not about producing a one-render-old observable state to an unrelated later user event [1][2]. - Also, React’s own docs note that timing can differ for certain cases (e.g., effects caused by discrete user input or flushSync may be processed differently, and it’s also not guaranteed that useEffect always runs after paint) [1][3]. So while you typically won’t see a parent submit guard observe a previous value “for one render” due to passive-effect updates, you should not design the app assuming a strict paint-phase ordering across all scheduling situations. If you need the parent guard to observe the child-driven state change immediately before a user event: - Don’t depend on child useEffect for that; instead, update the parent state in the same interaction path (e.g., lift state up and call the parent’s state update directly, or call setState in an event handler). This avoids the asynchronous/deferred nature of passive effects [1][2]. - If you truly must block paint and ensure ordering with layout, useLayoutEffect is the correct tool (but note it has different performance/ordering semantics and can effectively make passive effects flush earlier) [4][2][5]. Conclusion: - In React 19, a passive child useEffect-driven state update is not expected to be applied “after render/paint” in a way that causes the parent’s next submit guard to see the previous value for exactly one render. React flushes previous effects before starting a new update [2]. - But you should avoid assuming a rigid “after paint” timing relationship for correctness, because React documentation and discussions note that effect timing can vary in special cases [1][3]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="apps/web-dashboard/src/pages/ForgotPassword.jsx"
# Show the relevant sections around the mentioned line ranges
nl -ba "$FILE" | sed -n '70,140p'
nl -ba "$FILE" | sed -n '190,260p'Repository: geturbackend/urBackend Length of output: 109 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="apps/web-dashboard/src/pages/ForgotPassword.jsx"
# Print with line numbers (avoid nl since it's missing)
echo "== $FILE : lines ~70-140 =="
cat -n "$FILE" | sed -n '70,140p'
echo
echo "== $FILE : lines ~190-260 =="
cat -n "$FILE" | sed -n '190,260p'Repository: geturbackend/urBackend Length of output: 5865 🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) Find where PasswordStrengthMeter is defined/implemented
rg -n "function PasswordStrengthMeter|const PasswordStrengthMeter|PasswordStrengthMeter" apps/web-dashboard/src -S
# 2) Find the prop name `onStrengthChange` usage
rg -n "onStrengthChange" apps/web-dashboard/src -S
# 3) Inspect how `handleResetPassword` is connected (form onSubmit / button type submit)
rg -n "handleResetPassword|onSubmit=\{handleResetPassword\}|type=\"submit\"" apps/web-dashboard/src/pages/ForgotPassword.jsx -nRepository: geturbackend/urBackend Length of output: 2041 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE1="apps/web-dashboard/src/components/PasswordStrengthMeter.jsx"
FILE2="apps/web-dashboard/src/pages/ForgotPassword.jsx"
echo "== $FILE1 : lines 1-140 =="
cat -n "$FILE1" | sed -n '1,160p'
echo
echo "== $FILE2 : lines 1-120 =="
cat -n "$FILE2" | sed -n '1,140p'
echo
echo "== $FILE2 : around form onSubmit + submit button area =="
cat -n "$FILE2" | sed -n '140,320p'Repository: geturbackend/urBackend Length of output: 15117 Avoid effect-lagged password strength in reset submit gating
🤖 Prompt for AI Agents |
||
|
|
||
| setIsSubmitting(true); | ||
| const loadingToast = toast.loading('Resetting password...'); | ||
|
|
||
|
|
@@ -203,6 +215,11 @@ function ForgotPassword() { | |
| {showNewPassword ? <EyeOff size={18} /> : <Eye size={18} />} | ||
| </button> | ||
| </div> | ||
| <PasswordStrengthMeter | ||
| password={formData.newPassword} | ||
| userInputs={userInputs} | ||
| onStrengthChange={setPasswordStrength} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="auth-field"> | ||
|
|
@@ -232,7 +249,11 @@ function ForgotPassword() { | |
| </div> | ||
| </div> | ||
|
|
||
| <button type="submit" className="btn btn-primary auth-submit" disabled={isSubmitting}> | ||
| <button | ||
| type="submit" | ||
| className="btn btn-primary auth-submit" | ||
| disabled={isSubmitting || (formData.newPassword.length > 0 && !passwordStrength.isStrongEnough)} | ||
| > | ||
| {isSubmitting ? 'Resetting...' : 'Reset password'} | ||
| </button> | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CRITICAL:
registerSchemais undefined at runtime.The pipeline failure indicates that
registerSchema.parsethrows "Cannot read properties of undefined (reading 'parse')", meaningregisterSchemaitself is undefined when this code executes. While the import statement on line 11 looks syntactically correct, the export chain from@urbackend/commonappears broken at runtime.Run the following script to verify the export chain:
If the script confirms the export exists, the dashboard-api package may need to reinstall dependencies or rebuild to pick up the changes:
Also applies to: 278-278
🤖 Prompt for AI Agents