Skip to content

Commit 9d75089

Browse files
Merge pull request #199 from Nitin-kumar-yadav1307/feature/web-dashboard-login-lockout-423
fix(web): handle 423 login lockout with retry countdown
2 parents 06a4e17 + 1e91b8e commit 9d75089

1 file changed

Lines changed: 104 additions & 7 deletions

File tree

  • examples/social-demo/client/src/pages/auth

‎examples/social-demo/client/src/pages/auth/Login.jsx‎

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react';
1+
import { useEffect, useMemo, useState } from 'react';
22
import { Link, useNavigate } from 'react-router-dom';
33
import { useAuth } from '../../contexts/useAuth';
44
import { 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*seconds?/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

Comments
 (0)