diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index d286c704..6eac6d22 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -2041,13 +2041,37 @@ router.post("/verify-reset-code", async (req, res) => { } try { + const lockStatus = loginRateLimiter.isResetCodeLocked(username); + if (lockStatus.locked) { + authLogger.warn("Reset code verification blocked due to rate limiting", { + operation: "reset_code_verify_blocked", + username, + remainingTime: lockStatus.remainingTime, + }); + return res.status(429).json({ + error: `Rate limited: Too many verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`, + remainingTime: lockStatus.remainingTime, + code: "RESET_CODE_RATE_LIMITED", + }); + } + + loginRateLimiter.recordResetCodeAttempt(username); + const resetDataRow = db.$client .prepare("SELECT value FROM settings WHERE key = ?") .get(`reset_code_${username}`); if (!resetDataRow) { - return res - .status(400) - .json({ error: "No reset code found for this user" }); + authLogger.warn("Reset code verification failed - no code found", { + operation: "reset_code_verify_failed", + username, + remainingAttempts: + loginRateLimiter.getRemainingResetCodeAttempts(username), + }); + return res.status(400).json({ + error: "No reset code found for this user", + remainingAttempts: + loginRateLimiter.getRemainingResetCodeAttempts(username), + }); } const resetData = JSON.parse( @@ -2060,13 +2084,35 @@ router.post("/verify-reset-code", async (req, res) => { db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`reset_code_${username}`); - return res.status(400).json({ error: "Reset code has expired" }); + authLogger.warn("Reset code verification failed - code expired", { + operation: "reset_code_verify_failed", + username, + remainingAttempts: + loginRateLimiter.getRemainingResetCodeAttempts(username), + }); + return res.status(400).json({ + error: "Reset code has expired", + remainingAttempts: + loginRateLimiter.getRemainingResetCodeAttempts(username), + }); } if (resetData.code !== resetCode) { - return res.status(400).json({ error: "Invalid reset code" }); + authLogger.warn("Reset code verification failed - invalid code", { + operation: "reset_code_verify_failed", + username, + remainingAttempts: + loginRateLimiter.getRemainingResetCodeAttempts(username), + }); + return res.status(400).json({ + error: "Invalid reset code", + remainingAttempts: + loginRateLimiter.getRemainingResetCodeAttempts(username), + }); } + loginRateLimiter.resetResetCodeAttempts(username); + const tempToken = nanoid(); const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); @@ -2953,6 +2999,22 @@ router.post("/totp/verify-login", async (req, res) => { const userRecord = user[0]; + const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id); + if (lockStatus.locked) { + authLogger.warn("TOTP verification blocked due to rate limiting", { + operation: "totp_verify_blocked", + userId: userRecord.id, + remainingTime: lockStatus.remainingTime, + }); + return res.status(429).json({ + error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`, + remainingTime: lockStatus.remainingTime, + code: "TOTP_RATE_LIMITED", + }); + } + + loginRateLimiter.recordFailedTOTPAttempt(userRecord.id); + if (!userRecord.totp_enabled || !userRecord.totp_secret) { return res.status(400).json({ error: "TOTP not enabled for this user" }); } @@ -3012,7 +3074,19 @@ router.post("/totp/verify-login", async (req, res) => { const backupIndex = backupCodes.indexOf(totp_code); if (backupIndex === -1) { - return res.status(401).json({ error: "Invalid TOTP code" }); + authLogger.warn("TOTP verification failed - invalid code", { + operation: "totp_verify_failed", + userId: userRecord.id, + remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts( + userRecord.id, + ), + }); + return res.status(401).json({ + error: "Invalid TOTP code", + remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts( + userRecord.id, + ), + }); } backupCodes.splice(backupIndex, 1); @@ -3022,6 +3096,8 @@ router.post("/totp/verify-login", async (req, res) => { .where(eq(users.id, userRecord.id)); } + loginRateLimiter.resetTOTPAttempts(userRecord.id); + const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, diff --git a/src/backend/utils/login-rate-limiter.ts b/src/backend/utils/login-rate-limiter.ts index 105995f3..8aef0cf0 100644 --- a/src/backend/utils/login-rate-limiter.ts +++ b/src/backend/utils/login-rate-limiter.ts @@ -7,11 +7,21 @@ interface LoginAttempt { class LoginRateLimiter { private ipAttempts = new Map(); private usernameAttempts = new Map(); + private totpAttempts = new Map(); + private resetCodeAttempts = new Map(); private readonly MAX_ATTEMPTS = 5; private readonly WINDOW_MS = 10 * 60 * 1000; private readonly LOCKOUT_MS = 10 * 60 * 1000; + private readonly TOTP_MAX_ATTEMPTS = 5; + private readonly TOTP_WINDOW_MS = 1 * 60 * 1000; + private readonly TOTP_LOCKOUT_MS = 5 * 60 * 1000; + + private readonly RESET_CODE_MAX_ATTEMPTS = 5; + private readonly RESET_CODE_WINDOW_MS = 1 * 60 * 1000; + private readonly RESET_CODE_LOCKOUT_MS = 5 * 60 * 1000; + constructor() { setInterval(() => this.cleanup(), 5 * 60 * 1000); } @@ -40,6 +50,28 @@ class LoginRateLimiter { this.usernameAttempts.delete(username); } } + + for (const [userId, attempt] of this.totpAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.totpAttempts.delete(userId); + } else if ( + !attempt.lockedUntil && + now - attempt.firstAttempt > this.TOTP_WINDOW_MS + ) { + this.totpAttempts.delete(userId); + } + } + + for (const [username, attempt] of this.resetCodeAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.resetCodeAttempts.delete(username); + } else if ( + !attempt.lockedUntil && + now - attempt.firstAttempt > this.RESET_CODE_WINDOW_MS + ) { + this.resetCodeAttempts.delete(username); + } + } } recordFailedAttempt(ip: string, username?: string): void { @@ -141,6 +173,114 @@ class LoginRateLimiter { return minRemaining; } + + recordFailedTOTPAttempt(userId: string): void { + const now = Date.now(); + + const totpAttempt = this.totpAttempts.get(userId); + if (!totpAttempt) { + this.totpAttempts.set(userId, { + count: 1, + firstAttempt: now, + }); + } else if (now - totpAttempt.firstAttempt > this.TOTP_WINDOW_MS) { + this.totpAttempts.set(userId, { + count: 1, + firstAttempt: now, + }); + } else { + totpAttempt.count++; + if (totpAttempt.count >= this.TOTP_MAX_ATTEMPTS) { + totpAttempt.lockedUntil = now + this.TOTP_LOCKOUT_MS; + } + } + } + + resetTOTPAttempts(userId: string): void { + this.totpAttempts.delete(userId); + } + + isTOTPLocked(userId: string): { locked: boolean; remainingTime?: number } { + const now = Date.now(); + + const totpAttempt = this.totpAttempts.get(userId); + if (totpAttempt?.lockedUntil && totpAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((totpAttempt.lockedUntil - now) / 1000), + }; + } + + return { locked: false }; + } + + getRemainingTOTPAttempts(userId: string): number { + const now = Date.now(); + + const totpAttempt = this.totpAttempts.get(userId); + if (totpAttempt && now - totpAttempt.firstAttempt <= this.TOTP_WINDOW_MS) { + return Math.max(0, this.TOTP_MAX_ATTEMPTS - totpAttempt.count); + } + + return this.TOTP_MAX_ATTEMPTS; + } + + recordResetCodeAttempt(username: string): void { + const now = Date.now(); + + const resetAttempt = this.resetCodeAttempts.get(username); + if (!resetAttempt) { + this.resetCodeAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else if (now - resetAttempt.firstAttempt > this.RESET_CODE_WINDOW_MS) { + this.resetCodeAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else { + resetAttempt.count++; + if (resetAttempt.count >= this.RESET_CODE_MAX_ATTEMPTS) { + resetAttempt.lockedUntil = now + this.RESET_CODE_LOCKOUT_MS; + } + } + } + + resetResetCodeAttempts(username: string): void { + this.resetCodeAttempts.delete(username); + } + + isResetCodeLocked(username: string): { + locked: boolean; + remainingTime?: number; + } { + const now = Date.now(); + + const resetAttempt = this.resetCodeAttempts.get(username); + if (resetAttempt?.lockedUntil && resetAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((resetAttempt.lockedUntil - now) / 1000), + }; + } + + return { locked: false }; + } + + getRemainingResetCodeAttempts(username: string): number { + const now = Date.now(); + + const resetAttempt = this.resetCodeAttempts.get(username); + if ( + resetAttempt && + now - resetAttempt.firstAttempt <= this.RESET_CODE_WINDOW_MS + ) { + return Math.max(0, this.RESET_CODE_MAX_ATTEMPTS - resetAttempt.count); + } + + return this.RESET_CODE_MAX_ATTEMPTS; + } } export const loginRateLimiter = new LoginRateLimiter(); diff --git a/src/lib/db-health-monitor.ts b/src/lib/db-health-monitor.ts index 2413a0c7..298a84c1 100644 --- a/src/lib/db-health-monitor.ts +++ b/src/lib/db-health-monitor.ts @@ -40,9 +40,10 @@ class DatabaseHealthMonitor { } } - reportDatabaseError(error: any) { + reportDatabaseError(error: any, wasAuthenticated: boolean = false) { const errorMessage = error?.response?.data?.error || error?.message || ""; const errorCode = error?.response?.data?.code || error?.code; + const httpStatus = error?.response?.status; const isDatabaseError = errorMessage.toLowerCase().includes("database") || @@ -57,7 +58,20 @@ class DatabaseHealthMonitor { (errorMessage.toLowerCase().includes("network error") && error?.response === undefined); - if ((isDatabaseError || isBackendUnreachable) && this.dbHealthy) { + const isAuthenticationLost = + wasAuthenticated && + httpStatus === 401 && + (errorCode === "AUTH_REQUIRED" || + errorCode === "SESSION_EXPIRED" || + errorCode === "SESSION_NOT_FOUND" || + errorMessage === "Missing authentication token" || + errorMessage === "Invalid token" || + errorMessage === "Authentication required"); + + if ( + (isDatabaseError || isBackendUnreachable || isAuthenticationLost) && + this.dbHealthy + ) { this.dbHealthy = false; this.emit("database-connection-lost", { error: errorMessage || "Backend server unreachable", diff --git a/src/locales/en.json b/src/locales/en.json index b334426c..bc9eb6cd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -466,6 +466,7 @@ "retry": "Retry", "checking": "Checking...", "checkingDatabase": "Checking database connection...", + "checkingAuthentication": "Checking authentication...", "actions": "Actions", "remove": "Remove", "revoke": "Revoke", @@ -1889,7 +1890,8 @@ "authenticationDisabled": "Authentication Disabled", "authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator.", "passwordResetSuccess": "Password Reset Successful", - "passwordResetSuccessDesc": "Your password has been reset successfully. You can now log in with your new password." + "passwordResetSuccessDesc": "Your password has been reset successfully. You can now log in with your new password.", + "attemptsRemaining": "attempts remaining" }, "errors": { "notFound": "Page not found", @@ -1921,7 +1923,11 @@ "emailExists": "Email already exists", "loadFailed": "Failed to load data", "saveError": "Failed to save", - "sessionExpired": "Session expired - please log in again" + "sessionExpired": "Session expired - please log in again", + "totpRateLimited": "Rate limited: Too many TOTP verification attempts. Please try again later.", + "totpRateLimitedWithTime": "Rate limited: Too many TOTP verification attempts. Please wait {{time}} seconds before trying again.", + "resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.", + "resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again." }, "messages": { "saveSuccess": "Saved successfully", diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index f6f4e0b1..915a42b0 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -18,8 +18,10 @@ import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts"; import { useTheme } from "@/components/theme-provider"; import { dbHealthMonitor } from "@/lib/db-health-monitor.ts"; +import { useTranslation } from "react-i18next"; function AppContent() { + const { t } = useTranslation(); const [isAuthenticated, setIsAuthenticated] = useState(false); const [username, setUsername] = useState(null); const [isAdmin, setIsAdmin] = useState(false); @@ -291,7 +293,12 @@ function AppContent() { >
-
+
+
+

+ {t("common.checkingAuthentication")} +

+
diff --git a/src/ui/desktop/apps/features/file-manager/FileManager.tsx b/src/ui/desktop/apps/features/file-manager/FileManager.tsx index 7028e09b..25a68228 100644 --- a/src/ui/desktop/apps/features/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/features/file-manager/FileManager.tsx @@ -435,7 +435,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const axiosError = error as { response?: { status?: number; - data?: { needsSudo?: boolean; error?: string; sudoFailed?: boolean }; + data?: { + needsSudo?: boolean; + error?: string; + sudoFailed?: boolean; + }; }; message?: string; }; @@ -462,10 +466,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { // Show more specific error message const errorMessage = - axiosError.response?.data?.error || axiosError.message || String(error); + axiosError.response?.data?.error || + axiosError.message || + String(error); if (initialLoadDoneRef.current) { - toast.error(t("fileManager.failedToLoadDirectory") + ": " + errorMessage); + toast.error( + t("fileManager.failedToLoadDirectory") + ": " + errorMessage, + ); } if ( @@ -830,9 +838,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { return; } - toast.error( - axiosError.message || t("fileManager.sudoOperationFailed"), - ); + toast.error(axiosError.message || t("fileManager.sudoOperationFailed")); setPendingSudoOperation(null); } } @@ -2281,13 +2287,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { if (!open) setPendingSudoOperation(null); }} onSubmit={handleSudoPasswordSubmit} - operation={ - pendingSudoOperation?.type === "delete" - ? t("fileManager.deleteOperation") - : pendingSudoOperation?.type === "navigate" - ? t("fileManager.accessDirectory") - : undefined - } />
); diff --git a/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx b/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx index 469d367e..b77f80c6 100644 --- a/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx +++ b/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx @@ -16,14 +16,12 @@ interface SudoPasswordDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSubmit: (password: string) => void; - operation?: string; } export function SudoPasswordDialog({ open, onOpenChange, onSubmit, - operation, }: SudoPasswordDialogProps) { const { t } = useTranslation(); const [password, setPassword] = useState(""); @@ -51,31 +49,25 @@ export function SudoPasswordDialog({ return ( - +
- - - {t("fileManager.sudoPasswordRequired")} - - + {t("fileManager.sudoPasswordRequired")} + {t("fileManager.enterSudoPassword")} - {operation && ( - - {operation} - - )} -
- setPassword(e.target.value)} - placeholder={t("fileManager.sudoPassword")} - autoFocus - disabled={loading} - /> +
+
+ setPassword(e.target.value)} + placeholder={t("fileManager.sudoPassword")} + autoFocus + disabled={loading} + /> +
diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 9ca11da4..cdd4c5ca 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -392,8 +392,40 @@ export function Auth({ setResetStep("newPassword"); toast.success(t("messages.codeVerified")); } catch (err: unknown) { - const error = err as { response?: { data?: { error?: string } } }; - toast.error(error?.response?.data?.error || t("errors.failedVerifyCode")); + const error = err as { + response?: { + data?: { + error?: string; + code?: string; + remainingTime?: number; + remainingAttempts?: number; + }; + }; + }; + const errorCode = error?.response?.data?.code; + const remainingTime = error?.response?.data?.remainingTime; + const remainingAttempts = error?.response?.data?.remainingAttempts; + + let errorMessage = + error?.response?.data?.error || t("errors.failedVerifyCode"); + + if (errorCode === "RESET_CODE_RATE_LIMITED") { + if (remainingTime) { + errorMessage = t("errors.resetCodeRateLimitedWithTime", { + time: remainingTime, + }); + } else { + errorMessage = t("errors.resetCodeRateLimited"); + } + } else if ( + remainingAttempts !== undefined && + remainingAttempts <= 2 && + remainingAttempts > 0 + ) { + errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`; + } + + toast.error(errorMessage); } finally { setResetLoading(false); } @@ -514,10 +546,20 @@ export function Auth({ } catch (err: unknown) { const error = err as { message?: string; - response?: { data?: { code?: string; error?: string } }; + response?: { + data?: { + code?: string; + error?: string; + remainingTime?: number; + remainingAttempts?: number; + }; + }; }; const errorCode = error?.response?.data?.code; - const errorMessage = + const remainingTime = error?.response?.data?.remainingTime; + const remainingAttempts = error?.response?.data?.remainingAttempts; + + let errorMessage = error?.response?.data?.error || error?.message || t("errors.invalidTotpCode"); @@ -528,7 +570,23 @@ export function Auth({ setTotpTempToken(""); setTab("login"); toast.error(t("errors.sessionExpired")); + } else if (errorCode === "TOTP_RATE_LIMITED") { + if (remainingTime) { + errorMessage = t("errors.totpRateLimitedWithTime", { + time: remainingTime, + }); + } else { + errorMessage = t("errors.totpRateLimited"); + } + toast.error(errorMessage); } else { + if ( + remainingAttempts !== undefined && + remainingAttempts <= 2 && + remainingAttempts > 0 + ) { + errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`; + } toast.error(errorMessage); } } finally { @@ -744,12 +802,28 @@ export function Auth({ ) { return (
-
-
+
+
+
+
+

+ {t("common.checkingAuthentication")} +

+
+
); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index b7c0b7ba..8ddb2971 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -279,6 +279,8 @@ export function getCookie(name: string): string | undefined { } } +let userWasAuthenticated = false; + function createApiInstance( baseURL: string, serviceName: string = "API", @@ -320,6 +322,7 @@ function createApiInstance( const token = localStorage.getItem("jwt"); if (token) { config.headers["Authorization"] = `Bearer ${token}`; + userWasAuthenticated = true; } } @@ -339,6 +342,15 @@ function createApiInstance( config.headers["User-Agent"] = `Termix-Mobile/${platform}`; } + if (!isElectron()) { + const token = document.cookie + .split("; ") + .find((row) => row.startsWith("jwt=")); + if (token) { + userWasAuthenticated = true; + } + } + return config; }); @@ -432,8 +444,6 @@ function createApiInstance( } } - dbHealthMonitor.reportDatabaseError(error); - if (status === 401) { const errorCode = (error.response?.data as Record) ?.code; @@ -444,9 +454,12 @@ function createApiInstance( const isInvalidToken = errorCode === "AUTH_REQUIRED" || errorMessage === "Invalid token" || - errorMessage === "Authentication required"; + errorMessage === "Authentication required" || + errorMessage === "Missing authentication token"; if (isSessionExpired || isSessionNotFound || isInvalidToken) { + const wasAuthenticated = userWasAuthenticated; + localStorage.removeItem("jwt"); if (isElectron()) { @@ -462,7 +475,14 @@ function createApiInstance( console.warn("Session expired - please log in again"); toast.warning("Session expired. Please log in again."); } + + dbHealthMonitor.reportDatabaseError(error, wasAuthenticated); + + userWasAuthenticated = false; } + } else { + const wasAuthenticated = !!localStorage.getItem("jwt"); + dbHealthMonitor.reportDatabaseError(error, wasAuthenticated); } return Promise.reject(error); diff --git a/src/ui/mobile/authentication/Auth.tsx b/src/ui/mobile/authentication/Auth.tsx index 22aed1b5..281cee6d 100644 --- a/src/ui/mobile/authentication/Auth.tsx +++ b/src/ui/mobile/authentication/Auth.tsx @@ -333,9 +333,40 @@ export function Auth({ setResetStep("newPassword"); toast.success(t("messages.codeVerified")); } catch (err: unknown) { - const error = err as { response?: { data?: { error?: string } } }; - const errorMessage = + const error = err as { + response?: { + data?: { + error?: string; + code?: string; + remainingTime?: number; + remainingAttempts?: number; + }; + }; + }; + const errorCode = error?.response?.data?.code; + const remainingTime = error?.response?.data?.remainingTime; + const remainingAttempts = error?.response?.data?.remainingAttempts; + + let errorMessage = error?.response?.data?.error || t("errors.failedVerifyCode"); + + if (errorCode === "RESET_CODE_RATE_LIMITED") { + if (remainingTime) { + errorMessage = t("errors.resetCodeRateLimitedWithTime", { + time: remainingTime, + }); + } else { + errorMessage = t("errors.resetCodeRateLimited"); + } + toast.error(errorMessage); + } else if ( + remainingAttempts !== undefined && + remainingAttempts <= 2 && + remainingAttempts > 0 + ) { + errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`; + } + setError(errorMessage); } finally { setResetLoading(false); @@ -448,10 +479,20 @@ export function Auth({ } catch (err: unknown) { const error = err as { message?: string; - response?: { data?: { code?: string; error?: string } }; + response?: { + data?: { + code?: string; + error?: string; + remainingTime?: number; + remainingAttempts?: number; + }; + }; }; const errorCode = error?.response?.data?.code; - const errorMessage = + const remainingTime = error?.response?.data?.remainingTime; + const remainingAttempts = error?.response?.data?.remainingAttempts; + + let errorMessage = error?.response?.data?.error || error?.message || t("errors.invalidTotpCode"); @@ -462,7 +503,24 @@ export function Auth({ setTotpTempToken(""); setTab("login"); toast.error(t("errors.sessionExpired")); + } else if (errorCode === "TOTP_RATE_LIMITED") { + if (remainingTime) { + errorMessage = t("errors.totpRateLimitedWithTime", { + time: remainingTime, + }); + } else { + errorMessage = t("errors.totpRateLimited"); + } + setError(errorMessage); + toast.error(errorMessage); } else { + if ( + remainingAttempts !== undefined && + remainingAttempts <= 2 && + remainingAttempts > 0 + ) { + errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`; + } setError(errorMessage); } } finally {