fix: Sidebar resize issues and issues with TOTP interfering with password auth
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -792,7 +792,9 @@
|
||||
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。",
|
||||
"noneAuthTitle": "键盘交互式认证",
|
||||
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
|
||||
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。"
|
||||
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
|
||||
"forceKeyboardInteractive": "强制键盘交互式认证",
|
||||
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "终端",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user