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,
|
tunnel_connections TEXT,
|
||||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||||
default_path TEXT,
|
default_path TEXT,
|
||||||
|
autostart_password TEXT,
|
||||||
|
autostart_key TEXT,
|
||||||
|
autostart_key_password TEXT,
|
||||||
|
force_keyboard_interactive TEXT,
|
||||||
stats_config TEXT,
|
stats_config TEXT,
|
||||||
terminal_config TEXT,
|
terminal_config TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -417,7 +421,10 @@ const migrateSchema = () => {
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
"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(
|
addColumnIfNotExists(
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
"credential_id",
|
"credential_id",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||||
authType: text("auth_type").notNull(),
|
authType: text("auth_type").notNull(),
|
||||||
|
forceKeyboardInteractive: text("force_keyboard_interactive"),
|
||||||
|
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
key: text("key", { length: 8192 }),
|
key: text("key", { length: 8192 }),
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ router.post(
|
|||||||
tunnelConnections,
|
tunnelConnections,
|
||||||
statsConfig,
|
statsConfig,
|
||||||
terminalConfig,
|
terminalConfig,
|
||||||
|
forceKeyboardInteractive,
|
||||||
} = hostData;
|
} = hostData;
|
||||||
if (
|
if (
|
||||||
!isNonEmptyString(userId) ||
|
!isNonEmptyString(userId) ||
|
||||||
@@ -273,6 +274,7 @@ router.post(
|
|||||||
defaultPath: defaultPath || null,
|
defaultPath: defaultPath || null,
|
||||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||||
|
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (effectiveAuthType === "password") {
|
if (effectiveAuthType === "password") {
|
||||||
@@ -424,6 +426,7 @@ router.put(
|
|||||||
tunnelConnections,
|
tunnelConnections,
|
||||||
statsConfig,
|
statsConfig,
|
||||||
terminalConfig,
|
terminalConfig,
|
||||||
|
forceKeyboardInteractive,
|
||||||
} = hostData;
|
} = hostData;
|
||||||
if (
|
if (
|
||||||
!isNonEmptyString(userId) ||
|
!isNonEmptyString(userId) ||
|
||||||
@@ -462,6 +465,7 @@ router.put(
|
|||||||
defaultPath: defaultPath || null,
|
defaultPath: defaultPath || null,
|
||||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||||
|
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (effectiveAuthType === "password") {
|
if (effectiveAuthType === "password") {
|
||||||
@@ -611,6 +615,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
terminalConfig: row.terminalConfig
|
terminalConfig: row.terminalConfig
|
||||||
? JSON.parse(row.terminalConfig as string)
|
? JSON.parse(row.terminalConfig as string)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||||
@@ -681,6 +686,7 @@ router.get(
|
|||||||
terminalConfig: host.terminalConfig
|
terminalConfig: host.terminalConfig
|
||||||
? JSON.parse(host.terminalConfig)
|
? JSON.parse(host.terminalConfig)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json((await resolveHostCredentials(result)) || result);
|
res.json((await resolveHostCredentials(result)) || result);
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
authType,
|
authType,
|
||||||
credentialId,
|
credentialId,
|
||||||
userProvidedPassword,
|
userProvidedPassword,
|
||||||
|
forceKeyboardInteractive,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
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> = {
|
const config: Record<string, unknown> = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port: port || 22,
|
port,
|
||||||
username,
|
username,
|
||||||
tryKeyboard: true,
|
tryKeyboard: true,
|
||||||
readyTimeout: 60000,
|
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
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: {
|
algorithms: {
|
||||||
kex: [
|
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-sha256",
|
||||||
"diffie-hellman-group14-sha1",
|
"diffie-hellman-group14-sha1",
|
||||||
"diffie-hellman-group1-sha1",
|
|
||||||
"diffie-hellman-group-exchange-sha256",
|
|
||||||
"diffie-hellman-group-exchange-sha1",
|
"diffie-hellman-group-exchange-sha1",
|
||||||
"ecdh-sha2-nistp256",
|
"diffie-hellman-group1-sha1",
|
||||||
"ecdh-sha2-nistp384",
|
],
|
||||||
"ecdh-sha2-nistp521",
|
serverHostKey: [
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"rsa-sha2-512",
|
||||||
|
"rsa-sha2-256",
|
||||||
|
"ssh-rsa",
|
||||||
|
"ssh-dss",
|
||||||
],
|
],
|
||||||
cipher: [
|
cipher: [
|
||||||
"aes128-ctr",
|
"chacha20-poly1305@openssh.com",
|
||||||
"aes192-ctr",
|
|
||||||
"aes256-ctr",
|
|
||||||
"aes128-gcm@openssh.com",
|
|
||||||
"aes256-gcm@openssh.com",
|
"aes256-gcm@openssh.com",
|
||||||
"aes128-cbc",
|
"aes128-gcm@openssh.com",
|
||||||
"aes192-cbc",
|
"aes256-ctr",
|
||||||
|
"aes192-ctr",
|
||||||
|
"aes128-ctr",
|
||||||
"aes256-cbc",
|
"aes256-cbc",
|
||||||
|
"aes192-cbc",
|
||||||
|
"aes128-cbc",
|
||||||
"3des-cbc",
|
"3des-cbc",
|
||||||
],
|
],
|
||||||
hmac: [
|
hmac: [
|
||||||
"hmac-sha2-256-etm@openssh.com",
|
|
||||||
"hmac-sha2-512-etm@openssh.com",
|
"hmac-sha2-512-etm@openssh.com",
|
||||||
"hmac-sha2-256",
|
"hmac-sha2-256-etm@openssh.com",
|
||||||
"hmac-sha2-512",
|
"hmac-sha2-512",
|
||||||
|
"hmac-sha2-256",
|
||||||
"hmac-sha1",
|
"hmac-sha1",
|
||||||
"hmac-md5",
|
"hmac-md5",
|
||||||
],
|
],
|
||||||
@@ -335,7 +363,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 (userProvidedPassword) {
|
if (!forceKeyboardInteractive) {
|
||||||
config.password = resolvedCredentials.password;
|
config.password = resolvedCredentials.password;
|
||||||
}
|
}
|
||||||
} else if (resolvedCredentials.authType === "none") {
|
} else if (resolvedCredentials.authType === "none") {
|
||||||
@@ -413,27 +441,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
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;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
fileLogger.error("SSH connection failed for file manager", {
|
fileLogger.error("SSH connection failed for file manager", {
|
||||||
@@ -613,7 +620,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
keyboardInteractiveResponded = true;
|
|
||||||
finish(responses);
|
finish(responses);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -788,10 +788,26 @@ function addLegacyCredentials(
|
|||||||
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||||
const base: ConnectConfig = {
|
const base: ConnectConfig = {
|
||||||
host: host.ip,
|
host: host.ip,
|
||||||
port: host.port || 22,
|
port: host.port,
|
||||||
username: host.username || "root",
|
username: host.username,
|
||||||
tryKeyboard: true,
|
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: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
"curve25519-sha256",
|
"curve25519-sha256",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface ConnectToHostData {
|
|||||||
authType?: string;
|
authType?: string;
|
||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
forceKeyboardInteractive?: boolean;
|
||||||
};
|
};
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
executeCommand?: string;
|
executeCommand?: string;
|
||||||
@@ -149,6 +150,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
let pingInterval: NodeJS.Timeout | null = null;
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||||
let totpPromptSent = false;
|
let totpPromptSent = false;
|
||||||
|
let isKeyboardInteractive = false;
|
||||||
let keyboardInteractiveResponded = false;
|
let keyboardInteractiveResponded = false;
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
@@ -362,10 +364,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleConnectToHost(
|
async function handleConnectToHost(data: ConnectToHostData) {
|
||||||
data: ConnectToHostData,
|
|
||||||
retryWithPassword = false,
|
|
||||||
) {
|
|
||||||
const { hostConfig, initialPath, executeCommand } = data;
|
const { hostConfig, initialPath, executeCommand } = data;
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -661,22 +660,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
sshConn.on("error", (err: Error) => {
|
sshConn.on("error", (err: Error) => {
|
||||||
clearTimeout(connectionTimeout);
|
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 (
|
if (
|
||||||
(authMethodNotAvailable && resolvedCredentials.authType === "none") ||
|
(authMethodNotAvailable && resolvedCredentials.authType === "none") ||
|
||||||
(resolvedCredentials.authType === "none" &&
|
(resolvedCredentials.authType === "none" &&
|
||||||
@@ -756,6 +739,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||||
finish: (responses: string[]) => void,
|
finish: (responses: string[]) => void,
|
||||||
) => {
|
) => {
|
||||||
|
isKeyboardInteractive = true;
|
||||||
const promptTexts = prompts.map((p) => p.prompt);
|
const promptTexts = prompts.map((p) => p.prompt);
|
||||||
const totpPromptIndex = prompts.findIndex((p) =>
|
const totpPromptIndex = prompts.findIndex((p) =>
|
||||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||||
@@ -840,7 +824,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
keyboardInteractiveResponded = true;
|
|
||||||
finish(responses);
|
finish(responses);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -931,7 +914,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((hostConfig as any).userProvidedPassword || retryWithPassword) {
|
if (!hostConfig.forceKeyboardInteractive) {
|
||||||
connectConfig.password = resolvedCredentials.password;
|
connectConfig.password = resolvedCredentials.password;
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
@@ -1033,6 +1016,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
totpPromptSent = false;
|
totpPromptSent = false;
|
||||||
|
isKeyboardInteractive = false;
|
||||||
keyboardInteractiveResponded = false;
|
keyboardInteractiveResponded = false;
|
||||||
keyboardInteractiveFinish = null;
|
keyboardInteractiveFinish = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -895,11 +895,24 @@ async function connectSSHTunnel(
|
|||||||
host: tunnelConfig.sourceIP,
|
host: tunnelConfig.sourceIP,
|
||||||
port: tunnelConfig.sourceSSHPort,
|
port: tunnelConfig.sourceSSHPort,
|
||||||
username: tunnelConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
|
tryKeyboard: true,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 60000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
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: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
"curve25519-sha256",
|
"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.",
|
"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",
|
"noneAuthTitle": "Keyboard-Interactive-Authentifizierung",
|
||||||
"noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die 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": {
|
"terminal": {
|
||||||
"title": "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.",
|
"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",
|
"noneAuthTitle": "Keyboard-Interactive Authentication",
|
||||||
"noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.",
|
"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": {
|
"terminal": {
|
||||||
"title": "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.",
|
"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",
|
"noneAuthTitle": "Autenticação Interativa por Teclado",
|
||||||
"noneAuthDescription": "Este método de autenticação usará autenticação interativa por teclado ao conectar ao servidor SSH.",
|
"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": {
|
"terminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
|
|||||||
@@ -792,7 +792,9 @@
|
|||||||
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。",
|
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。",
|
||||||
"noneAuthTitle": "键盘交互式认证",
|
"noneAuthTitle": "键盘交互式认证",
|
||||||
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
|
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
|
||||||
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。"
|
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
|
||||||
|
"forceKeyboardInteractive": "强制键盘交互式认证",
|
||||||
|
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "终端",
|
"title": "终端",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface SSHHost {
|
|||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
|
forceKeyboardInteractive?: boolean;
|
||||||
|
|
||||||
autostartPassword?: string;
|
autostartPassword?: string;
|
||||||
autostartKey?: string;
|
autostartKey?: string;
|
||||||
@@ -55,6 +56,7 @@ export interface SSHHostData {
|
|||||||
enableTunnel?: boolean;
|
enableTunnel?: boolean;
|
||||||
enableFileManager?: boolean;
|
enableFileManager?: boolean;
|
||||||
defaultPath?: string;
|
defaultPath?: string;
|
||||||
|
forceKeyboardInteractive?: boolean;
|
||||||
tunnelConnections?: TunnelConnection[];
|
tunnelConnections?: TunnelConnection[];
|
||||||
statsConfig?: string | Record<string, unknown>;
|
statsConfig?: string | Record<string, unknown>;
|
||||||
terminalConfig?: TerminalConfig;
|
terminalConfig?: TerminalConfig;
|
||||||
|
|||||||
@@ -298,8 +298,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
[systemDrag, clearSelection],
|
[systemDrag, clearSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isConnectingRef = useRef(false);
|
||||||
|
|
||||||
async function initializeSSHConnection() {
|
async function initializeSSHConnection() {
|
||||||
if (!currentHost) return;
|
if (!currentHost || isConnectingRef.current) return;
|
||||||
|
|
||||||
|
isConnectingRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -318,6 +322,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
authType: currentHost.authType,
|
authType: currentHost.authType,
|
||||||
credentialId: currentHost.credentialId,
|
credentialId: currentHost.credentialId,
|
||||||
userId: currentHost.userId,
|
userId: currentHost.userId,
|
||||||
|
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.requires_totp) {
|
if (result?.requires_totp) {
|
||||||
@@ -359,6 +364,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
isConnectingRef.current = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export function HostManagerEditor({
|
|||||||
moshCommand: z.string(),
|
moshCommand: z.string(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
forceKeyboardInteractive: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.authType === "none") {
|
if (data.authType === "none") {
|
||||||
@@ -399,6 +400,7 @@ export function HostManagerEditor({
|
|||||||
tunnelConnections: [],
|
tunnelConnections: [],
|
||||||
statsConfig: DEFAULT_STATS_CONFIG,
|
statsConfig: DEFAULT_STATS_CONFIG,
|
||||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||||
|
forceKeyboardInteractive: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,6 +475,7 @@ export function HostManagerEditor({
|
|||||||
tunnelConnections: cleanedHost.tunnelConnections || [],
|
tunnelConnections: cleanedHost.tunnelConnections || [],
|
||||||
statsConfig: parsedStatsConfig,
|
statsConfig: parsedStatsConfig,
|
||||||
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||||
|
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (defaultAuthType === "password") {
|
if (defaultAuthType === "password") {
|
||||||
@@ -520,6 +523,7 @@ export function HostManagerEditor({
|
|||||||
tunnelConnections: [],
|
tunnelConnections: [],
|
||||||
statsConfig: DEFAULT_STATS_CONFIG,
|
statsConfig: DEFAULT_STATS_CONFIG,
|
||||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||||
|
forceKeyboardInteractive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
form.reset(defaultFormData);
|
form.reset(defaultFormData);
|
||||||
@@ -577,6 +581,7 @@ export function HostManagerEditor({
|
|||||||
tunnelConnections: data.tunnelConnections || [],
|
tunnelConnections: data.tunnelConnections || [],
|
||||||
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
|
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
|
||||||
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||||
|
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
|
||||||
};
|
};
|
||||||
|
|
||||||
submitData.credentialId = null;
|
submitData.credentialId = null;
|
||||||
@@ -1296,6 +1301,28 @@ export function HostManagerEditor({
|
|||||||
</Alert>
|
</Alert>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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>
|
||||||
<TabsContent value="terminal" className="space-y-1">
|
<TabsContent value="terminal" className="space-y-1">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -698,7 +698,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
|
|
||||||
toast.error("Authentication failed. Please log in again.");
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import React, { useState } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { isElectron, logoutUser } from "@/ui/main-axios.ts";
|
import { isElectron, logoutUser } from "@/ui/main-axios.ts";
|
||||||
|
|
||||||
@@ -241,10 +248,9 @@ export function LeftSidebar({
|
|||||||
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
|
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
|
||||||
}, [isSidebarOpen]);
|
}, [isSidebarOpen]);
|
||||||
|
|
||||||
// Sidebar width state for resizing
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||||
const saved = localStorage.getItem("leftSidebarWidth");
|
const saved = localStorage.getItem("leftSidebarWidth");
|
||||||
return saved !== null ? parseInt(saved, 10) : 320;
|
return saved !== null ? parseInt(saved, 10) : 250;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
@@ -350,151 +356,171 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh">
|
<div className="min-h-svh">
|
||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
open={isSidebarOpen}
|
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">
|
<div className="flex h-screen w-full">
|
||||||
<Sidebar variant="floating" className="">
|
<Sidebar variant="floating">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarGroupLabel className="text-lg font-bold text-white">
|
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||||
Termix
|
Termix
|
||||||
<Button
|
<div className="absolute right-5 flex gap-1">
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
variant="outline"
|
||||||
className="w-[28px] h-[28px] absolute right-5"
|
onClick={() => setSidebarWidth(250)}
|
||||||
title={t("common.toggleSidebar")}
|
className="w-[28px] h-[28px]"
|
||||||
>
|
title="Reset sidebar width"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<RotateCcw className="h-4 w-4" />
|
||||||
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"
|
</Button>
|
||||||
onClick={() => {
|
<Button
|
||||||
openUserProfileTab();
|
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
|
<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"
|
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={() => {
|
onClick={() => {
|
||||||
if (isAdmin) openAdminTab();
|
openUserProfileTab();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{t("admin.title")}</span>
|
<span>{t("profile.title")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
{isAdmin && (
|
||||||
<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"
|
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}
|
onClick={() => {
|
||||||
>
|
if (isAdmin) openAdminTab();
|
||||||
<span>{t("common.logout")}</span>
|
}}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</DropdownMenuContent>
|
<span>{t("admin.title")}</span>
|
||||||
</DropdownMenu>
|
</DropdownMenuItem>
|
||||||
</SidebarMenuItem>
|
)}
|
||||||
</SidebarMenu>
|
<DropdownMenuItem
|
||||||
</SidebarFooter>
|
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"
|
||||||
</Sidebar>
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
{/* Resizable divider */}
|
<span>{t("common.logout")}</span>
|
||||||
{isSidebarOpen && (
|
</DropdownMenuItem>
|
||||||
<div
|
</DropdownMenuContent>
|
||||||
className="w-4 cursor-col-resize h-screen z-50 bg-transparent hover:bg-dark-border/30 flex items-center justify-center"
|
</DropdownMenu>
|
||||||
onMouseDown={handleMouseDown}
|
</SidebarMenuItem>
|
||||||
title="Drag to resize sidebar"
|
</SidebarMenu>
|
||||||
>
|
</SidebarFooter>
|
||||||
<div
|
{isSidebarOpen && (
|
||||||
className={`w-1 h-full transition-colors duration-200 ${
|
<div
|
||||||
isResizing
|
className="absolute top-0 h-full cursor-col-resize z-[60]"
|
||||||
? "bg-dark-active"
|
onMouseDown={handleMouseDown}
|
||||||
: "bg-dark-border hover:bg-dark-border-hover"
|
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>
|
<SidebarInset>{children}</SidebarInset>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function SSHAuthDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
hostInfo,
|
hostInfo,
|
||||||
backgroundColor = "#1e1e1e",
|
backgroundColor = "#18181b",
|
||||||
}: SSHAuthDialogProps) {
|
}: SSHAuthDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||||||
@@ -136,7 +136,10 @@ export function SSHAuthDialog({
|
|||||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||||
|
|
||||||
return (
|
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">
|
<Card className="w-full max-w-2xl mx-4 border-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function TopNavbar({
|
|||||||
allSplitScreenTab: number[];
|
allSplitScreenTab: number[];
|
||||||
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||||
};
|
};
|
||||||
// Use CSS variable for dynamic sidebar width + divider width (4px) + some padding
|
const leftPosition =
|
||||||
const leftPosition = state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 20px)";
|
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||||
@@ -301,7 +301,7 @@ export function TopNavbar({
|
|||||||
top: isTopbarOpen ? "0.5rem" : "-3rem",
|
top: isTopbarOpen ? "0.5rem" : "-3rem",
|
||||||
left: leftPosition,
|
left: leftPosition,
|
||||||
right: "17px",
|
right: "17px",
|
||||||
backgroundColor: "#1e1e21",
|
backgroundColor: "#18181b",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -491,8 +491,7 @@ export function TopNavbar({
|
|||||||
{!isTopbarOpen && (
|
{!isTopbarOpen && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setIsTopbarOpen(true)}
|
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"
|
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"
|
||||||
style={{ backgroundColor: "#1e1e21" }}
|
|
||||||
>
|
>
|
||||||
<ChevronDown size={10} />
|
<ChevronDown size={10} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -316,7 +316,13 @@ function createApiInstance(
|
|||||||
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;
|
||||||
|
const errorMessage = (error.response?.data as Record<string, unknown>)
|
||||||
|
?.error;
|
||||||
const isSessionExpired = errorCode === "SESSION_EXPIRED";
|
const isSessionExpired = errorCode === "SESSION_EXPIRED";
|
||||||
|
const isInvalidToken =
|
||||||
|
errorCode === "AUTH_REQUIRED" ||
|
||||||
|
errorMessage === "Invalid token" ||
|
||||||
|
errorMessage === "Authentication required";
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
@@ -324,17 +330,22 @@ function createApiInstance(
|
|||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSessionExpired && typeof window !== "undefined") {
|
if (
|
||||||
console.warn("Session expired - please log in again");
|
(isSessionExpired || isInvalidToken) &&
|
||||||
|
typeof window !== "undefined"
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"Session expired or invalid token - please log in again",
|
||||||
|
);
|
||||||
|
|
||||||
document.cookie =
|
document.cookie =
|
||||||
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||||
|
|
||||||
import("sonner").then(({ toast }) => {
|
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)
|
: JSON.stringify(hostData.statsConfig)
|
||||||
: null,
|
: null,
|
||||||
terminalConfig: hostData.terminalConfig || null,
|
terminalConfig: hostData.terminalConfig || null,
|
||||||
|
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!submitData.enableTunnel) {
|
if (!submitData.enableTunnel) {
|
||||||
@@ -854,6 +866,7 @@ export async function updateSSHHost(
|
|||||||
: JSON.stringify(hostData.statsConfig)
|
: JSON.stringify(hostData.statsConfig)
|
||||||
: null,
|
: null,
|
||||||
terminalConfig: hostData.terminalConfig || null,
|
terminalConfig: hostData.terminalConfig || null,
|
||||||
|
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!submitData.enableTunnel) {
|
if (!submitData.enableTunnel) {
|
||||||
@@ -1164,6 +1177,7 @@ export async function connectSSH(
|
|||||||
authType?: string;
|
authType?: string;
|
||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
forceKeyboardInteractive?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user