fix: Several bug fixes for terminals, server stats, and general feature improvements

This commit is contained in:
LukeGus
2025-11-08 15:23:14 -06:00
parent c69d31062e
commit b43e98073f
16 changed files with 445 additions and 209 deletions

View File

@@ -456,6 +456,11 @@ const migrateSchema = () => {
"credential_id", "credential_id",
"INTEGER REFERENCES ssh_credentials(id)", "INTEGER REFERENCES ssh_credentials(id)",
); );
addColumnIfNotExists(
"ssh_data",
"override_credential_username",
"INTEGER",
);
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");

View File

@@ -72,6 +72,9 @@ export const sshData = sqliteTable("ssh_data", {
autostartKeyPassword: text("autostart_key_password"), autostartKeyPassword: text("autostart_key_password"),
credentialId: integer("credential_id").references(() => sshCredentials.id), credentialId: integer("credential_id").references(() => sshCredentials.id),
overrideCredentialUsername: integer("override_credential_username", {
mode: "boolean",
}),
enableTerminal: integer("enable_terminal", { mode: "boolean" }) enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),

View File

@@ -8,6 +8,7 @@ import {
fileManagerRecent, fileManagerRecent,
fileManagerPinned, fileManagerPinned,
fileManagerShortcuts, fileManagerShortcuts,
recentActivity,
} from "../db/schema.js"; } from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
@@ -225,6 +226,7 @@ router.post(
authMethod, authMethod,
authType, authType,
credentialId, credentialId,
overrideCredentialUsername,
key, key,
keyPassword, keyPassword,
keyType, keyType,
@@ -264,6 +266,7 @@ router.post(
username, username,
authType: effectiveAuthType, authType: effectiveAuthType,
credentialId: credentialId || null, credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0, pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0,
@@ -323,6 +326,7 @@ router.post(
: [] : []
: [], : [],
pin: !!createdHost.pin, pin: !!createdHost.pin,
overrideCredentialUsername: !!createdHost.overrideCredentialUsername,
enableTerminal: !!createdHost.enableTerminal, enableTerminal: !!createdHost.enableTerminal,
enableTunnel: !!createdHost.enableTunnel, enableTunnel: !!createdHost.enableTunnel,
tunnelConnections: createdHost.tunnelConnections tunnelConnections: createdHost.tunnelConnections
@@ -349,6 +353,27 @@ router.post(
}, },
); );
try {
const fetch = (await import("node-fetch")).default;
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
await fetch("http://localhost:30005/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Cookie: `jwt=${token}` }),
},
});
} catch (refreshError) {
sshLogger.warn("Failed to refresh server stats polling", {
operation: "stats_refresh_after_create",
error:
refreshError instanceof Error
? refreshError.message
: "Unknown error",
});
}
res.json(resolvedHost); res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error("Failed to save SSH host to database", err, { sshLogger.error("Failed to save SSH host to database", err, {
@@ -415,6 +440,7 @@ router.put(
authMethod, authMethod,
authType, authType,
credentialId, credentialId,
overrideCredentialUsername,
key, key,
keyPassword, keyPassword,
keyType, keyType,
@@ -455,6 +481,7 @@ router.put(
username, username,
authType: effectiveAuthType, authType: effectiveAuthType,
credentialId: credentialId || null, credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0, pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0,
@@ -532,6 +559,7 @@ router.put(
: [] : []
: [], : [],
pin: !!updatedHost.pin, pin: !!updatedHost.pin,
overrideCredentialUsername: !!updatedHost.overrideCredentialUsername,
enableTerminal: !!updatedHost.enableTerminal, enableTerminal: !!updatedHost.enableTerminal,
enableTunnel: !!updatedHost.enableTunnel, enableTunnel: !!updatedHost.enableTunnel,
tunnelConnections: updatedHost.tunnelConnections tunnelConnections: updatedHost.tunnelConnections
@@ -558,6 +586,27 @@ router.put(
}, },
); );
try {
const fetch = (await import("node-fetch")).default;
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
await fetch("http://localhost:30005/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Cookie: `jwt=${token}` }),
},
});
} catch (refreshError) {
sshLogger.warn("Failed to refresh server stats polling", {
operation: "stats_refresh_after_update",
error:
refreshError instanceof Error
? refreshError.message
: "Unknown error",
});
}
res.json(resolvedHost); res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error("Failed to update SSH host in database", err, { sshLogger.error("Failed to update SSH host in database", err, {
@@ -585,6 +634,18 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
}); });
return res.status(400).json({ error: "Invalid userId" }); return res.status(400).json({ error: "Invalid userId" });
} }
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
sshLogger.warn("User data not unlocked for SSH host fetch", {
operation: "host_fetch",
userId,
});
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try { try {
const data = await SimpleDBOps.select( const data = await SimpleDBOps.select(
db.select().from(sshData).where(eq(sshData.userId, userId)), db.select().from(sshData).where(eq(sshData.userId, userId)),
@@ -603,6 +664,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
: [] : []
: [], : [],
pin: !!row.pin, pin: !!row.pin,
overrideCredentialUsername: !!row.overrideCredentialUsername,
enableTerminal: !!row.enableTerminal, enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel, enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections tunnelConnections: row.tunnelConnections
@@ -649,6 +711,19 @@ router.get(
}); });
return res.status(400).json({ error: "Invalid userId or hostId" }); return res.status(400).json({ error: "Invalid userId or hostId" });
} }
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
sshLogger.warn("User data not unlocked for SSH host fetch by ID", {
operation: "host_fetch_by_id",
hostId: parseInt(hostId),
userId,
});
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try { try {
const data = await db const data = await db
.select() .select()
@@ -674,6 +749,7 @@ router.get(
: [] : []
: [], : [],
pin: !!host.pin, pin: !!host.pin,
overrideCredentialUsername: !!host.overrideCredentialUsername,
enableTerminal: !!host.enableTerminal, enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel, enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections tunnelConnections: host.tunnelConnections
@@ -848,6 +924,15 @@ router.delete(
), ),
); );
await db
.delete(recentActivity)
.where(
and(
eq(recentActivity.userId, userId),
eq(recentActivity.hostId, numericHostId),
),
);
await db await db
.delete(sshData) .delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId))); .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -1267,7 +1352,9 @@ async function resolveHostCredentials(
const credential = credentials[0]; const credential = credentials[0];
return { return {
...host, ...host,
username: credential.username, username: host.overrideCredentialUsername
? host.username
: credential.username,
authType: credential.auth_type || credential.authType, authType: credential.auth_type || credential.authType,
password: credential.password, password: credential.password,
key: credential.key, key: credential.key,
@@ -1446,8 +1533,10 @@ router.post(
username: hostData.username, username: hostData.username,
password: hostData.authType === "password" ? hostData.password : null, password: hostData.authType === "password" ? hostData.password : null,
authType: hostData.authType, authType: hostData.authType,
credentialId: credentialId: hostData.credentialId || null,
hostData.authType === "credential" ? hostData.credentialId : null, overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
key: hostData.authType === "key" ? hostData.key : null, key: hostData.authType === "key" ? hostData.key : null,
keyPassword: keyPassword:
hostData.authType === "key" hostData.authType === "key"

View File

@@ -226,6 +226,16 @@ router.post("/create", async (req, res) => {
}); });
} }
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist user to disk", saveError, {
operation: "user_create_save_failed",
userId: id,
});
}
authLogger.success( authLogger.success(
`Traditional user created: ${username} (is_admin: ${isFirstUser})`, `Traditional user created: ${username} (is_admin: ${isFirstUser})`,
{ {
@@ -785,6 +795,16 @@ router.get("/oidc/callback", async (req, res) => {
}); });
} }
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist OIDC user to disk", saveError, {
operation: "oidc_user_create_save_failed",
userId: id,
});
}
user = await db.select().from(users).where(eq(users.id, id)); user = await db.select().from(users).where(eq(users.id, id));
} else { } else {
await db await db
@@ -836,6 +856,9 @@ router.get("/oidc/callback", async (req, res) => {
? 30 * 24 * 60 * 60 * 1000 ? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000; : 7 * 24 * 60 * 60 * 1000;
// Clear any existing JWT cookie first to prevent conflicts
res.clearCookie("jwt", authManager.getSecureCookieOptions(req));
return res return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.redirect(redirectUrl.toString()); .redirect(redirectUrl.toString());
@@ -1653,6 +1676,16 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
.set({ is_admin: true }) .set({ is_admin: true })
.where(eq(users.username, username)); .where(eq(users.username, username));
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist admin promotion to disk", saveError, {
operation: "make_admin_save_failed",
username,
});
}
authLogger.success( authLogger.success(
`User ${username} made admin by ${adminUser[0].username}`, `User ${username} made admin by ${adminUser[0].username}`,
); );
@@ -1702,6 +1735,16 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
.set({ is_admin: false }) .set({ is_admin: false })
.where(eq(users.username, username)); .where(eq(users.username, username));
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist admin removal to disk", saveError, {
operation: "remove_admin_save_failed",
username,
});
}
authLogger.success( authLogger.success(
`Admin status removed from ${username} by ${adminUser[0].username}`, `Admin status removed from ${username} by ${adminUser[0].username}`,
); );

View File

@@ -845,7 +845,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH listFiles error:", err); fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });

View File

@@ -455,6 +455,13 @@ class PollingManager {
} }
} }
if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
this.pollingConfigs.delete(host.id);
this.statusStore.delete(host.id);
this.metricsStore.delete(host.id);
return;
}
const config: HostPollingConfig = { const config: HostPollingConfig = {
host, host,
statsConfig, statsConfig,
@@ -514,7 +521,7 @@ class PollingManager {
} catch (error) {} } catch (error) {}
} }
stopPollingForHost(hostId: number): void { stopPollingForHost(hostId: number, clearData = true): void {
const config = this.pollingConfigs.get(hostId); const config = this.pollingConfigs.get(hostId);
if (config) { if (config) {
if (config.statusTimer) { if (config.statusTimer) {
@@ -524,8 +531,10 @@ class PollingManager {
clearInterval(config.metricsTimer); clearInterval(config.metricsTimer);
} }
this.pollingConfigs.delete(hostId); this.pollingConfigs.delete(hostId);
this.statusStore.delete(hostId); if (clearData) {
this.metricsStore.delete(hostId); this.statusStore.delete(hostId);
this.metricsStore.delete(hostId);
}
} }
} }
@@ -554,11 +563,23 @@ class PollingManager {
} }
async refreshHostPolling(userId: string): Promise<void> { async refreshHostPolling(userId: string): Promise<void> {
const hosts = await fetchAllHosts(userId);
const currentHostIds = new Set(hosts.map((h) => h.id));
for (const hostId of this.pollingConfigs.keys()) { for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId); this.stopPollingForHost(hostId, false);
} }
await this.initializePolling(userId); for (const hostId of this.statusStore.keys()) {
if (!currentHostIds.has(hostId)) {
this.statusStore.delete(hostId);
this.metricsStore.delete(hostId);
}
}
for (const host of hosts) {
await this.startPollingForHost(host);
}
} }
destroy(): void { destroy(): void {

View File

@@ -152,6 +152,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
let totpPromptSent = false; let totpPromptSent = false;
let isKeyboardInteractive = false; let isKeyboardInteractive = false;
let keyboardInteractiveResponded = false; let keyboardInteractiveResponded = false;
let isConnecting = false;
let isConnected = false;
let isCleaningUp = false;
ws.on("close", () => { ws.on("close", () => {
const userWs = userConnections.get(userId); const userWs = userConnections.get(userId);
@@ -417,10 +420,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
if (isConnecting || isConnected) {
sshLogger.warn("Connection already in progress or established", {
operation: "ssh_connect",
hostId: id,
isConnecting,
isConnected,
});
return;
}
isConnecting = true;
sshConn = new Client(); sshConn = new Client();
const connectionTimeout = setTimeout(() => { const connectionTimeout = setTimeout(() => {
if (sshConn) { if (sshConn && isConnecting && !isConnected) {
sshLogger.error("SSH connection timeout", undefined, { sshLogger.error("SSH connection timeout", undefined, {
operation: "ssh_connect", operation: "ssh_connect",
hostId: id, hostId: id,
@@ -433,7 +447,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
} }
}, 60000); }, 120000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType }; let resolvedCredentials = { password, key, keyPassword, keyType, authType };
let authMethodNotAvailable = false; let authMethodNotAvailable = false;
@@ -498,7 +512,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("ready", () => { sshConn.on("ready", () => {
clearTimeout(connectionTimeout); clearTimeout(connectionTimeout);
if (!sshConn) { const conn = sshConn;
if (!conn || isCleaningUp) {
sshLogger.warn( sshLogger.warn(
"SSH connection was cleaned up before shell could be created", "SSH connection was cleaned up before shell could be created",
{ {
@@ -507,6 +523,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ip, ip,
port, port,
username, username,
isCleaningUp,
}, },
); );
ws.send( ws.send(
@@ -519,7 +536,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
sshConn.shell( isConnecting = false;
isConnected = true;
conn.shell(
{ {
rows: data.rows, rows: data.rows,
cols: data.cols, cols: data.cols,
@@ -836,9 +856,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
tryKeyboard: true, tryKeyboard: true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 60000, readyTimeout: 120000,
tcpKeepAlive: true, tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000, tcpKeepAliveInitialDelay: 30000,
timeout: 120000,
env: { env: {
TERM: "xterm-256color", TERM: "xterm-256color",
LANG: "en_US.UTF-8", LANG: "en_US.UTF-8",
@@ -982,6 +1003,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
function cleanupSSH(timeoutId?: NodeJS.Timeout) { function cleanupSSH(timeoutId?: NodeJS.Timeout) {
if (isCleaningUp) {
return;
}
isCleaningUp = true;
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
@@ -1019,6 +1045,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
isKeyboardInteractive = false; isKeyboardInteractive = false;
keyboardInteractiveResponded = false; keyboardInteractiveResponded = false;
keyboardInteractiveFinish = null; keyboardInteractiveFinish = null;
isConnecting = false;
isConnected = false;
setTimeout(() => {
isCleaningUp = false;
}, 100);
} }
function setupPingInterval() { function setupPingInterval() {

View File

@@ -109,6 +109,7 @@
"orCreateNewFolder": "Or create new folder", "orCreateNewFolder": "Or create new folder",
"addTag": "Add tag", "addTag": "Add tag",
"saving": "Saving...", "saving": "Saving...",
"credentialId": "Credential ID",
"overview": "Overview", "overview": "Overview",
"security": "Security", "security": "Security",
"usage": "Usage", "usage": "Usage",
@@ -782,7 +783,9 @@
"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", "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)." "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).",
"overrideCredentialUsername": "Override Credential Username",
"overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames."
}, },
"terminal": { "terminal": {
"title": "Terminal", "title": "Terminal",

View File

@@ -26,6 +26,7 @@ export interface SSHHost {
autostartKeyPassword?: string; autostartKeyPassword?: string;
credentialId?: number; credentialId?: number;
overrideCredentialUsername?: boolean;
userId?: string; userId?: string;
enableTerminal: boolean; enableTerminal: boolean;
enableTunnel: boolean; enableTunnel: boolean;
@@ -52,6 +53,7 @@ export interface SSHHostData {
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
credentialId?: number | null; credentialId?: number | null;
overrideCredentialUsername?: boolean;
enableTerminal?: boolean; enableTerminal?: boolean;
enableTunnel?: boolean; enableTunnel?: boolean;
enableFileManager?: boolean; enableFileManager?: boolean;

View File

@@ -640,6 +640,9 @@ export function CredentialsManager({
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{credential.username} {credential.username}
</p> </p>
<p className="text-xs text-muted-foreground truncate">
ID: {credential.id}
</p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{credential.authType === "password" {credential.authType === "password"
? t("credentials.password") ? t("credentials.password")

View File

@@ -527,41 +527,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const reader = new FileReader(); const reader = new FileReader();
reader.onerror = () => reject(reader.error); reader.onerror = () => reject(reader.error);
const isTextFile = reader.onload = () => {
file.type.startsWith("text/") || if (reader.result instanceof ArrayBuffer) {
file.type === "application/json" || const bytes = new Uint8Array(reader.result);
file.type === "application/javascript" || let binary = "";
file.type === "application/xml" || for (let i = 0; i < bytes.byteLength; i++) {
file.type === "image/svg+xml" || binary += String.fromCharCode(bytes[i]);
file.name.match(
/\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i,
);
if (isTextFile) {
reader.onload = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to read text file content"));
} }
}; const base64 = btoa(binary);
reader.readAsText(file); resolve(base64);
} else { } else {
reader.onload = () => { reject(new Error("Failed to read file"));
if (reader.result instanceof ArrayBuffer) { }
const bytes = new Uint8Array(reader.result); };
let binary = ""; reader.readAsArrayBuffer(file);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
resolve(base64);
} else {
reject(new Error("Failed to read binary file"));
}
};
reader.readAsArrayBuffer(file);
}
}); });
await uploadSSHFile( await uploadSSHFile(

View File

@@ -217,6 +217,7 @@ export function HostManagerEditor({
pin: z.boolean().default(false), pin: z.boolean().default(false),
authType: z.enum(["password", "key", "credential", "none"]), authType: z.enum(["password", "key", "credential", "none"]),
credentialId: z.number().optional().nullable(), credentialId: z.number().optional().nullable(),
overrideCredentialUsername: z.boolean().optional(),
password: z.string().optional(), password: z.string().optional(),
key: z.any().optional().nullable(), key: z.any().optional().nullable(),
keyPassword: z.string().optional(), keyPassword: z.string().optional(),
@@ -389,6 +390,7 @@ export function HostManagerEditor({
pin: false, pin: false,
authType: "password" as const, authType: "password" as const,
credentialId: null, credentialId: null,
overrideCredentialUsername: false,
password: "", password: "",
key: null, key: null,
keyPassword: "", keyPassword: "",
@@ -407,7 +409,8 @@ export function HostManagerEditor({
useEffect(() => { useEffect(() => {
if (authTab === "credential") { if (authTab === "credential") {
const currentCredentialId = form.getValues("credentialId"); const currentCredentialId = form.getValues("credentialId");
if (currentCredentialId) { const overrideUsername = form.getValues("overrideCredentialUsername");
if (currentCredentialId && !overrideUsername) {
const selectedCredential = credentials.find( const selectedCredential = credentials.find(
(c) => c.id === currentCredentialId, (c) => c.id === currentCredentialId,
); );
@@ -464,6 +467,9 @@ export function HostManagerEditor({
pin: Boolean(cleanedHost.pin), pin: Boolean(cleanedHost.pin),
authType: defaultAuthType as "password" | "key" | "credential" | "none", authType: defaultAuthType as "password" | "key" | "credential" | "none",
credentialId: null, credentialId: null,
overrideCredentialUsername: Boolean(
cleanedHost.overrideCredentialUsername,
),
password: "", password: "",
key: null, key: null,
keyPassword: "", keyPassword: "",
@@ -512,6 +518,7 @@ export function HostManagerEditor({
pin: false, pin: false,
authType: "password" as const, authType: "password" as const,
credentialId: null, credentialId: null,
overrideCredentialUsername: false,
password: "", password: "",
key: null, key: null,
keyPassword: "", keyPassword: "",
@@ -574,6 +581,7 @@ export function HostManagerEditor({
tags: data.tags || [], tags: data.tags || [],
pin: Boolean(data.pin), pin: Boolean(data.pin),
authType: data.authType, authType: data.authType,
overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
enableTerminal: Boolean(data.enableTerminal), enableTerminal: Boolean(data.enableTerminal),
enableTunnel: Boolean(data.enableTunnel), enableTunnel: Boolean(data.enableTunnel),
enableFileManager: Boolean(data.enableFileManager), enableFileManager: Boolean(data.enableFileManager),
@@ -882,17 +890,28 @@ export function HostManagerEditor({
<FormField <FormField
control={form.control} control={form.control}
name="username" name="username"
render={({ field }) => ( render={({ field }) => {
<FormItem className="col-span-6"> const isCredentialAuth = authTab === "credential";
<FormLabel>{t("hosts.username")}</FormLabel> const hasCredential = !!form.watch("credentialId");
<FormControl> const overrideEnabled = !!form.watch(
<Input "overrideCredentialUsername",
placeholder={t("placeholders.username")} );
{...field} const shouldDisable =
/> isCredentialAuth && hasCredential && !overrideEnabled;
</FormControl>
</FormItem> return (
)} <FormItem className="col-span-6">
<FormLabel>{t("hosts.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
disabled={shouldDisable}
{...field}
/>
</FormControl>
</FormItem>
);
}}
/> />
</div> </div>
<FormLabel className="mb-3 mt-3 font-bold"> <FormLabel className="mb-3 mt-3 font-bold">
@@ -1263,29 +1282,60 @@ export function HostManagerEditor({
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="credential"> <TabsContent value="credential">
<FormField <div className="space-y-4">
control={form.control} <FormField
name="credentialId" control={form.control}
render={({ field }) => ( name="credentialId"
<FormItem> render={({ field }) => (
<CredentialSelector <FormItem>
value={field.value} <CredentialSelector
onValueChange={field.onChange} value={field.value}
onCredentialSelect={(credential) => { onValueChange={field.onChange}
if (credential) { onCredentialSelect={(credential) => {
form.setValue( if (
"username", credential &&
credential.username, !form.getValues(
); "overrideCredentialUsername",
} )
}} ) {
/> form.setValue(
<FormDescription> "username",
{t("hosts.credentialDescription")} credential.username,
</FormDescription> );
</FormItem> }
}}
/>
<FormDescription>
{t("hosts.credentialDescription")}
</FormDescription>
</FormItem>
)}
/>
{form.watch("credentialId") && (
<FormField
control={form.control}
name="overrideCredentialUsername"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>
{t("hosts.overrideCredentialUsername")}
</FormLabel>
<FormDescription>
{t("hosts.overrideCredentialUsernameDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)} )}
/> </div>
</TabsContent> </TabsContent>
<TabsContent value="none"> <TabsContent value="none">
<Alert className="mt-2"> <Alert className="mt-2">

View File

@@ -112,6 +112,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
useState(false); useState(false);
const isVisibleRef = useRef<boolean>(false); const isVisibleRef = useRef<boolean>(false);
const isReadyRef = useRef<boolean>(false);
const isFittingRef = useRef(false); const isFittingRef = useRef(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
@@ -157,6 +158,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
isVisibleRef.current = isVisible; isVisibleRef.current = isVisible;
}, [isVisible]); }, [isVisible]);
useEffect(() => {
isReadyRef.current = isReady;
}, [isReady]);
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
const jwtToken = getCookie("jwt"); const jwtToken = getCookie("jwt");
@@ -507,6 +512,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}), }),
); );
terminal.onData((data) => { terminal.onData((data) => {
if (data === "\x00" || data === "\u0000") {
return;
}
ws.send(JSON.stringify({ type: "input", data })); ws.send(JSON.stringify({ type: "input", data }));
}); });
@@ -915,15 +923,21 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
element?.addEventListener("keydown", handleMacKeyboard, true); element?.addEventListener("keydown", handleMacKeyboard, true);
const resizeObserver = new ResizeObserver(() => { const handleResize = () => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => { resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current || !isReady) return; if (!isVisibleRef.current || !isReadyRef.current) return;
performFit(); performFit();
}, 50); }, 100);
}); };
resizeObserver.observe(xtermRef.current); const resizeObserver = new ResizeObserver(handleResize);
if (xtermRef.current) {
resizeObserver.observe(xtermRef.current);
}
window.addEventListener("resize", handleResize);
setVisible(true); setVisible(true);
@@ -936,6 +950,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsReady(false); setIsReady(false);
isFittingRef.current = false; isFittingRef.current = false;
resizeObserver.disconnect(); resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
element?.removeEventListener("contextmenu", handleContextMenu); element?.removeEventListener("contextmenu", handleContextMenu);
element?.removeEventListener("keydown", handleMacKeyboard, true); element?.removeEventListener("keydown", handleMacKeyboard, true);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);

View File

@@ -558,65 +558,61 @@ export function Auth({
if (success) { if (success) {
setOidcLoading(true); setOidcLoading(true);
getUserInfo() // Clear the success parameter first to prevent re-processing
.then((meRes) => { window.history.replaceState({}, document.title, window.location.pathname);
if (isInElectronWebView()) {
const token = getCookie("jwt") || localStorage.getItem("jwt"); setTimeout(() => {
if (token) { getUserInfo()
try { .then((meRes) => {
window.parent.postMessage( if (isInElectronWebView()) {
{ const token = getCookie("jwt") || localStorage.getItem("jwt");
type: "AUTH_SUCCESS", if (token) {
token: token, try {
source: "oidc_callback", window.parent.postMessage(
platform: "desktop", {
timestamp: Date.now(), type: "AUTH_SUCCESS",
}, token: token,
"*", source: "oidc_callback",
); platform: "desktop",
setWebviewAuthSuccess(true); timestamp: Date.now(),
setTimeout(() => window.location.reload(), 100); },
setOidcLoading(false); "*",
return; );
} catch (e) { setWebviewAuthSuccess(true);
console.error("Error posting auth success message:", e); setTimeout(() => window.location.reload(), 100);
setOidcLoading(false);
return;
} catch (e) {
console.error("Error posting auth success message:", e);
}
} }
} }
}
setInternalLoggedIn(true); setInternalLoggedIn(true);
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); setUsername(meRes.username || null);
setUserId(meRes.userId || null); setUserId(meRes.userId || null);
setDbError(null); setDbError(null);
onAuthSuccess({ onAuthSuccess({
isAdmin: !!meRes.is_admin, isAdmin: !!meRes.is_admin,
username: meRes.username || null, username: meRes.username || null,
userId: meRes.userId || null, userId: meRes.userId || null,
});
setInternalLoggedIn(true);
})
.catch((err) => {
console.error("Failed to get user info after OIDC callback:", err);
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
})
.finally(() => {
setOidcLoading(false);
}); });
setInternalLoggedIn(true); }, 200);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.catch(() => {
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.finally(() => {
setOidcLoading(false);
});
} }
}, [ }, [
onAuthSuccess, onAuthSuccess,

View File

@@ -318,34 +318,37 @@ function createApiInstance(
const errorMessage = (error.response?.data as Record<string, unknown>) const errorMessage = (error.response?.data as Record<string, unknown>)
?.error; ?.error;
const isSessionExpired = errorCode === "SESSION_EXPIRED"; const isSessionExpired = errorCode === "SESSION_EXPIRED";
const isSessionNotFound = errorCode === "SESSION_NOT_FOUND";
const isInvalidToken = const isInvalidToken =
errorCode === "AUTH_REQUIRED" || errorCode === "AUTH_REQUIRED" ||
errorMessage === "Invalid token" || errorMessage === "Invalid token" ||
errorMessage === "Authentication required"; errorMessage === "Authentication required";
if (isElectron()) { if (isSessionExpired || isSessionNotFound) {
localStorage.removeItem("jwt"); if (isElectron()) {
} else { localStorage.removeItem("jwt");
localStorage.removeItem("jwt"); } else {
} localStorage.removeItem("jwt");
}
if ( if (typeof window !== "undefined") {
(isSessionExpired || isInvalidToken) && console.warn("Session expired or not found - please log in again");
typeof window !== "undefined"
) { 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.");
window.location.reload();
});
setTimeout(() => window.location.reload(), 1000);
}
} else if (isInvalidToken && typeof window !== "undefined") {
console.warn( console.warn(
"Session expired or invalid token - please log in again", "Authentication error - token may be invalid",
errorMessage,
); );
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.");
window.location.reload();
});
setTimeout(() => window.location.reload(), 1000);
} }
} }
@@ -792,6 +795,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
keyType: hostData.authType === "key" ? hostData.keyType : null, keyType: hostData.authType === "key" ? hostData.keyType : null,
credentialId: credentialId:
hostData.authType === "credential" ? hostData.credentialId : null, hostData.authType === "credential" ? hostData.credentialId : null,
overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername),
enableTerminal: Boolean(hostData.enableTerminal), enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel), enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager), enableFileManager: Boolean(hostData.enableFileManager),
@@ -855,6 +859,7 @@ export async function updateSSHHost(
keyType: hostData.authType === "key" ? hostData.keyType : null, keyType: hostData.authType === "key" ? hostData.keyType : null,
credentialId: credentialId:
hostData.authType === "credential" ? hostData.credentialId : null, hostData.authType === "credential" ? hostData.credentialId : null,
overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername),
enableTerminal: Boolean(hostData.enableTerminal), enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel), enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager), enableFileManager: Boolean(hostData.enableFileManager),

View File

@@ -504,55 +504,45 @@ export function Auth({
setOidcLoading(true); setOidcLoading(true);
setError(null); setError(null);
getUserInfo() window.history.replaceState({}, document.title, window.location.pathname);
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
postJWTToWebView();
if (isReactNativeWebView()) { setTimeout(() => {
setMobileAuthSuccess(true); getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
postJWTToWebView();
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setOidcLoading(false);
return;
}
setLoggedIn(true);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
});
setInternalLoggedIn(true);
})
.catch((err) => {
console.error("Failed to get user info after OIDC callback:", err);
setError(t("errors.failedUserInfo"));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
})
.finally(() => {
setOidcLoading(false); setOidcLoading(false);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
return;
}
setLoggedIn(true);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
}); });
}, 200);
setInternalLoggedIn(true);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.catch(() => {
setError(t("errors.failedUserInfo"));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.finally(() => {
setOidcLoading(false);
});
} }
}, []); }, []);