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 { 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 const resetDataRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?") .prepare("SELECT value FROM settings WHERE key = ?")
.get(`reset_code_${username}`); .get(`reset_code_${username}`);
if (!resetDataRow) { if (!resetDataRow) {
return res authLogger.warn("Reset code verification failed - no code found", {
.status(400) operation: "reset_code_verify_failed",
.json({ error: "No reset code found for this user" }); 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( const resetData = JSON.parse(
@@ -2060,13 +2084,35 @@ router.post("/verify-reset-code", async (req, res) => {
db.$client db.$client
.prepare("DELETE FROM settings WHERE key = ?") .prepare("DELETE FROM settings WHERE key = ?")
.run(`reset_code_${username}`); .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) { 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 tempToken = nanoid();
const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); 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 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) { if (!userRecord.totp_enabled || !userRecord.totp_secret) {
return res.status(400).json({ error: "TOTP not enabled for this user" }); 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); const backupIndex = backupCodes.indexOf(totp_code);
if (backupIndex === -1) { 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); backupCodes.splice(backupIndex, 1);
@@ -3022,6 +3096,8 @@ router.post("/totp/verify-login", async (req, res) => {
.where(eq(users.id, userRecord.id)); .where(eq(users.id, userRecord.id));
} }
loginRateLimiter.resetTOTPAttempts(userRecord.id);
const deviceInfo = parseUserAgent(req); const deviceInfo = parseUserAgent(req);
const token = await authManager.generateJWTToken(userRecord.id, { const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type, deviceType: deviceInfo.type,

View File

@@ -7,11 +7,21 @@ interface LoginAttempt {
class LoginRateLimiter { class LoginRateLimiter {
private ipAttempts = new Map<string, LoginAttempt>(); private ipAttempts = new Map<string, LoginAttempt>();
private usernameAttempts = 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 MAX_ATTEMPTS = 5;
private readonly WINDOW_MS = 10 * 60 * 1000; private readonly WINDOW_MS = 10 * 60 * 1000;
private readonly LOCKOUT_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() { constructor() {
setInterval(() => this.cleanup(), 5 * 60 * 1000); setInterval(() => this.cleanup(), 5 * 60 * 1000);
} }
@@ -40,6 +50,28 @@ class LoginRateLimiter {
this.usernameAttempts.delete(username); 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 { recordFailedAttempt(ip: string, username?: string): void {
@@ -141,6 +173,114 @@ class LoginRateLimiter {
return minRemaining; 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(); 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 errorMessage = error?.response?.data?.error || error?.message || "";
const errorCode = error?.response?.data?.code || error?.code; const errorCode = error?.response?.data?.code || error?.code;
const httpStatus = error?.response?.status;
const isDatabaseError = const isDatabaseError =
errorMessage.toLowerCase().includes("database") || errorMessage.toLowerCase().includes("database") ||
@@ -57,7 +58,20 @@ class DatabaseHealthMonitor {
(errorMessage.toLowerCase().includes("network error") && (errorMessage.toLowerCase().includes("network error") &&
error?.response === undefined); 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.dbHealthy = false;
this.emit("database-connection-lost", { this.emit("database-connection-lost", {
error: errorMessage || "Backend server unreachable", error: errorMessage || "Backend server unreachable",

View File

@@ -466,6 +466,7 @@
"retry": "Retry", "retry": "Retry",
"checking": "Checking...", "checking": "Checking...",
"checkingDatabase": "Checking database connection...", "checkingDatabase": "Checking database connection...",
"checkingAuthentication": "Checking authentication...",
"actions": "Actions", "actions": "Actions",
"remove": "Remove", "remove": "Remove",
"revoke": "Revoke", "revoke": "Revoke",
@@ -1889,7 +1890,8 @@
"authenticationDisabled": "Authentication Disabled", "authenticationDisabled": "Authentication Disabled",
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator.", "authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator.",
"passwordResetSuccess": "Password Reset Successful", "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": { "errors": {
"notFound": "Page not found", "notFound": "Page not found",
@@ -1921,7 +1923,11 @@
"emailExists": "Email already exists", "emailExists": "Email already exists",
"loadFailed": "Failed to load data", "loadFailed": "Failed to load data",
"saveError": "Failed to save", "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": { "messages": {
"saveSuccess": "Saved successfully", "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 { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts"; import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next";
function AppContent() { function AppContent() {
const { t } = useTranslation();
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false); 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="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="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> </div>
</div> </div>

View File

@@ -435,7 +435,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const axiosError = error as { const axiosError = error as {
response?: { response?: {
status?: number; status?: number;
data?: { needsSudo?: boolean; error?: string; sudoFailed?: boolean }; data?: {
needsSudo?: boolean;
error?: string;
sudoFailed?: boolean;
};
}; };
message?: string; message?: string;
}; };
@@ -462,10 +466,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
// Show more specific error message // Show more specific error message
const errorMessage = const errorMessage =
axiosError.response?.data?.error || axiosError.message || String(error); axiosError.response?.data?.error ||
axiosError.message ||
String(error);
if (initialLoadDoneRef.current) { if (initialLoadDoneRef.current) {
toast.error(t("fileManager.failedToLoadDirectory") + ": " + errorMessage); toast.error(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
} }
if ( if (
@@ -830,9 +838,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return; return;
} }
toast.error( toast.error(axiosError.message || t("fileManager.sudoOperationFailed"));
axiosError.message || t("fileManager.sudoOperationFailed"),
);
setPendingSudoOperation(null); setPendingSudoOperation(null);
} }
} }
@@ -2281,13 +2287,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
if (!open) setPendingSudoOperation(null); if (!open) setPendingSudoOperation(null);
}} }}
onSubmit={handleSudoPasswordSubmit} onSubmit={handleSudoPasswordSubmit}
operation={
pendingSudoOperation?.type === "delete"
? t("fileManager.deleteOperation")
: pendingSudoOperation?.type === "navigate"
? t("fileManager.accessDirectory")
: undefined
}
/> />
</div> </div>
); );

View File

@@ -16,14 +16,12 @@ interface SudoPasswordDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (password: string) => void; onSubmit: (password: string) => void;
operation?: string;
} }
export function SudoPasswordDialog({ export function SudoPasswordDialog({
open, open,
onOpenChange, onOpenChange,
onSubmit, onSubmit,
operation,
}: SudoPasswordDialogProps) { }: SudoPasswordDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -51,24 +49,17 @@ export function SudoPasswordDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle>{t("fileManager.sudoPasswordRequired")}</DialogTitle>
<ShieldAlert className="h-5 w-5 text-yellow-500" /> <DialogDescription className="text-muted-foreground">
{t("fileManager.sudoPasswordRequired")}
</DialogTitle>
<DialogDescription>
{t("fileManager.enterSudoPassword")} {t("fileManager.enterSudoPassword")}
{operation && (
<span className="block mt-1 text-muted-foreground">
{operation}
</span>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4"> <div className="space-y-6 py-4">
<div className="space-y-3">
<PasswordInput <PasswordInput
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -77,6 +68,7 @@ export function SudoPasswordDialog({
disabled={loading} disabled={loading}
/> />
</div> </div>
</div>
<DialogFooter> <DialogFooter>
<Button <Button

View File

@@ -392,8 +392,40 @@ export function Auth({
setResetStep("newPassword"); setResetStep("newPassword");
toast.success(t("messages.codeVerified")); toast.success(t("messages.codeVerified"));
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as {
toast.error(error?.response?.data?.error || t("errors.failedVerifyCode")); 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 { } finally {
setResetLoading(false); setResetLoading(false);
} }
@@ -514,10 +546,20 @@ export function Auth({
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { const error = err as {
message?: string; 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 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?.response?.data?.error ||
error?.message || error?.message ||
t("errors.invalidTotpCode"); t("errors.invalidTotpCode");
@@ -528,7 +570,23 @@ export function Auth({
setTotpTempToken(""); setTotpTempToken("");
setTab("login"); setTab("login");
toast.error(t("errors.sessionExpired")); toast.error(t("errors.sessionExpired"));
} else if (errorCode === "TOTP_RATE_LIMITED") {
if (remainingTime) {
errorMessage = t("errors.totpRateLimitedWithTime", {
time: remainingTime,
});
} else { } 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); toast.error(errorMessage);
} }
} finally { } finally {
@@ -744,12 +802,28 @@ export function Auth({
) { ) {
return ( return (
<div <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 || ""}`} className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
style={{ maxHeight: "calc(100vh - 1rem)" }} style={{
background: "var(--bg-elevated)",
backgroundImage: `repeating-linear-gradient(
45deg,
transparent,
transparent 35px,
${lineColor} 35px,
${lineColor} 37px
)`,
}}
{...props} {...props}
> >
<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="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="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>
</div> </div>
); );

View File

@@ -279,6 +279,8 @@ export function getCookie(name: string): string | undefined {
} }
} }
let userWasAuthenticated = false;
function createApiInstance( function createApiInstance(
baseURL: string, baseURL: string,
serviceName: string = "API", serviceName: string = "API",
@@ -320,6 +322,7 @@ function createApiInstance(
const token = localStorage.getItem("jwt"); const token = localStorage.getItem("jwt");
if (token) { if (token) {
config.headers["Authorization"] = `Bearer ${token}`; config.headers["Authorization"] = `Bearer ${token}`;
userWasAuthenticated = true;
} }
} }
@@ -339,6 +342,15 @@ function createApiInstance(
config.headers["User-Agent"] = `Termix-Mobile/${platform}`; 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; return config;
}); });
@@ -432,8 +444,6 @@ function createApiInstance(
} }
} }
dbHealthMonitor.reportDatabaseError(error);
if (status === 401) { if (status === 401) {
const errorCode = (error.response?.data as Record<string, unknown>) const errorCode = (error.response?.data as Record<string, unknown>)
?.code; ?.code;
@@ -444,9 +454,12 @@ function createApiInstance(
const isInvalidToken = const isInvalidToken =
errorCode === "AUTH_REQUIRED" || errorCode === "AUTH_REQUIRED" ||
errorMessage === "Invalid token" || errorMessage === "Invalid token" ||
errorMessage === "Authentication required"; errorMessage === "Authentication required" ||
errorMessage === "Missing authentication token";
if (isSessionExpired || isSessionNotFound || isInvalidToken) { if (isSessionExpired || isSessionNotFound || isInvalidToken) {
const wasAuthenticated = userWasAuthenticated;
localStorage.removeItem("jwt"); localStorage.removeItem("jwt");
if (isElectron()) { if (isElectron()) {
@@ -462,7 +475,14 @@ function createApiInstance(
console.warn("Session expired - please log in again"); console.warn("Session expired - please log in again");
toast.warning("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); return Promise.reject(error);

View File

@@ -333,9 +333,40 @@ export function Auth({
setResetStep("newPassword"); setResetStep("newPassword");
toast.success(t("messages.codeVerified")); toast.success(t("messages.codeVerified"));
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } }; const error = err as {
const errorMessage = 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"); 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); setError(errorMessage);
} finally { } finally {
setResetLoading(false); setResetLoading(false);
@@ -448,10 +479,20 @@ export function Auth({
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { const error = err as {
message?: string; 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 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?.response?.data?.error ||
error?.message || error?.message ||
t("errors.invalidTotpCode"); t("errors.invalidTotpCode");
@@ -462,7 +503,24 @@ export function Auth({
setTotpTempToken(""); setTotpTempToken("");
setTab("login"); setTab("login");
toast.error(t("errors.sessionExpired")); toast.error(t("errors.sessionExpired"));
} else if (errorCode === "TOTP_RATE_LIMITED") {
if (remainingTime) {
errorMessage = t("errors.totpRateLimitedWithTime", {
time: remainingTime,
});
} else { } 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); setError(errorMessage);
} }
} finally { } finally {