|
1 | 1 | 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'; |
3 | 3 | import { UNIFORM_HEADER_BUTTON_CLASS, UNIFORM_HEADER_ICON_BUTTON_CLASS } from '../../../utils/ui/uiConstants'; |
4 | 4 | import { OnboardingModalShell } from '../ui/OnboardingModalShell'; |
5 | 5 | import { HevyLoginHelp } from './HevyLoginHelp'; |
@@ -51,8 +51,71 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({ |
51 | 51 | const [password, setPassword] = useState(''); |
52 | 52 | const [apiKey, setApiKey] = useState(() => getHevyProApiKey() || ''); |
53 | 53 | const [showLoginHelp, setShowLoginHelp] = useState(false); |
| 54 | + const [cooldownSeconds, setCooldownSeconds] = useState(0); |
| 55 | + const [failedAttempts, setFailedAttempts] = useState(0); |
| 56 | + const [showPassword, setShowPassword] = useState(false); |
54 | 57 | const passwordTouchedRef = useRef(false); |
55 | 58 | 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 | + }, []); |
56 | 119 |
|
57 | 120 | // When in API key mode, back should return to credentials view, not unit/gender screen |
58 | 121 | const handleBack = () => { |
@@ -200,38 +263,53 @@ export const HevyLoginModal: React.FC<HevyLoginModalProps> = ({ |
200 | 263 |
|
201 | 264 | <div> |
202 | 265 | <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> |
218 | 290 | </div> |
219 | 291 | </> |
220 | 292 | )} |
221 | 293 |
|
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 ? ( |
223 | 301 | <div className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-200"> |
224 | 302 | {errorMessage} |
225 | 303 | </div> |
226 | 304 | ) : null} |
227 | 305 |
|
228 | 306 | <button |
229 | 307 | type="submit" |
230 | | - disabled={isLoading} |
| 308 | + disabled={isLoading || isCooldownActive} |
231 | 309 | className={`${UNIFORM_HEADER_BUTTON_CLASS} w-full h-10 text-sm font-semibold disabled:opacity-60 gap-2 justify-center`} |
232 | 310 | > |
233 | 311 | <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'} |
235 | 313 | </span> |
236 | 314 | <ArrowRight className="w-4 h-4" /> |
237 | 315 | </button> |
|
0 commit comments