1- import { useState } from 'react' ;
1+ import { useEffect , useMemo , useState } from 'react' ;
22import { Link , useNavigate } from 'react-router-dom' ;
33import { useAuth } from '../../contexts/useAuth' ;
44import { authApi } from '../../lib/api' ;
@@ -12,11 +12,102 @@ export default function Login() {
1212 email : '' ,
1313 password : '' ,
1414 } ) ;
15+ const [ lockoutSecondsRemaining , setLockoutSecondsRemaining ] = useState ( 0 ) ;
16+ const [ lockedEmail , setLockedEmail ] = useState ( '' ) ;
1517
18+ /**
19+ * Normalizes an email address for stable lockout comparison.
20+ * @param {string } value - Raw email input value.
21+ * @returns {string } Normalized email.
22+ */
23+ const normalizeEmail = ( value ) => String ( value || '' ) . trim ( ) . toLowerCase ( ) ;
24+
25+ /**
26+ * Extracts the retry window in seconds from a lockout API error.
27+ * @param {Error & { response?: { data?: object, headers?: object } } } error - Axios error.
28+ * @returns {number } Retry delay in seconds.
29+ */
30+ const parseRetryAfterSeconds = ( error ) => {
31+ const payload = error ?. response ?. data || { } ;
32+
33+ const retryAfterFromBody = Number ( payload ?. retryAfterSeconds ) ;
34+ if ( Number . isFinite ( retryAfterFromBody ) && retryAfterFromBody > 0 ) {
35+ return Math . floor ( retryAfterFromBody ) ;
36+ }
37+
38+ const retryAfterHeader = Number ( error ?. response ?. headers ?. [ 'retry-after' ] ) ;
39+ if ( Number . isFinite ( retryAfterHeader ) && retryAfterHeader > 0 ) {
40+ return Math . floor ( retryAfterHeader ) ;
41+ }
42+
43+ const message = String ( payload ?. message || payload ?. error || '' ) ;
44+ const secondsMatch = message . match ( / ( \d + ) \s * s e c o n d s ? / i) ;
45+ if ( secondsMatch ) {
46+ return Number ( secondsMatch [ 1 ] ) ;
47+ }
48+
49+ return 60 ;
50+ } ;
51+
52+ useEffect ( ( ) => {
53+ if ( lockoutSecondsRemaining <= 0 ) return undefined ;
54+
55+ const timer = window . setInterval ( ( ) => {
56+ setLockoutSecondsRemaining ( ( current ) => Math . max ( 0 , current - 1 ) ) ;
57+ } , 1000 ) ;
58+
59+ return ( ) => window . clearInterval ( timer ) ;
60+ } , [ lockoutSecondsRemaining ] ) ;
61+
62+ const isLockoutActive = useMemo ( ( ) => {
63+ return (
64+ lockoutSecondsRemaining > 0 &&
65+ normalizeEmail ( formData . email ) !== '' &&
66+ normalizeEmail ( formData . email ) === lockedEmail
67+ ) ;
68+ } , [ formData . email , lockoutSecondsRemaining , lockedEmail ] ) ;
69+
70+ /**
71+ * Updates the email field in the login form.
72+ * @param {React.ChangeEvent<HTMLInputElement> } e - Email input change event.
73+ */
74+ const handleEmailChange = ( e ) => {
75+ setFormData ( ( current ) => ( { ...current , email : e . target . value } ) ) ;
76+ } ;
77+
78+ /**
79+ * Updates the password field in the login form.
80+ * @param {React.ChangeEvent<HTMLInputElement> } e - Password input change event.
81+ */
82+ const handlePasswordChange = ( e ) => {
83+ setFormData ( ( current ) => ( { ...current , password : e . target . value } ) ) ;
84+ } ;
85+
86+ /**
87+ * Navigates to the dashboard after a successful login mutation.
88+ */
89+ const handleLoginSuccess = ( ) => {
90+ navigate ( '/' ) ;
91+ } ;
92+
93+ /**
94+ * Submits the login form and forwards successful sign-ins to the home page.
95+ * @param {React.FormEvent<HTMLFormElement> } e - Form submit event.
96+ * @returns {Promise<void> }
97+ */
1698 const handleSubmit = async ( e ) => {
1799 e . preventDefault ( ) ;
100+ if ( isLockoutActive ) return ;
101+
18102 login ( formData , {
19- onSuccess : ( ) => navigate ( '/' ) ,
103+ onSuccess : handleLoginSuccess ,
104+ onError : ( error ) => {
105+ if ( error ?. response ?. status === 423 ) {
106+ const seconds = parseRetryAfterSeconds ( error ) ;
107+ setLockedEmail ( normalizeEmail ( formData . email ) ) ;
108+ setLockoutSecondsRemaining ( seconds ) ;
109+ }
110+ } ,
20111 } ) ;
21112 } ;
22113
@@ -49,7 +140,7 @@ export default function Login() {
49140 label = "Email"
50141 placeholder = "Enter your email"
51142 value = { formData . email }
52- onChange = { ( e ) => setFormData ( { ... formData , email : e . target . value } ) }
143+ onChange = { handleEmailChange }
53144 required
54145 />
55146
@@ -58,11 +149,17 @@ export default function Login() {
58149 label = "Password"
59150 placeholder = "Enter your password"
60151 value = { formData . password }
61- onChange = { ( e ) => setFormData ( { ... formData , password : e . target . value } ) }
152+ onChange = { handlePasswordChange }
62153 required
63154 />
64155
65- { loginError && (
156+ { isLockoutActive && (
157+ < div className = "p-3 bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-500 rounded-lg text-yellow-700 dark:text-yellow-300 text-sm" >
158+ Too many failed attempts. Try again in { lockoutSecondsRemaining } seconds.
159+ </ div >
160+ ) }
161+
162+ { loginError && ! isLockoutActive && (
66163 < div className = "p-3 bg-red-100 dark:bg-red-900/20 border border-red-500 rounded-lg text-red-500 text-sm" >
67164 { loginError . response ?. data ?. message || 'Failed to login. Please check your credentials.' }
68165 </ div >
@@ -72,9 +169,9 @@ export default function Login() {
72169 type = "submit"
73170 className = "w-full"
74171 size = "lg"
75- disabled = { isLoginLoading }
172+ disabled = { isLoginLoading || isLockoutActive }
76173 >
77- { isLoginLoading ? 'Signing in...' : 'Sign In' }
174+ { isLoginLoading ? 'Signing in...' : isLockoutActive ? `Locked ( ${ lockoutSecondsRemaining } s)` : 'Sign In' }
78175 </ Button >
79176
80177 < div className = "relative py-2" >
0 commit comments