v1.8.0 #429

Merged
LukeGus merged 198 commits from dev-1.8.0 into main 2025-11-05 16:36:16 +00:00
19 changed files with 338 additions and 218 deletions
Showing only changes of commit 855a2b5a64 - Show all commits

View File

@@ -180,6 +180,10 @@ async function initializeCompleteDatabase(): Promise<void> {
tunnel_connections TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
default_path TEXT,
autostart_password TEXT,
autostart_key TEXT,
autostart_key_password TEXT,
force_keyboard_interactive TEXT,
stats_config TEXT,
terminal_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -417,7 +421,10 @@ const migrateSchema = () => {
"updated_at",
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists(
"ssh_data",
"credential_id",

View File

@@ -60,6 +60,7 @@ export const sshData = sqliteTable("ssh_data", {
tags: text("tags"),
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
authType: text("auth_type").notNull(),
forceKeyboardInteractive: text("force_keyboard_interactive"),
password: text("password"),
key: text("key", { length: 8192 }),

View File

@@ -236,6 +236,7 @@ router.post(
tunnelConnections,
statsConfig,
terminalConfig,
forceKeyboardInteractive,
} = hostData;
if (
!isNonEmptyString(userId) ||
@@ -273,6 +274,7 @@ router.post(
defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
};
if (effectiveAuthType === "password") {
@@ -424,6 +426,7 @@ router.put(
tunnelConnections,
statsConfig,
terminalConfig,
forceKeyboardInteractive,
} = hostData;
if (
!isNonEmptyString(userId) ||
@@ -462,6 +465,7 @@ router.put(
defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
};
if (effectiveAuthType === "password") {
@@ -611,6 +615,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
terminalConfig: row.terminalConfig
? JSON.parse(row.terminalConfig as string)
: undefined,
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
};
return (await resolveHostCredentials(baseHost)) || baseHost;
@@ -681,6 +686,7 @@ router.get(
terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig)
: undefined,
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
};
res.json((await resolveHostCredentials(result)) || result);

View File

@@ -173,6 +173,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
authType,
credentialId,
userProvidedPassword,
forceKeyboardInteractive,
} = req.body;
const userId = (req as AuthenticatedRequest).userId;
@@ -257,39 +258,66 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
const config: Record<string, unknown> = {
host: ip,
port: port || 22,
port,
username,
tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
@@ -335,7 +363,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
.json({ error: "Password required for password authentication" });
}
if (userProvidedPassword) {
if (!forceKeyboardInteractive) {
config.password = resolvedCredentials.password;
}
} else if (resolvedCredentials.authType === "none") {
@@ -413,27 +441,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
});
client.on("error", (err) => {
if (
(err.message.includes("All configured authentication methods failed") ||
err.message.includes("No authentication methods remaining")) &&
resolvedCredentials.authType === "password" &&
!config.password &&
resolvedCredentials.password &&
!userProvidedPassword
) {
fileLogger.info(
"Retrying password auth with password method for file manager",
{
operation: "file_connect_retry",
sessionId,
hostId,
},
);
config.password = resolvedCredentials.password;
client.connect(config);
return;
}
if (responseSent) return;
responseSent = true;
fileLogger.error("SSH connection failed for file manager", {
@@ -613,7 +620,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
return "";
});
keyboardInteractiveResponded = true;
finish(responses);
}
},

View File

@@ -788,10 +788,26 @@ function addLegacyCredentials(
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
const base: ConnectConfig = {
host: host.ip,
port: host.port || 22,
username: host.username || "root",
port: host.port,
username: host.username,
tryKeyboard: true,
readyTimeout: 10_000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"curve25519-sha256",

View File

@@ -30,6 +30,7 @@ interface ConnectToHostData {
authType?: string;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
};
initialPath?: string;
executeCommand?: string;
@@ -149,6 +150,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
let pingInterval: NodeJS.Timeout | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false;
let isKeyboardInteractive = false;
let keyboardInteractiveResponded = false;
ws.on("close", () => {
@@ -362,10 +364,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
});
async function handleConnectToHost(
data: ConnectToHostData,
retryWithPassword = false,
) {
async function handleConnectToHost(data: ConnectToHostData) {
const { hostConfig, initialPath, executeCommand } = data;
const {
id,
@@ -661,22 +660,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("error", (err: Error) => {
clearTimeout(connectionTimeout);
if (
(err.message.includes("All configured authentication methods failed") ||
err.message.includes("No authentication methods remaining")) &&
resolvedCredentials.authType === "password" &&
!retryWithPassword &&
!(hostConfig as any).userProvidedPassword
) {
sshLogger.info("Retrying password auth with password method", {
operation: "ssh_connect_retry",
hostId: id,
});
cleanupSSH();
handleConnectToHost(data, true);
return;
}
if (
(authMethodNotAvailable && resolvedCredentials.authType === "none") ||
(resolvedCredentials.authType === "none" &&
@@ -756,6 +739,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
isKeyboardInteractive = true;
const promptTexts = prompts.map((p) => p.prompt);
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
@@ -840,7 +824,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
return "";
});
keyboardInteractiveResponded = true;
finish(responses);
}
},
@@ -931,7 +914,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
if ((hostConfig as any).userProvidedPassword || retryWithPassword) {
if (!hostConfig.forceKeyboardInteractive) {
connectConfig.password = resolvedCredentials.password;
}
} else if (
@@ -1033,6 +1016,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
totpPromptSent = false;
isKeyboardInteractive = false;
keyboardInteractiveResponded = false;
keyboardInteractiveFinish = null;
}

View File

@@ -895,11 +895,24 @@ async function connectSSHTunnel(
host: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 15000,
tcpKeepAliveInitialDelay: 30000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"curve25519-sha256",

View File

@@ -701,7 +701,9 @@
"terminalCustomizationNotice": "Hinweis: Terminal-Anpassungen funktionieren nur in der Desktop-Website-Version. Mobile und Electron-Apps verwenden die Standard-Terminaleinstellungen des Systems.",
"noneAuthTitle": "Keyboard-Interactive-Authentifizierung",
"noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.",
"noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern."
"noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern.",
"forceKeyboardInteractive": "Tastatur-Interaktiv erzwingen",
"forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden."
},
"terminal": {
"title": "Terminal",

View File

@@ -780,7 +780,9 @@
"terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.",
"noneAuthTitle": "Keyboard-Interactive Authentication",
"noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.",
"noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally."
"noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.",
"forceKeyboardInteractive": "Force Keyboard-Interactive",
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA)."
},
"terminal": {
"title": "Terminal",

View File

@@ -718,7 +718,9 @@
"terminalCustomizationNotice": "Nota: As personalizações do terminal funcionam apenas na versão Desktop Website. Aplicativos Mobile e Electron usam as configurações padrão do terminal do sistema.",
"noneAuthTitle": "Autenticação Interativa por Teclado",
"noneAuthDescription": "Este método de autenticação usará autenticação interativa por teclado ao conectar ao servidor SSH.",
"noneAuthDetails": "A autenticação interativa por teclado permite que o servidor solicite credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multifator ou entrada de senha dinâmica."
"noneAuthDetails": "A autenticação interativa por teclado permite que o servidor solicite credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multifator ou entrada de senha dinâmica.",
"forceKeyboardInteractive": "Forçar Interativo com Teclado",
"forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA)."
},
"terminal": {
"title": "Terminal",

View File

@@ -792,7 +792,9 @@
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。",
"noneAuthTitle": "键盘交互式认证",
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。"
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
"forceKeyboardInteractive": "强制键盘交互式认证",
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证TOTP/2FA的服务器所必需的。"
},
"terminal": {
"title": "终端",

View File

@@ -19,6 +19,7 @@ export interface SSHHost {
key?: string;
keyPassword?: string;
keyType?: string;
forceKeyboardInteractive?: boolean;
autostartPassword?: string;
autostartKey?: string;
@@ -55,6 +56,7 @@ export interface SSHHostData {
enableTunnel?: boolean;
enableFileManager?: boolean;
defaultPath?: string;
forceKeyboardInteractive?: boolean;
tunnelConnections?: TunnelConnection[];
statsConfig?: string | Record<string, unknown>;
terminalConfig?: TerminalConfig;

View File

@@ -298,8 +298,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
[systemDrag, clearSelection],
);
const isConnectingRef = useRef(false);
async function initializeSSHConnection() {
if (!currentHost) return;
if (!currentHost || isConnectingRef.current) return;
isConnectingRef.current = true;
try {
setIsLoading(true);
@@ -318,6 +322,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId,
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
});
if (result?.requires_totp) {
@@ -359,6 +364,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
);
} finally {
setIsLoading(false);
isConnectingRef.current = false;
}
}

View File

@@ -311,6 +311,7 @@ export function HostManagerEditor({
moshCommand: z.string(),
})
.optional(),
forceKeyboardInteractive: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "none") {
@@ -399,6 +400,7 @@ export function HostManagerEditor({
tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
},
});
@@ -473,6 +475,7 @@ export function HostManagerEditor({
tunnelConnections: cleanedHost.tunnelConnections || [],
statsConfig: parsedStatsConfig,
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
};
if (defaultAuthType === "password") {
@@ -520,6 +523,7 @@ export function HostManagerEditor({
tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
};
form.reset(defaultFormData);
@@ -577,6 +581,7 @@ export function HostManagerEditor({
tunnelConnections: data.tunnelConnections || [],
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
};
submitData.credentialId = null;
@@ -1296,6 +1301,28 @@ export function HostManagerEditor({
</Alert>
</TabsContent>
</Tabs>
<FormField
control={form.control}
name="forceKeyboardInteractive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 mt-4">
<div className="space-y-0.5">
<FormLabel>
{t("hosts.forceKeyboardInteractive")}
</FormLabel>
<FormDescription>
{t("hosts.forceKeyboardInteractiveDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="terminal" className="space-y-1">
<FormField

View File

@@ -698,7 +698,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
localStorage.removeItem("jwt");
toast.error("Authentication failed. Please log in again.");
setTimeout(() => {
window.location.reload();
}, 1000);
return;
}

View File

@@ -1,5 +1,12 @@
import React, { useState } from "react";
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
import {
ChevronUp,
User2,
HardDrive,
Menu,
ChevronRight,
RotateCcw,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { isElectron, logoutUser } from "@/ui/main-axios.ts";
@@ -241,10 +248,9 @@ export function LeftSidebar({
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
}, [isSidebarOpen]);
// Sidebar width state for resizing
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("leftSidebarWidth");
return saved !== null ? parseInt(saved, 10) : 320;
return saved !== null ? parseInt(saved, 10) : 250;
});
const [isResizing, setIsResizing] = useState(false);
@@ -350,151 +356,171 @@ export function LeftSidebar({
return (
<div className="min-h-svh">
<SidebarProvider
<SidebarProvider
open={isSidebarOpen}
style={{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
style={
{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties
}
>
<div className="flex h-screen w-full">
<Sidebar variant="floating" className="">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
Termix
<Button
variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px] absolute right-5"
title={t("common.toggleSidebar")}
>
<Menu className="h-4 w-4" />
</Button>
</SidebarGroupLabel>
</SidebarHeader>
<Separator className="p-0.25" />
<SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline"
onClick={openSshManagerTab}
disabled={!!sshManagerTab || isSplitScreenActive}
title={
sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" />
{t("nav.hostManager")}
</Button>
</SidebarGroup>
<Separator className="p-0.25" />
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
<div className="!bg-dark-bg-input rounded-lg">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("placeholders.searchHostsAny")}
className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md"
autoComplete="off"
/>
</div>
{hostsError && (
<div className="!bg-dark-bg-input rounded-lg">
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
{t("leftSidebar.failedToLoadHosts")}
</div>
</div>
)}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
{t("hosts.loadingHosts")}
</div>
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
disabled={disabled}
>
<User2 /> {username ? username : t("common.logout")}
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
<Sidebar variant="floating">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
Termix
<div className="absolute right-5 flex gap-1">
<Button
variant="outline"
onClick={() => setSidebarWidth(250)}
className="w-[28px] h-[28px]"
title="Reset sidebar width"
>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
openUserProfileTab();
}}
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px]"
title={t("common.toggleSidebar")}
>
<Menu className="h-4 w-4" />
</Button>
</div>
</SidebarGroupLabel>
</SidebarHeader>
<Separator className="p-0.25" />
<SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline"
onClick={openSshManagerTab}
disabled={!!sshManagerTab || isSplitScreenActive}
title={
sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" />
{t("nav.hostManager")}
</Button>
</SidebarGroup>
<Separator className="p-0.25" />
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
<div className="!bg-dark-bg-input rounded-lg">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("placeholders.searchHostsAny")}
className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md"
autoComplete="off"
/>
</div>
{hostsError && (
<div className="!bg-dark-bg-input rounded-lg">
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
{t("leftSidebar.failedToLoadHosts")}
</div>
</div>
)}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
{t("hosts.loadingHosts")}
</div>
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
disabled={disabled}
>
<User2 /> {username ? username : t("common.logout")}
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
<span>{t("profile.title")}</span>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isAdmin) openAdminTab();
openUserProfileTab();
}}
>
<span>{t("admin.title")}</span>
<span>{t("profile.title")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}
>
<span>{t("common.logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
{/* Resizable divider */}
{isSidebarOpen && (
<div
className="w-4 cursor-col-resize h-screen z-50 bg-transparent hover:bg-dark-border/30 flex items-center justify-center"
onMouseDown={handleMouseDown}
title="Drag to resize sidebar"
>
<div
className={`w-1 h-full transition-colors duration-200 ${
isResizing
? "bg-dark-active"
: "bg-dark-border hover:bg-dark-border-hover"
}`}
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isAdmin) openAdminTab();
}}
>
<span>{t("admin.title")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}
>
<span>{t("common.logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{isSidebarOpen && (
<div
className="absolute top-0 h-full cursor-col-resize z-[60]"
onMouseDown={handleMouseDown}
style={{
right: "-8px",
width: "18px",
backgroundColor: isResizing
? "var(--dark-active)"
: "transparent",
}}
onMouseEnter={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor =
"var(--dark-border-hover)";
}
}}
onMouseLeave={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
title="Drag to resize sidebar"
/>
</div>
)}
)}
</Sidebar>
<SidebarInset>{children}</SidebarInset>
</div>

View File

@@ -42,7 +42,7 @@ export function SSHAuthDialog({
onSubmit,
onCancel,
hostInfo,
backgroundColor = "#1e1e1e",
backgroundColor = "#18181b",
}: SSHAuthDialogProps) {
const { t } = useTranslation();
const [authTab, setAuthTab] = useState<"password" | "key">("password");
@@ -136,7 +136,10 @@ export function SSHAuthDialog({
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg">
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg"
style={{ backgroundColor }}
>
<Card className="w-full max-w-2xl mx-4 border-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">

View File

@@ -50,8 +50,8 @@ export function TopNavbar({
allSplitScreenTab: number[];
reorderTabs: (fromIndex: number, toIndex: number) => void;
};
// Use CSS variable for dynamic sidebar width + divider width (4px) + some padding
const leftPosition = state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 20px)";
const leftPosition =
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
const { t } = useTranslation();
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
@@ -301,7 +301,7 @@ export function TopNavbar({
top: isTopbarOpen ? "0.5rem" : "-3rem",
left: leftPosition,
right: "17px",
backgroundColor: "#1e1e21",
backgroundColor: "#18181b",
}}
>
<div
@@ -491,8 +491,7 @@ export function TopNavbar({
{!isTopbarOpen && (
<div
onClick={() => setIsTopbarOpen(true)}
className="absolute top-0 left-0 w-full h-[10px] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md"
style={{ backgroundColor: "#1e1e21" }}
className="absolute top-0 left-0 w-full h-[10px] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md bg-dark"
>
<ChevronDown size={10} />
</div>

View File

@@ -316,7 +316,13 @@ function createApiInstance(
if (status === 401) {
const errorCode = (error.response?.data as Record<string, unknown>)
?.code;
const errorMessage = (error.response?.data as Record<string, unknown>)
?.error;
const isSessionExpired = errorCode === "SESSION_EXPIRED";
const isInvalidToken =
errorCode === "AUTH_REQUIRED" ||
errorMessage === "Invalid token" ||
errorMessage === "Authentication required";
if (isElectron()) {
localStorage.removeItem("jwt");
@@ -324,17 +330,22 @@ function createApiInstance(
localStorage.removeItem("jwt");
}
if (isSessionExpired && typeof window !== "undefined") {
console.warn("Session expired - please log in again");
if (
(isSessionExpired || isInvalidToken) &&
typeof window !== "undefined"
) {
console.warn(
"Session expired or invalid token - please log in again",
);
document.cookie =
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
import("sonner").then(({ toast }) => {
toast.warning("Session expired - please log in again");
toast.warning("Session expired. Please log in again.");
});
setTimeout(() => window.location.reload(), 100);
setTimeout(() => window.location.reload(), 1000);
}
}
@@ -792,6 +803,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
: JSON.stringify(hostData.statsConfig)
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
};
if (!submitData.enableTunnel) {
@@ -854,6 +866,7 @@ export async function updateSSHHost(
: JSON.stringify(hostData.statsConfig)
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
};
if (!submitData.enableTunnel) {
@@ -1164,6 +1177,7 @@ export async function connectSSH(
authType?: string;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
},
): Promise<Record<string, unknown>> {
try {