feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated

This commit is contained in:
LukeGus
2026-01-14 18:14:51 -06:00
parent 230ab2f737
commit b7bd1e50b3
10 changed files with 446 additions and 60 deletions

View File

@@ -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,

View File

@@ -7,11 +7,21 @@ interface LoginAttempt {
class LoginRateLimiter {
private ipAttempts = new Map<string, LoginAttempt>();
private usernameAttempts = new Map<string, LoginAttempt>();
private totpAttempts = new Map<string, LoginAttempt>();
private resetCodeAttempts = new Map<string, LoginAttempt>();
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();

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
@@ -291,7 +293,12 @@ function AppContent() {
>
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
<div className="flex items-center justify-center h-32">
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" />
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground">
{t("common.checkingAuthentication")}
</p>
</div>
</div>
</div>
</div>

View File

@@ -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
}
/>
</div>
);

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-yellow-500" />
{t("fileManager.sudoPasswordRequired")}
</DialogTitle>
<DialogDescription>
<DialogTitle>{t("fileManager.sudoPasswordRequired")}</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("fileManager.enterSudoPassword")}
{operation && (
<span className="block mt-1 text-muted-foreground">
{operation}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("fileManager.sudoPassword")}
autoFocus
disabled={loading}
/>
<div className="space-y-6 py-4">
<div className="space-y-3">
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("fileManager.sudoPassword")}
autoFocus
disabled={loading}
/>
</div>
</div>
<DialogFooter>

View File

@@ -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 (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-canvas border-2 border-edge rounded-md overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
style={{ maxHeight: "calc(100vh - 1rem)" }}
className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
style={{
background: "var(--bg-elevated)",
backgroundImage: `repeating-linear-gradient(
45deg,
transparent,
transparent 35px,
${lineColor} 35px,
${lineColor} 37px
)`,
}}
{...props}
>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
<div className="flex items-center justify-center h-32">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground">
{t("common.checkingAuthentication")}
</p>
</div>
</div>
</div>
</div>
);

View File

@@ -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<string, unknown>)
?.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);

View File

@@ -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 {