Skip to content

Commit 9fac154

Browse files
committed
feat: implement rate limit error handling and cooldown mechanism in login modal; enhance password visibility toggle
1 parent 81b8d00 commit 9fac154

File tree

3 files changed

+111
-21
lines changed

3 files changed

+111
-21
lines changed

backend/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ const loginLimiter = rateLimit({
8282
limit: 5,
8383
standardHeaders: 'draft-7',
8484
legacyHeaders: false,
85+
message: { error: 'RateLimitExceeded', message: 'Too many login attempts. Please wait 1 minute.' },
86+
skip: (req) => {
87+
// Don't rate limit warmup requests
88+
return req.path === '/api/hevy/recaptcha/session-warmup';
89+
},
8590
});
8691

8792
const requireAuthTokenHeader = (req: express.Request): string => {

frontend/app/ui/appErrorMessages.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
export const getErrorMessage = (err: unknown): string => {
22
if (err instanceof Error && err.message) return err.message;
3-
return 'Failed to import CSV. Please export your workout data from the Hevy app and try again.';
3+
return 'Failed to import CSV. Please try again.';
44
};
55

6-
export const getHevyErrorMessage = (err: unknown): string => {
6+
export const getHevyErrorMessage = (err: unknown, cooldownSeconds?: number): string => {
77
if (err instanceof Error && err.message) {
88
const msg = err.message;
9+
// Rate limit error - show dynamic countdown
10+
if (msg.toLowerCase().includes('rate') || msg.toLowerCase().includes('429') || msg.toLowerCase().includes('too many requests')) {
11+
if (cooldownSeconds && cooldownSeconds > 0) {
12+
return `Too many login attempts. Please wait ${cooldownSeconds}s before trying again.`;
13+
}
14+
return 'Too many login attempts. Please wait 60s before trying again.';
15+
}
916
// "Load failed" is a Safari-specific error, often caused by content blockers, VPNs, or network issues
1017
if (msg.toLowerCase().includes('load failed') || msg.toLowerCase().includes('failed to fetch')) {
1118
return `Network error: ${msg}. This is often caused by content blockers, VPNs, or network issues. Try disabling ad blockers or switching browsers.`;

frontend/components/modals/auth/HevyLoginModal.tsx

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useEffect, useRef, useState } from 'react';
2-
import { ArrowLeft, ArrowRight, HelpCircle, Key, LogIn, RefreshCw, Trash2, Upload } from 'lucide-react';
2+
import { ArrowLeft, ArrowRight, Eye, EyeOff, HelpCircle, Key, LogIn, RefreshCw, Trash2, Upload } from 'lucide-react';
33
import { UNIFORM_HEADER_BUTTON_CLASS, UNIFORM_HEADER_ICON_BUTTON_CLASS } from '../../../utils/ui/uiConstants';
44
import { OnboardingModalShell } from '../ui/OnboardingModalShell';
55
import { HevyLoginHelp } from './HevyLoginHelp';
@@ -51,8 +51,71 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({
5151
const [password, setPassword] = useState('');
5252
const [apiKey, setApiKey] = useState(() => getHevyProApiKey() || '');
5353
const [showLoginHelp, setShowLoginHelp] = useState(false);
54+
const [cooldownSeconds, setCooldownSeconds] = useState(0);
55+
const [failedAttempts, setFailedAttempts] = useState(0);
56+
const [showPassword, setShowPassword] = useState(false);
5457
const passwordTouchedRef = useRef(false);
5558
const warmupTriggeredRef = useRef(false);
59+
const passwordHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60+
61+
// Cooldown timer effect
62+
useEffect(() => {
63+
if (cooldownSeconds <= 0) return;
64+
const timer = setInterval(() => {
65+
setCooldownSeconds((prev) => {
66+
if (prev <= 1) {
67+
clearInterval(timer);
68+
return 0;
69+
}
70+
return prev - 1;
71+
});
72+
}, 1000);
73+
return () => clearInterval(timer);
74+
}, [cooldownSeconds > 0]);
75+
76+
// Trigger cooldown when rate limit error received or after 3 failed attempts
77+
useEffect(() => {
78+
if (!errorMessage || isLoading) return;
79+
80+
const isRateLimited = errorMessage?.toLowerCase().includes('rate') || errorMessage?.toLowerCase().includes('429') || errorMessage?.toLowerCase().includes('too many');
81+
const isAuthError = errorMessage?.toLowerCase().includes('invalid') || errorMessage?.toLowerCase().includes('wrong') || errorMessage?.toLowerCase().includes('password');
82+
83+
if (isRateLimited) {
84+
setCooldownSeconds(120);
85+
setFailedAttempts(0);
86+
} else if (isAuthError) {
87+
// Track failed attempts for non-rate-limit errors
88+
setFailedAttempts((prev) => {
89+
const newCount = prev + 1;
90+
if (newCount >= 3) {
91+
setCooldownSeconds(120);
92+
return 0;
93+
}
94+
return newCount;
95+
});
96+
}
97+
}, [errorMessage, isLoading]);
98+
99+
const isCooldownActive = cooldownSeconds > 0;
100+
101+
// Handle password show/hide
102+
const togglePasswordVisibility = () => {
103+
setShowPassword((prev) => !prev);
104+
// Auto-hide after 5 seconds
105+
if (passwordHideTimerRef.current) clearTimeout(passwordHideTimerRef.current);
106+
if (!showPassword) {
107+
passwordHideTimerRef.current = setTimeout(() => {
108+
setShowPassword(false);
109+
}, 5000);
110+
}
111+
};
112+
113+
// Cleanup timer on unmount
114+
useEffect(() => {
115+
return () => {
116+
if (passwordHideTimerRef.current) clearTimeout(passwordHideTimerRef.current);
117+
};
118+
}, []);
56119

57120
// When in API key mode, back should return to credentials view, not unit/gender screen
58121
const handleBack = () => {
@@ -200,38 +263,53 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({
200263

201264
<div>
202265
<label className="block text-xs font-semibold text-slate-200">Password</label>
203-
<input
204-
name="password"
205-
type="password"
206-
value={password}
207-
onFocus={() => maybeWarmup()}
208-
onChange={(e) => {
209-
passwordTouchedRef.current = true;
210-
setPassword(e.target.value);
211-
}}
212-
disabled={isLoading}
213-
className="mt-1 w-full h-10 rounded-md bg-slate-900/20 border border-slate-700/60 px-3 text-sm text-slate-200 placeholder:text-slate-500 outline-none focus:border-emerald-500/60"
214-
placeholder="Password"
215-
autoComplete="current-password"
216-
required
217-
/>
266+
<div className="relative">
267+
<input
268+
name="password"
269+
type={showPassword ? 'text' : 'password'}
270+
value={password}
271+
onFocus={() => maybeWarmup()}
272+
onChange={(e) => {
273+
passwordTouchedRef.current = true;
274+
setPassword(e.target.value);
275+
}}
276+
disabled={isLoading}
277+
className="mt-1 w-full h-10 rounded-md bg-slate-900/20 border border-slate-700/60 px-3 pr-10 text-sm text-slate-200 placeholder:text-slate-500 outline-none focus:border-emerald-500/60"
278+
placeholder="Password"
279+
autoComplete="current-password"
280+
required
281+
/>
282+
<button
283+
type="button"
284+
onClick={togglePasswordVisibility}
285+
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
286+
>
287+
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
288+
</button>
289+
</div>
218290
</div>
219291
</>
220292
)}
221293

222-
{errorMessage ? (
294+
{isCooldownActive ? (
295+
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 text-center">
296+
{cooldownSeconds > 0
297+
? `Too many login attempts. Please wait ${cooldownSeconds}s before trying again.`
298+
: 'Retry now!'}
299+
</div>
300+
) : errorMessage ? (
223301
<div className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
224302
{errorMessage}
225303
</div>
226304
) : null}
227305

228306
<button
229307
type="submit"
230-
disabled={isLoading}
308+
disabled={isLoading || isCooldownActive}
231309
className={`${UNIFORM_HEADER_BUTTON_CLASS} w-full h-10 text-sm font-semibold disabled:opacity-60 gap-2 justify-center`}
232310
>
233311
<span className="truncate">
234-
{isLoading ? (loginMode === 'apiKey' ? 'Logging in…' : 'Logging in…') : 'Login'}
312+
{isLoading ? (loginMode === 'apiKey' ? 'Logging in…' : 'Logging in…') : isCooldownActive ? (cooldownSeconds > 0 ? `Wait ${cooldownSeconds}s` : 'Retry now!') : 'Login'}
235313
</span>
236314
<ArrowRight className="w-4 h-4" />
237315
</button>

0 commit comments

Comments
 (0)