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 {
|
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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user