fix: General bug fixes/small feature improvements
This commit is contained in:
@@ -84,7 +84,8 @@ jobs:
|
|||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
outputs: type=registry,compression=zstd,compression-level=19
|
org.opencontainers.image.created=${{ github.run_id }}
|
||||||
|
outputs: type=registry,compression=gzip,compression-level=9
|
||||||
|
|
||||||
- name: Cleanup Docker
|
- name: Cleanup Docker
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -395,6 +395,48 @@ ipcMain.handle("save-server-config", (event, config) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("get-setting", (event, key) => {
|
||||||
|
try {
|
||||||
|
const userDataPath = app.getPath("userData");
|
||||||
|
const settingsPath = path.join(userDataPath, "settings.json");
|
||||||
|
|
||||||
|
if (!fs.existsSync(settingsPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
||||||
|
const settings = JSON.parse(settingsData);
|
||||||
|
return settings[key] !== undefined ? settings[key] : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading setting:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("set-setting", (event, key, value) => {
|
||||||
|
try {
|
||||||
|
const userDataPath = app.getPath("userData");
|
||||||
|
const settingsPath = path.join(userDataPath, "settings.json");
|
||||||
|
|
||||||
|
if (!fs.existsSync(userDataPath)) {
|
||||||
|
fs.mkdirSync(userDataPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = {};
|
||||||
|
if (fs.existsSync(settingsPath)) {
|
||||||
|
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
||||||
|
settings = JSON.parse(settingsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings[key] = value;
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving setting:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||||
try {
|
try {
|
||||||
const https = require("https");
|
const https = require("https");
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
isElectron: true,
|
isElectron: true,
|
||||||
isDev: process.env.NODE_ENV === "development",
|
isDev: process.env.NODE_ENV === "development",
|
||||||
|
|
||||||
|
// Settings/preferences storage
|
||||||
|
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||||
|
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
||||||
|
|
||||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { DatabaseMigration } from "../utils/database-migration.js";
|
|||||||
import { UserDataExport } from "../utils/user-data-export.js";
|
import { UserDataExport } from "../utils/user-data-export.js";
|
||||||
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
|
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||||
import {
|
import {
|
||||||
users,
|
users,
|
||||||
sshData,
|
sshData,
|
||||||
@@ -457,8 +458,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
code: "PASSWORD_REQUIRED",
|
code: "PASSWORD_REQUIRED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const deviceInfo = parseUserAgent(req);
|
||||||
const unlocked = await authManager.authenticateUser(userId, password);
|
const unlocked = await authManager.authenticateUser(
|
||||||
|
userId,
|
||||||
|
password,
|
||||||
|
deviceInfo.type,
|
||||||
|
);
|
||||||
if (!unlocked) {
|
if (!unlocked) {
|
||||||
return res.status(401).json({ error: "Invalid password" });
|
return res.status(401).json({ error: "Invalid password" });
|
||||||
}
|
}
|
||||||
@@ -905,6 +910,7 @@ app.post(
|
|||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { password } = req.body;
|
const { password } = req.body;
|
||||||
const mainDb = getDb();
|
const mainDb = getDb();
|
||||||
|
const deviceInfo = parseUserAgent(req);
|
||||||
|
|
||||||
const userRecords = await mainDb
|
const userRecords = await mainDb
|
||||||
.select()
|
.select()
|
||||||
@@ -925,12 +931,19 @@ app.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlocked = await authManager.authenticateUser(userId, password);
|
const unlocked = await authManager.authenticateUser(
|
||||||
|
userId,
|
||||||
|
password,
|
||||||
|
deviceInfo.type,
|
||||||
|
);
|
||||||
if (!unlocked) {
|
if (!unlocked) {
|
||||||
return res.status(401).json({ error: "Invalid password" });
|
return res.status(401).json({ error: "Invalid password" });
|
||||||
}
|
}
|
||||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||||
|
userId,
|
||||||
|
deviceInfo.type,
|
||||||
|
);
|
||||||
if (!oidcUnlocked) {
|
if (!oidcUnlocked) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: "Failed to unlock user data with SSO credentials",
|
error: "Failed to unlock user data with SSO credentials",
|
||||||
@@ -948,7 +961,10 @@ app.post(
|
|||||||
|
|
||||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
if (!userDataKey && isOidcUser) {
|
if (!userDataKey && isOidcUser) {
|
||||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||||
|
userId,
|
||||||
|
deviceInfo.type,
|
||||||
|
);
|
||||||
if (oidcUnlocked) {
|
if (oidcUnlocked) {
|
||||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||||
@@ -214,7 +214,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
terminal_config TEXT,
|
terminal_config TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||||
@@ -224,8 +224,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||||
@@ -235,8 +235,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||||
@@ -246,8 +246,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||||
@@ -255,7 +255,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
alert_id TEXT NOT NULL,
|
alert_id TEXT NOT NULL,
|
||||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||||
@@ -275,7 +275,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
last_used TEXT,
|
last_used TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||||
@@ -284,9 +284,9 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
host_id INTEGER NOT NULL,
|
host_id INTEGER NOT NULL,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS snippets (
|
CREATE TABLE IF NOT EXISTS snippets (
|
||||||
@@ -297,7 +297,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ssh_folders (
|
CREATE TABLE IF NOT EXISTS ssh_folders (
|
||||||
@@ -308,7 +308,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
icon TEXT,
|
icon TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||||
@@ -318,8 +318,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
host_id INTEGER NOT NULL,
|
host_id INTEGER NOT NULL,
|
||||||
host_name TEXT NOT NULL,
|
host_name TEXT NOT NULL,
|
||||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS command_history (
|
CREATE TABLE IF NOT EXISTS command_history (
|
||||||
@@ -328,8 +328,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
host_id INTEGER NOT NULL,
|
host_id INTEGER NOT NULL,
|
||||||
command TEXT NOT NULL,
|
command TEXT NOT NULL,
|
||||||
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
`);
|
`);
|
||||||
@@ -452,6 +452,7 @@ const migrateSchema = () => {
|
|||||||
"INTEGER NOT NULL DEFAULT 1",
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
);
|
);
|
||||||
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||||
|
addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT");
|
||||||
addColumnIfNotExists(
|
addColumnIfNotExists(
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
"enable_file_manager",
|
"enable_file_manager",
|
||||||
@@ -475,7 +476,7 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists(
|
addColumnIfNotExists(
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
"credential_id",
|
"credential_id",
|
||||||
"INTEGER REFERENCES ssh_credentials(id)",
|
"INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL",
|
||||||
);
|
);
|
||||||
addColumnIfNotExists(
|
addColumnIfNotExists(
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
jwtToken: text("jwt_token").notNull(),
|
jwtToken: text("jwt_token").notNull(),
|
||||||
deviceType: text("device_type").notNull(),
|
deviceType: text("device_type").notNull(),
|
||||||
deviceInfo: text("device_info").notNull(),
|
deviceInfo: text("device_info").notNull(),
|
||||||
@@ -51,7 +51,7 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
@@ -71,7 +71,7 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
autostartKey: text("autostart_key", { length: 8192 }),
|
autostartKey: text("autostart_key", { length: 8192 }),
|
||||||
autostartKeyPassword: text("autostart_key_password"),
|
autostartKeyPassword: text("autostart_key_password"),
|
||||||
|
|
||||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||||
overrideCredentialUsername: integer("override_credential_username", {
|
overrideCredentialUsername: integer("override_credential_username", {
|
||||||
mode: "boolean",
|
mode: "boolean",
|
||||||
}),
|
}),
|
||||||
@@ -82,6 +82,7 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
tunnelConnections: text("tunnel_connections"),
|
tunnelConnections: text("tunnel_connections"),
|
||||||
|
jumpHosts: text("jump_hosts"),
|
||||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
@@ -98,12 +99,9 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
|
|
||||||
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id),
|
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id),
|
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
path: text("path").notNull(),
|
path: text("path").notNull(),
|
||||||
lastOpened: text("last_opened")
|
lastOpened: text("last_opened")
|
||||||
@@ -115,10 +113,10 @@ export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id),
|
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
path: text("path").notNull(),
|
path: text("path").notNull(),
|
||||||
pinnedAt: text("pinned_at")
|
pinnedAt: text("pinned_at")
|
||||||
@@ -130,13 +128,10 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id),
|
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
|
||||||
path: text("path").notNull(),
|
|
||||||
createdAt: text("created_at")
|
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
@@ -145,7 +140,7 @@ export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
alertId: text("alert_id").notNull(),
|
alertId: text("alert_id").notNull(),
|
||||||
dismissedAt: text("dismissed_at")
|
dismissedAt: text("dismissed_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -156,7 +151,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: text("folder"),
|
||||||
@@ -184,13 +179,13 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
credentialId: integer("credential_id")
|
credentialId: integer("credential_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshCredentials.id),
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id),
|
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
usedAt: text("used_at")
|
usedAt: text("used_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -200,7 +195,7 @@ export const snippets = sqliteTable("snippets", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
@@ -216,7 +211,7 @@ export const sshFolders = sqliteTable("ssh_folders", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
color: text("color"),
|
color: text("color"),
|
||||||
icon: text("icon"),
|
icon: text("icon"),
|
||||||
@@ -232,12 +227,11 @@ export const recentActivity = sqliteTable("recent_activity", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
type: text("type").notNull(),
|
type: text("type").notNull(),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id),
|
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||||
hostName: text("host_name").notNull(),
|
|
||||||
timestamp: text("timestamp")
|
timestamp: text("timestamp")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -247,11 +241,10 @@ export const commandHistory = sqliteTable("command_history", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id),
|
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||||
command: text("command").notNull(),
|
|
||||||
executedAt: text("executed_at")
|
executedAt: text("executed_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ router.post(
|
|||||||
enableFileManager,
|
enableFileManager,
|
||||||
defaultPath,
|
defaultPath,
|
||||||
tunnelConnections,
|
tunnelConnections,
|
||||||
|
jumpHosts,
|
||||||
statsConfig,
|
statsConfig,
|
||||||
terminalConfig,
|
terminalConfig,
|
||||||
forceKeyboardInteractive,
|
forceKeyboardInteractive,
|
||||||
@@ -271,6 +272,7 @@ router.post(
|
|||||||
tunnelConnections: Array.isArray(tunnelConnections)
|
tunnelConnections: Array.isArray(tunnelConnections)
|
||||||
? JSON.stringify(tunnelConnections)
|
? JSON.stringify(tunnelConnections)
|
||||||
: null,
|
: null,
|
||||||
|
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||||
enableFileManager: enableFileManager ? 1 : 0,
|
enableFileManager: enableFileManager ? 1 : 0,
|
||||||
defaultPath: defaultPath || null,
|
defaultPath: defaultPath || null,
|
||||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||||
@@ -329,6 +331,9 @@ router.post(
|
|||||||
tunnelConnections: createdHost.tunnelConnections
|
tunnelConnections: createdHost.tunnelConnections
|
||||||
? JSON.parse(createdHost.tunnelConnections as string)
|
? JSON.parse(createdHost.tunnelConnections as string)
|
||||||
: [],
|
: [],
|
||||||
|
jumpHosts: createdHost.jumpHosts
|
||||||
|
? JSON.parse(createdHost.jumpHosts as string)
|
||||||
|
: [],
|
||||||
enableFileManager: !!createdHost.enableFileManager,
|
enableFileManager: !!createdHost.enableFileManager,
|
||||||
statsConfig: createdHost.statsConfig
|
statsConfig: createdHost.statsConfig
|
||||||
? JSON.parse(createdHost.statsConfig as string)
|
? JSON.parse(createdHost.statsConfig as string)
|
||||||
@@ -370,6 +375,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/db/host/:id",
|
"/db/host/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const hostId = req.params.id;
|
const hostId = req.params.id;
|
||||||
@@ -425,6 +431,7 @@ router.put(
|
|||||||
enableFileManager,
|
enableFileManager,
|
||||||
defaultPath,
|
defaultPath,
|
||||||
tunnelConnections,
|
tunnelConnections,
|
||||||
|
jumpHosts,
|
||||||
statsConfig,
|
statsConfig,
|
||||||
terminalConfig,
|
terminalConfig,
|
||||||
forceKeyboardInteractive,
|
forceKeyboardInteractive,
|
||||||
@@ -462,6 +469,7 @@ router.put(
|
|||||||
tunnelConnections: Array.isArray(tunnelConnections)
|
tunnelConnections: Array.isArray(tunnelConnections)
|
||||||
? JSON.stringify(tunnelConnections)
|
? JSON.stringify(tunnelConnections)
|
||||||
: null,
|
: null,
|
||||||
|
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
|
||||||
enableFileManager: enableFileManager ? 1 : 0,
|
enableFileManager: enableFileManager ? 1 : 0,
|
||||||
defaultPath: defaultPath || null,
|
defaultPath: defaultPath || null,
|
||||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||||
@@ -538,6 +546,9 @@ router.put(
|
|||||||
tunnelConnections: updatedHost.tunnelConnections
|
tunnelConnections: updatedHost.tunnelConnections
|
||||||
? JSON.parse(updatedHost.tunnelConnections as string)
|
? JSON.parse(updatedHost.tunnelConnections as string)
|
||||||
: [],
|
: [],
|
||||||
|
jumpHosts: updatedHost.jumpHosts
|
||||||
|
? JSON.parse(updatedHost.jumpHosts as string)
|
||||||
|
: [],
|
||||||
enableFileManager: !!updatedHost.enableFileManager,
|
enableFileManager: !!updatedHost.enableFileManager,
|
||||||
statsConfig: updatedHost.statsConfig
|
statsConfig: updatedHost.statsConfig
|
||||||
? JSON.parse(updatedHost.statsConfig as string)
|
? JSON.parse(updatedHost.statsConfig as string)
|
||||||
@@ -577,7 +588,11 @@ router.put(
|
|||||||
|
|
||||||
// Route: Get SSH data for the authenticated user (requires JWT)
|
// Route: Get SSH data for the authenticated user (requires JWT)
|
||||||
// GET /ssh/host
|
// GET /ssh/host
|
||||||
router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
router.get(
|
||||||
|
"/db/host",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
if (!isNonEmptyString(userId)) {
|
if (!isNonEmptyString(userId)) {
|
||||||
sshLogger.warn("Invalid userId for SSH data fetch", {
|
sshLogger.warn("Invalid userId for SSH data fetch", {
|
||||||
@@ -609,6 +624,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
tunnelConnections: row.tunnelConnections
|
tunnelConnections: row.tunnelConnections
|
||||||
? JSON.parse(row.tunnelConnections as string)
|
? JSON.parse(row.tunnelConnections as string)
|
||||||
: [],
|
: [],
|
||||||
|
jumpHosts: row.jumpHosts ? JSON.parse(row.jumpHosts as string) : [],
|
||||||
enableFileManager: !!row.enableFileManager,
|
enableFileManager: !!row.enableFileManager,
|
||||||
statsConfig: row.statsConfig
|
statsConfig: row.statsConfig
|
||||||
? JSON.parse(row.statsConfig as string)
|
? JSON.parse(row.statsConfig as string)
|
||||||
@@ -631,13 +647,15 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
res.status(500).json({ error: "Failed to fetch SSH data" });
|
res.status(500).json({ error: "Failed to fetch SSH data" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Route: Get SSH host by ID (requires JWT)
|
// Route: Get SSH host by ID (requires JWT)
|
||||||
// GET /ssh/host/:id
|
// GET /ssh/host/:id
|
||||||
router.get(
|
router.get(
|
||||||
"/db/host/:id",
|
"/db/host/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const hostId = req.params.id;
|
const hostId = req.params.id;
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -813,42 +831,6 @@ router.delete(
|
|||||||
|
|
||||||
const numericHostId = Number(hostId);
|
const numericHostId = Number(hostId);
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(fileManagerRecent)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(fileManagerRecent.userId, userId),
|
|
||||||
eq(fileManagerRecent.hostId, numericHostId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(fileManagerPinned)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(fileManagerPinned.userId, userId),
|
|
||||||
eq(fileManagerPinned.hostId, numericHostId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(fileManagerShortcuts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(fileManagerShortcuts.userId, userId),
|
|
||||||
eq(fileManagerShortcuts.hostId, numericHostId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(sshCredentialUsage)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sshCredentialUsage.userId, userId),
|
|
||||||
eq(sshCredentialUsage.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)));
|
||||||
|
|||||||
@@ -747,6 +747,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceInfo = parseUserAgent(req);
|
||||||
let user = await db
|
let user = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -780,7 +781,11 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authManager.registerOIDCUser(id);
|
const sessionDurationMs =
|
||||||
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||||
|
? 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
: 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
await authManager.registerOIDCUser(id, sessionDurationMs);
|
||||||
} catch (encryptionError) {
|
} catch (encryptionError) {
|
||||||
await db.delete(users).where(eq(users.id, id));
|
await db.delete(users).where(eq(users.id, id));
|
||||||
authLogger.error(
|
authLogger.error(
|
||||||
@@ -819,7 +824,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authManager.authenticateOIDCUser(userRecord.id);
|
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||||
} catch (setupError) {
|
} catch (setupError) {
|
||||||
authLogger.error("Failed to setup OIDC user encryption", setupError, {
|
authLogger.error("Failed to setup OIDC user encryption", setupError, {
|
||||||
operation: "oidc_user_encryption_setup_failed",
|
operation: "oidc_user_encryption_setup_failed",
|
||||||
@@ -827,7 +832,6 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceInfo = parseUserAgent(req);
|
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
deviceInfo: deviceInfo.deviceInfo,
|
deviceInfo: deviceInfo.deviceInfo,
|
||||||
@@ -941,7 +945,10 @@ router.post("/login", async (req, res) => {
|
|||||||
operation: "user_login",
|
operation: "user_login",
|
||||||
username,
|
username,
|
||||||
ip: clientIp,
|
ip: clientIp,
|
||||||
remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username),
|
remainingAttempts: loginRateLimiter.getRemainingAttempts(
|
||||||
|
clientIp,
|
||||||
|
username,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return res.status(401).json({ error: "Invalid username or password" });
|
return res.status(401).json({ error: "Invalid username or password" });
|
||||||
}
|
}
|
||||||
@@ -967,7 +974,10 @@ router.post("/login", async (req, res) => {
|
|||||||
username,
|
username,
|
||||||
userId: userRecord.id,
|
userId: userRecord.id,
|
||||||
ip: clientIp,
|
ip: clientIp,
|
||||||
remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username),
|
remainingAttempts: loginRateLimiter.getRemainingAttempts(
|
||||||
|
clientIp,
|
||||||
|
username,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return res.status(401).json({ error: "Invalid username or password" });
|
return res.status(401).json({ error: "Invalid username or password" });
|
||||||
}
|
}
|
||||||
@@ -985,9 +995,11 @@ router.post("/login", async (req, res) => {
|
|||||||
databaseLogger.debug("Operation failed, continuing", { error });
|
databaseLogger.debug("Operation failed, continuing", { error });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceInfo = parseUserAgent(req);
|
||||||
const dataUnlocked = await authManager.authenticateUser(
|
const dataUnlocked = await authManager.authenticateUser(
|
||||||
userRecord.id,
|
userRecord.id,
|
||||||
password,
|
password,
|
||||||
|
deviceInfo.type,
|
||||||
);
|
);
|
||||||
if (!dataUnlocked) {
|
if (!dataUnlocked) {
|
||||||
return res.status(401).json({ error: "Incorrect password" });
|
return res.status(401).json({ error: "Incorrect password" });
|
||||||
@@ -1005,7 +1017,6 @@ router.post("/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceInfo = parseUserAgent(req);
|
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
deviceInfo: deviceInfo.deviceInfo,
|
deviceInfo: deviceInfo.deviceInfo,
|
||||||
|
|||||||
@@ -89,6 +89,173 @@ app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
|
|||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
app.use(authManager.createAuthMiddleware());
|
app.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
|
async function resolveJumpHost(
|
||||||
|
hostId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const hosts = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||||
|
"ssh_data",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = hosts[0];
|
||||||
|
|
||||||
|
if (host.credentialId) {
|
||||||
|
const credentials = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, host.credentialId as number),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ssh_credentials",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentials.length > 0) {
|
||||||
|
const credential = credentials[0];
|
||||||
|
return {
|
||||||
|
...host,
|
||||||
|
password: credential.password,
|
||||||
|
key:
|
||||||
|
credential.private_key || credential.privateKey || credential.key,
|
||||||
|
keyPassword: credential.key_password || credential.keyPassword,
|
||||||
|
keyType: credential.key_type || credential.keyType,
|
||||||
|
authType: credential.auth_type || credential.authType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
|
} catch (error) {
|
||||||
|
fileLogger.error("Failed to resolve jump host", error, {
|
||||||
|
operation: "resolve_jump_host",
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJumpHostChain(
|
||||||
|
jumpHosts: Array<{ hostId: number }>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<SSHClient | null> {
|
||||||
|
if (!jumpHosts || jumpHosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentClient: SSHClient | null = null;
|
||||||
|
const clients: SSHClient[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < jumpHosts.length; i++) {
|
||||||
|
const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||||
|
|
||||||
|
if (!jumpHostConfig) {
|
||||||
|
fileLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||||
|
operation: "jump_host_chain",
|
||||||
|
hostId: jumpHosts[i].hostId,
|
||||||
|
});
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpClient = new SSHClient();
|
||||||
|
clients.push(jumpClient);
|
||||||
|
|
||||||
|
const connected = await new Promise<boolean>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
resolve(false);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
jumpClient.on("ready", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
jumpClient.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
fileLogger.error(`Jump host ${i + 1} connection failed`, err, {
|
||||||
|
operation: "jump_host_connect",
|
||||||
|
hostId: jumpHostConfig.id,
|
||||||
|
ip: jumpHostConfig.ip,
|
||||||
|
});
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectConfig: any = {
|
||||||
|
host: jumpHostConfig.ip,
|
||||||
|
port: jumpHostConfig.port || 22,
|
||||||
|
username: jumpHostConfig.username,
|
||||||
|
tryKeyboard: true,
|
||||||
|
readyTimeout: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||||
|
connectConfig.password = jumpHostConfig.password;
|
||||||
|
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||||
|
const cleanKey = jumpHostConfig.key
|
||||||
|
.trim()
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\r/g, "\n");
|
||||||
|
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||||
|
if (jumpHostConfig.keyPassword) {
|
||||||
|
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentClient) {
|
||||||
|
currentClient.forwardOut(
|
||||||
|
"127.0.0.1",
|
||||||
|
0,
|
||||||
|
jumpHostConfig.ip,
|
||||||
|
jumpHostConfig.port || 22,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectConfig.sock = stream;
|
||||||
|
jumpClient.connect(connectConfig);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jumpClient.connect(connectConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentClient = jumpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentClient;
|
||||||
|
} catch (error) {
|
||||||
|
fileLogger.error("Failed to create jump host chain", error, {
|
||||||
|
operation: "jump_host_chain",
|
||||||
|
});
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface SSHSession {
|
interface SSHSession {
|
||||||
client: SSHClient;
|
client: SSHClient;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
@@ -176,6 +343,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
credentialId,
|
credentialId,
|
||||||
userProvidedPassword,
|
userProvidedPassword,
|
||||||
forceKeyboardInteractive,
|
forceKeyboardInteractive,
|
||||||
|
jumpHosts,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -627,8 +795,55 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (jumpHosts && jumpHosts.length > 0 && userId) {
|
||||||
|
try {
|
||||||
|
const jumpClient = await createJumpHostChain(jumpHosts, userId);
|
||||||
|
|
||||||
|
if (!jumpClient) {
|
||||||
|
fileLogger.error("Failed to establish jump host chain", {
|
||||||
|
operation: "file_jump_chain",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to connect through jump hosts" });
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
fileLogger.error("Failed to forward through jump host", err, {
|
||||||
|
operation: "file_jump_forward",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
jumpClient.end();
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({
|
||||||
|
error: "Failed to forward through jump host: " + err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
config.sock = stream;
|
||||||
client.connect(config);
|
client.connect(config);
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fileLogger.error("Jump host error", error, {
|
||||||
|
operation: "file_jump_host",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to connect through jump hosts" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
client.connect(config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||||
const { sessionId, totpCode } = req.body;
|
const { sessionId, totpCode } = req.body;
|
||||||
|
|||||||
@@ -19,6 +19,173 @@ import { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
|||||||
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||||
|
|
||||||
|
async function resolveJumpHost(
|
||||||
|
hostId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const hosts = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||||
|
"ssh_data",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = hosts[0];
|
||||||
|
|
||||||
|
if (host.credentialId) {
|
||||||
|
const credentials = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, host.credentialId as number),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ssh_credentials",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentials.length > 0) {
|
||||||
|
const credential = credentials[0];
|
||||||
|
return {
|
||||||
|
...host,
|
||||||
|
password: credential.password,
|
||||||
|
key:
|
||||||
|
credential.private_key || credential.privateKey || credential.key,
|
||||||
|
keyPassword: credential.key_password || credential.keyPassword,
|
||||||
|
keyType: credential.key_type || credential.keyType,
|
||||||
|
authType: credential.auth_type || credential.authType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
|
} catch (error) {
|
||||||
|
statsLogger.error("Failed to resolve jump host", error, {
|
||||||
|
operation: "resolve_jump_host",
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJumpHostChain(
|
||||||
|
jumpHosts: Array<{ hostId: number }>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Client | null> {
|
||||||
|
if (!jumpHosts || jumpHosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentClient: Client | null = null;
|
||||||
|
const clients: Client[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < jumpHosts.length; i++) {
|
||||||
|
const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||||
|
|
||||||
|
if (!jumpHostConfig) {
|
||||||
|
statsLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||||
|
operation: "jump_host_chain",
|
||||||
|
hostId: jumpHosts[i].hostId,
|
||||||
|
});
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpClient = new Client();
|
||||||
|
clients.push(jumpClient);
|
||||||
|
|
||||||
|
const connected = await new Promise<boolean>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
resolve(false);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
jumpClient.on("ready", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
jumpClient.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
statsLogger.error(`Jump host ${i + 1} connection failed`, err, {
|
||||||
|
operation: "jump_host_connect",
|
||||||
|
hostId: jumpHostConfig.id,
|
||||||
|
ip: jumpHostConfig.ip,
|
||||||
|
});
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectConfig: any = {
|
||||||
|
host: jumpHostConfig.ip,
|
||||||
|
port: jumpHostConfig.port || 22,
|
||||||
|
username: jumpHostConfig.username,
|
||||||
|
tryKeyboard: true,
|
||||||
|
readyTimeout: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||||
|
connectConfig.password = jumpHostConfig.password;
|
||||||
|
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||||
|
const cleanKey = jumpHostConfig.key
|
||||||
|
.trim()
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\r/g, "\n");
|
||||||
|
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||||
|
if (jumpHostConfig.keyPassword) {
|
||||||
|
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentClient) {
|
||||||
|
currentClient.forwardOut(
|
||||||
|
"127.0.0.1",
|
||||||
|
0,
|
||||||
|
jumpHostConfig.ip,
|
||||||
|
jumpHostConfig.port || 22,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectConfig.sock = stream;
|
||||||
|
jumpClient.connect(connectConfig);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jumpClient.connect(connectConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentClient = jumpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentClient;
|
||||||
|
} catch (error) {
|
||||||
|
statsLogger.error("Failed to create jump host chain", error, {
|
||||||
|
operation: "jump_host_chain",
|
||||||
|
});
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface PooledConnection {
|
interface PooledConnection {
|
||||||
client: Client;
|
client: Client;
|
||||||
lastUsed: number;
|
lastUsed: number;
|
||||||
@@ -87,7 +254,7 @@ class SSHConnectionPool {
|
|||||||
private async createConnection(
|
private async createConnection(
|
||||||
host: SSHHostWithCredentials,
|
host: SSHHostWithCredentials,
|
||||||
): Promise<Client> {
|
): Promise<Client> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
client.end();
|
client.end();
|
||||||
@@ -137,7 +304,44 @@ class SSHConnectionPool {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
client.connect(buildSshConfig(host));
|
const config = buildSshConfig(host);
|
||||||
|
|
||||||
|
if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) {
|
||||||
|
const jumpClient = await createJumpHostChain(
|
||||||
|
host.jumpHosts,
|
||||||
|
host.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!jumpClient) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error("Failed to establish jump host chain"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpClient.forwardOut(
|
||||||
|
"127.0.0.1",
|
||||||
|
0,
|
||||||
|
host.ip,
|
||||||
|
host.port,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
jumpClient.end();
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"Failed to forward through jump host: " + err.message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
config.sock = stream;
|
||||||
|
client.connect(config);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
client.connect(config);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -396,6 +600,7 @@ interface SSHHostWithCredentials {
|
|||||||
enableFileManager: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: unknown[];
|
tunnelConnections: unknown[];
|
||||||
|
jumpHosts?: Array<{ hostId: number }>;
|
||||||
statsConfig?: string;
|
statsConfig?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface ConnectToHostData {
|
|||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
forceKeyboardInteractive?: boolean;
|
forceKeyboardInteractive?: boolean;
|
||||||
|
jumpHosts?: Array<{ hostId: number }>;
|
||||||
};
|
};
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
executeCommand?: string;
|
executeCommand?: string;
|
||||||
@@ -57,6 +58,173 @@ const userCrypto = UserCrypto.getInstance();
|
|||||||
|
|
||||||
const userConnections = new Map<string, Set<WebSocket>>();
|
const userConnections = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
|
async function resolveJumpHost(
|
||||||
|
hostId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const hosts = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||||
|
"ssh_data",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = hosts[0];
|
||||||
|
|
||||||
|
if (host.credentialId) {
|
||||||
|
const credentials = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, host.credentialId as number),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ssh_credentials",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentials.length > 0) {
|
||||||
|
const credential = credentials[0];
|
||||||
|
return {
|
||||||
|
...host,
|
||||||
|
password: credential.password,
|
||||||
|
key:
|
||||||
|
credential.private_key || credential.privateKey || credential.key,
|
||||||
|
keyPassword: credential.key_password || credential.keyPassword,
|
||||||
|
keyType: credential.key_type || credential.keyType,
|
||||||
|
authType: credential.auth_type || credential.authType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to resolve jump host", error, {
|
||||||
|
operation: "resolve_jump_host",
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJumpHostChain(
|
||||||
|
jumpHosts: Array<{ hostId: number }>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Client | null> {
|
||||||
|
if (!jumpHosts || jumpHosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentClient: Client | null = null;
|
||||||
|
const clients: Client[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < jumpHosts.length; i++) {
|
||||||
|
const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||||
|
|
||||||
|
if (!jumpHostConfig) {
|
||||||
|
sshLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||||
|
operation: "jump_host_chain",
|
||||||
|
hostId: jumpHosts[i].hostId,
|
||||||
|
});
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpClient = new Client();
|
||||||
|
clients.push(jumpClient);
|
||||||
|
|
||||||
|
const connected = await new Promise<boolean>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
resolve(false);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
jumpClient.on("ready", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
jumpClient.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
sshLogger.error(`Jump host ${i + 1} connection failed`, err, {
|
||||||
|
operation: "jump_host_connect",
|
||||||
|
hostId: jumpHostConfig.id,
|
||||||
|
ip: jumpHostConfig.ip,
|
||||||
|
});
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectConfig: any = {
|
||||||
|
host: jumpHostConfig.ip,
|
||||||
|
port: jumpHostConfig.port || 22,
|
||||||
|
username: jumpHostConfig.username,
|
||||||
|
tryKeyboard: true,
|
||||||
|
readyTimeout: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||||
|
connectConfig.password = jumpHostConfig.password;
|
||||||
|
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||||
|
const cleanKey = jumpHostConfig.key
|
||||||
|
.trim()
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\r/g, "\n");
|
||||||
|
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||||
|
if (jumpHostConfig.keyPassword) {
|
||||||
|
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentClient) {
|
||||||
|
currentClient.forwardOut(
|
||||||
|
"127.0.0.1",
|
||||||
|
0,
|
||||||
|
jumpHostConfig.ip,
|
||||||
|
jumpHostConfig.port || 22,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectConfig.sock = stream;
|
||||||
|
jumpClient.connect(connectConfig);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jumpClient.connect(connectConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentClient = jumpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentClient;
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to create jump host chain", error, {
|
||||||
|
operation: "jump_host_chain",
|
||||||
|
});
|
||||||
|
clients.forEach((c) => c.end());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
port: 30002,
|
port: 30002,
|
||||||
verifyClient: async (info) => {
|
verifyClient: async (info) => {
|
||||||
@@ -990,7 +1158,68 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hostConfig.jumpHosts &&
|
||||||
|
hostConfig.jumpHosts.length > 0 &&
|
||||||
|
hostConfig.userId
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const jumpClient = await createJumpHostChain(
|
||||||
|
hostConfig.jumpHosts,
|
||||||
|
hostConfig.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!jumpClient) {
|
||||||
|
sshLogger.error("Failed to establish jump host chain");
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Failed to connect through jump hosts",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cleanupSSH(connectionTimeout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
sshLogger.error("Failed to forward through jump host", err, {
|
||||||
|
operation: "ssh_jump_forward",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Failed to forward through jump host: " + err.message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jumpClient.end();
|
||||||
|
cleanupSSH(connectionTimeout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectConfig.sock = stream;
|
||||||
sshConn.connect(connectConfig);
|
sshConn.connect(connectConfig);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Jump host error", error, {
|
||||||
|
operation: "ssh_jump_host",
|
||||||
|
hostId: id,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Failed to connect through jump hosts",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cleanupSSH(connectionTimeout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sshConn.connect(connectConfig);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResize(data: ResizeData) {
|
function handleResize(data: ResizeData) {
|
||||||
|
|||||||
@@ -85,12 +85,26 @@ class AuthManager {
|
|||||||
await this.userCrypto.setupUserEncryption(userId, password);
|
await this.userCrypto.setupUserEncryption(userId, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerOIDCUser(userId: string): Promise<void> {
|
async registerOIDCUser(
|
||||||
await this.userCrypto.setupOIDCUserEncryption(userId);
|
userId: string,
|
||||||
|
sessionDurationMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
async authenticateOIDCUser(
|
||||||
const authenticated = await this.userCrypto.authenticateOIDCUser(userId);
|
userId: string,
|
||||||
|
deviceType?: DeviceType,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const sessionDurationMs =
|
||||||
|
deviceType === "desktop" || deviceType === "mobile"
|
||||||
|
? 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
: 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
const authenticated = await this.userCrypto.authenticateOIDCUser(
|
||||||
|
userId,
|
||||||
|
sessionDurationMs,
|
||||||
|
);
|
||||||
|
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
await this.performLazyEncryptionMigration(userId);
|
await this.performLazyEncryptionMigration(userId);
|
||||||
@@ -99,10 +113,20 @@ class AuthManager {
|
|||||||
return authenticated;
|
return authenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
async authenticateUser(
|
||||||
|
userId: string,
|
||||||
|
password: string,
|
||||||
|
deviceType?: DeviceType,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const sessionDurationMs =
|
||||||
|
deviceType === "desktop" || deviceType === "mobile"
|
||||||
|
? 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
: 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
const authenticated = await this.userCrypto.authenticateUser(
|
const authenticated = await this.userCrypto.authenticateUser(
|
||||||
userId,
|
userId,
|
||||||
password,
|
password,
|
||||||
|
sessionDurationMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ interface EncryptedDEK {
|
|||||||
|
|
||||||
interface UserSession {
|
interface UserSession {
|
||||||
dataKey: Buffer;
|
dataKey: Buffer;
|
||||||
lastActivity: number;
|
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +32,6 @@ class UserCrypto {
|
|||||||
private static readonly PBKDF2_ITERATIONS = 100000;
|
private static readonly PBKDF2_ITERATIONS = 100000;
|
||||||
private static readonly KEK_LENGTH = 32;
|
private static readonly KEK_LENGTH = 32;
|
||||||
private static readonly DEK_LENGTH = 32;
|
private static readonly DEK_LENGTH = 32;
|
||||||
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
|
|
||||||
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
setInterval(
|
setInterval(
|
||||||
@@ -69,7 +66,10 @@ class UserCrypto {
|
|||||||
DEK.fill(0);
|
DEK.fill(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setupOIDCUserEncryption(userId: string): Promise<void> {
|
async setupOIDCUserEncryption(
|
||||||
|
userId: string,
|
||||||
|
sessionDurationMs: number,
|
||||||
|
): Promise<void> {
|
||||||
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
|
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
|
||||||
|
|
||||||
let DEK: Buffer;
|
let DEK: Buffer;
|
||||||
@@ -104,14 +104,17 @@ class UserCrypto {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.userSessions.set(userId, {
|
this.userSessions.set(userId, {
|
||||||
dataKey: Buffer.from(DEK),
|
dataKey: Buffer.from(DEK),
|
||||||
lastActivity: now,
|
expiresAt: now + sessionDurationMs,
|
||||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
DEK.fill(0);
|
DEK.fill(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
async authenticateUser(
|
||||||
|
userId: string,
|
||||||
|
password: string,
|
||||||
|
sessionDurationMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const kekSalt = await this.getKEKSalt(userId);
|
const kekSalt = await this.getKEKSalt(userId);
|
||||||
if (!kekSalt) return false;
|
if (!kekSalt) return false;
|
||||||
@@ -144,8 +147,7 @@ class UserCrypto {
|
|||||||
|
|
||||||
this.userSessions.set(userId, {
|
this.userSessions.set(userId, {
|
||||||
dataKey: Buffer.from(DEK),
|
dataKey: Buffer.from(DEK),
|
||||||
lastActivity: now,
|
expiresAt: now + sessionDurationMs,
|
||||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
DEK.fill(0);
|
DEK.fill(0);
|
||||||
@@ -161,13 +163,16 @@ class UserCrypto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
async authenticateOIDCUser(
|
||||||
|
userId: string,
|
||||||
|
sessionDurationMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const kekSalt = await this.getKEKSalt(userId);
|
const kekSalt = await this.getKEKSalt(userId);
|
||||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||||
|
|
||||||
if (!kekSalt || !encryptedDEK) {
|
if (!kekSalt || !encryptedDEK) {
|
||||||
await this.setupOIDCUserEncryption(userId);
|
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +181,7 @@ class UserCrypto {
|
|||||||
systemKey.fill(0);
|
systemKey.fill(0);
|
||||||
|
|
||||||
if (!DEK || DEK.length === 0) {
|
if (!DEK || DEK.length === 0) {
|
||||||
await this.setupOIDCUserEncryption(userId);
|
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,15 +194,14 @@ class UserCrypto {
|
|||||||
|
|
||||||
this.userSessions.set(userId, {
|
this.userSessions.set(userId, {
|
||||||
dataKey: Buffer.from(DEK),
|
dataKey: Buffer.from(DEK),
|
||||||
lastActivity: now,
|
expiresAt: now + sessionDurationMs,
|
||||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
DEK.fill(0);
|
DEK.fill(0);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
await this.setupOIDCUserEncryption(userId);
|
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,16 +223,6 @@ class UserCrypto {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
|
||||||
this.userSessions.delete(userId);
|
|
||||||
session.dataKey.fill(0);
|
|
||||||
if (this.sessionExpiredCallback) {
|
|
||||||
this.sessionExpiredCallback(userId);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.lastActivity = now;
|
|
||||||
return session.dataKey;
|
return session.dataKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,10 +353,7 @@ class UserCrypto {
|
|||||||
const expiredUsers: string[] = [];
|
const expiredUsers: string[] = [];
|
||||||
|
|
||||||
for (const [userId, session] of this.userSessions.entries()) {
|
for (const [userId, session] of this.userSessions.entries()) {
|
||||||
if (
|
if (now > session.expiresAt) {
|
||||||
now > session.expiresAt ||
|
|
||||||
now - session.lastActivity > UserCrypto.MAX_INACTIVITY
|
|
||||||
) {
|
|
||||||
session.dataKey.fill(0);
|
session.dataKey.fill(0);
|
||||||
expiredUsers.push(userId);
|
expiredUsers.push(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,7 @@
|
|||||||
"submit": "Senden",
|
"submit": "Senden",
|
||||||
"change": "Ändern",
|
"change": "Ändern",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
"saving": "Speichern...",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
@@ -1192,7 +1193,7 @@
|
|||||||
"from": "von"
|
"from": "von"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"tagline": "SSH TERMINAL MANAGER",
|
"tagline": "SSH SERVER MANAGER",
|
||||||
"description": "Sichere, leistungsstarke und intuitive SSH-Verbindungsverwaltung",
|
"description": "Sichere, leistungsstarke und intuitive SSH-Verbindungsverwaltung",
|
||||||
"welcomeBack": "Willkommen zurück bei TERMIX",
|
"welcomeBack": "Willkommen zurück bei TERMIX",
|
||||||
"createAccount": "Erstellen Sie Ihr TERMIX-Konto",
|
"createAccount": "Erstellen Sie Ihr TERMIX-Konto",
|
||||||
|
|||||||
@@ -344,6 +344,7 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"change": "Change",
|
"change": "Change",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
@@ -1343,7 +1344,7 @@
|
|||||||
"from": "from"
|
"from": "from"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"tagline": "SSH TERMINAL MANAGER",
|
"tagline": "SSH SERVER MANAGER",
|
||||||
"description": "Secure, powerful, and intuitive SSH connection management",
|
"description": "Secure, powerful, and intuitive SSH connection management",
|
||||||
"welcomeBack": "Welcome back to TERMIX",
|
"welcomeBack": "Welcome back to TERMIX",
|
||||||
"createAccount": "Create your TERMIX account",
|
"createAccount": "Create your TERMIX account",
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { Request } from "express";
|
|||||||
// SSH HOST TYPES
|
// SSH HOST TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface JumpHost {
|
||||||
|
hostId: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SSHHost {
|
export interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -33,12 +37,17 @@ export interface SSHHost {
|
|||||||
enableFileManager: boolean;
|
enableFileManager: boolean;
|
||||||
defaultPath: string;
|
defaultPath: string;
|
||||||
tunnelConnections: TunnelConnection[];
|
tunnelConnections: TunnelConnection[];
|
||||||
|
jumpHosts?: JumpHost[];
|
||||||
statsConfig?: string;
|
statsConfig?: string;
|
||||||
terminalConfig?: TerminalConfig;
|
terminalConfig?: TerminalConfig;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JumpHostData {
|
||||||
|
hostId: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SSHHostData {
|
export interface SSHHostData {
|
||||||
name?: string;
|
name?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -60,6 +69,7 @@ export interface SSHHostData {
|
|||||||
defaultPath?: string;
|
defaultPath?: string;
|
||||||
forceKeyboardInteractive?: boolean;
|
forceKeyboardInteractive?: boolean;
|
||||||
tunnelConnections?: TunnelConnection[];
|
tunnelConnections?: TunnelConnection[];
|
||||||
|
jumpHosts?: JumpHostData[];
|
||||||
statsConfig?: string | Record<string, unknown>;
|
statsConfig?: string | Record<string, unknown>;
|
||||||
terminalConfig?: TerminalConfig;
|
terminalConfig?: TerminalConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useTabs,
|
useTabs,
|
||||||
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
||||||
|
import { CommandHistoryProvider } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
|
||||||
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
||||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||||
@@ -37,12 +38,17 @@ function AppContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.code === "ShiftLeft") {
|
if (event.code === "ShiftLeft") {
|
||||||
|
if (event.repeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastShiftPressTime.current < 300) {
|
if (now - lastShiftPressTime.current < 300) {
|
||||||
setIsCommandPaletteOpen((isOpen) => !isOpen);
|
setIsCommandPaletteOpen((isOpen) => !isOpen);
|
||||||
}
|
lastShiftPressTime.current = 0; // Reset on double press
|
||||||
|
} else {
|
||||||
lastShiftPressTime.current = now;
|
lastShiftPressTime.current = now;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setIsCommandPaletteOpen(false);
|
setIsCommandPaletteOpen(false);
|
||||||
}
|
}
|
||||||
@@ -314,7 +320,7 @@ function AppContent() {
|
|||||||
"subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
"subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
SSH TERMINAL MANAGER
|
SSH SERVER MANAGER
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -421,7 +427,9 @@ function AppContent() {
|
|||||||
function DesktopApp() {
|
function DesktopApp() {
|
||||||
return (
|
return (
|
||||||
<TabProvider>
|
<TabProvider>
|
||||||
|
<CommandHistoryProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
|
</CommandHistoryProvider>
|
||||||
</TabProvider>
|
</TabProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1072,6 +1072,7 @@ export function AdminSettings({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md overflow-hidden">
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -1095,7 +1096,9 @@ export function AdminSettings({
|
|||||||
: Globe;
|
: Globe;
|
||||||
|
|
||||||
const createdDate = new Date(session.createdAt);
|
const createdDate = new Date(session.createdAt);
|
||||||
const lastActiveDate = new Date(session.lastActiveAt);
|
const lastActiveDate = new Date(
|
||||||
|
session.lastActiveAt,
|
||||||
|
);
|
||||||
const expiresDate = new Date(session.expiresAt);
|
const expiresDate = new Date(session.expiresAt);
|
||||||
|
|
||||||
const formatDate = (date: Date) =>
|
const formatDate = (date: Date) =>
|
||||||
@@ -1176,6 +1179,7 @@ export function AdminSettings({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1185,8 +1189,8 @@ export function AdminSettings({
|
|||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">
|
||||||
{t("admin.adminManagement")}
|
{t("admin.adminManagement")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
<div className="space-y-4 p-4 border rounded-md bg-dark-bg-panel">
|
||||||
<h4 className="font-medium">{t("admin.makeUserAdmin")}</h4>
|
<h4 className="font-semibold">{t("admin.makeUserAdmin")}</h4>
|
||||||
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-admin-username">
|
<Label htmlFor="new-admin-username">
|
||||||
@@ -1279,32 +1283,17 @@ export function AdminSettings({
|
|||||||
<TabsContent value="security" className="space-y-6">
|
<TabsContent value="security" className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Database className="h-5 w-5" />
|
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">
|
||||||
{t("admin.databaseSecurity")}
|
{t("admin.databaseSecurity")}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border rounded bg-card">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Lock className="h-4 w-4 text-green-500" />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{t("admin.encryptionStatus")}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-green-500">
|
|
||||||
{t("admin.encryptionEnabled")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="p-4 border rounded bg-card">
|
<div className="p-4 border rounded-lg bg-dark-bg-panel">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Download className="h-4 w-4 text-blue-500" />
|
<Download className="h-4 w-4 text-blue-500" />
|
||||||
<h4 className="font-medium">{t("admin.export")}</h4>
|
<h4 className="font-semibold">{t("admin.export")}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("admin.exportDescription")}
|
{t("admin.exportDescription")}
|
||||||
@@ -1351,11 +1340,11 @@ export function AdminSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border rounded bg-card">
|
<div className="p-4 border rounded-lg bg-dark-bg-panel">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Upload className="h-4 w-4 text-green-500" />
|
<Upload className="h-4 w-4 text-green-500" />
|
||||||
<h4 className="font-medium">{t("admin.import")}</h4>
|
<h4 className="font-semibold">{t("admin.import")}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("admin.importDescription")}
|
{t("admin.importDescription")}
|
||||||
|
|||||||
@@ -369,6 +369,18 @@ export function FileManagerContextMenu({
|
|||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSingleFile && onProperties) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <Info className="w-4 h-4" />,
|
||||||
|
label: t("fileManager.properties"),
|
||||||
|
action: () => onProperties(files[0]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isSingleFile && onProperties) || onDelete) {
|
||||||
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
|
}
|
||||||
|
|
||||||
if (onDelete) {
|
if (onDelete) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
icon: <Trash2 className="w-4 h-4" />,
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
@@ -380,18 +392,6 @@ export function FileManagerContextMenu({
|
|||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onDelete) {
|
|
||||||
menuItems.push({ separator: true } as MenuItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSingleFile && onProperties) {
|
|
||||||
menuItems.push({
|
|
||||||
icon: <Info className="w-4 h-4" />,
|
|
||||||
label: t("fileManager.properties"),
|
|
||||||
action: () => onProperties(files[0]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (onOpenTerminal && currentPath) {
|
if (onOpenTerminal && currentPath) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
|
|||||||
@@ -73,17 +73,22 @@ export function CompressDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
|
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="text-muted-foreground">
|
||||||
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
|
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="space-y-6 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="archiveName">{t("fileManager.archiveName")}</Label>
|
<Label
|
||||||
|
className="text-base font-semibold text-foreground"
|
||||||
|
htmlFor="archiveName"
|
||||||
|
>
|
||||||
|
{t("fileManager.archiveName")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="archiveName"
|
id="archiveName"
|
||||||
value={archiveName}
|
value={archiveName}
|
||||||
@@ -98,8 +103,13 @@ export function CompressDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="format">{t("fileManager.compressionFormat")}</Label>
|
<Label
|
||||||
|
className="text-base font-semibold text-foreground"
|
||||||
|
htmlFor="format"
|
||||||
|
>
|
||||||
|
{t("fileManager.compressionFormat")}
|
||||||
|
</Label>
|
||||||
<Select value={format} onValueChange={setFormat}>
|
<Select value={format} onValueChange={setFormat}>
|
||||||
<SelectTrigger id="format">
|
<SelectTrigger id="format">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -115,18 +125,18 @@ export function CompressDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md bg-muted p-3">
|
<div className="rounded-md bg-dark-hover/50 border border-dark-border p-3">
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-gray-400 mb-2">
|
||||||
{t("fileManager.selectedFiles")}:
|
{t("fileManager.selectedFiles")}:
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
{fileNames.slice(0, 5).map((name, index) => (
|
{fileNames.slice(0, 5).map((name, index) => (
|
||||||
<li key={index} className="truncate">
|
<li key={index} className="truncate text-foreground">
|
||||||
• {name}
|
• {name}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{fileNames.length > 5 && (
|
{fileNames.length > 5 && (
|
||||||
<li className="text-muted-foreground italic">
|
<li className="text-gray-400 italic">
|
||||||
{t("fileManager.andMoreFiles", {
|
{t("fileManager.andMoreFiles", {
|
||||||
count: fileNames.length - 5,
|
count: fileNames.length - 5,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +50,18 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select.tsx";
|
} from "@/components/ui/select.tsx";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/components/ui/command.tsx";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover.tsx";
|
||||||
import { Slider } from "@/components/ui/slider.tsx";
|
import { Slider } from "@/components/ui/slider.tsx";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -66,7 +79,7 @@ import {
|
|||||||
} from "@/constants/terminal-themes";
|
} from "@/constants/terminal-themes";
|
||||||
import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx";
|
import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx";
|
||||||
import type { TerminalConfig } from "@/types";
|
import type { TerminalConfig } from "@/types";
|
||||||
import { Plus, X } from "lucide-react";
|
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
|
||||||
interface SSHHost {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -94,6 +107,9 @@ interface SSHHost {
|
|||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
jumpHosts?: Array<{
|
||||||
|
hostId: number;
|
||||||
|
}>;
|
||||||
statsConfig?: StatsConfig;
|
statsConfig?: StatsConfig;
|
||||||
terminalConfig?: TerminalConfig;
|
terminalConfig?: TerminalConfig;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -113,6 +129,7 @@ export function HostManagerEditor({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||||
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||||
const [credentials, setCredentials] = useState<
|
const [credentials, setCredentials] = useState<
|
||||||
Array<{ id: number; username: string; authType: string }>
|
Array<{ id: number; username: string; authType: string }>
|
||||||
>([]);
|
>([]);
|
||||||
@@ -146,6 +163,7 @@ export function HostManagerEditor({
|
|||||||
getCredentials(),
|
getCredentials(),
|
||||||
getSnippets(),
|
getSnippets(),
|
||||||
]);
|
]);
|
||||||
|
setHosts(hostsData);
|
||||||
setCredentials(credentialsData);
|
setCredentials(credentialsData);
|
||||||
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
|
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
|
||||||
|
|
||||||
@@ -327,6 +345,13 @@ export function HostManagerEditor({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
forceKeyboardInteractive: z.boolean().optional(),
|
forceKeyboardInteractive: z.boolean().optional(),
|
||||||
|
jumpHosts: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
hostId: z.number().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.authType === "none") {
|
if (data.authType === "none") {
|
||||||
@@ -414,6 +439,7 @@ export function HostManagerEditor({
|
|||||||
enableFileManager: true,
|
enableFileManager: true,
|
||||||
defaultPath: "/",
|
defaultPath: "/",
|
||||||
tunnelConnections: [],
|
tunnelConnections: [],
|
||||||
|
jumpHosts: [],
|
||||||
statsConfig: DEFAULT_STATS_CONFIG,
|
statsConfig: DEFAULT_STATS_CONFIG,
|
||||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||||
forceKeyboardInteractive: false,
|
forceKeyboardInteractive: false,
|
||||||
@@ -433,7 +459,7 @@ export function HostManagerEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [authTab, credentials, form]);
|
}, [authTab, credentials, form.getValues, form.setValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingHost) {
|
if (editingHost) {
|
||||||
@@ -477,7 +503,7 @@ export function HostManagerEditor({
|
|||||||
port: cleanedHost.port || 22,
|
port: cleanedHost.port || 22,
|
||||||
username: cleanedHost.username || "",
|
username: cleanedHost.username || "",
|
||||||
folder: cleanedHost.folder || "",
|
folder: cleanedHost.folder || "",
|
||||||
tags: cleanedHost.tags || [],
|
tags: Array.isArray(cleanedHost.tags) ? cleanedHost.tags : [],
|
||||||
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,
|
||||||
@@ -492,9 +518,22 @@ export function HostManagerEditor({
|
|||||||
enableTunnel: Boolean(cleanedHost.enableTunnel),
|
enableTunnel: Boolean(cleanedHost.enableTunnel),
|
||||||
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
||||||
defaultPath: cleanedHost.defaultPath || "/",
|
defaultPath: cleanedHost.defaultPath || "/",
|
||||||
tunnelConnections: cleanedHost.tunnelConnections || [],
|
tunnelConnections: Array.isArray(cleanedHost.tunnelConnections)
|
||||||
|
? cleanedHost.tunnelConnections
|
||||||
|
: [],
|
||||||
|
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
|
||||||
|
? cleanedHost.jumpHosts
|
||||||
|
: [],
|
||||||
statsConfig: parsedStatsConfig,
|
statsConfig: parsedStatsConfig,
|
||||||
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: {
|
||||||
|
...DEFAULT_TERMINAL_CONFIG,
|
||||||
|
...(cleanedHost.terminalConfig || {}),
|
||||||
|
environmentVariables: Array.isArray(
|
||||||
|
cleanedHost.terminalConfig?.environmentVariables,
|
||||||
|
)
|
||||||
|
? cleanedHost.terminalConfig.environmentVariables
|
||||||
|
: [],
|
||||||
|
},
|
||||||
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
|
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -542,6 +581,7 @@ export function HostManagerEditor({
|
|||||||
enableFileManager: true,
|
enableFileManager: true,
|
||||||
defaultPath: "/",
|
defaultPath: "/",
|
||||||
tunnelConnections: [],
|
tunnelConnections: [],
|
||||||
|
jumpHosts: [],
|
||||||
statsConfig: DEFAULT_STATS_CONFIG,
|
statsConfig: DEFAULT_STATS_CONFIG,
|
||||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||||
forceKeyboardInteractive: false,
|
forceKeyboardInteractive: false,
|
||||||
@@ -601,6 +641,7 @@ export function HostManagerEditor({
|
|||||||
enableFileManager: Boolean(data.enableFileManager),
|
enableFileManager: Boolean(data.enableFileManager),
|
||||||
defaultPath: data.defaultPath || "/",
|
defaultPath: data.defaultPath || "/",
|
||||||
tunnelConnections: data.tunnelConnections || [],
|
tunnelConnections: data.tunnelConnections || [],
|
||||||
|
jumpHosts: data.jumpHosts || [],
|
||||||
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),
|
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
|
||||||
@@ -1387,6 +1428,147 @@ export function HostManagerEditor({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<FormLabel className="mb-3 font-bold">
|
||||||
|
{t("hosts.jumpHosts")}
|
||||||
|
</FormLabel>
|
||||||
|
<Alert className="mt-2 mb-4">
|
||||||
|
<AlertDescription>
|
||||||
|
{t("hosts.jumpHostsDescription")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="jumpHosts"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mt-4">
|
||||||
|
<FormLabel>{t("hosts.jumpHostChain")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{field.value.map((jumpHost, index) => {
|
||||||
|
const selectedHost = hosts.find(
|
||||||
|
(h) => h.id === jumpHost.hostId,
|
||||||
|
);
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{index + 1}.
|
||||||
|
</span>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex-1 justify-between"
|
||||||
|
>
|
||||||
|
{selectedHost
|
||||||
|
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}`
|
||||||
|
: t("hosts.selectServer")}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[400px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t(
|
||||||
|
"hosts.searchServers",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<CommandEmpty>
|
||||||
|
{t("hosts.noServerFound")}
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||||
|
{hosts
|
||||||
|
.filter(
|
||||||
|
(h) =>
|
||||||
|
!editingHost ||
|
||||||
|
h.id !== editingHost.id,
|
||||||
|
)
|
||||||
|
.map((host) => (
|
||||||
|
<CommandItem
|
||||||
|
key={host.id}
|
||||||
|
value={`${host.name} ${host.ip} ${host.username}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const newJumpHosts = [
|
||||||
|
...field.value,
|
||||||
|
];
|
||||||
|
newJumpHosts[index] = {
|
||||||
|
hostId: host.id,
|
||||||
|
};
|
||||||
|
field.onChange(
|
||||||
|
newJumpHosts,
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
jumpHost.hostId ===
|
||||||
|
host.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{host.name ||
|
||||||
|
`${host.username}@${host.ip}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{host.username}@{host.ip}:
|
||||||
|
{host.port}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const newJumpHosts = field.value.filter(
|
||||||
|
(_, i) => i !== index,
|
||||||
|
);
|
||||||
|
field.onChange(newJumpHosts);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange([...field.value, { hostId: 0 }]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t("hosts.addJumpHost")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.jumpHostsOrder")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="terminal" className="space-y-1">
|
<TabsContent value="terminal" className="space-y-1">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1417,7 +1599,9 @@ export function HostManagerEditor({
|
|||||||
</h1>
|
</h1>
|
||||||
<Accordion type="multiple" className="w-full">
|
<Accordion type="multiple" className="w-full">
|
||||||
<AccordionItem value="appearance">
|
<AccordionItem value="appearance">
|
||||||
<AccordionTrigger>{t("hosts.appearance")}</AccordionTrigger>
|
<AccordionTrigger>
|
||||||
|
{t("hosts.appearance")}
|
||||||
|
</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4 pt-4">
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
@@ -1452,7 +1636,9 @@ export function HostManagerEditor({
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t("hosts.selectTheme")} />
|
<SelectValue
|
||||||
|
placeholder={t("hosts.selectTheme")}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -1484,7 +1670,9 @@ export function HostManagerEditor({
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t("hosts.selectFont")} />
|
<SelectValue
|
||||||
|
placeholder={t("hosts.selectFont")}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -1510,7 +1698,11 @@ export function HostManagerEditor({
|
|||||||
name="terminalConfig.fontSize"
|
name="terminalConfig.fontSize"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("hosts.fontSizeValue", { value: field.value })}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.fontSizeValue", {
|
||||||
|
value: field.value,
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Slider
|
||||||
min={8}
|
min={8}
|
||||||
@@ -1535,7 +1727,9 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.letterSpacingValue", { value: field.value })}
|
{t("hosts.letterSpacingValue", {
|
||||||
|
value: field.value,
|
||||||
|
})}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -1560,7 +1754,11 @@ export function HostManagerEditor({
|
|||||||
name="terminalConfig.lineHeight"
|
name="terminalConfig.lineHeight"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("hosts.lineHeightValue", { value: field.value })}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.lineHeightValue", {
|
||||||
|
value: field.value,
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Slider
|
||||||
min={1}
|
min={1}
|
||||||
@@ -1591,15 +1789,21 @@ export function HostManagerEditor({
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t("hosts.selectCursorStyle")} />
|
<SelectValue
|
||||||
|
placeholder={t("hosts.selectCursorStyle")}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="block">{t("hosts.cursorStyleBlock")}</SelectItem>
|
<SelectItem value="block">
|
||||||
|
{t("hosts.cursorStyleBlock")}
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="underline">
|
<SelectItem value="underline">
|
||||||
{t("hosts.cursorStyleUnderline")}
|
{t("hosts.cursorStyleUnderline")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="bar">{t("hosts.cursorStyleBar")}</SelectItem>
|
<SelectItem value="bar">
|
||||||
|
{t("hosts.cursorStyleBar")}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -1641,7 +1845,9 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.scrollbackBufferValue", { value: field.value })}
|
{t("hosts.scrollbackBufferValue", {
|
||||||
|
value: field.value,
|
||||||
|
})}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -1673,14 +1879,24 @@ export function HostManagerEditor({
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t("hosts.selectBellStyle")} />
|
<SelectValue
|
||||||
|
placeholder={t("hosts.selectBellStyle")}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">{t("hosts.bellStyleNone")}</SelectItem>
|
<SelectItem value="none">
|
||||||
<SelectItem value="sound">{t("hosts.bellStyleSound")}</SelectItem>
|
{t("hosts.bellStyleNone")}
|
||||||
<SelectItem value="visual">{t("hosts.bellStyleVisual")}</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="both">{t("hosts.bellStyleBoth")}</SelectItem>
|
<SelectItem value="sound">
|
||||||
|
{t("hosts.bellStyleSound")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="visual">
|
||||||
|
{t("hosts.bellStyleVisual")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="both">
|
||||||
|
{t("hosts.bellStyleBoth")}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -1696,7 +1912,9 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>{t("hosts.rightClickSelectsWord")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.rightClickSelectsWord")}
|
||||||
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("hosts.rightClickSelectsWordDesc")}
|
{t("hosts.rightClickSelectsWordDesc")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
@@ -1716,20 +1934,30 @@ export function HostManagerEditor({
|
|||||||
name="terminalConfig.fastScrollModifier"
|
name="terminalConfig.fastScrollModifier"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("hosts.fastScrollModifier")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.fastScrollModifier")}
|
||||||
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t("hosts.selectModifier")} />
|
<SelectValue
|
||||||
|
placeholder={t("hosts.selectModifier")}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="alt">{t("hosts.modifierAlt")}</SelectItem>
|
<SelectItem value="alt">
|
||||||
<SelectItem value="ctrl">{t("hosts.modifierCtrl")}</SelectItem>
|
{t("hosts.modifierAlt")}
|
||||||
<SelectItem value="shift">{t("hosts.modifierShift")}</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="ctrl">
|
||||||
|
{t("hosts.modifierCtrl")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="shift">
|
||||||
|
{t("hosts.modifierShift")}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -1745,7 +1973,9 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.fastScrollSensitivityValue", { value: field.value })}
|
{t("hosts.fastScrollSensitivityValue", {
|
||||||
|
value: field.value,
|
||||||
|
})}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -1771,7 +2001,9 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.minimumContrastRatioValue", { value: field.value })}
|
{t("hosts.minimumContrastRatioValue", {
|
||||||
|
value: field.value,
|
||||||
|
})}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -1802,7 +2034,9 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>{t("hosts.sshAgentForwarding")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.sshAgentForwarding")}
|
||||||
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("hosts.sshAgentForwardingDesc")}
|
{t("hosts.sshAgentForwardingDesc")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
@@ -1829,7 +2063,11 @@ export function HostManagerEditor({
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t("hosts.selectBackspaceMode")} />
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"hosts.selectBackspaceMode",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -1865,7 +2103,9 @@ export function HostManagerEditor({
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t("hosts.selectSnippet")} />
|
<SelectValue
|
||||||
|
placeholder={t("hosts.selectSnippet")}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -1882,7 +2122,9 @@ export function HostManagerEditor({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[200px] overflow-y-auto">
|
<div className="max-h-[200px] overflow-y-auto">
|
||||||
<SelectItem value="none">{t("hosts.snippetNone")}</SelectItem>
|
<SelectItem value="none">
|
||||||
|
{t("hosts.snippetNone")}
|
||||||
|
</SelectItem>
|
||||||
{snippets
|
{snippets
|
||||||
.filter((snippet) =>
|
.filter((snippet) =>
|
||||||
snippet.name
|
snippet.name
|
||||||
|
|||||||
@@ -105,14 +105,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
fetchFolderMetadata();
|
fetchFolderMetadata();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFoldersRefresh = () => {
|
||||||
|
fetchFolderMetadata();
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
||||||
window.addEventListener("ssh-hosts:changed", handleHostsRefresh);
|
window.addEventListener("ssh-hosts:changed", handleHostsRefresh);
|
||||||
window.addEventListener("folders:changed", handleHostsRefresh);
|
window.addEventListener("folders:changed", handleFoldersRefresh);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("hosts:refresh", handleHostsRefresh);
|
window.removeEventListener("hosts:refresh", handleHostsRefresh);
|
||||||
window.removeEventListener("ssh-hosts:changed", handleHostsRefresh);
|
window.removeEventListener("ssh-hosts:changed", handleHostsRefresh);
|
||||||
window.removeEventListener("folders:changed", handleHostsRefresh);
|
window.removeEventListener("folders:changed", handleFoldersRefresh);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Search, Clock, X, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface CommandHistoryDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
commands: string[];
|
|
||||||
onSelectCommand: (command: string) => void;
|
|
||||||
onDeleteCommand?: (command: string) => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandHistoryDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
commands,
|
|
||||||
onSelectCommand,
|
|
||||||
onDeleteCommand,
|
|
||||||
isLoading = false,
|
|
||||||
}: CommandHistoryDialogProps) {
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
const selectedRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Filter commands based on search query
|
|
||||||
const filteredCommands = searchQuery
|
|
||||||
? commands.filter((cmd) =>
|
|
||||||
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
: commands;
|
|
||||||
|
|
||||||
// Reset state when dialog opens/closes
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setSearchQuery("");
|
|
||||||
setSelectedIndex(0);
|
|
||||||
// Focus search input
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
// Scroll selected item into view
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedRef.current && listRef.current) {
|
|
||||||
selectedRef.current.scrollIntoView({
|
|
||||||
block: "nearest",
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [selectedIndex]);
|
|
||||||
|
|
||||||
// Handle keyboard navigation
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (filteredCommands.length === 0) return;
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case "ArrowDown":
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex((prev) =>
|
|
||||||
prev < filteredCommands.length - 1 ? prev + 1 : prev,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "ArrowUp":
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "Enter":
|
|
||||||
e.preventDefault();
|
|
||||||
if (filteredCommands[selectedIndex]) {
|
|
||||||
onSelectCommand(filteredCommands[selectedIndex]);
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "Escape":
|
|
||||||
e.preventDefault();
|
|
||||||
onOpenChange(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (command: string) => {
|
|
||||||
onSelectCommand(command);
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[600px] p-0 gap-0">
|
|
||||||
<DialogHeader className="px-6 pt-6 pb-4">
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5" />
|
|
||||||
Command History
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="px-6 pb-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
placeholder="Search commands... (↑↓ to navigate, Enter to select)"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
setSelectedIndex(0);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="pl-10 pr-10"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery("");
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea ref={listRef} className="h-[400px] px-6 pb-6">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
Loading history...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : filteredCommands.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
|
||||||
{searchQuery ? (
|
|
||||||
<>
|
|
||||||
<Search className="h-12 w-12 mb-2 opacity-20" />
|
|
||||||
<p>No commands found matching "{searchQuery}"</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Clock className="h-12 w-12 mb-2 opacity-20" />
|
|
||||||
<p>No command history yet</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Execute commands to build your history
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{filteredCommands.map((command, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
ref={index === selectedIndex ? selectedRef : null}
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-2.5 rounded-md transition-colors group",
|
|
||||||
"font-mono text-sm flex items-center justify-between gap-2",
|
|
||||||
"hover:bg-accent",
|
|
||||||
index === selectedIndex &&
|
|
||||||
"bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/50",
|
|
||||||
)}
|
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="flex-1 cursor-pointer"
|
|
||||||
onClick={() => handleSelect(command)}
|
|
||||||
>
|
|
||||||
{command}
|
|
||||||
</span>
|
|
||||||
{onDeleteCommand && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteCommand(command);
|
|
||||||
}}
|
|
||||||
title="Delete command"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="px-6 py-3 border-t border-border bg-muted/30">
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span>
|
|
||||||
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
|
|
||||||
↑↓
|
|
||||||
</kbd>{" "}
|
|
||||||
Navigate
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
|
|
||||||
Enter
|
|
||||||
</kbd>{" "}
|
|
||||||
Select
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
|
|
||||||
Esc
|
|
||||||
</kbd>{" "}
|
|
||||||
Close
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{filteredCommands.length} command
|
|
||||||
{filteredCommands.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -28,8 +28,8 @@ import {
|
|||||||
} from "@/constants/terminal-themes";
|
} from "@/constants/terminal-themes";
|
||||||
import type { TerminalConfig } from "@/types";
|
import type { TerminalConfig } from "@/types";
|
||||||
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
|
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
|
||||||
import { useCommandHistory } from "@/ui/hooks/useCommandHistory";
|
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
|
||||||
import { CommandHistoryDialog } from "./CommandHistoryDialog";
|
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
|
||||||
import { CommandAutocomplete } from "./CommandAutocomplete";
|
import { CommandAutocomplete } from "./CommandAutocomplete";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
|
|
||||||
@@ -91,6 +91,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||||
|
const commandHistoryContext = useCommandHistory();
|
||||||
|
|
||||||
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
||||||
const themeColors =
|
const themeColors =
|
||||||
@@ -183,20 +184,24 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showHistoryDialog && hostConfig.id) {
|
if (showHistoryDialog && hostConfig.id) {
|
||||||
setIsLoadingHistory(true);
|
setIsLoadingHistory(true);
|
||||||
|
commandHistoryContext.setIsLoading(true);
|
||||||
import("@/ui/main-axios.ts")
|
import("@/ui/main-axios.ts")
|
||||||
.then((module) => module.getCommandHistory(hostConfig.id!))
|
.then((module) => module.getCommandHistory(hostConfig.id!))
|
||||||
.then((history) => {
|
.then((history) => {
|
||||||
setCommandHistory(history);
|
setCommandHistory(history);
|
||||||
|
commandHistoryContext.setCommandHistory(history);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to load command history:", error);
|
console.error("Failed to load command history:", error);
|
||||||
setCommandHistory([]);
|
setCommandHistory([]);
|
||||||
|
commandHistoryContext.setCommandHistory([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoadingHistory(false);
|
setIsLoadingHistory(false);
|
||||||
|
commandHistoryContext.setIsLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [showHistoryDialog, hostConfig.id]);
|
}, [showHistoryDialog, hostConfig.id, commandHistoryContext]);
|
||||||
|
|
||||||
// Load command history for autocomplete on mount (Stage 3)
|
// Load command history for autocomplete on mount (Stage 3)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -898,6 +903,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
[terminal],
|
[terminal],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Register handlers with context
|
||||||
|
useEffect(() => {
|
||||||
|
commandHistoryContext.setOnSelectCommand(handleSelectCommand);
|
||||||
|
}, [handleSelectCommand, commandHistoryContext]);
|
||||||
|
|
||||||
// Handle autocomplete selection (mouse click)
|
// Handle autocomplete selection (mouse click)
|
||||||
const handleAutocompleteSelect = useCallback(
|
const handleAutocompleteSelect = useCallback(
|
||||||
(selectedCommand: string) => {
|
(selectedCommand: string) => {
|
||||||
@@ -944,7 +954,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
await deleteCommandFromHistory(hostConfig.id, command);
|
await deleteCommandFromHistory(hostConfig.id, command);
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
setCommandHistory((prev) => prev.filter((cmd) => cmd !== command));
|
setCommandHistory((prev) => {
|
||||||
|
const newHistory = prev.filter((cmd) => cmd !== command);
|
||||||
|
commandHistoryContext.setCommandHistory(newHistory);
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
|
||||||
// Update autocomplete history
|
// Update autocomplete history
|
||||||
autocompleteHistory.current = autocompleteHistory.current.filter(
|
autocompleteHistory.current = autocompleteHistory.current.filter(
|
||||||
@@ -956,9 +970,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
console.error("Failed to delete command from history:", error);
|
console.error("Failed to delete command from history:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[hostConfig.id],
|
[hostConfig.id, commandHistoryContext],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Register delete handler with context
|
||||||
|
useEffect(() => {
|
||||||
|
commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
|
||||||
|
}, [handleDeleteCommand, commandHistoryContext]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !xtermRef.current) return;
|
if (!terminal || !xtermRef.current) return;
|
||||||
|
|
||||||
@@ -1074,6 +1093,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowHistoryDialog(true);
|
setShowHistoryDialog(true);
|
||||||
|
// Also trigger the sidebar to open
|
||||||
|
if (commandHistoryContext.openCommandHistory) {
|
||||||
|
commandHistoryContext.openCommandHistory();
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1476,15 +1499,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommandHistoryDialog
|
|
||||||
open={showHistoryDialog}
|
|
||||||
onOpenChange={setShowHistoryDialog}
|
|
||||||
commands={commandHistory}
|
|
||||||
onSelectCommand={handleSelectCommand}
|
|
||||||
onDeleteCommand={handleDeleteCommand}
|
|
||||||
isLoading={isLoadingHistory}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CommandAutocomplete
|
<CommandAutocomplete
|
||||||
visible={showAutocomplete}
|
visible={showAutocomplete}
|
||||||
suggestions={autocompleteSuggestions}
|
suggestions={autocompleteSuggestions}
|
||||||
|
|||||||
@@ -23,7 +23,17 @@ import {
|
|||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
} from "@/components/ui/sidebar.tsx";
|
} from "@/components/ui/sidebar.tsx";
|
||||||
import { Plus, Play, Edit, Trash2, Copy, X, RotateCcw } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
Play,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
X,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
@@ -56,6 +66,12 @@ interface SSHUtilitySidebarProps {
|
|||||||
onSnippetExecute: (content: string) => void;
|
onSnippetExecute: (content: string) => void;
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
setSidebarWidth: (width: number) => void;
|
setSidebarWidth: (width: number) => void;
|
||||||
|
commandHistory?: string[];
|
||||||
|
onSelectCommand?: (command: string) => void;
|
||||||
|
onDeleteCommand?: (command: string) => void;
|
||||||
|
isHistoryLoading?: boolean;
|
||||||
|
initialTab?: string;
|
||||||
|
onTabChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHUtilitySidebar({
|
export function SSHUtilitySidebar({
|
||||||
@@ -64,15 +80,39 @@ export function SSHUtilitySidebar({
|
|||||||
onSnippetExecute,
|
onSnippetExecute,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
setSidebarWidth,
|
setSidebarWidth,
|
||||||
|
commandHistory = [],
|
||||||
|
onSelectCommand,
|
||||||
|
onDeleteCommand,
|
||||||
|
isHistoryLoading = false,
|
||||||
|
initialTab,
|
||||||
|
onTabChange,
|
||||||
}: SSHUtilitySidebarProps) {
|
}: SSHUtilitySidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { confirmWithToast } = useConfirmation();
|
const { confirmWithToast } = useConfirmation();
|
||||||
const { tabs } = useTabs() as { tabs: TabData[] };
|
const { tabs } = useTabs() as { tabs: TabData[] };
|
||||||
const [activeTab, setActiveTab] = useState("ssh-tools");
|
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
|
||||||
|
|
||||||
|
// Update active tab when initialTab changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialTab && isOpen) {
|
||||||
|
setActiveTab(initialTab);
|
||||||
|
}
|
||||||
|
}, [initialTab, isOpen]);
|
||||||
|
|
||||||
|
// Call onTabChange when active tab changes
|
||||||
|
const handleTabChange = (tab: string) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
if (onTabChange) {
|
||||||
|
onTabChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// SSH Tools state
|
// SSH Tools state
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||||
|
const [rightClickCopyPaste, setRightClickCopyPaste] = useState<boolean>(
|
||||||
|
() => getCookie("rightClickCopyPaste") === "true",
|
||||||
|
);
|
||||||
|
|
||||||
// Snippets state
|
// Snippets state
|
||||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||||
@@ -92,6 +132,10 @@ export function SSHUtilitySidebar({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Command History state
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||||
|
|
||||||
// Resize state
|
// Resize state
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const startXRef = React.useRef<number | null>(null);
|
const startXRef = React.useRef<number | null>(null);
|
||||||
@@ -99,6 +143,13 @@ export function SSHUtilitySidebar({
|
|||||||
|
|
||||||
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
|
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
|
||||||
|
|
||||||
|
// Filter command history based on search query
|
||||||
|
const filteredCommands = searchQuery
|
||||||
|
? commandHistory.filter((cmd) =>
|
||||||
|
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
: commandHistory;
|
||||||
|
|
||||||
// Initialize CSS variable on mount and when sidebar width changes
|
// Initialize CSS variable on mount and when sidebar width changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
@@ -327,6 +378,7 @@ export function SSHUtilitySidebar({
|
|||||||
|
|
||||||
const updateRightClickCopyPaste = (checked: boolean) => {
|
const updateRightClickCopyPaste = (checked: boolean) => {
|
||||||
setCookie("rightClickCopyPaste", checked.toString());
|
setCookie("rightClickCopyPaste", checked.toString());
|
||||||
|
setRightClickCopyPaste(checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Snippets handlers
|
// Snippets handlers
|
||||||
@@ -441,6 +493,33 @@ export function SSHUtilitySidebar({
|
|||||||
toast.success(t("snippets.copySuccess", { name: snippet.name }));
|
toast.success(t("snippets.copySuccess", { name: snippet.name }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Command History handlers
|
||||||
|
const handleCommandSelect = (command: string) => {
|
||||||
|
if (onSelectCommand) {
|
||||||
|
onSelectCommand(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommandDelete = (command: string) => {
|
||||||
|
if (onDeleteCommand) {
|
||||||
|
confirmWithToast(
|
||||||
|
t("commandHistory.deleteConfirmDescription", {
|
||||||
|
defaultValue: `Delete "${command}" from history?`,
|
||||||
|
command,
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
onDeleteCommand(command);
|
||||||
|
toast.success(
|
||||||
|
t("commandHistory.deleteSuccess", {
|
||||||
|
defaultValue: "Command deleted from history",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -482,14 +561,17 @@ export function SSHUtilitySidebar({
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<Separator className="p-0.25" />
|
<Separator className="p-0.25" />
|
||||||
<SidebarContent className="p-4">
|
<SidebarContent className="p-4">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
<TabsList className="w-full grid grid-cols-2 mb-4">
|
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||||
<TabsTrigger value="ssh-tools">
|
<TabsTrigger value="ssh-tools">
|
||||||
{t("sshTools.title")}
|
{t("sshTools.title")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="snippets">
|
<TabsTrigger value="snippets">
|
||||||
{t("snippets.title")}
|
{t("snippets.title")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="command-history">
|
||||||
|
{t("commandHistory.title", { defaultValue: "History" })}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="ssh-tools" className="space-y-4">
|
<TabsContent value="ssh-tools" className="space-y-4">
|
||||||
@@ -577,9 +659,7 @@ export function SSHUtilitySidebar({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
id="enable-copy-paste"
|
id="enable-copy-paste"
|
||||||
onCheckedChange={updateRightClickCopyPaste}
|
onCheckedChange={updateRightClickCopyPaste}
|
||||||
defaultChecked={
|
checked={rightClickCopyPaste}
|
||||||
getCookie("rightClickCopyPaste") === "true"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="enable-copy-paste"
|
htmlFor="enable-copy-paste"
|
||||||
@@ -763,6 +843,129 @@ export function SSHUtilitySidebar({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="command-history" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t("commandHistory.searchPlaceholder", {
|
||||||
|
defaultValue: "Search commands...",
|
||||||
|
})}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setSelectedCommandIndex(0);
|
||||||
|
}}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{isHistoryLoading ? (
|
||||||
|
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse py-8">
|
||||||
|
<Loader2 className="animate-spin mr-2" size={16} />
|
||||||
|
<span>
|
||||||
|
{t("commandHistory.loading", {
|
||||||
|
defaultValue: "Loading history...",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : filteredCommands.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
{searchQuery ? (
|
||||||
|
<>
|
||||||
|
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
|
||||||
|
<p className="mb-2 font-medium">
|
||||||
|
{t("commandHistory.noResults", {
|
||||||
|
defaultValue: "No commands found",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{t("commandHistory.noResultsHint", {
|
||||||
|
defaultValue: `No commands matching "${searchQuery}"`,
|
||||||
|
query: searchQuery,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 font-medium">
|
||||||
|
{t("commandHistory.empty", {
|
||||||
|
defaultValue: "No command history yet",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{t("commandHistory.emptyHint", {
|
||||||
|
defaultValue:
|
||||||
|
"Execute commands to build your history",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 overflow-y-auto max-h-[calc(100vh-300px)]">
|
||||||
|
{filteredCommands.map((command, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-dark-bg border-2 border-dark-border rounded-md px-3 py-2.5 hover:bg-dark-hover-alt hover:border-blue-400/50 transition-all duration-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className="flex-1 font-mono text-sm cursor-pointer text-white"
|
||||||
|
onClick={() => handleCommandSelect(command)}
|
||||||
|
>
|
||||||
|
{command}
|
||||||
|
</span>
|
||||||
|
{onDeleteCommand && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCommandDelete(command);
|
||||||
|
}}
|
||||||
|
title={t("commandHistory.deleteTooltip", {
|
||||||
|
defaultValue: "Delete command",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{filteredCommands.length}{" "}
|
||||||
|
{t("commandHistory.commandCount", {
|
||||||
|
defaultValue:
|
||||||
|
filteredCommands.length !== 1
|
||||||
|
? "commands"
|
||||||
|
: "command",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|||||||
@@ -847,7 +847,7 @@ export function Auth({
|
|||||||
<div className="w-full h-full flex flex-col md:flex-row">
|
<div className="w-full h-full flex flex-col md:flex-row">
|
||||||
{/* Left Side - Brand Showcase */}
|
{/* Left Side - Brand Showcase */}
|
||||||
<div
|
<div
|
||||||
className="hidden md:flex md:w-2/5 items-center justify-center relative"
|
className="hidden md:flex md:w-2/5 items-center justify-center relative border-r-2 border-bg-border-dark"
|
||||||
style={{
|
style={{
|
||||||
background: "#0e0e10",
|
background: "#0e0e10",
|
||||||
backgroundImage: `repeating-linear-gradient(
|
backgroundImage: `repeating-linear-gradient(
|
||||||
@@ -871,21 +871,14 @@ export function Auth({
|
|||||||
TERMIX
|
TERMIX
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg text-muted-foreground tracking-widest font-light">
|
<div className="text-lg text-muted-foreground tracking-widest font-light">
|
||||||
{t("auth.tagline") || "SSH TERMINAL MANAGER"}
|
{t("auth.tagline")}
|
||||||
</div>
|
|
||||||
<div className="mt-8 text-sm text-muted-foreground/80 max-w-md">
|
|
||||||
{t("auth.description") ||
|
|
||||||
"Secure, powerful, and intuitive SSH connection management"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Side - Auth Form */}
|
{/* Right Side - Auth Form */}
|
||||||
<div className="flex-1 flex items-center justify-center p-6 md:p-12 bg-background overflow-y-auto">
|
<div className="flex-1 flex p-6 md:p-12 bg-background overflow-y-auto">
|
||||||
<div
|
<div className="m-auto w-full max-w-md backdrop-blur-sm bg-card/50 rounded-2xl p-8 shadow-xl border-2 border-dark-border animate-in fade-in slide-in-from-bottom-4 duration-500 flex flex-col">
|
||||||
className="w-full max-w-md backdrop-blur-sm bg-card/50 rounded-2xl p-8 shadow-xl border-2 border-dark-border animate-in fade-in slide-in-from-bottom-4 duration-500"
|
|
||||||
style={{ maxHeight: "calc(100vh - 3rem)" }}
|
|
||||||
>
|
|
||||||
{isInElectronWebView() && !webviewAuthSuccess && (
|
{isInElectronWebView() && !webviewAuthSuccess && (
|
||||||
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
|
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
|
||||||
<Monitor className="h-4 w-4" />
|
<Monitor className="h-4 w-4" />
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export function ElectronLoginForm({
|
|||||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||||
const hasLoadedOnce = useRef(false);
|
const hasLoadedOnce = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear any existing token to prevent login loops with expired tokens
|
||||||
|
localStorage.removeItem("jwt");
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
const handleMessage = async (event: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
interface CommandHistoryContextType {
|
||||||
|
commandHistory: string[];
|
||||||
|
isLoading: boolean;
|
||||||
|
setCommandHistory: (history: string[]) => void;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
|
onSelectCommand?: (command: string) => void;
|
||||||
|
setOnSelectCommand: (callback: (command: string) => void) => void;
|
||||||
|
onDeleteCommand?: (command: string) => void;
|
||||||
|
setOnDeleteCommand: (callback: (command: string) => void) => void;
|
||||||
|
openCommandHistory: () => void;
|
||||||
|
setOpenCommandHistory: (callback: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandHistoryContext = createContext<
|
||||||
|
CommandHistoryContextType | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [onSelectCommand, setOnSelectCommand] = useState<
|
||||||
|
((command: string) => void) | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [onDeleteCommand, setOnDeleteCommand] = useState<
|
||||||
|
((command: string) => void) | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [openCommandHistory, setOpenCommandHistory] = useState<
|
||||||
|
(() => void) | undefined
|
||||||
|
>(() => () => {});
|
||||||
|
|
||||||
|
const handleSetOnSelectCommand = useCallback(
|
||||||
|
(callback: (command: string) => void) => {
|
||||||
|
setOnSelectCommand(() => callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetOnDeleteCommand = useCallback(
|
||||||
|
(callback: (command: string) => void) => {
|
||||||
|
setOnDeleteCommand(() => callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
|
||||||
|
setOpenCommandHistory(() => callback);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandHistoryContext.Provider
|
||||||
|
value={{
|
||||||
|
commandHistory,
|
||||||
|
isLoading,
|
||||||
|
setCommandHistory,
|
||||||
|
setIsLoading,
|
||||||
|
onSelectCommand,
|
||||||
|
setOnSelectCommand: handleSetOnSelectCommand,
|
||||||
|
onDeleteCommand,
|
||||||
|
setOnDeleteCommand: handleSetOnDeleteCommand,
|
||||||
|
openCommandHistory: openCommandHistory || (() => {}),
|
||||||
|
setOpenCommandHistory: handleSetOpenCommandHistory,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CommandHistoryContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandHistory() {
|
||||||
|
const context = useContext(CommandHistoryContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"useCommandHistory must be used within a CommandHistoryProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -247,6 +247,9 @@ export function LeftSidebar({
|
|||||||
const handleCredentialsChanged = () => {
|
const handleCredentialsChanged = () => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
};
|
};
|
||||||
|
const handleFoldersChanged = () => {
|
||||||
|
fetchFolderMetadata();
|
||||||
|
};
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
"ssh-hosts:changed",
|
"ssh-hosts:changed",
|
||||||
handleHostsChanged as EventListener,
|
handleHostsChanged as EventListener,
|
||||||
@@ -255,6 +258,10 @@ export function LeftSidebar({
|
|||||||
"credentials:changed",
|
"credentials:changed",
|
||||||
handleCredentialsChanged as EventListener,
|
handleCredentialsChanged as EventListener,
|
||||||
);
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
"folders:changed",
|
||||||
|
handleFoldersChanged as EventListener,
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
"ssh-hosts:changed",
|
"ssh-hosts:changed",
|
||||||
@@ -264,6 +271,10 @@ export function LeftSidebar({
|
|||||||
"credentials:changed",
|
"credentials:changed",
|
||||||
handleCredentialsChanged as EventListener,
|
handleCredentialsChanged as EventListener,
|
||||||
);
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
"folders:changed",
|
||||||
|
handleFoldersChanged as EventListener,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, [fetchHosts, fetchFolderMetadata]);
|
}, [fetchHosts, fetchFolderMetadata]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
|
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
|
||||||
import { SSHUtilitySidebar } from "@/ui/desktop/apps/tools/SSHUtilitySidebar.tsx";
|
import { SSHUtilitySidebar } from "@/ui/desktop/apps/tools/SSHUtilitySidebar.tsx";
|
||||||
|
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
|
||||||
|
|
||||||
interface TabData {
|
interface TabData {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -55,11 +56,13 @@ export function TopNavbar({
|
|||||||
const leftPosition =
|
const leftPosition =
|
||||||
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
|
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const commandHistory = useCommandHistory();
|
||||||
|
|
||||||
const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
|
const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
|
||||||
|
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
|
||||||
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
|
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
|
||||||
const saved = localStorage.getItem("rightSidebarWidth");
|
const saved = localStorage.getItem("rightSidebarWidth");
|
||||||
return saved !== null ? parseInt(saved, 10) : 400;
|
return saved !== null ? parseInt(saved, 10) : 350;
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -72,6 +75,14 @@ export function TopNavbar({
|
|||||||
}
|
}
|
||||||
}, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]);
|
}, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]);
|
||||||
|
|
||||||
|
// Register function to open command history sidebar
|
||||||
|
React.useEffect(() => {
|
||||||
|
commandHistory.setOpenCommandHistory(() => {
|
||||||
|
setToolsSidebarOpen(true);
|
||||||
|
setCommandHistoryTabActive(true);
|
||||||
|
});
|
||||||
|
}, [commandHistory]);
|
||||||
|
|
||||||
const rightPosition = toolsSidebarOpen
|
const rightPosition = toolsSidebarOpen
|
||||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||||
: "17px";
|
: "17px";
|
||||||
@@ -359,8 +370,7 @@ export function TopNavbar({
|
|||||||
tab.type === "user_profile") &&
|
tab.type === "user_profile") &&
|
||||||
isSplitScreenActive);
|
isSplitScreenActive);
|
||||||
const isHome = tab.type === "home";
|
const isHome = tab.type === "home";
|
||||||
const disableClose =
|
const disableClose = (isSplitScreenActive && isActive) || isHome;
|
||||||
(isSplitScreenActive && isActive) || isHome;
|
|
||||||
|
|
||||||
const isDraggingThisTab = dragState.draggedIndex === index;
|
const isDraggingThisTab = dragState.draggedIndex === index;
|
||||||
const isTheDraggedTab = tab.id === dragState.draggedId;
|
const isTheDraggedTab = tab.id === dragState.draggedId;
|
||||||
@@ -536,6 +546,12 @@ export function TopNavbar({
|
|||||||
onSnippetExecute={handleSnippetExecute}
|
onSnippetExecute={handleSnippetExecute}
|
||||||
sidebarWidth={rightSidebarWidth}
|
sidebarWidth={rightSidebarWidth}
|
||||||
setSidebarWidth={setRightSidebarWidth}
|
setSidebarWidth={setRightSidebarWidth}
|
||||||
|
commandHistory={commandHistory.commandHistory}
|
||||||
|
onSelectCommand={commandHistory.onSelectCommand}
|
||||||
|
onDeleteCommand={commandHistory.onDeleteCommand}
|
||||||
|
isHistoryLoading={commandHistory.isLoading}
|
||||||
|
initialTab={commandHistoryTabActive ? "command-history" : undefined}
|
||||||
|
onTabChange={() => setCommandHistoryTabActive(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -137,9 +137,64 @@ function getLoggerForService(serviceName: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache for Electron settings
|
||||||
|
const electronSettingsCache = new Map<string, string>();
|
||||||
|
|
||||||
|
// Load settings from Electron IPC on startup
|
||||||
|
if (isElectron()) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const electronAPI = (
|
||||||
|
window as Window &
|
||||||
|
typeof globalThis & {
|
||||||
|
electronAPI?: any;
|
||||||
|
}
|
||||||
|
).electronAPI;
|
||||||
|
|
||||||
|
if (electronAPI?.getSetting) {
|
||||||
|
// Preload common settings
|
||||||
|
const settingsToLoad = ["rightClickCopyPaste", "jwt"];
|
||||||
|
for (const key of settingsToLoad) {
|
||||||
|
const value = await electronAPI.getSetting(key);
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
electronSettingsCache.set(key, value);
|
||||||
|
localStorage.setItem(key, value); // Sync to localStorage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Electron] Failed to load settings cache:", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
export function setCookie(name: string, value: string, days = 7): void {
|
export function setCookie(name: string, value: string, days = 7): void {
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
|
try {
|
||||||
|
// Update cache
|
||||||
|
electronSettingsCache.set(name, value);
|
||||||
|
|
||||||
|
// Set in localStorage
|
||||||
localStorage.setItem(name, value);
|
localStorage.setItem(name, value);
|
||||||
|
|
||||||
|
// Persist to file system via Electron IPC
|
||||||
|
const electronAPI = (
|
||||||
|
window as Window &
|
||||||
|
typeof globalThis & {
|
||||||
|
electronAPI?: any;
|
||||||
|
}
|
||||||
|
).electronAPI;
|
||||||
|
|
||||||
|
if (electronAPI?.setSetting) {
|
||||||
|
electronAPI.setSetting(name, value).catch((err: Error) => {
|
||||||
|
console.error(`[Electron] Failed to persist setting ${name}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Electron] Set setting: ${name} = ${value}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Electron] Failed to set setting: ${name}`, error);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||||
@@ -148,8 +203,23 @@ export function setCookie(name: string, value: string, days = 7): void {
|
|||||||
|
|
||||||
export function getCookie(name: string): string | undefined {
|
export function getCookie(name: string): string | undefined {
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
|
try {
|
||||||
|
// Try cache first
|
||||||
|
if (electronSettingsCache.has(name)) {
|
||||||
|
return electronSettingsCache.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to localStorage
|
||||||
const token = localStorage.getItem(name) || undefined;
|
const token = localStorage.getItem(name) || undefined;
|
||||||
|
if (token) {
|
||||||
|
electronSettingsCache.set(name, token);
|
||||||
|
}
|
||||||
|
console.log(`[Electron] Get setting: ${name} = ${token}`);
|
||||||
return token;
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Electron] Failed to get setting: ${name}`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${name}=`);
|
||||||
@@ -802,6 +872,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
enableFileManager: Boolean(hostData.enableFileManager),
|
enableFileManager: Boolean(hostData.enableFileManager),
|
||||||
defaultPath: hostData.defaultPath || "/",
|
defaultPath: hostData.defaultPath || "/",
|
||||||
tunnelConnections: hostData.tunnelConnections || [],
|
tunnelConnections: hostData.tunnelConnections || [],
|
||||||
|
jumpHosts: hostData.jumpHosts || [],
|
||||||
statsConfig: hostData.statsConfig
|
statsConfig: hostData.statsConfig
|
||||||
? typeof hostData.statsConfig === "string"
|
? typeof hostData.statsConfig === "string"
|
||||||
? hostData.statsConfig
|
? hostData.statsConfig
|
||||||
@@ -866,6 +937,7 @@ export async function updateSSHHost(
|
|||||||
enableFileManager: Boolean(hostData.enableFileManager),
|
enableFileManager: Boolean(hostData.enableFileManager),
|
||||||
defaultPath: hostData.defaultPath || "/",
|
defaultPath: hostData.defaultPath || "/",
|
||||||
tunnelConnections: hostData.tunnelConnections || [],
|
tunnelConnections: hostData.tunnelConnections || [],
|
||||||
|
jumpHosts: hostData.jumpHosts || [],
|
||||||
statsConfig: hostData.statsConfig
|
statsConfig: hostData.statsConfig
|
||||||
? typeof hostData.statsConfig === "string"
|
? typeof hostData.statsConfig === "string"
|
||||||
? hostData.statsConfig
|
? hostData.statsConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user