v1.8.0 #429
@@ -417,6 +417,7 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
|
|
||||||
// Check if user is authenticated admin
|
// Check if user is authenticated admin
|
||||||
let isAuthenticatedAdmin = false;
|
let isAuthenticatedAdmin = false;
|
||||||
|
let userId: string | null = null;
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
const token = authHeader.split(" ")[1];
|
const token = authHeader.split(" ")[1];
|
||||||
@@ -424,45 +425,11 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
const payload = await authManager.verifyJWTToken(token);
|
const payload = await authManager.verifyJWTToken(token);
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const userId = payload.userId;
|
userId = payload.userId;
|
||||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
|
||||||
if (user && user.length > 0 && user[0].is_admin) {
|
if (user && user.length > 0 && user[0].is_admin) {
|
||||||
isAuthenticatedAdmin = true;
|
isAuthenticatedAdmin = true;
|
||||||
|
|
||||||
// Only decrypt for authenticated admins
|
|
||||||
if (config.client_secret?.startsWith("encrypted:")) {
|
|
||||||
try {
|
|
||||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
|
||||||
if (adminDataKey) {
|
|
||||||
config = DataCrypto.decryptRecord(
|
|
||||||
"settings",
|
|
||||||
config,
|
|
||||||
userId,
|
|
||||||
adminDataKey,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
|
||||||
operation: "oidc_config_decrypt_failed",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
|
||||||
}
|
|
||||||
} else if (config.client_secret?.startsWith("encoded:")) {
|
|
||||||
// Decode for authenticated admins only
|
|
||||||
try {
|
|
||||||
const decoded = Buffer.from(
|
|
||||||
config.client_secret.substring(8),
|
|
||||||
"base64",
|
|
||||||
).toString("utf8");
|
|
||||||
config.client_secret = decoded;
|
|
||||||
} catch {
|
|
||||||
config.client_secret = "[ENCODING ERROR]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +451,46 @@ router.get("/oidc-config", async (req, res) => {
|
|||||||
return res.json(publicConfig);
|
return res.json(publicConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For authenticated admins, decrypt sensitive fields
|
||||||
|
if (config.client_secret?.startsWith("encrypted:")) {
|
||||||
|
try {
|
||||||
|
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
|
if (adminDataKey) {
|
||||||
|
config = DataCrypto.decryptRecord(
|
||||||
|
"settings",
|
||||||
|
config,
|
||||||
|
userId,
|
||||||
|
adminDataKey,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Admin is authenticated but data key is not available
|
||||||
|
// This can happen if they haven't unlocked their data yet
|
||||||
|
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
||||||
|
}
|
||||||
|
} catch (decryptError) {
|
||||||
|
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
||||||
|
operation: "oidc_config_decrypt_failed",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
||||||
|
}
|
||||||
|
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||||
|
// Decode for authenticated admins
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(
|
||||||
|
config.client_secret.substring(8),
|
||||||
|
"base64",
|
||||||
|
).toString("utf8");
|
||||||
|
config.client_secret = decoded;
|
||||||
|
} catch (decodeError) {
|
||||||
|
authLogger.warn("Failed to decode OIDC config for admin", {
|
||||||
|
operation: "oidc_config_decode_failed",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
config.client_secret = "[ENCODING ERROR]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json(config);
|
res.json(config);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to get OIDC config", err);
|
authLogger.error("Failed to get OIDC config", err);
|
||||||
|
|||||||
@@ -350,7 +350,40 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
}
|
}
|
||||||
config.password = resolvedCredentials.password;
|
config.password = resolvedCredentials.password;
|
||||||
} else if (resolvedCredentials.authType === "none") {
|
} else if (resolvedCredentials.authType === "none") {
|
||||||
// Don't set password in config - rely on keyboard-interactive
|
// Use authHandler to control authentication flow
|
||||||
|
// This ensures we only try keyboard-interactive, not password auth
|
||||||
|
config.authHandler = (
|
||||||
|
methodsLeft: string[],
|
||||||
|
partialSuccess: boolean,
|
||||||
|
callback: (nextMethod: string | false) => void,
|
||||||
|
) => {
|
||||||
|
fileLogger.info("Auth handler called", {
|
||||||
|
operation: "ssh_auth_handler",
|
||||||
|
hostId,
|
||||||
|
sessionId,
|
||||||
|
methodsLeft,
|
||||||
|
partialSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only try keyboard-interactive
|
||||||
|
if (methodsLeft.includes("keyboard-interactive")) {
|
||||||
|
callback("keyboard-interactive");
|
||||||
|
} else {
|
||||||
|
fileLogger.error("Server does not support keyboard-interactive auth", {
|
||||||
|
operation: "ssh_auth_handler_no_keyboard",
|
||||||
|
hostId,
|
||||||
|
sessionId,
|
||||||
|
methodsLeft,
|
||||||
|
});
|
||||||
|
callback(false); // No more methods to try
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fileLogger.info("Using keyboard-interactive auth (authType: none)", {
|
||||||
|
operation: "ssh_auth_config",
|
||||||
|
hostId,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
fileLogger.warn(
|
fileLogger.warn(
|
||||||
"No valid authentication method provided for file manager",
|
"No valid authentication method provided for file manager",
|
||||||
@@ -531,30 +564,38 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
// If no stored password (including authType "none"), prompt the user
|
// If no stored password (including authType "none"), prompt the user
|
||||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||||
if (responseSent) {
|
if (responseSent) {
|
||||||
const responses = prompts.map((p) => {
|
// Connection is already being handled, don't send duplicate responses
|
||||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
fileLogger.info(
|
||||||
return resolvedCredentials.password;
|
"Skipping duplicate password prompt - response already sent",
|
||||||
}
|
{
|
||||||
return "";
|
operation: "keyboard_interactive_skip",
|
||||||
});
|
hostId,
|
||||||
finish(responses);
|
sessionId,
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
|
|
||||||
if (pendingTOTPSessions[sessionId]) {
|
if (pendingTOTPSessions[sessionId]) {
|
||||||
const responses = prompts.map((p) => {
|
// Session already waiting for TOTP, don't override
|
||||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
fileLogger.info("Skipping password prompt - TOTP session pending", {
|
||||||
return resolvedCredentials.password;
|
operation: "keyboard_interactive_skip",
|
||||||
}
|
hostId,
|
||||||
return "";
|
sessionId,
|
||||||
});
|
});
|
||||||
finish(responses);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
keyboardInteractiveResponded = true;
|
keyboardInteractiveResponded = true;
|
||||||
|
|
||||||
|
fileLogger.info("Requesting password from user (authType: none)", {
|
||||||
|
operation: "keyboard_interactive_password",
|
||||||
|
hostId,
|
||||||
|
sessionId,
|
||||||
|
prompt: prompts[passwordPromptIndex].prompt,
|
||||||
|
});
|
||||||
|
|
||||||
pendingTOTPSessions[sessionId] = {
|
pendingTOTPSessions[sessionId] = {
|
||||||
client,
|
client,
|
||||||
finish,
|
finish,
|
||||||
|
|||||||
@@ -518,9 +518,6 @@ class PollingManager {
|
|||||||
};
|
};
|
||||||
this.statusStore.set(host.id, statusEntry);
|
this.statusStore.set(host.id, statusEntry);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statsLogger.warn(
|
|
||||||
`Failed to poll status for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
const statusEntry: StatusEntry = {
|
const statusEntry: StatusEntry = {
|
||||||
status: "offline",
|
status: "offline",
|
||||||
lastChecked: new Date().toISOString(),
|
lastChecked: new Date().toISOString(),
|
||||||
@@ -1088,10 +1085,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
const coresNum = Number((coresOut.stdout || "").trim());
|
const coresNum = Number((coresOut.stdout || "").trim());
|
||||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statsLogger.warn(
|
|
||||||
`Failed to collect CPU metrics for host ${host.id}`,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
cpuPercent = null;
|
cpuPercent = null;
|
||||||
cores = null;
|
cores = null;
|
||||||
loadTriplet = null;
|
loadTriplet = null;
|
||||||
@@ -1118,10 +1111,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
totalGiB = kibToGiB(totalKb);
|
totalGiB = kibToGiB(totalKb);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statsLogger.warn(
|
|
||||||
`Failed to collect memory metrics for host ${host.id}`,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
memPercent = null;
|
memPercent = null;
|
||||||
usedGiB = null;
|
usedGiB = null;
|
||||||
totalGiB = null;
|
totalGiB = null;
|
||||||
@@ -1171,10 +1160,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statsLogger.warn(
|
|
||||||
`Failed to collect disk metrics for host ${host.id}`,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
diskPercent = null;
|
diskPercent = null;
|
||||||
usedHuman = null;
|
usedHuman = null;
|
||||||
totalHuman = null;
|
totalHuman = null;
|
||||||
@@ -1238,12 +1223,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
txBytes: null,
|
txBytes: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.warn(
|
|
||||||
`Failed to collect network metrics for host ${host.id}`,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect uptime
|
// Collect uptime
|
||||||
let uptimeSeconds: number | null = null;
|
let uptimeSeconds: number | null = null;
|
||||||
@@ -1260,9 +1240,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.warn(`Failed to collect uptime for host ${host.id}`, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect process information
|
// Collect process information
|
||||||
let totalProcesses: number | null = null;
|
let totalProcesses: number | null = null;
|
||||||
@@ -1305,12 +1283,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
);
|
);
|
||||||
totalProcesses = Number(procCount.stdout.trim()) - 1;
|
totalProcesses = Number(procCount.stdout.trim()) - 1;
|
||||||
runningProcesses = Number(runningCount.stdout.trim());
|
runningProcesses = Number(runningCount.stdout.trim());
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.warn(
|
|
||||||
`Failed to collect process info for host ${host.id}`,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect system information
|
// Collect system information
|
||||||
let hostname: string | null = null;
|
let hostname: string | null = null;
|
||||||
@@ -1327,12 +1300,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
hostname = hostnameOut.stdout.trim() || null;
|
hostname = hostnameOut.stdout.trim() || null;
|
||||||
kernel = kernelOut.stdout.trim() || null;
|
kernel = kernelOut.stdout.trim() || null;
|
||||||
os = osOut.stdout.trim() || null;
|
os = osOut.stdout.trim() || null;
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.warn(
|
|
||||||
`Failed to collect system info for host ${host.id}`,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
|
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
|
||||||
|
|||||||
@@ -793,11 +793,26 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
// If no stored password (including authType "none"), prompt the user
|
// If no stored password (including authType "none"), prompt the user
|
||||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||||
if (keyboardInteractiveResponded) {
|
// Don't block duplicate password prompts - some servers (like Warpgate) may ask multiple times
|
||||||
|
if (keyboardInteractiveResponded && totpPromptSent) {
|
||||||
|
// Only block if we already sent a TOTP prompt
|
||||||
|
sshLogger.info(
|
||||||
|
"Skipping duplicate password prompt after TOTP sent",
|
||||||
|
{
|
||||||
|
operation: "keyboard_interactive_skip",
|
||||||
|
hostId: id,
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyboardInteractiveResponded = true;
|
keyboardInteractiveResponded = true;
|
||||||
|
|
||||||
|
sshLogger.info("Requesting password from user (authType: none)", {
|
||||||
|
operation: "keyboard_interactive_password",
|
||||||
|
hostId: id,
|
||||||
|
prompt: prompts[passwordPromptIndex].prompt,
|
||||||
|
});
|
||||||
|
|
||||||
keyboardInteractiveFinish = (userResponses: string[]) => {
|
keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||||
const userInput = (userResponses[0] || "").trim();
|
const userInput = (userResponses[0] || "").trim();
|
||||||
|
|
||||||
@@ -916,7 +931,37 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resolvedCredentials.authType === "none") {
|
if (resolvedCredentials.authType === "none") {
|
||||||
// Don't set password in config - rely on keyboard-interactive
|
// Use authHandler to control authentication flow
|
||||||
|
// This ensures we only try keyboard-interactive, not password auth
|
||||||
|
connectConfig.authHandler = (
|
||||||
|
methodsLeft: string[],
|
||||||
|
partialSuccess: boolean,
|
||||||
|
callback: (nextMethod: string | false) => void,
|
||||||
|
) => {
|
||||||
|
sshLogger.info("Auth handler called", {
|
||||||
|
operation: "ssh_auth_handler",
|
||||||
|
hostId: id,
|
||||||
|
methodsLeft,
|
||||||
|
partialSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only try keyboard-interactive
|
||||||
|
if (methodsLeft.includes("keyboard-interactive")) {
|
||||||
|
callback("keyboard-interactive");
|
||||||
|
} else {
|
||||||
|
sshLogger.error("Server does not support keyboard-interactive auth", {
|
||||||
|
operation: "ssh_auth_handler_no_keyboard",
|
||||||
|
hostId: id,
|
||||||
|
methodsLeft,
|
||||||
|
});
|
||||||
|
callback(false); // No more methods to try
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sshLogger.info("Using keyboard-interactive auth (authType: none)", {
|
||||||
|
operation: "ssh_auth_config",
|
||||||
|
hostId: id,
|
||||||
|
});
|
||||||
} else if (resolvedCredentials.authType === "password") {
|
} else if (resolvedCredentials.authType === "password") {
|
||||||
if (!resolvedCredentials.password) {
|
if (!resolvedCredentials.password) {
|
||||||
sshLogger.error(
|
sshLogger.error(
|
||||||
|
|||||||
@@ -31,25 +31,55 @@ export function HostManager({
|
|||||||
username: string;
|
username: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const { state: sidebarState } = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
const prevHostConfigRef = useRef<SSHHost | undefined>(hostConfig);
|
const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
|
||||||
|
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Update editing host when hostConfig prop changes
|
// Update editing host when hostConfig prop changes (from sidebar edit button)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hostConfig && hostConfig !== prevHostConfigRef.current) {
|
// Skip if we should ignore this change
|
||||||
setEditingHost(hostConfig);
|
if (ignoreNextHostConfigChangeRef.current) {
|
||||||
setActiveTab(initialTab || "add_host");
|
ignoreNextHostConfigChangeRef.current = false;
|
||||||
prevHostConfigRef.current = hostConfig;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process if this is an external edit request (from sidebar)
|
||||||
|
if (hostConfig && initialTab === "add_host") {
|
||||||
|
const currentHostId = hostConfig.id;
|
||||||
|
|
||||||
|
// Open editor if it's a different host OR same host but user is on viewer/credentials tabs
|
||||||
|
if (currentHostId !== lastProcessedHostIdRef.current) {
|
||||||
|
// Different host - always open
|
||||||
|
setEditingHost(hostConfig);
|
||||||
|
setActiveTab("add_host");
|
||||||
|
lastProcessedHostIdRef.current = currentHostId;
|
||||||
|
} else if (
|
||||||
|
activeTab === "host_viewer" ||
|
||||||
|
activeTab === "credentials" ||
|
||||||
|
activeTab === "add_credential"
|
||||||
|
) {
|
||||||
|
// Same host but user manually navigated away - reopen
|
||||||
|
setEditingHost(hostConfig);
|
||||||
|
setActiveTab("add_host");
|
||||||
|
}
|
||||||
|
// If same host and already on add_host tab, do nothing (don't block tab changes)
|
||||||
}
|
}
|
||||||
}, [hostConfig, initialTab]);
|
}, [hostConfig, initialTab]);
|
||||||
|
|
||||||
const handleEditHost = (host: SSHHost) => {
|
const handleEditHost = (host: SSHHost) => {
|
||||||
setEditingHost(host);
|
setEditingHost(host);
|
||||||
setActiveTab("add_host");
|
setActiveTab("add_host");
|
||||||
|
lastProcessedHostIdRef.current = host.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = () => {
|
const handleFormSubmit = () => {
|
||||||
|
// Ignore the next hostConfig change (which will come from ssh-hosts:changed event)
|
||||||
|
ignoreNextHostConfigChangeRef.current = true;
|
||||||
setEditingHost(null);
|
setEditingHost(null);
|
||||||
setActiveTab("host_viewer");
|
setActiveTab("host_viewer");
|
||||||
|
// Clear after a delay so the same host can be edited again
|
||||||
|
setTimeout(() => {
|
||||||
|
lastProcessedHostIdRef.current = undefined;
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditCredential = (credential: {
|
const handleEditCredential = (credential: {
|
||||||
@@ -70,6 +100,7 @@ export function HostManager({
|
|||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
if (value !== "add_host") {
|
if (value !== "add_host") {
|
||||||
setEditingHost(null);
|
setEditingHost(null);
|
||||||
|
isEditingRef.current = false;
|
||||||
}
|
}
|
||||||
if (value !== "add_credential") {
|
if (value !== "add_credential") {
|
||||||
setEditingCredential(null);
|
setEditingCredential(null);
|
||||||
|
|||||||
@@ -666,8 +666,6 @@ export function HostManagerEditor({
|
|||||||
// Refresh backend polling to pick up new/updated host configuration
|
// Refresh backend polling to pick up new/updated host configuration
|
||||||
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||||
refreshServerPolling();
|
refreshServerPolling();
|
||||||
|
|
||||||
form.reset();
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("hosts.failedToSaveHost"));
|
toast.error(t("hosts.failedToSaveHost"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
) {
|
) {
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
connectionTimeoutRef.current = setTimeout(() => {
|
connectionTimeoutRef.current = setTimeout(() => {
|
||||||
if (!isConnected && !totpRequired) {
|
if (!isConnected && !totpRequired && !isPasswordPrompt) {
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
terminal.clear();
|
terminal.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
t.id === existingTab.id
|
t.id === existingTab.id
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
|
// Keep the original title (Host Manager)
|
||||||
|
title: existingTab.title,
|
||||||
hostConfig: tabData.hostConfig
|
hostConfig: tabData.hostConfig
|
||||||
? { ...tabData.hostConfig }
|
? { ...tabData.hostConfig }
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -220,6 +222,15 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
setTabs((prev) =>
|
setTabs((prev) =>
|
||||||
prev.map((tab) => {
|
prev.map((tab) => {
|
||||||
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
||||||
|
// Don't update the title for ssh_manager tabs - they should stay as "Host Manager"
|
||||||
|
if (tab.type === "ssh_manager") {
|
||||||
|
return {
|
||||||
|
...tab,
|
||||||
|
hostConfig: newHostConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other tabs (terminal, server, file_manager), update both config and title
|
||||||
return {
|
return {
|
||||||
...tab,
|
...tab,
|
||||||
hostConfig: newHostConfig,
|
hostConfig: newHostConfig,
|
||||||
|
|||||||
@@ -350,7 +350,9 @@ export function TopNavbar({
|
|||||||
tab.type === "admin" ||
|
tab.type === "admin" ||
|
||||||
tab.type === "user_profile") &&
|
tab.type === "user_profile") &&
|
||||||
isSplitScreenActive);
|
isSplitScreenActive);
|
||||||
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
const isHome = tab.type === "home";
|
||||||
|
const disableClose =
|
||||||
|
(isSplitScreenActive && isActive) || isSplit || isHome;
|
||||||
|
|
||||||
const isDraggingThisTab = dragState.draggedIndex === index;
|
const isDraggingThisTab = dragState.draggedIndex === index;
|
||||||
const isTheDraggedTab = tab.id === dragState.draggedId;
|
const isTheDraggedTab = tab.id === dragState.draggedId;
|
||||||
@@ -420,6 +422,14 @@ export function TopNavbar({
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
e
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Middle mouse button (button === 1)
|
||||||
|
if (e.button === 1 && !disableClose) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTabClose(tab.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
transform,
|
transform,
|
||||||
transition:
|
transition:
|
||||||
|
|||||||
Reference in New Issue
Block a user