feat: fix sudo password dialog ui, add totp/pass reset limiting, and refreshed users screen when auth is outdated
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user