fix: translations

This commit is contained in:
LukeGus
2025-12-30 19:00:33 -06:00
parent b025befd08
commit 7ed8ac625a
33 changed files with 197 additions and 59673 deletions

View File

@@ -472,6 +472,14 @@ app.post("/docker/ssh/connect", async (req, res) => {
cleanupSession(sessionId); cleanupSession(sessionId);
} }
// Clean up any stale pending TOTP sessions
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
} catch {}
delete pendingTOTPSessions[sessionId];
}
let resolvedCredentials: any = { let resolvedCredentials: any = {
password: host.password, password: host.password,
sshKey: host.key, sshKey: host.key,
@@ -552,9 +560,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
host: host.ip, host: host.ip,
port: host.port || 22, port: host.port || 22,
username: host.username, username: host.username,
tryKeyboard: tryKeyboard: true,
resolvedCredentials.authType === "none" ||
forceKeyboardInteractive === true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 60000, readyTimeout: 60000,
@@ -564,7 +570,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (resolvedCredentials.authType === "none") { if (resolvedCredentials.authType === "none") {
} else if (resolvedCredentials.authType === "password") { } else if (resolvedCredentials.authType === "password") {
if (!forceKeyboardInteractive && resolvedCredentials.password) { if (resolvedCredentials.password) {
config.password = resolvedCredentials.password; config.password = resolvedCredentials.password;
} }
} else if ( } else if (
@@ -689,37 +695,29 @@ app.post("/docker/ssh/connect", async (req, res) => {
); );
if (totpPromptIndex !== -1) { if (totpPromptIndex !== -1) {
if (pendingTOTPSessions[sessionId]) { if (responseSent) {
const existingSession = pendingTOTPSessions[sessionId]; const responses = prompts.map((p) => {
if (existingSession.totpAttempts >= 3) { if (/password/i.test(p.prompt) && resolvedCredentials.password) {
if (!responseSent) { return resolvedCredentials.password;
responseSent = true;
delete pendingTOTPSessions[sessionId];
client.end();
res.status(401).json({
error: "Maximum TOTP attempts reached",
code: "TOTP_MAX_ATTEMPTS",
});
} }
return "";
});
finish(responses);
return; return;
} }
existingSession.totpAttempts++;
if (!responseSent) {
responseSent = true; responseSent = true;
res.json({
requires_totp: true, if (pendingTOTPSessions[sessionId]) {
sessionId, const responses = prompts.map((p) => {
prompt: prompts[totpPromptIndex].prompt, if (/password/i.test(p.prompt) && resolvedCredentials.password) {
attempts_remaining: 3 - existingSession.totpAttempts, return resolvedCredentials.password;
});
} }
return "";
});
finish(responses);
return; return;
} }
if (responseSent) {
return;
}
responseSent = true;
keyboardInteractiveResponded = true; keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {
@@ -768,36 +766,35 @@ app.post("/docker/ssh/connect", async (req, res) => {
resolvedCredentials.authType !== "none"; resolvedCredentials.authType !== "none";
if (!hasStoredPassword && passwordPromptIndex !== -1) { if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (pendingTOTPSessions[sessionId]) { if (responseSent) {
const existingSession = pendingTOTPSessions[sessionId]; const responses = prompts.map((p) => {
if (existingSession.totpAttempts >= 3) { if (
if (!responseSent) { /password/i.test(p.prompt) &&
responseSent = true; resolvedCredentials.password
delete pendingTOTPSessions[sessionId]; ) {
client.end(); return resolvedCredentials.password;
res.status(401).json({
error: "Maximum password attempts reached",
code: "PASSWORD_MAX_ATTEMPTS",
});
} }
return "";
});
finish(responses);
return; return;
} }
existingSession.totpAttempts++;
if (!responseSent) {
responseSent = true; responseSent = true;
res.json({
requires_totp: true, if (pendingTOTPSessions[sessionId]) {
sessionId, const responses = prompts.map((p) => {
prompt: prompts[passwordPromptIndex].prompt, if (
isPassword: true, /password/i.test(p.prompt) &&
attempts_remaining: 3 - existingSession.totpAttempts, resolvedCredentials.password
}); ) {
return resolvedCredentials.password;
} }
return "";
});
finish(responses);
return; return;
} }
if (responseSent) return;
responseSent = true;
keyboardInteractiveResponded = true; keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {

View File

@@ -390,6 +390,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
if (sshSessions[sessionId]?.isConnected) { if (sshSessions[sessionId]?.isConnected) {
cleanupSession(sessionId); cleanupSession(sessionId);
} }
// Clean up any stale pending TOTP sessions
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
} catch {}
delete pendingTOTPSessions[sessionId];
}
const client = new SSHClient(); const client = new SSHClient();
let resolvedCredentials = { password, sshKey, keyPassword, authType }; let resolvedCredentials = { password, sshKey, keyPassword, authType };
@@ -450,9 +459,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
host: ip, host: ip,
port, port,
username, username,
tryKeyboard: tryKeyboard: true,
resolvedCredentials.authType === "none" ||
forceKeyboardInteractive === true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 60000, readyTimeout: 60000,
@@ -555,9 +562,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
.json({ error: "Password required for password authentication" }); .json({ error: "Password required for password authentication" });
} }
if (!forceKeyboardInteractive) {
config.password = resolvedCredentials.password; config.password = resolvedCredentials.password;
}
} else if (resolvedCredentials.authType === "none") { } else if (resolvedCredentials.authType === "none") {
} else { } else {
fileLogger.warn( fileLogger.warn(
@@ -684,37 +689,29 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
); );
if (totpPromptIndex !== -1) { if (totpPromptIndex !== -1) {
if (pendingTOTPSessions[sessionId]) { if (responseSent) {
const existingSession = pendingTOTPSessions[sessionId]; const responses = prompts.map((p) => {
if (existingSession.totpAttempts >= 3) { if (/password/i.test(p.prompt) && resolvedCredentials.password) {
if (!responseSent) { return resolvedCredentials.password;
responseSent = true;
delete pendingTOTPSessions[sessionId];
client.end();
res.status(401).json({
error: "Maximum TOTP attempts reached",
code: "TOTP_MAX_ATTEMPTS",
});
} }
return "";
});
finish(responses);
return; return;
} }
existingSession.totpAttempts++;
if (!responseSent) {
responseSent = true; responseSent = true;
res.json({
requires_totp: true, if (pendingTOTPSessions[sessionId]) {
sessionId, const responses = prompts.map((p) => {
prompt: prompts[totpPromptIndex].prompt, if (/password/i.test(p.prompt) && resolvedCredentials.password) {
attempts_remaining: 3 - existingSession.totpAttempts, return resolvedCredentials.password;
});
} }
return "";
});
finish(responses);
return; return;
} }
if (responseSent) {
return;
}
responseSent = true;
keyboardInteractiveResponded = true; keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {
@@ -765,38 +762,29 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
} }
if (!hasStoredPassword && passwordPromptIndex !== -1) { if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (pendingTOTPSessions[sessionId]) { if (responseSent) {
const existingSession = pendingTOTPSessions[sessionId]; const responses = prompts.map((p) => {
if (existingSession.totpAttempts >= 3) { if (/password/i.test(p.prompt) && resolvedCredentials.password) {
if (!responseSent) { return resolvedCredentials.password;
responseSent = true;
delete pendingTOTPSessions[sessionId];
client.end();
res.status(401).json({
error: "Maximum password attempts reached",
code: "PASSWORD_MAX_ATTEMPTS",
});
} }
return "";
});
finish(responses);
return; return;
} }
existingSession.totpAttempts++;
if (!responseSent) {
responseSent = true; responseSent = true;
res.json({
requires_totp: true, if (pendingTOTPSessions[sessionId]) {
sessionId, const responses = prompts.map((p) => {
prompt: prompts[passwordPromptIndex].prompt, if (/password/i.test(p.prompt) && resolvedCredentials.password) {
isPassword: true, return resolvedCredentials.password;
attempts_remaining: 3 - existingSession.totpAttempts,
});
} }
return "";
});
finish(responses);
return; return;
} }
if (responseSent) {
return;
}
responseSent = true;
keyboardInteractiveResponded = true; keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {

View File

@@ -456,6 +456,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
const totpCode = totpData.code; const totpCode = totpData.code;
totpAttempts++; totpAttempts++;
keyboardInteractiveFinish([totpCode]); keyboardInteractiveFinish([totpCode]);
keyboardInteractiveFinish = null;
totpPromptSent = false;
} else { } else {
sshLogger.warn("TOTP response received but no callback available", { sshLogger.warn("TOTP response received but no callback available", {
operation: "totp_response_error", operation: "totp_response_error",
@@ -482,6 +484,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
const password = passwordData.code; const password = passwordData.code;
keyboardInteractiveFinish([password]); keyboardInteractiveFinish([password]);
keyboardInteractiveFinish = null;
} else { } else {
sshLogger.warn( sshLogger.warn(
"Password response received but no callback available", "Password response received but no callback available",
@@ -988,29 +991,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (totpPromptIndex !== -1) { if (totpPromptIndex !== -1) {
if (totpPromptSent) { if (totpPromptSent) {
if (totpAttempts >= 3) { sshLogger.warn("TOTP prompt asked again - ignoring duplicate", {
sshLogger.error("TOTP maximum attempts reached", { operation: "ssh_keyboard_interactive_totp_duplicate",
operation: "ssh_keyboard_interactive_totp_max_attempts",
hostId: id, hostId: id,
attempts: totpAttempts, prompts: promptTexts,
}); });
ws.send(
JSON.stringify({
type: "error",
message: "Maximum TOTP attempts reached",
code: "TOTP_MAX_ATTEMPTS",
}),
);
cleanupSSH();
return;
}
ws.send(
JSON.stringify({
type: "totp_retry",
attempts_remaining: 3 - totpAttempts,
prompt: prompts[totpPromptIndex].prompt,
}),
);
return; return;
} }
totpPromptSent = true; totpPromptSent = true;
@@ -1032,19 +1017,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
finish(responses); finish(responses);
}; };
// Set timeout for TOTP response
totpTimeout = setTimeout(() => { totpTimeout = setTimeout(() => {
if (keyboardInteractiveFinish) { if (keyboardInteractiveFinish) {
keyboardInteractiveFinish = null; keyboardInteractiveFinish = null;
totpPromptSent = false; totpPromptSent = false;
totpAttempts = 0; sshLogger.warn("TOTP prompt timeout", {
operation: "totp_timeout",
hostId: id,
});
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "error", type: "error",
message: "TOTP verification timeout", message: "TOTP verification timeout. Please reconnect.",
code: "TOTP_TIMEOUT",
}), }),
); );
cleanupSSH(); cleanupSSH(connectionTimeout);
} }
}, 180000); }, 180000);
@@ -1082,6 +1070,25 @@ wss.on("connection", async (ws: WebSocket, req) => {
finish(responses); finish(responses);
}; };
// Set timeout for password response
totpTimeout = setTimeout(() => {
if (keyboardInteractiveFinish) {
keyboardInteractiveFinish = null;
keyboardInteractiveResponded = false;
sshLogger.warn("Password prompt timeout", {
operation: "password_timeout",
hostId: id,
});
ws.send(
JSON.stringify({
type: "error",
message: "Password verification timeout. Please reconnect.",
}),
);
cleanupSSH(connectionTimeout);
}
}, 180000);
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "password_required", type: "password_required",
@@ -1107,9 +1114,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
host: ip, host: ip,
port, port,
username, username,
tryKeyboard: tryKeyboard: true,
resolvedCredentials.authType === "none" ||
hostConfig.forceKeyboardInteractive === true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 30000, readyTimeout: 30000,
@@ -1191,9 +1196,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
if (!hostConfig.forceKeyboardInteractive) {
connectConfig.password = resolvedCredentials.password; connectConfig.password = resolvedCredentials.password;
}
} else if ( } else if (
resolvedCredentials.authType === "key" && resolvedCredentials.authType === "key" &&
resolvedCredentials.key resolvedCredentials.key
@@ -1385,6 +1388,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
}
if (sshStream) { if (sshStream) {
try { try {
sshStream.end(); sshStream.end();
@@ -1409,11 +1417,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn = null; sshConn = null;
} }
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
}
totpPromptSent = false; totpPromptSent = false;
totpAttempts = 0; totpAttempts = 0;
isKeyboardInteractive = false; isKeyboardInteractive = false;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -218,6 +218,7 @@
"run": "Run", "run": "Run",
"empty": "No snippets yet", "empty": "No snippets yet",
"emptyHint": "Create a snippet to save commonly used commands", "emptyHint": "Create a snippet to save commonly used commands",
"searchSnippets": "Search snippets...",
"name": "Name", "name": "Name",
"description": "Description", "description": "Description",
"content": "Command", "content": "Command",
@@ -283,7 +284,7 @@
"deleteSuccess": "Command deleted from history", "deleteSuccess": "Command deleted from history",
"deleteFailed": "Failed to delete command.", "deleteFailed": "Failed to delete command.",
"deleteTooltip": "Delete command", "deleteTooltip": "Delete command",
"tabHint": "Use Tab in Terminal to autocomplete from command history", "tabHint": "Use Tab in Terminal to autocomplete from command history if enabled in User Profile",
"authRequiredRefresh": "Authentication required. Please refresh the page.", "authRequiredRefresh": "Authentication required. Please refresh the page.",
"dataAccessLockedReauth": "Data access locked. Please re-authenticate.", "dataAccessLockedReauth": "Data access locked. Please re-authenticate.",
"loading": "Loading command history...", "loading": "Loading command history...",
@@ -1417,7 +1418,7 @@
"connectToServer": "Connect to a Server", "connectToServer": "Connect to a Server",
"selectServerToEdit": "Select a server from the sidebar to start editing files", "selectServerToEdit": "Select a server from the sidebar to start editing files",
"fileOperations": "File Operations", "fileOperations": "File Operations",
"confirmDeleteMessage": "Are you sure you want to delete <strong>{{name}}</strong>?", "confirmDeleteMessage": "Are you sure you want to delete {{name}}?",
"confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?", "confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?",
"confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?", "confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?",
"confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.", "confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ interface DockerManagerProps {
isVisible?: boolean; isVisible?: boolean;
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
embedded?: boolean; embedded?: boolean;
onClose?: () => void;
} }
export function DockerManager({ export function DockerManager({
@@ -40,6 +41,7 @@ export function DockerManager({
isVisible = true, isVisible = true,
isTopbarOpen = true, isTopbarOpen = true,
embedded = false, embedded = false,
onClose,
}: DockerManagerProps): React.ReactElement { }: DockerManagerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
@@ -177,6 +179,7 @@ export function DockerManager({
); );
setIsConnecting(false); setIsConnecting(false);
setIsValidating(false); setIsValidating(false);
onClose?.();
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }
@@ -347,6 +350,7 @@ export function DockerManager({
toast.error(error instanceof Error ? error.message : "Failed to connect"); toast.error(error instanceof Error ? error.message : "Failed to connect");
setIsConnecting(false); setIsConnecting(false);
setIsValidating(false); setIsValidating(false);
onClose?.();
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }
@@ -355,6 +359,7 @@ export function DockerManager({
const handleAuthCancel = () => { const handleAuthCancel = () => {
setShowAuthDialog(false); setShowAuthDialog(false);
setIsConnecting(false); setIsConnecting(false);
onClose?.();
}; };
const topMarginPx = isTopbarOpen ? 74 : 16; const topMarginPx = isTopbarOpen ? 74 : 16;

View File

@@ -675,6 +675,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
if (reconnectAttempts.current > 0) { if (reconnectAttempts.current > 0) {
attemptReconnection(); attemptReconnection();
} else {
shouldNotReconnectRef.current = true;
if (onClose) {
onClose();
}
} }
} }
}, 35000); }, 35000);

View File

@@ -202,6 +202,7 @@ export function SSHToolsSidebar({
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false"; localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
return shouldCollapse ? new Set() : new Set(); return shouldCollapse ? new Set() : new Set();
}); });
const [snippetSearchQuery, setSnippetSearchQuery] = useState("");
const [showFolderDialog, setShowFolderDialog] = useState(false); const [showFolderDialog, setShowFolderDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState<SnippetFolder | null>( const [editingFolder, setEditingFolder] = useState<SnippetFolder | null>(
null, null,
@@ -772,7 +773,22 @@ export function SSHToolsSidebar({
} }
}); });
snippets.forEach((snippet) => { const filteredSnippets = snippetSearchQuery
? snippets.filter(
(snippet) =>
snippet.name
.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()) ||
snippet.content
.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()) ||
snippet.description
?.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()),
)
: snippets;
filteredSnippets.forEach((snippet) => {
const folderName = snippet.folder || ""; const folderName = snippet.folder || "";
if (!grouped.has(folderName)) { if (!grouped.has(folderName)) {
grouped.set(folderName, []); grouped.set(folderName, []);
@@ -1280,6 +1296,28 @@ export function SSHToolsSidebar({
</> </>
)} )}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("snippets.searchSnippets")}
value={snippetSearchQuery}
onChange={(e) => {
setSnippetSearchQuery(e.target.value);
}}
className="pl-10 pr-10"
/>
{snippetSearchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => setSnippetSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleCreate} onClick={handleCreate}

View File

@@ -368,6 +368,7 @@ export function AppView({
isVisible={effectiveVisible} isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
embedded embedded
onClose={() => removeTab(t.id)}
/> />
) : ( ) : (
<FileManager <FileManager