feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc

This commit is contained in:
LukeGus
2025-11-12 00:58:02 -06:00
parent 26b71c0b69
commit 8028e5d7cb
34 changed files with 1724 additions and 588 deletions

View File

@@ -1,61 +0,0 @@
# Termix Download Links
## Windows
| Architecture | Type | Download Link |
| ------------ | -------- | ---------------------------------------------------------------------------------------------------------- |
| x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) |
| x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) |
| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) |
| ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) |
| ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) |
| ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) |
## Linux
| Architecture | Type | Download Link |
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------- |
| x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) |
| x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) |
| x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) |
| arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) |
| arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) |
| arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) |
| armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) |
| armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) |
| armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) |
## macOS
| Architecture | Type | Download Link |
| ------------ | ------------- | -------------------------------------------------------------------------------------------------------- |
| Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) |
| Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) |
| x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) |
| arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) |
---
## All Platforms - Complete Download List
| Platform | Architecture | Type | Download Link |
| -------- | ------------ | ------------- | --------------------------------------------------------------------------------------------------------------- |
| Windows | x64 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_nsis.exe) |
| Windows | x64 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_msi.msi) |
| Windows | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_x64_portable.zip) |
| Windows | ia32 | NSIS | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_nsis.exe) |
| Windows | ia32 | MSI | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_msi.msi) |
| Windows | ia32 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_windows_ia32_portable.zip) |
| Linux | x64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_appimage.AppImage) |
| Linux | x64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_deb.deb) |
| Linux | x64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_x64_portable.tar.gz) |
| Linux | arm64 | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_appimage.AppImage) |
| Linux | arm64 | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_deb.deb) |
| Linux | arm64 | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_arm64_portable.tar.gz) |
| Linux | armv7l | AppImage | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_appimage.AppImage) |
| Linux | armv7l | DEB | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_deb.deb) |
| Linux | armv7l | Portable | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_linux_armv7l_portable.tar.gz) |
| macOS | Universal | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_dmg.dmg) |
| macOS | Universal | Mac App Store | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_universal_mas.pkg) |
| macOS | x64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_x64_dmg.dmg) |
| macOS | arm64 | DMG | [Download](https://github.com/Termix-SSH/Termix/releases/latest/download/termix_macos_arm64_dmg.dmg) |

View File

@@ -316,7 +316,7 @@ async function initializeCompleteDatabase(): Promise<void> {
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
host_id INTEGER NOT NULL, host_id INTEGER NOT NULL,
host_name TEXT NOT NULL, host_name TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
@@ -489,6 +489,7 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_data", "stats_config", "TEXT"); addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT"); addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");

View File

@@ -89,6 +89,7 @@ export const sshData = sqliteTable("ssh_data", {
defaultPath: text("default_path"), defaultPath: text("default_path"),
statsConfig: text("stats_config"), statsConfig: text("stats_config"),
terminalConfig: text("terminal_config"), terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -238,6 +239,7 @@ export const recentActivity = sqliteTable("recent_activity", {
hostId: integer("host_id") hostId: integer("host_id")
.notNull() .notNull()
.references(() => sshData.id, { onDelete: "cascade" }), .references(() => sshData.id, { onDelete: "cascade" }),
hostName: text("host_name"),
timestamp: text("timestamp") timestamp: text("timestamp")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),

View File

@@ -524,6 +524,8 @@ router.delete(
return res.status(404).json({ error: "Credential not found" }); return res.status(404).json({ error: "Credential not found" });
} }
// Update hosts using this credential to set credentialId to null
// This prevents orphaned references before deletion
const hostsUsingCredential = await db const hostsUsingCredential = await db
.select() .select()
.from(sshData) .from(sshData)
@@ -552,14 +554,8 @@ router.delete(
); );
} }
await db // sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
.delete(sshCredentialUsage) // No need for manual deletion
.where(
and(
eq(sshCredentialUsage.credentialId, parseInt(id)),
eq(sshCredentialUsage.userId, userId),
),
);
await db await db
.delete(sshCredentials) .delete(sshCredentials)

View File

@@ -257,4 +257,244 @@ router.delete(
}, },
); );
// Execute a snippet on a host
// POST /snippets/execute
router.post(
"/execute",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { snippetId, hostId } = req.body;
if (!isNonEmptyString(userId) || !snippetId || !hostId) {
authLogger.warn("Invalid snippet execution request", {
userId,
snippetId,
hostId,
});
return res
.status(400)
.json({ error: "Snippet ID and Host ID are required" });
}
try {
// Get the snippet
const snippetResult = await db
.select()
.from(snippets)
.where(
and(
eq(snippets.id, parseInt(snippetId)),
eq(snippets.userId, userId),
),
);
if (snippetResult.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
const snippet = snippetResult[0];
// Import SSH connection utilities
const { Client } = await import("ssh2");
const { sshData, sshCredentials } = await import("../db/schema.js");
// Get host configuration
const hostResult = await db
.select()
.from(sshData)
.where(
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
);
if (hostResult.length === 0) {
return res.status(404).json({ error: "Host not found" });
}
const host = hostResult[0];
// Resolve credentials if needed
let password = host.password;
let privateKey = host.key;
let passphrase = host.key_password;
if (host.credentialId) {
const credResult = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, userId),
),
);
if (credResult.length > 0) {
const cred = credResult[0];
password = cred.password || undefined;
privateKey = cred.private_key || cred.key || undefined;
passphrase = cred.key_password || undefined;
}
}
// Create SSH connection
const conn = new Client();
let output = "";
let errorOutput = "";
const executePromise = new Promise<{
success: boolean;
output: string;
error?: string;
}>((resolve, reject) => {
const timeout = setTimeout(() => {
conn.end();
reject(new Error("Command execution timeout (30s)"));
}, 30000);
conn.on("ready", () => {
conn.exec(snippet.content, (err, stream) => {
if (err) {
clearTimeout(timeout);
conn.end();
return reject(err);
}
stream.on("close", () => {
clearTimeout(timeout);
conn.end();
if (errorOutput) {
resolve({ success: false, output, error: errorOutput });
} else {
resolve({ success: true, output });
}
});
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
});
});
conn.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
// Connect to SSH
const config: any = {
host: host.ip,
port: host.port,
username: host.username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 30000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
timeout: 30000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (password) {
config.password = password;
}
if (privateKey) {
const cleanKey = privateKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (passphrase) {
config.passphrase = passphrase;
}
}
conn.connect(config);
});
const result = await executePromise;
authLogger.success(
`Snippet executed: ${snippet.name} on host ${hostId}`,
{
operation: "snippet_execute_success",
userId,
snippetId,
hostId,
},
);
res.json(result);
} catch (err) {
authLogger.error("Failed to execute snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to execute snippet",
});
}
},
);
export default router; export default router;

View File

@@ -237,6 +237,7 @@ router.post(
defaultPath, defaultPath,
tunnelConnections, tunnelConnections,
jumpHosts, jumpHosts,
quickActions,
statsConfig, statsConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
@@ -274,6 +275,9 @@ router.post(
? JSON.stringify(tunnelConnections) ? JSON.stringify(tunnelConnections)
: null, : null,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: 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,
@@ -456,6 +460,7 @@ router.put(
defaultPath, defaultPath,
tunnelConnections, tunnelConnections,
jumpHosts, jumpHosts,
quickActions,
statsConfig, statsConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
@@ -494,6 +499,9 @@ router.put(
? JSON.stringify(tunnelConnections) ? JSON.stringify(tunnelConnections)
: null, : null,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: 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,
@@ -672,6 +680,9 @@ router.get(
? JSON.parse(row.tunnelConnections as string) ? JSON.parse(row.tunnelConnections as string)
: [], : [],
jumpHosts: row.jumpHosts ? JSON.parse(row.jumpHosts as string) : [], jumpHosts: row.jumpHosts ? JSON.parse(row.jumpHosts as string) : [],
quickActions: row.quickActions
? JSON.parse(row.quickActions 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)
@@ -745,6 +756,8 @@ router.get(
tunnelConnections: host.tunnelConnections tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections) ? JSON.parse(host.tunnelConnections)
: [], : [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts) : [],
quickActions: host.quickActions ? JSON.parse(host.quickActions) : [],
enableFileManager: !!host.enableFileManager, enableFileManager: !!host.enableFileManager,
statsConfig: host.statsConfig statsConfig: host.statsConfig
? JSON.parse(host.statsConfig) ? JSON.parse(host.statsConfig)

View File

@@ -762,6 +762,44 @@ router.get("/oidc/callback", async (req, res) => {
.get(); .get();
isFirstUser = ((countResult as { count?: number })?.count || 0) === 0; isFirstUser = ((countResult as { count?: number })?.count || 0) === 0;
// Check if registration is allowed (unless this is the first user)
if (!isFirstUser) {
try {
const regRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_registration'",
)
.get();
if (regRow && (regRow as Record<string, unknown>).value !== "true") {
authLogger.warn(
"OIDC user attempted to register when registration is disabled",
{
operation: "oidc_registration_disabled",
identifier,
name,
},
);
let frontendUrl = (redirectUri as string).replace(
"/users/oidc/callback",
"",
);
if (frontendUrl.includes("localhost")) {
frontendUrl = "http://localhost:5173";
}
const redirectUrl = new URL(frontendUrl);
redirectUrl.searchParams.set("error", "registration_disabled");
return res.redirect(redirectUrl.toString());
}
} catch (e) {
authLogger.warn("Failed to check registration status during OIDC", {
operation: "oidc_registration_check",
error: e,
});
}
}
const id = nanoid(); const id = nanoid();
await db.insert(users).values({ await db.insert(users).values({
id, id,
@@ -1681,6 +1719,7 @@ router.get("/list", authenticateJWT, async (req, res) => {
username: users.username, username: users.username,
is_admin: users.is_admin, is_admin: users.is_admin,
is_oidc: users.is_oidc, is_oidc: users.is_oidc,
password_hash: users.password_hash,
}) })
.from(users); .from(users);
@@ -2517,21 +2556,15 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
} }
}); });
// Route: Convert OIDC user to password user (link accounts) // Route: Link OIDC user to existing password account (merge accounts)
// POST /users/convert-oidc-to-password // POST /users/link-oidc-to-password
router.post("/convert-oidc-to-password", authenticateJWT, async (req, res) => { router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
const adminUserId = (req as AuthenticatedRequest).userId; const adminUserId = (req as AuthenticatedRequest).userId;
const { targetUserId, newPassword, totpCode } = req.body; const { oidcUserId, targetUsername } = req.body;
if (!isNonEmptyString(targetUserId) || !isNonEmptyString(newPassword)) { if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
return res.status(400).json({ return res.status(400).json({
error: "Target user ID and new password are required", error: "OIDC user ID and target username are required",
});
}
if (newPassword.length < 8) {
return res.status(400).json({
error: "New password must be at least 8 characters long",
}); });
} }
@@ -2545,185 +2578,128 @@ router.post("/convert-oidc-to-password", authenticateJWT, async (req, res) => {
return res.status(403).json({ error: "Admin access required" }); return res.status(403).json({ error: "Admin access required" });
} }
// Get target user // Get OIDC user
const oidcUserRecords = await db
.select()
.from(users)
.where(eq(users.id, oidcUserId));
if (!oidcUserRecords || oidcUserRecords.length === 0) {
return res.status(404).json({ error: "OIDC user not found" });
}
const oidcUser = oidcUserRecords[0];
// Verify user is OIDC
if (!oidcUser.is_oidc) {
return res.status(400).json({
error: "Source user is not an OIDC user",
});
}
// Get target password user
const targetUserRecords = await db const targetUserRecords = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.id, targetUserId)); .where(eq(users.username, targetUsername));
if (!targetUserRecords || targetUserRecords.length === 0) { if (!targetUserRecords || targetUserRecords.length === 0) {
return res.status(404).json({ error: "Target user not found" }); return res.status(404).json({ error: "Target password user not found" });
} }
const targetUser = targetUserRecords[0]; const targetUser = targetUserRecords[0];
// Verify user is OIDC // Verify target user has password authentication
if (!targetUser.is_oidc) { if (targetUser.is_oidc || !targetUser.password_hash) {
return res.status(400).json({ return res.status(400).json({
error: "User is already a password-based user", error: "Target user must be a password-based account",
}); });
} }
// Verify TOTP if enabled // Check if target user already has OIDC configured
if (targetUser.totp_enabled && targetUser.totp_secret) { if (targetUser.client_id && targetUser.oidc_identifier) {
if (!totpCode) { return res.status(400).json({
return res.status(400).json({ error: "Target user already has OIDC authentication configured",
error: "TOTP code required for this user",
code: "TOTP_REQUIRED",
});
}
const verified = speakeasy.totp.verify({
secret: targetUser.totp_secret,
encoding: "base32",
token: totpCode,
window: 2,
}); });
if (!verified) {
return res.status(401).json({ error: "Invalid TOTP code" });
}
} }
authLogger.info("Converting OIDC user to password user", { authLogger.info("Linking OIDC user to password account", {
operation: "convert_oidc_to_password", operation: "link_oidc_to_password",
targetUserId, oidcUserId,
adminUserId, oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username, targetUsername: targetUser.username,
adminUserId,
}); });
// Step 1: Get current DEK from memory (requires user to be logged in) // Copy OIDC configuration from OIDC user to target password user
// For admin conversion, we need to authenticate as OIDC user first
const deviceType = "web";
const unlocked = await authManager.authenticateOIDCUser(
targetUserId,
deviceType,
);
if (!unlocked) {
return res.status(500).json({
error: "Failed to unlock user data for conversion",
});
}
// Get the DEK from memory
const { DataCrypto } = await import("../../utils/data-crypto.js");
const currentDEK = DataCrypto.getUserDataKey(targetUserId);
if (!currentDEK) {
return res.status(500).json({
error: "User data encryption key not available",
});
}
// Step 2: Setup new password-based encryption
const { UserCrypto } = await import("../../utils/user-crypto.js");
// Generate new KEK from password
const kekSalt = crypto.randomBytes(32);
const kekSaltHex = kekSalt.toString("hex");
// Derive KEK from new password
const kek = await new Promise<Buffer>((resolve, reject) => {
crypto.pbkdf2(
newPassword,
kekSalt,
100000,
32,
"sha256",
(err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
},
);
});
// Encrypt the existing DEK with new password-derived KEK
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv);
let encryptedDEK = cipher.update(currentDEK);
encryptedDEK = Buffer.concat([encryptedDEK, cipher.final()]);
const authTag = cipher.getAuthTag();
const encryptedDEKData = JSON.stringify({
iv: iv.toString("hex"),
encryptedKey: encryptedDEK.toString("hex"),
authTag: authTag.toString("hex"),
});
// Step 3: Hash the new password
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
// Step 4: Update user record atomically
await db await db
.update(users) .update(users)
.set({ .set({
password_hash, is_oidc: true, // Enable OIDC login for this account
is_oidc: false, oidc_identifier: oidcUser.oidc_identifier,
oidc_identifier: null, client_id: oidcUser.client_id,
client_id: "", client_secret: oidcUser.client_secret,
client_secret: "", issuer_url: oidcUser.issuer_url,
issuer_url: "", authorization_url: oidcUser.authorization_url,
authorization_url: "", token_url: oidcUser.token_url,
token_url: "", identifier_path: oidcUser.identifier_path,
identifier_path: "", name_path: oidcUser.name_path,
name_path: "", scopes: oidcUser.scopes || "openid email profile",
scopes: "openid email profile",
}) })
.where(eq(users.id, targetUserId)); .where(eq(users.id, targetUser.id));
// Step 5: Update KEK salt and encrypted DEK in settings // Revoke all sessions for the OIDC user before deletion
await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId);
// Delete OIDC user's recent activity first (to avoid NOT NULL constraint issues)
await db
.delete(recentActivity)
.where(eq(recentActivity.userId, oidcUserId));
// Delete the OIDC user (CASCADE will delete related data: hosts, credentials, sessions, etc.)
await db.delete(users).where(eq(users.id, oidcUserId));
// Clean up OIDC user's settings
db.$client db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .prepare("DELETE FROM settings WHERE key LIKE ?")
.run(`user_kek_salt_${targetUserId}`, kekSaltHex); .run(`user_%_${oidcUserId}`);
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(`user_encrypted_dek_${targetUserId}`, encryptedDEKData);
// Step 6: Remove OIDC session duration setting if exists
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`user_oidc_session_duration_${targetUserId}`);
// Step 7: Revoke all existing sessions to force re-login
await authManager.revokeAllUserSessions(targetUserId);
// Step 8: Clear the in-memory DEK
authManager.logoutUser(targetUserId);
try { try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js"); const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} catch (saveError) { } catch (saveError) {
authLogger.error("Failed to persist conversion to disk", saveError, { authLogger.error("Failed to persist account linking to disk", saveError, {
operation: "convert_oidc_save_failed", operation: "link_oidc_save_failed",
targetUserId, oidcUserId,
targetUserId: targetUser.id,
}); });
} }
authLogger.success( authLogger.success(
`OIDC user converted to password user: ${targetUser.username}`, `OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
{ {
operation: "convert_oidc_to_password_success", operation: "link_oidc_to_password_success",
targetUserId, oidcUserId,
adminUserId, oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username, targetUsername: targetUser.username,
adminUserId,
}, },
); );
res.json({ res.json({
success: true, success: true,
message: `User ${targetUser.username} has been converted to password authentication. All sessions have been revoked.`, message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
}); });
} catch (err) { } catch (err) {
authLogger.error("Failed to convert OIDC user to password user", err, { authLogger.error("Failed to link OIDC user to password account", err, {
operation: "convert_oidc_to_password_failed", operation: "link_oidc_to_password_failed",
targetUserId, oidcUserId,
targetUsername,
adminUserId, adminUserId,
}); });
res.status(500).json({ res.status(500).json({
error: "Failed to convert user account", error: "Failed to link accounts",
details: err instanceof Error ? err.message : "Unknown error", details: err instanceof Error ? err.message : "Unknown error",
}); });
} }

View File

@@ -704,15 +704,6 @@ class PollingManager {
config.statusTimer = setInterval(() => { config.statusTimer = setInterval(() => {
this.pollHostStatus(host); this.pollHostStatus(host);
}, intervalMs); }, intervalMs);
statsLogger.debug(
`Started status polling for host ${host.id} (interval: ${statsConfig.statusCheckInterval}s)`,
{
operation: "status_polling_started",
hostId: host.id,
interval: statsConfig.statusCheckInterval,
},
);
} else { } else {
this.statusStore.delete(host.id); this.statusStore.delete(host.id);
statsLogger.debug(`Status polling disabled for host ${host.id}`, { statsLogger.debug(`Status polling disabled for host ${host.id}`, {
@@ -729,15 +720,6 @@ class PollingManager {
config.metricsTimer = setInterval(() => { config.metricsTimer = setInterval(() => {
this.pollHostMetrics(host); this.pollHostMetrics(host);
}, intervalMs); }, intervalMs);
statsLogger.debug(
`Started metrics polling for host ${host.id} (interval: ${statsConfig.metricsInterval}s)`,
{
operation: "metrics_polling_started",
hostId: host.id,
interval: statsConfig.metricsInterval,
},
);
} else { } else {
this.metricsStore.delete(host.id); this.metricsStore.delete(host.id);
statsLogger.debug(`Metrics polling disabled for host ${host.id}`, { statsLogger.debug(`Metrics polling disabled for host ${host.id}`, {

View File

@@ -189,9 +189,23 @@
"enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren", "enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren",
"shareIdeas": "Haben Sie Vorschläge welche weiteren SSH-Tools ergänzt werden sollen? Dann teilen Sie diese gerne mit uns auf" "shareIdeas": "Haben Sie Vorschläge welche weiteren SSH-Tools ergänzt werden sollen? Dann teilen Sie diese gerne mit uns auf"
}, },
"commandHistory": {
"title": "Verlauf",
"searchPlaceholder": "Befehle suchen...",
"noTerminal": "Kein aktives Terminal",
"noTerminalHint": "Öffnen Sie ein Terminal, um dessen Befehlsverlauf anzuzeigen.",
"empty": "Noch kein Befehlsverlauf",
"emptyHint": "Führen Sie Befehle im aktiven Terminal aus, um einen Verlauf zu erstellen.",
"noResults": "Keine Befehle gefunden",
"noResultsHint": "Keine Befehle mit \"{{query}}\" gefunden",
"deleteSuccess": "Befehl aus Verlauf gelöscht",
"deleteFailed": "Befehl konnte nicht gelöscht werden.",
"deleteTooltip": "Befehl löschen",
"tabHint": "Verwenden Sie Tab im Terminal, um aus dem Befehlsverlauf zu vervollständigen"
},
"homepage": { "homepage": {
"loggedInTitle": "Eingeloggt!", "loggedInTitle": "Eingeloggt!",
"loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab SSH-Manager. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.", "loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab SSH-Manager. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.",
"failedToLoadAlerts": "Warnmeldungen konnten nicht geladen werden", "failedToLoadAlerts": "Warnmeldungen konnten nicht geladen werden",
"failedToDismissAlert": "Benachrichtigung konnte nicht geschlossen werden" "failedToDismissAlert": "Benachrichtigung konnte nicht geschlossen werden"
}, },
@@ -1374,6 +1388,10 @@
"local": "Lokal", "local": "Lokal",
"external": "Extern (OIDC)", "external": "Extern (OIDC)",
"selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche", "selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche",
"fileColorCoding": "Dateifarb-Codierung",
"fileColorCodingDesc": "Farbcodierung von Dateien nach Typ: Ordner (rot), Dateien (blau), Symlinks (grün)",
"commandAutocomplete": "Befehlsautovervollständigung",
"commandAutocompleteDesc": "Tab-Taste Autovervollständigung für Terminal-Befehle basierend auf Ihrem Befehlsverlauf aktivieren",
"currentPassword": "Aktuelles Passwort", "currentPassword": "Aktuelles Passwort",
"passwordChangedSuccess": "Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.", "passwordChangedSuccess": "Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.",
"failedToChangePassword": "Passwort konnte nicht geändert werden. Bitte überprüfen Sie Ihr aktuelles Passwort und versuchen Sie es erneut." "failedToChangePassword": "Passwort konnte nicht geändert werden. Bitte überprüfen Sie Ihr aktuelles Passwort und versuchen Sie es erneut."

View File

@@ -226,6 +226,20 @@
"editTooltip": "Edit this snippet", "editTooltip": "Edit this snippet",
"deleteTooltip": "Delete this snippet" "deleteTooltip": "Delete this snippet"
}, },
"commandHistory": {
"title": "History",
"searchPlaceholder": "Search commands...",
"noTerminal": "No active terminal",
"noTerminalHint": "Open a terminal to see its command history.",
"empty": "No command history yet",
"emptyHint": "Execute commands in the active terminal to build its history.",
"noResults": "No commands found",
"noResultsHint": "No commands matching \"{{query}}\"",
"deleteSuccess": "Command deleted from history",
"deleteFailed": "Failed to delete command.",
"deleteTooltip": "Delete command",
"tabHint": "Use Tab in Terminal to autocomplete from command history"
},
"homepage": { "homepage": {
"loggedInTitle": "Logged in!", "loggedInTitle": "Logged in!",
"loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.", "loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.",
@@ -482,26 +496,20 @@
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?", "confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
"failedToRevokeSessions": "Failed to revoke sessions", "failedToRevokeSessions": "Failed to revoke sessions",
"sessionsRevokedSuccessfully": "Sessions revoked successfully", "sessionsRevokedSuccessfully": "Sessions revoked successfully",
"convertToPasswordAuth": "Convert to Password Authentication", "linkToPasswordAccount": "Link to Password Account",
"convertOIDCToPassword": "Convert {{username}} from OIDC/SSO authentication to password-based authentication. This will allow the user to log in with a username and password instead of through an external provider.", "linkOIDCDialogTitle": "Link OIDC Account to Password Account",
"convertUserDialogTitle": "Convert to Password Authentication", "linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.",
"convertUserDialogDescription": "This action will set a new password, disable OIDC/SSO login, log out all sessions, and preserve all user data.", "linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted",
"convertActionWillSetPassword": "Set a new password for this user", "linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data",
"convertActionWillDisableOIDC": "Disable OIDC/SSO login for this account", "linkOIDCActionAddCapability": "Add OIDC login capability to the target password account",
"convertActionWillLogout": "Log out all active sessions", "linkOIDCActionDualAuth": "Allow the password account to login with both password and OIDC",
"convertActionWillPreserveData": "Preserve all user data (SSH hosts, credentials, etc.)", "linkTargetUsernameLabel": "Target Password Account Username",
"convertPasswordLabel": "New Password (min 8 chars)", "linkTargetUsernamePlaceholder": "Enter username of password account",
"convertPasswordPlaceholder": "Enter new password", "linkAccountsButton": "Link Accounts",
"convertTotpLabel": "TOTP Code (if user has 2FA enabled)", "linkingAccounts": "Linking...",
"convertTotpPlaceholder": "000000", "accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}",
"convertUserButton": "Convert User", "failedToLinkAccounts": "Failed to link accounts",
"convertingUser": "Converting...", "linkTargetUsernameRequired": "Target username is required",
"userConvertedSuccessfully": "User {{username}} has been converted to password authentication. All sessions have been revoked.",
"failedToConvertUser": "Failed to convert user account",
"convertPasswordRequired": "Password is required",
"convertPasswordTooShort": "Password must be at least 8 characters long",
"convertTotpRequired": "TOTP code is required for this user",
"convertToPasswordTitle": "Convert to password authentication",
"databaseSecurity": "Database Security", "databaseSecurity": "Database Security",
"encryptionStatus": "Encryption Status", "encryptionStatus": "Encryption Status",
"encryptionEnabled": "Encryption Enabled", "encryptionEnabled": "Encryption Enabled",
@@ -889,6 +897,13 @@
"searchServers": "Search servers...", "searchServers": "Search servers...",
"noServerFound": "No server found", "noServerFound": "No server found",
"jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server", "jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server",
"quickActions": "Quick Actions",
"quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.",
"quickActionsList": "Quick Actions List",
"addQuickAction": "Add Quick Action",
"quickActionName": "Action name",
"noSnippetFound": "No snippet found",
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
"advancedAuthSettings": "Advanced Authentication Settings" "advancedAuthSettings": "Advanced Authentication Settings"
}, },
"terminal": { "terminal": {
@@ -1370,7 +1385,13 @@
"recentSuccessfulLogins": "Recent Successful Logins", "recentSuccessfulLogins": "Recent Successful Logins",
"recentFailedAttempts": "Recent Failed Attempts", "recentFailedAttempts": "Recent Failed Attempts",
"noRecentLoginData": "No recent login data", "noRecentLoginData": "No recent login data",
"from": "from" "from": "from",
"quickActions": "Quick Actions",
"executeQuickAction": "Execute {{name}}",
"executingQuickAction": "Executing {{name}}...",
"quickActionSuccess": "{{name}} completed successfully",
"quickActionFailed": "{{name}} failed",
"quickActionError": "Failed to execute {{name}}"
}, },
"auth": { "auth": {
"tagline": "SSH SERVER MANAGER", "tagline": "SSH SERVER MANAGER",
@@ -1553,6 +1574,8 @@
"selectPreferredLanguage": "Select your preferred language for the interface", "selectPreferredLanguage": "Select your preferred language for the interface",
"fileColorCoding": "File Color Coding", "fileColorCoding": "File Color Coding",
"fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)", "fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)",
"commandAutocomplete": "Command Autocomplete",
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
"currentPassword": "Current Password", "currentPassword": "Current Password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.", "passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again." "failedToChangePassword": "Failed to change password. Please check your current password and try again."

View File

@@ -223,6 +223,20 @@
"editTooltip": "Modifier cet extrait", "editTooltip": "Modifier cet extrait",
"deleteTooltip": "Supprimer cet extrait" "deleteTooltip": "Supprimer cet extrait"
}, },
"commandHistory": {
"title": "Historique",
"searchPlaceholder": "Rechercher des commandes...",
"noTerminal": "Aucun terminal actif",
"noTerminalHint": "Ouvrez un terminal pour voir son historique de commandes.",
"empty": "Aucun historique de commandes",
"emptyHint": "Exécutez des commandes dans le terminal actif pour créer un historique.",
"noResults": "Aucune commande trouvée",
"noResultsHint": "Aucune commande correspondant à \"{{query}}\"",
"deleteSuccess": "Commande supprimée de l'historique",
"deleteFailed": "Échec de la suppression de la commande.",
"deleteTooltip": "Supprimer la commande",
"tabHint": "Utilisez Tab dans le terminal pour compléter automatiquement depuis l'historique des commandes"
},
"homepage": { "homepage": {
"loggedInTitle": "Connexion réussie !", "loggedInTitle": "Connexion réussie !",
"loggedInMessage": "Vous êtes connecté ! Utilisez la barre latérale pour accéder à tous les outils disponibles. Pour commencer, créez un hôte SSH dans l'onglet Gestionnaire SSH. Une fois créé, vous pourrez vous connecter à cet hôte avec les autres applications de la barre latérale.", "loggedInMessage": "Vous êtes connecté ! Utilisez la barre latérale pour accéder à tous les outils disponibles. Pour commencer, créez un hôte SSH dans l'onglet Gestionnaire SSH. Une fois créé, vous pourrez vous connecter à cet hôte avec les autres applications de la barre latérale.",
@@ -1368,6 +1382,10 @@
"local": "Local", "local": "Local",
"external": "Externe (OIDC)", "external": "Externe (OIDC)",
"selectPreferredLanguage": "Choisissez votre langue préférée pour l'interface", "selectPreferredLanguage": "Choisissez votre langue préférée pour l'interface",
"fileColorCoding": "Codage couleur des fichiers",
"fileColorCodingDesc": "Codage couleur des fichiers par type : dossiers (rouge), fichiers (bleu), liens symboliques (vert)",
"commandAutocomplete": "Autocomplétion des commandes",
"commandAutocompleteDesc": "Activer les suggestions d'autocomplétion avec la touche Tab pour les commandes du terminal basées sur votre historique",
"currentPassword": "Mot de passe actuel", "currentPassword": "Mot de passe actuel",
"passwordChangedSuccess": "Mot de passe modifié avec succès ! Veuillez vous reconnecter.", "passwordChangedSuccess": "Mot de passe modifié avec succès ! Veuillez vous reconnecter.",
"failedToChangePassword": "Échec de la modification du mot de passe. Vérifiez votre mot de passe actuel et réessayez." "failedToChangePassword": "Échec de la modification du mot de passe. Vérifiez votre mot de passe actuel et réessayez."

View File

@@ -191,6 +191,20 @@
"enableRightClickCopyPaste": "Habilitar copiar/colar com botão direito", "enableRightClickCopyPaste": "Habilitar copiar/colar com botão direito",
"shareIdeas": "Tem ideias sobre o que deve vir a seguir nas ferramentas SSH? Compartilhe em" "shareIdeas": "Tem ideias sobre o que deve vir a seguir nas ferramentas SSH? Compartilhe em"
}, },
"commandHistory": {
"title": "Histórico",
"searchPlaceholder": "Pesquisar comandos...",
"noTerminal": "Nenhum terminal ativo",
"noTerminalHint": "Abra um terminal para ver seu histórico de comandos.",
"empty": "Ainda não há histórico de comandos",
"emptyHint": "Execute comandos no terminal ativo para criar um histórico.",
"noResults": "Nenhum comando encontrado",
"noResultsHint": "Nenhum comando correspondente a \"{{query}}\"",
"deleteSuccess": "Comando removido do histórico",
"deleteFailed": "Falha ao excluir comando.",
"deleteTooltip": "Excluir comando",
"tabHint": "Use Tab no Terminal para autocompletar do histórico de comandos"
},
"homepage": { "homepage": {
"loggedInTitle": "Conectado!", "loggedInTitle": "Conectado!",
"loggedInMessage": "Você está conectado! Use a barra lateral para acessar todas as ferramentas disponíveis. Para começar, crie um Host SSH na aba Gerenciador SSH. Depois de criado, você pode se conectar a esse host usando os outros apps na barra lateral.", "loggedInMessage": "Você está conectado! Use a barra lateral para acessar todas as ferramentas disponíveis. Para começar, crie um Host SSH na aba Gerenciador SSH. Depois de criado, você pode se conectar a esse host usando os outros apps na barra lateral.",
@@ -1322,6 +1336,10 @@
"local": "Local", "local": "Local",
"external": "Externo (OIDC)", "external": "Externo (OIDC)",
"selectPreferredLanguage": "Selecione seu idioma preferido para a interface", "selectPreferredLanguage": "Selecione seu idioma preferido para a interface",
"fileColorCoding": "Codificação de Cores de Arquivos",
"fileColorCodingDesc": "Codificar arquivos por cores por tipo: pastas (vermelho), arquivos (azul), links simbólicos (verde)",
"commandAutocomplete": "Autocompletar Comandos",
"commandAutocompleteDesc": "Ativar sugestões de autocompletar com a tecla Tab para comandos do terminal baseado no seu histórico",
"currentPassword": "Senha Atual", "currentPassword": "Senha Atual",
"passwordChangedSuccess": "Senha alterada com sucesso! Por favor, faça login novamente.", "passwordChangedSuccess": "Senha alterada com sucesso! Por favor, faça login novamente.",
"failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente." "failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente."

View File

@@ -225,6 +225,20 @@
"editTooltip": "Редактировать этот сниппет", "editTooltip": "Редактировать этот сниппет",
"deleteTooltip": "Удалить этот сниппет" "deleteTooltip": "Удалить этот сниппет"
}, },
"commandHistory": {
"title": "История",
"searchPlaceholder": "Поиск команд...",
"noTerminal": "Нет активного терминала",
"noTerminalHint": "Откройте терминал, чтобы увидеть историю команд.",
"empty": "История команд пока пуста",
"emptyHint": "Выполните команды в активном терминале, чтобы создать историю.",
"noResults": "Команды не найдены",
"noResultsHint": "Нет команд, соответствующих \"{{query}}\"",
"deleteSuccess": "Команда удалена из истории",
"deleteFailed": "Не удалось удалить команду.",
"deleteTooltip": "Удалить команду",
"tabHint": "Используйте Tab в Терминале для автозаполнения из истории команд"
},
"homepage": { "homepage": {
"loggedInTitle": "Вы вошли в систему!", "loggedInTitle": "Вы вошли в систему!",
"loggedInMessage": "Вы вошли в систему! Используйте боковую панель для доступа ко всем доступным инструментам. Чтобы начать, создайте SSH-хост в разделе SSH-менеджера. После создания вы можете подключиться к этому хосту, используя другие приложения на боковой панели.", "loggedInMessage": "Вы вошли в систему! Используйте боковую панель для доступа ко всем доступным инструментам. Чтобы начать, создайте SSH-хост в разделе SSH-менеджера. После создания вы можете подключиться к этому хосту, используя другие приложения на боковой панели.",
@@ -1461,6 +1475,10 @@
"local": "Локальный", "local": "Локальный",
"external": "Внешний (OIDC)", "external": "Внешний (OIDC)",
"selectPreferredLanguage": "Выберите предпочитаемый язык интерфейса", "selectPreferredLanguage": "Выберите предпочитаемый язык интерфейса",
"fileColorCoding": "Цветовое кодирование файлов",
"fileColorCodingDesc": "Цветовая кодировка файлов по типу: папки (красный), файлы (синий), символические ссылки (зелёный)",
"commandAutocomplete": "Автодополнение команд",
"commandAutocompleteDesc": "Включить автодополнение команд терминала клавишей Tab на основе вашей истории команд",
"currentPassword": "Текущий пароль", "currentPassword": "Текущий пароль",
"passwordChangedSuccess": "Пароль успешно изменен! Пожалуйста, войдите снова.", "passwordChangedSuccess": "Пароль успешно изменен! Пожалуйста, войдите снова.",
"failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова." "failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова."

View File

@@ -223,6 +223,20 @@
"editTooltip": "编辑此片段", "editTooltip": "编辑此片段",
"deleteTooltip": "删除此片段" "deleteTooltip": "删除此片段"
}, },
"commandHistory": {
"title": "历史记录",
"searchPlaceholder": "搜索命令...",
"noTerminal": "无活动终端",
"noTerminalHint": "打开终端以查看其命令历史记录。",
"empty": "暂无命令历史记录",
"emptyHint": "在活动终端中执行命令以建立历史记录。",
"noResults": "未找到命令",
"noResultsHint": "没有匹配 \"{{query}}\" 的命令",
"deleteSuccess": "命令已从历史记录中删除",
"deleteFailed": "删除命令失败。",
"deleteTooltip": "删除命令",
"tabHint": "在终端中使用 Tab 键从命令历史记录自动完成"
},
"homepage": { "homepage": {
"loggedInTitle": "登录成功!", "loggedInTitle": "登录成功!",
"loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。", "loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。",
@@ -1494,6 +1508,8 @@
"selectPreferredLanguage": "选择您的界面首选语言", "selectPreferredLanguage": "选择您的界面首选语言",
"fileColorCoding": "文件颜色编码", "fileColorCoding": "文件颜色编码",
"fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)", "fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)",
"commandAutocomplete": "命令自动补全",
"commandAutocompleteDesc": "启用基于命令历史记录的 Tab 键终端命令自动补全建议",
"currentPassword": "当前密码", "currentPassword": "当前密码",
"passwordChangedSuccess": "密码修改成功!请重新登录。", "passwordChangedSuccess": "密码修改成功!请重新登录。",
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。" "failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"

View File

@@ -9,6 +9,11 @@ export interface JumpHost {
hostId: number; hostId: number;
} }
export interface QuickAction {
name: string;
snippetId: number;
}
export interface SSHHost { export interface SSHHost {
id: number; id: number;
name: string; name: string;
@@ -38,6 +43,7 @@ export interface SSHHost {
defaultPath: string; defaultPath: string;
tunnelConnections: TunnelConnection[]; tunnelConnections: TunnelConnection[];
jumpHosts?: JumpHost[]; jumpHosts?: JumpHost[];
quickActions?: QuickAction[];
statsConfig?: string; statsConfig?: string;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
createdAt: string; createdAt: string;
@@ -48,6 +54,11 @@ export interface JumpHostData {
hostId: number; hostId: number;
} }
export interface QuickActionData {
name: string;
snippetId: number;
}
export interface SSHHostData { export interface SSHHostData {
name?: string; name?: string;
ip: string; ip: string;
@@ -70,6 +81,7 @@ export interface SSHHostData {
forceKeyboardInteractive?: boolean; forceKeyboardInteractive?: boolean;
tunnelConnections?: TunnelConnection[]; tunnelConnections?: TunnelConnection[];
jumpHosts?: JumpHostData[]; jumpHosts?: JumpHostData[];
quickActions?: QuickActionData[];
statsConfig?: string | Record<string, unknown>; statsConfig?: string | Record<string, unknown>;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
} }
@@ -317,6 +329,22 @@ export interface TabContextTab {
initialTab?: string; initialTab?: string;
} }
// Split Screen Layout Types
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
export interface SplitConfiguration {
layout: SplitLayout;
positions: Map<number, number>; // position index -> tab ID
}
export interface SplitLayoutOption {
id: SplitLayout;
name: string;
description: string;
cellCount: number;
icon: string; // lucide icon name
}
// ============================================================================ // ============================================================================
// CONNECTION STATES // CONNECTION STATES
// ============================================================================ // ============================================================================

View File

@@ -31,7 +31,7 @@ function AppContent() {
const { currentTab, tabs } = useTabs(); const { currentTab, tabs } = useTabs();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [rightSidebarOpen, setRightSidebarOpen] = useState(false); const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(300); const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
const lastShiftPressTime = useRef(0); const lastShiftPressTime = useRef(0);
@@ -69,6 +69,8 @@ function AppContent() {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
// Clear invalid token
localStorage.removeItem("jwt");
} else { } else {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
@@ -80,6 +82,9 @@ function AppContent() {
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
// Clear invalid token on any auth error
localStorage.removeItem("jwt");
const errorCode = err?.response?.data?.code; const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") { if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again"); console.warn("Session expired - please log in again");

View File

@@ -34,7 +34,7 @@ import {
Trash2, Trash2,
Users, Users,
Database, Database,
Lock, Link2,
Download, Download,
Upload, Upload,
Monitor, Monitor,
@@ -63,7 +63,7 @@ import {
getSessions, getSessions,
revokeSession, revokeSession,
revokeAllUserSessions, revokeAllUserSessions,
convertOIDCToPassword, linkOIDCToPasswordAccount,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
interface AdminSettingsProps { interface AdminSettingsProps {
@@ -75,7 +75,7 @@ interface AdminSettingsProps {
export function AdminSettings({ export function AdminSettings({
isTopbarOpen = true, isTopbarOpen = true,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 300, rightSidebarWidth = 400,
}: AdminSettingsProps): React.ReactElement { }: AdminSettingsProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
@@ -107,6 +107,7 @@ export function AdminSettings({
username: string; username: string;
is_admin: boolean; is_admin: boolean;
is_oidc: boolean; is_oidc: boolean;
password_hash?: string;
}> }>
>([]); >([]);
const [usersLoading, setUsersLoading] = React.useState(false); const [usersLoading, setUsersLoading] = React.useState(false);
@@ -147,15 +148,13 @@ export function AdminSettings({
>([]); >([]);
const [sessionsLoading, setSessionsLoading] = React.useState(false); const [sessionsLoading, setSessionsLoading] = React.useState(false);
const [convertUserDialogOpen, setConvertUserDialogOpen] = const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false);
React.useState(false); const [linkOidcUser, setLinkOidcUser] = React.useState<{
const [convertTargetUser, setConvertTargetUser] = React.useState<{
id: string; id: string;
username: string; username: string;
} | null>(null); } | null>(null);
const [convertPassword, setConvertPassword] = React.useState(""); const [linkTargetUsername, setLinkTargetUsername] = React.useState("");
const [convertTotpCode, setConvertTotpCode] = React.useState(""); const [linkLoading, setLinkLoading] = React.useState(false);
const [convertLoading, setConvertLoading] = React.useState(false);
const requiresImportPassword = React.useMemo( const requiresImportPassword = React.useMemo(
() => !currentUser?.is_oidc, () => !currentUser?.is_oidc,
@@ -655,54 +654,41 @@ export function AdminSettings({
); );
}; };
const handleConvertOIDCUser = (user: { id: string; username: string }) => { const handleLinkOIDCUser = (user: { id: string; username: string }) => {
setConvertTargetUser(user); setLinkOidcUser(user);
setConvertPassword(""); setLinkTargetUsername("");
setConvertTotpCode(""); setLinkAccountAlertOpen(true);
setConvertUserDialogOpen(true);
}; };
const handleConvertSubmit = async () => { const handleLinkSubmit = async () => {
if (!convertTargetUser || !convertPassword) { if (!linkOidcUser || !linkTargetUsername.trim()) {
toast.error("Password is required"); toast.error("Target username is required");
return; return;
} }
if (convertPassword.length < 8) { setLinkLoading(true);
toast.error("Password must be at least 8 characters long");
return;
}
setConvertLoading(true);
try { try {
const result = await convertOIDCToPassword( const result = await linkOIDCToPasswordAccount(
convertTargetUser.id, linkOidcUser.id,
convertPassword, linkTargetUsername.trim(),
convertTotpCode || undefined,
); );
toast.success( toast.success(
result.message || result.message ||
`User ${convertTargetUser.username} converted to password authentication`, `OIDC user ${linkOidcUser.username} linked to ${linkTargetUsername}`,
); );
setConvertUserDialogOpen(false); setLinkAccountAlertOpen(false);
setConvertPassword(""); setLinkTargetUsername("");
setConvertTotpCode(""); setLinkOidcUser(null);
setConvertTargetUser(null);
fetchUsers(); fetchUsers();
fetchSessions();
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { const err = error as {
response?: { data?: { error?: string; code?: string } }; response?: { data?: { error?: string; code?: string } };
}; };
if (err.response?.data?.code === "TOTP_REQUIRED") { toast.error(err.response?.data?.error || "Failed to link accounts");
toast.error("TOTP code is required for this user");
} else {
toast.error(
err.response?.data?.error || "Failed to convert user account",
);
}
} finally { } finally {
setConvertLoading(false); setLinkLoading(false);
} }
}; };
@@ -1095,26 +1081,28 @@ export function AdminSettings({
)} )}
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
{user.is_oidc {user.is_oidc && user.password_hash
? t("admin.external") ? "Dual Auth"
: t("admin.local")} : user.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<div className="flex gap-2"> <div className="flex gap-2">
{user.is_oidc && ( {user.is_oidc && !user.password_hash && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onClick={() =>
handleConvertOIDCUser({ handleLinkOIDCUser({
id: user.id, id: user.id,
username: user.username, username: user.username,
}) })
} }
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50" className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
title="Convert to password authentication" title="Link to password account"
> >
<Lock className="h-4 w-4" /> <Link2 className="h-4 w-4" />
</Button> </Button>
)} )}
<Button <Button
@@ -1505,78 +1493,87 @@ export function AdminSettings({
</div> </div>
</div> </div>
{/* Convert OIDC to Password Dialog */} {/* Link OIDC to Password Account Dialog */}
<Dialog {linkAccountAlertOpen && (
open={convertUserDialogOpen} <Dialog
onOpenChange={setConvertUserDialogOpen} open={linkAccountAlertOpen}
> onOpenChange={setLinkAccountAlertOpen}
<DialogContent> >
<DialogHeader> <DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogTitle>Convert to Password Authentication</DialogTitle> <DialogHeader>
<DialogDescription> <DialogTitle className="flex items-center gap-2">
Convert {convertTargetUser?.username} from OIDC/SSO authentication <Link2 className="w-5 h-5" />
to password-based authentication. This will allow the user to log Link OIDC Account to Password Account
in with a username and password instead of through an external </DialogTitle>
provider. <DialogDescription className="text-muted-foreground">
</DialogDescription> Link{" "}
</DialogHeader> <span className="font-mono text-foreground">
{linkOidcUser?.username}
</span>{" "}
(OIDC user) to an existing password account. This will enable
dual authentication for the password account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<Alert> <Alert variant="destructive">
<AlertTitle>Important</AlertTitle> <AlertTitle>Warning: OIDC User Data Will Be Deleted</AlertTitle>
<AlertDescription> <AlertDescription>
This action will: This action will:
<ul className="list-disc list-inside mt-2 space-y-1"> <ul className="list-disc list-inside mt-2 space-y-1">
<li>Set a new password for this user</li> <li>Delete the OIDC user account and all their data</li>
<li>Disable OIDC/SSO login for this account</li> <li>
<li>Log out all active sessions</li> Add OIDC login capability to the target password account
<li>Preserve all user data (SSH hosts, credentials, etc.)</li> </li>
</ul> <li>
</AlertDescription> Allow the password account to login with both password and
</Alert> OIDC
</li>
</ul>
</AlertDescription>
</Alert>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="convert-password"> <Label
New Password (min 8 chars) htmlFor="link-target-username"
</Label> className="text-base font-semibold text-foreground"
<PasswordInput >
id="convert-password" Target Password Account Username
value={convertPassword} </Label>
onChange={(e) => setConvertPassword(e.target.value)} <Input
placeholder="Enter new password" id="link-target-username"
disabled={convertLoading} value={linkTargetUsername}
/> onChange={(e) => setLinkTargetUsername(e.target.value)}
placeholder="Enter username of password account"
disabled={linkLoading}
onKeyDown={(e) => {
if (e.key === "Enter" && linkTargetUsername.trim()) {
handleLinkSubmit();
}
}}
/>
</div>
</div> </div>
<div className="space-y-2"> <DialogFooter>
<Label htmlFor="convert-totp"> <Button
TOTP Code (if user has 2FA enabled) variant="outline"
</Label> onClick={() => setLinkAccountAlertOpen(false)}
<Input disabled={linkLoading}
id="convert-totp" >
value={convertTotpCode} {t("common.cancel")}
onChange={(e) => setConvertTotpCode(e.target.value)} </Button>
placeholder="000000" <Button
disabled={convertLoading} onClick={handleLinkSubmit}
maxLength={6} disabled={linkLoading || !linkTargetUsername.trim()}
/> variant="destructive"
</div> >
</div> {linkLoading ? "Linking..." : "Link Accounts"}
</Button>
<DialogFooter> </DialogFooter>
<Button </DialogContent>
variant="outline" </Dialog>
onClick={() => setConvertUserDialogOpen(false)} )}
disabled={convertLoading}
>
Cancel
</Button>
<Button onClick={handleConvertSubmit} disabled={convertLoading}>
{convertLoading ? "Converting..." : "Convert User"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -122,7 +122,10 @@ export function CommandPalette({
if (adminTab) { if (adminTab) {
setCurrentTab(adminTab.id); setCurrentTab(adminTab.id);
} else { } else {
const id = addTab({ type: "admin", title: t("commandPalette.adminSettings") }); const id = addTab({
type: "admin",
title: t("commandPalette.adminSettings"),
});
setCurrentTab(id); setCurrentTab(id);
} }
setIsOpen(false); setIsOpen(false);
@@ -133,7 +136,10 @@ export function CommandPalette({
if (userProfileTab) { if (userProfileTab) {
setCurrentTab(userProfileTab.id); setCurrentTab(userProfileTab.id);
} else { } else {
const id = addTab({ type: "user_profile", title: t("commandPalette.userProfile") }); const id = addTab({
type: "user_profile",
title: t("commandPalette.userProfile"),
});
setCurrentTab(id); setCurrentTab(id);
} }
setIsOpen(false); setIsOpen(false);
@@ -288,83 +294,93 @@ export function CommandPalette({
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading={t("commandPalette.hosts")}> {hosts.length > 0 && (
{hosts.map((host, index) => { <>
const title = host.name?.trim() <CommandGroup heading={t("commandPalette.hosts")}>
? host.name {hosts.map((host, index) => {
: `${host.username}@${host.ip}:${host.port}`; const title = host.name?.trim()
return ( ? host.name
<CommandItem : `${host.username}@${host.ip}:${host.port}`;
key={`host-${index}-${host.id}`} return (
value={`host-${index}-${title}-${host.id}`} <CommandItem
onSelect={() => { key={`host-${index}-${host.id}`}
if (host.enableTerminal) { value={`host-${index}-${title}-${host.id}`}
handleHostTerminalClick(host); onSelect={() => {
} if (host.enableTerminal) {
}} handleHostTerminalClick(host);
className="flex items-center justify-between" }
> }}
<div className="flex items-center gap-2"> className="flex items-center justify-between"
<Server className="h-4 w-4" /> >
<span>{title}</span> <div className="flex items-center gap-2">
</div> <Server className="h-4 w-4" />
<div <span>{title}</span>
className="flex items-center gap-1" </div>
onClick={(e) => e.stopPropagation()} <div
> className="flex items-center gap-1"
<DropdownMenu modal={false}> onClick={(e) => e.stopPropagation()}
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="!px-2 h-7 border-1 border-dark-border"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="right"
className="w-56 bg-dark-bg border-dark-border text-white"
> >
<DropdownMenuItem <DropdownMenu modal={false}>
onClick={(e) => { <DropdownMenuTrigger asChild>
e.stopPropagation(); <Button
handleHostServerDetailsClick(host); variant="outline"
}} className="!px-2 h-7 border-1 border-dark-border"
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" onClick={(e) => e.stopPropagation()}
> >
<Server className="h-4 w-4" /> <EllipsisVertical className="h-3 w-3" />
<span className="flex-1">{t("commandPalette.openServerDetails")}</span> </Button>
</DropdownMenuItem> </DropdownMenuTrigger>
<DropdownMenuItem <DropdownMenuContent
onClick={(e) => { align="end"
e.stopPropagation(); side="right"
handleHostFileManagerClick(host); className="w-56 bg-dark-bg border-dark-border text-white"
}} >
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" <DropdownMenuItem
> onClick={(e) => {
<FolderOpen className="h-4 w-4" /> e.stopPropagation();
<span className="flex-1">{t("commandPalette.openFileManager")}</span> handleHostServerDetailsClick(host);
</DropdownMenuItem> }}
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
onClick={(e) => { >
e.stopPropagation(); <Server className="h-4 w-4" />
handleHostEditClick(host); <span className="flex-1">
}} {t("commandPalette.openServerDetails")}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" </span>
> </DropdownMenuItem>
<Pencil className="h-4 w-4" /> <DropdownMenuItem
<span className="flex-1">{t("commandPalette.edit")}</span> onClick={(e) => {
</DropdownMenuItem> e.stopPropagation();
</DropdownMenuContent> handleHostFileManagerClick(host);
</DropdownMenu> }}
</div> className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
</CommandItem> >
); <FolderOpen className="h-4 w-4" />
})} <span className="flex-1">
</CommandGroup> {t("commandPalette.openFileManager")}
<CommandSeparator /> </span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostEditClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Pencil className="h-4 w-4" />
<span className="flex-1">
{t("commandPalette.edit")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading={t("commandPalette.links")}> <CommandGroup heading={t("commandPalette.links")}>
<CommandItem onSelect={handleGitHub}> <CommandItem onSelect={handleGitHub}>
<Github /> <Github />

View File

@@ -62,7 +62,7 @@ export function Dashboard({
isTopbarOpen, isTopbarOpen,
onSelectView, onSelectView,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 300, rightSidebarWidth = 400,
}: DashboardProps): React.ReactElement { }: DashboardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);

View File

@@ -19,7 +19,7 @@ export function HostManager({
initialTab = "host_viewer", initialTab = "host_viewer",
hostConfig, hostConfig,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 300, rightSidebarWidth = 400,
}: HostManagerProps): React.ReactElement { }: HostManagerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(initialTab); const [activeTab, setActiveTab] = useState(initialTab);

View File

@@ -110,12 +110,12 @@ function JumpHostItem({
{index + 1}. {index + 1}.
</span> </span>
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild className="flex-1">
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="flex-1 justify-between" className="w-full justify-between"
> >
{selectedHost {selectedHost
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}` ? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}`
@@ -123,7 +123,10 @@ function JumpHostItem({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[400px] p-0"> <PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command> <Command>
<CommandInput placeholder={t("hosts.searchServers")} /> <CommandInput placeholder={t("hosts.searchServers")} />
<CommandEmpty>{t("hosts.noServerFound")}</CommandEmpty> <CommandEmpty>{t("hosts.noServerFound")}</CommandEmpty>
@@ -133,7 +136,7 @@ function JumpHostItem({
.map((host) => ( .map((host) => (
<CommandItem <CommandItem
key={host.id} key={host.id}
value={`${host.name} ${host.ip} ${host.username}`} value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
onSelect={() => { onSelect={() => {
onUpdate(host.id); onUpdate(host.id);
setOpen(false); setOpen(false);
@@ -162,7 +165,112 @@ function JumpHostItem({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
<Button type="button" variant="ghost" size="icon" onClick={onRemove}> <Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="ml-2"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}
interface QuickActionItemProps {
quickAction: { name: string; snippetId: number };
index: number;
snippets: Array<{ id: number; name: string; content: string }>;
onUpdate: (name: string, snippetId: number) => void;
onRemove: () => void;
t: (key: string) => string;
}
function QuickActionItem({
quickAction,
index,
snippets,
onUpdate,
onRemove,
t,
}: QuickActionItemProps) {
const [open, setOpen] = React.useState(false);
const selectedSnippet = snippets.find((s) => s.id === quickAction.snippetId);
return (
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex flex-col gap-2 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">
{index + 1}.
</span>
<Input
placeholder={t("hosts.quickActionName")}
value={quickAction.name}
onChange={(e) => onUpdate(e.target.value, quickAction.snippetId)}
className="flex-1"
/>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild className="w-full">
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedSnippet
? selectedSnippet.name
: t("hosts.selectSnippet")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("hosts.searchSnippets")} />
<CommandEmpty>{t("hosts.noSnippetFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{snippets.map((snippet) => (
<CommandItem
key={snippet.id}
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
onSelect={() => {
onUpdate(quickAction.name, snippet.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
quickAction.snippetId === snippet.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{snippet.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
{snippet.content}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="ml-2"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -198,6 +306,10 @@ interface SSHHost {
jumpHosts?: Array<{ jumpHosts?: Array<{
hostId: number; hostId: number;
}>; }>;
quickActions?: Array<{
name: string;
snippetId: number;
}>;
statsConfig?: StatsConfig; statsConfig?: StatsConfig;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
createdAt: string; createdAt: string;
@@ -440,6 +552,14 @@ export function HostManagerEditor({
}), }),
) )
.default([]), .default([]),
quickActions: z
.array(
z.object({
name: z.string().min(1),
snippetId: z.number().min(1),
}),
)
.default([]),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.authType === "none") { if (data.authType === "none") {
@@ -528,6 +648,7 @@ export function HostManagerEditor({
defaultPath: "/", defaultPath: "/",
tunnelConnections: [], tunnelConnections: [],
jumpHosts: [], jumpHosts: [],
quickActions: [],
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false, forceKeyboardInteractive: false,
@@ -612,6 +733,9 @@ export function HostManagerEditor({
jumpHosts: Array.isArray(cleanedHost.jumpHosts) jumpHosts: Array.isArray(cleanedHost.jumpHosts)
? cleanedHost.jumpHosts ? cleanedHost.jumpHosts
: [], : [],
quickActions: Array.isArray(cleanedHost.quickActions)
? cleanedHost.quickActions
: [],
statsConfig: parsedStatsConfig, statsConfig: parsedStatsConfig,
terminalConfig: { terminalConfig: {
...DEFAULT_TERMINAL_CONFIG, ...DEFAULT_TERMINAL_CONFIG,
@@ -670,6 +794,7 @@ export function HostManagerEditor({
defaultPath: "/", defaultPath: "/",
tunnelConnections: [], tunnelConnections: [],
jumpHosts: [], jumpHosts: [],
quickActions: [],
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false, forceKeyboardInteractive: false,
@@ -730,6 +855,7 @@ export function HostManagerEditor({
defaultPath: data.defaultPath || "/", defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || [], tunnelConnections: data.tunnelConnections || [],
jumpHosts: data.jumpHosts || [], jumpHosts: data.jumpHosts || [],
quickActions: data.quickActions || [],
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),
@@ -2983,6 +3109,70 @@ export function HostManagerEditor({
/> />
</> </>
)} )}
<div className="space-y-4">
<h3 className="text-lg font-semibold">
{t("hosts.quickActions")}
</h3>
<Alert>
<AlertDescription>
{t("hosts.quickActionsDescription")}
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="quickActions"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.quickActionsList")}</FormLabel>
<FormControl>
<div className="space-y-3">
{field.value.map((quickAction, index) => (
<QuickActionItem
key={index}
quickAction={quickAction}
index={index}
snippets={snippets}
onUpdate={(name, snippetId) => {
const newQuickActions = [...field.value];
newQuickActions[index] = {
name,
snippetId,
};
field.onChange(newQuickActions);
}}
onRemove={() => {
const newQuickActions = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newQuickActions);
}}
t={t}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange([
...field.value,
{ name: "", snippetId: 0 },
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addQuickAction")}
</Button>
</div>
</FormControl>
<FormDescription>
{t("hosts.quickActionsOrder")}
</FormDescription>
</FormItem>
)}
/>
</div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@@ -1240,6 +1240,9 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{host.username} {host.username}
</p> </p>
<p className="text-xs text-muted-foreground truncate">
ID: {host.id}
</p>
</div> </div>
<div className="flex gap-1 flex-shrink-0 ml-1"> <div className="flex gap-1 flex-shrink-0 ml-1">
{host.folder && host.folder !== "" && ( {host.folder && host.folder !== "" && (

View File

@@ -7,6 +7,7 @@ import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import { import {
getServerStatusById, getServerStatusById,
getServerMetricsById, getServerMetricsById,
executeSnippet,
type ServerMetrics, type ServerMetrics,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
@@ -29,6 +30,11 @@ import {
} from "./widgets"; } from "./widgets";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface QuickAction {
name: string;
snippetId: number;
}
interface HostConfig { interface HostConfig {
id: number; id: number;
name: string; name: string;
@@ -37,6 +43,7 @@ interface HostConfig {
folder?: string; folder?: string;
enableFileManager?: boolean; enableFileManager?: boolean;
tunnelConnections?: unknown[]; tunnelConnections?: unknown[];
quickActions?: QuickAction[];
statsConfig?: string | StatsConfig; statsConfig?: string | StatsConfig;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -81,6 +88,9 @@ export function Server({
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false); const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true); const [showStatsUI, setShowStatsUI] = React.useState(true);
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
);
const statsConfig = React.useMemo((): StatsConfig => { const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) { if (!currentHostConfig?.statsConfig) {
@@ -450,38 +460,147 @@ export function Server({
<Separator className="p-0.25 w-full" /> <Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
{metricsEnabled && showStatsUI && ( {(metricsEnabled && showStatsUI) ||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto relative"> (currentHostConfig?.quickActions &&
{!metrics && serverStatus === "offline" ? ( currentHostConfig.quickActions.length > 0) ? (
<div className="flex items-center justify-center py-8"> <div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col">
<div className="text-center"> {currentHostConfig?.quickActions &&
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center"> currentHostConfig.quickActions.length > 0 && (
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div> <div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
</div> <h3 className="text-sm font-semibold text-gray-400 mb-2">
<p className="text-gray-300 mb-1"> {t("serverStats.quickActions")}
{t("serverStats.serverOffline")} </h3>
</p> <div className="flex flex-wrap gap-2">
<p className="text-sm text-gray-500"> {currentHostConfig.quickActions.map((action, index) => {
{t("serverStats.cannotFetchMetrics")} const isExecuting = executingActions.has(
</p> action.snippetId,
</div> );
</div> return (
) : ( <Button
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> key={index}
{enabledWidgets.map((widgetType) => ( variant="outline"
<div key={widgetType} className="h-[280px]"> size="sm"
{renderWidget(widgetType)} className="font-semibold"
</div> disabled={isExecuting}
))} onClick={async () => {
</div> if (!currentHostConfig) return;
)}
<SimpleLoader setExecutingActions((prev) =>
visible={isLoadingMetrics && !metrics} new Set(prev).add(action.snippetId),
message={t("serverStats.loadingMetrics")} );
/> toast.loading(
t("serverStats.executingQuickAction", {
name: action.name,
}),
{ id: `quick-action-${action.snippetId}` },
);
try {
const result = await executeSnippet(
action.snippetId,
currentHostConfig.id,
);
if (result.success) {
toast.success(
t("serverStats.quickActionSuccess", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description: result.output
? result.output.substring(0, 200)
: undefined,
duration: 5000,
},
);
} else {
toast.error(
t("serverStats.quickActionFailed", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
result.error ||
result.output ||
undefined,
duration: 5000,
},
);
}
} catch (error: any) {
toast.error(
t("serverStats.quickActionError", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
error?.message || "Unknown error",
duration: 5000,
},
);
} finally {
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(action.snippetId);
return next;
});
}
}}
title={t("serverStats.executeQuickAction", {
name: action.name,
})}
>
{isExecuting ? (
<div className="flex items-center gap-2">
<div className="w-3 h-3 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
{action.name}
</div>
) : (
action.name
)}
</Button>
);
})}
</div>
</div>
)}
{metricsEnabled &&
showStatsUI &&
(!metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{enabledWidgets.map((widgetType) => (
<div key={widgetType} className="h-[280px]">
{renderWidget(widgetType)}
</div>
))}
</div>
))}
{metricsEnabled && showStatsUI && (
<SimpleLoader
visible={isLoadingMetrics && !metrics}
message={t("serverStats.loadingMetrics")}
/>
)}
</div> </div>
)} ) : null}
{currentHostConfig?.tunnelConnections && {currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && ( currentHostConfig.tunnelConnections.length > 0 && (

View File

@@ -221,7 +221,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
// Load command history for autocomplete on mount (Stage 3) // Load command history for autocomplete on mount (Stage 3)
useEffect(() => { useEffect(() => {
if (hostConfig.id) { // Check if command autocomplete is enabled
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") !== "false";
if (hostConfig.id && autocompleteEnabled) {
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) => {
@@ -231,6 +235,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
console.error("Failed to load autocomplete history:", error); console.error("Failed to load autocomplete history:", error);
autocompleteHistory.current = []; autocompleteHistory.current = [];
}); });
} else {
autocompleteHistory.current = [];
} }
}, [hostConfig.id]); }, [hostConfig.id]);
@@ -1318,6 +1324,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Check if command autocomplete is enabled in settings
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") !== "false";
if (!autocompleteEnabled) {
// If disabled, let the terminal handle Tab normally (send to server)
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: "\t" }),
);
}
return false;
}
const currentCmd = getCurrentCommandRef.current().trim(); const currentCmd = getCurrentCommandRef.current().trim();
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) { if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
// Filter commands that start with current input // Filter commands that start with current input
@@ -1328,7 +1348,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
cmd !== currentCmd && cmd !== currentCmd &&
cmd.length > currentCmd.length, cmd.length > currentCmd.length,
) )
.slice(0, 10); // Show up to 10 matches .slice(0, 5); // Show up to 5 matches for better UX
if (matches.length === 1) { if (matches.length === 1) {
// Only one match - auto-complete directly // Only one match - auto-complete directly
@@ -1359,21 +1379,31 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const cellWidth = const cellWidth =
terminal.cols > 0 ? rect.width / terminal.cols : 10; terminal.cols > 0 ? rect.width / terminal.cols : 10;
// Estimate autocomplete menu height (max-h-[240px] from component) // Calculate actual menu height based on number of items
const menuHeight = 240; // Each item is ~32px (py-1.5), footer is ~32px, max total 240px
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; const itemHeight = 32;
const spaceBelow = window.innerHeight - cursorBottomY; const footerHeight = 32;
const spaceAbove = rect.top + cursorY * cellHeight; const maxMenuHeight = 240;
const estimatedMenuHeight = Math.min(
matches.length * itemHeight + footerHeight,
maxMenuHeight,
);
// Show above cursor if not enough space below // Get cursor position in viewport coordinates
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
const cursorTopY = rect.top + cursorY * cellHeight;
const spaceBelow = window.innerHeight - cursorBottomY;
const spaceAbove = cursorTopY;
// Show above cursor if not enough space below and more space above
const showAbove = const showAbove =
spaceBelow < menuHeight && spaceAbove > spaceBelow; spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
setAutocompletePosition({ setAutocompletePosition({
top: showAbove top: showAbove
? rect.top + cursorY * cellHeight - menuHeight ? Math.max(0, cursorTopY - estimatedMenuHeight)
: cursorBottomY, : cursorBottomY,
left: rect.left + cursorX * cellWidth, left: Math.max(0, rect.left + cursorX * cellWidth),
}); });
} }

View File

@@ -33,33 +33,44 @@ export function CommandAutocomplete({
return null; return null;
} }
// Calculate max height for suggestions list to ensure footer is always visible
// Footer height is approximately 32px (text + padding + border)
const footerHeight = 32;
const maxSuggestionsHeight = 240 - footerHeight;
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg max-h-[240px] overflow-y-auto min-w-[200px] max-w-[600px]" className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
style={{ style={{
top: `${position.top}px`, top: `${position.top}px`,
left: `${position.left}px`, left: `${position.left}px`,
maxHeight: "240px",
}} }}
> >
{suggestions.map((suggestion, index) => ( <div
<div className="overflow-y-auto"
key={index} style={{ maxHeight: `${maxSuggestionsHeight}px` }}
ref={index === selectedIndex ? selectedRef : null} >
className={cn( {suggestions.map((suggestion, index) => (
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors", <div
"hover:bg-dark-hover", key={index}
index === selectedIndex && "bg-blue-500/20 text-blue-400", ref={index === selectedIndex ? selectedRef : null}
)} className={cn(
onClick={() => onSelect(suggestion)} "px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
onMouseEnter={() => { "hover:bg-dark-hover",
// Optional: update selected index on hover index === selectedIndex && "bg-gray-500/20 text-gray-400",
}} )}
> onClick={() => onSelect(suggestion)}
{suggestion} onMouseEnter={() => {
</div> // Optional: update selected index on hover
))} }}
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50"> >
{suggestion}
</div>
))}
</div>
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50 shrink-0">
Tab/Enter to complete to navigate Esc to close Tab/Enter to complete to navigate Esc to close
</div> </div>
</div> </div>

View File

@@ -34,6 +34,8 @@ import {
Search, Search,
Loader2, Loader2,
Terminal, Terminal,
LayoutGrid,
MonitorCheck,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -67,7 +69,7 @@ interface TabData {
[key: string]: unknown; [key: string]: unknown;
} }
interface SSHUtilitySidebarProps { interface SSHToolsSidebarProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSnippetExecute: (content: string) => void; onSnippetExecute: (content: string) => void;
@@ -85,12 +87,21 @@ export function SSHToolsSidebar({
setSidebarWidth, setSidebarWidth,
initialTab, initialTab,
onTabChange, onTabChange,
}: SSHUtilitySidebarProps) { }: SSHToolsSidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const { tabs, currentTab } = useTabs() as { const {
tabs,
currentTab,
allSplitScreenTab,
setSplitScreenTab,
setCurrentTab,
} = useTabs() as {
tabs: TabData[]; tabs: TabData[];
currentTab: number | null; currentTab: number | null;
allSplitScreenTab: number[];
setSplitScreenTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void;
}; };
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools"); const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
@@ -141,6 +152,17 @@ export function SSHToolsSidebar({
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0); const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null); const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
// Split Screen state
const [splitMode, setSplitMode] = useState<"none" | "2" | "3" | "4">("none");
const [splitAssignments, setSplitAssignments] = useState<Map<number, number>>(
new Map(),
);
const [previewKey, setPreviewKey] = useState(0);
const [draggedTabId, setDraggedTabId] = useState<number | null>(null);
const [dragOverCellIndex, setDragOverCellIndex] = useState<number | null>(
null,
);
// 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);
@@ -152,6 +174,15 @@ export function SSHToolsSidebar({
activeUiTab?.type === "terminal" ? activeUiTab : undefined; activeUiTab?.type === "terminal" ? activeUiTab : undefined;
const activeTerminalHostId = activeTerminal?.hostConfig?.id; const activeTerminalHostId = activeTerminal?.hostConfig?.id;
// Get splittable tabs (terminal, server, file_manager)
const splittableTabs = tabs.filter(
(tab: TabData) =>
tab.type === "terminal" ||
tab.type === "server" ||
tab.type === "file_manager" ||
tab.type === "user_profile",
);
// Fetch command history // Fetch command history
useEffect(() => { useEffect(() => {
if (isOpen && activeTab === "command-history") { if (isOpen && activeTab === "command-history") {
@@ -567,6 +598,148 @@ export function SSHToolsSidebar({
toast.success(t("snippets.copySuccess", { name: snippet.name })); toast.success(t("snippets.copySuccess", { name: snippet.name }));
}; };
// Split Screen handlers
const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => {
setSplitMode(mode);
if (mode === "none") {
// Clear all splits
handleClearSplit();
} else {
// Clear assignments when changing modes
setSplitAssignments(new Map());
setPreviewKey((prev) => prev + 1);
}
};
const handleDragStart = (tabId: number) => {
setDraggedTabId(tabId);
};
const handleDragEnd = () => {
setDraggedTabId(null);
setDragOverCellIndex(null);
};
const handleDragOver = (e: React.DragEvent, cellIndex: number) => {
e.preventDefault();
setDragOverCellIndex(cellIndex);
};
const handleDragLeave = () => {
setDragOverCellIndex(null);
};
const handleDrop = (cellIndex: number) => {
if (draggedTabId === null) return;
setSplitAssignments((prev) => {
const newMap = new Map(prev);
// Remove this tab from any other cell
Array.from(newMap.entries()).forEach(([idx, id]) => {
if (id === draggedTabId && idx !== cellIndex) {
newMap.delete(idx);
}
});
newMap.set(cellIndex, draggedTabId);
return newMap;
});
setDraggedTabId(null);
setDragOverCellIndex(null);
setPreviewKey((prev) => prev + 1);
};
const handleRemoveFromCell = (cellIndex: number) => {
setSplitAssignments((prev) => {
const newMap = new Map(prev);
newMap.delete(cellIndex);
setPreviewKey((prev) => prev + 1);
return newMap;
});
};
const handleApplySplit = () => {
if (splitMode === "none") {
handleClearSplit();
return;
}
if (splitAssignments.size === 0) {
toast.error(
t("splitScreen.error.noAssignments", {
defaultValue: "Please drag tabs to cells before applying",
}),
);
return;
}
const requiredSlots = parseInt(splitMode);
// Validate: All layout spots must be filled
if (splitAssignments.size < requiredSlots) {
toast.error(
t("splitScreen.error.fillAllSlots", {
defaultValue: `Please fill all ${requiredSlots} layout spots before applying`,
count: requiredSlots,
}),
);
return;
}
// Build ordered array of tab IDs based on cell index (0, 1, 2, 3)
const orderedTabIds: number[] = [];
for (let i = 0; i < requiredSlots; i++) {
const tabId = splitAssignments.get(i);
if (tabId !== undefined) {
orderedTabIds.push(tabId);
}
}
// First, clear ALL existing splits
const currentSplits = [...allSplitScreenTab];
currentSplits.forEach((tabId) => {
setSplitScreenTab(tabId); // Toggle off
});
// Then, add only the newly assigned tabs to split IN ORDER
orderedTabIds.forEach((tabId) => {
setSplitScreenTab(tabId); // Toggle on
});
// Set first assigned tab as active if current tab is not in split
if (!orderedTabIds.includes(currentTab ?? 0)) {
setCurrentTab(orderedTabIds[0]);
}
toast.success(
t("splitScreen.success", {
defaultValue: "Split screen applied",
}),
);
};
const handleClearSplit = () => {
// Remove all tabs from split screen
allSplitScreenTab.forEach((tabId) => {
setSplitScreenTab(tabId);
});
setSplitMode("none");
setSplitAssignments(new Map());
setPreviewKey((prev) => prev + 1);
toast.success(
t("splitScreen.cleared", {
defaultValue: "Split screen cleared",
}),
);
};
const handleResetToSingle = () => {
handleClearSplit();
};
// Command History handlers // Command History handlers
const handleCommandSelect = (command: string) => { const handleCommandSelect = (command: string) => {
if (activeTerminal?.terminalRef?.current?.sendInput) { if (activeTerminal?.terminalRef?.current?.sendInput) {
@@ -616,7 +789,7 @@ export function SSHToolsSidebar({
<div className="absolute right-5 flex gap-1"> <div className="absolute right-5 flex gap-1">
<Button <Button
variant="outline" variant="outline"
onClick={() => setSidebarWidth(300)} onClick={() => setSidebarWidth(400)}
className="w-[28px] h-[28px]" className="w-[28px] h-[28px]"
title="Reset sidebar width" title="Reset sidebar width"
> >
@@ -640,7 +813,7 @@ export function SSHToolsSidebar({
onValueChange={handleTabChange} onValueChange={handleTabChange}
className="flex flex-col h-full overflow-hidden" className="flex flex-col h-full overflow-hidden"
> >
<TabsList className="w-full grid grid-cols-3 mb-4 flex-shrink-0"> <TabsList className="w-full grid grid-cols-4 mb-4 flex-shrink-0">
<TabsTrigger value="ssh-tools"> <TabsTrigger value="ssh-tools">
{t("sshTools.title")} {t("sshTools.title")}
</TabsTrigger> </TabsTrigger>
@@ -650,6 +823,9 @@ export function SSHToolsSidebar({
<TabsTrigger value="command-history"> <TabsTrigger value="command-history">
{t("commandHistory.title", { defaultValue: "History" })} {t("commandHistory.title", { defaultValue: "History" })}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="split-screen">
{t("splitScreen.title", { defaultValue: "Split Screen" })}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="ssh-tools" className="space-y-4"> <TabsContent value="ssh-tools" className="space-y-4">
@@ -755,7 +931,7 @@ export function SSHToolsSidebar({
href="https://github.com/Termix-SSH/Termix/issues/new" href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 hover:underline" className="gray-500 hover:underline"
> >
GitHub GitHub
</a> </a>
@@ -832,7 +1008,7 @@ export function SSHToolsSidebar({
{snippets.map((snippet) => ( {snippets.map((snippet) => (
<div <div
key={snippet.id} key={snippet.id}
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group" className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-gray-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
> >
<div className="mb-2"> <div className="mb-2">
<h3 className="text-sm font-medium text-white mb-1"> <h3 className="text-sm font-medium text-white mb-1">
@@ -843,6 +1019,9 @@ export function SSHToolsSidebar({
{snippet.description} {snippet.description}
</p> </p>
)} )}
<p className="text-xs text-muted-foreground mt-1">
ID: {snippet.id}
</p>
</div> </div>
<div className="bg-muted/30 rounded p-2 mb-3"> <div className="bg-muted/30 rounded p-2 mb-3">
@@ -950,6 +1129,12 @@ export function SSHToolsSidebar({
</Button> </Button>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
{t("commandHistory.tabHint", {
defaultValue:
"Use Tab in Terminal to autocomplete from command history",
})}
</p>
</div> </div>
<div className="flex-1 overflow-hidden min-h-0"> <div className="flex-1 overflow-hidden min-h-0">
@@ -1040,6 +1225,219 @@ export function SSHToolsSidebar({
)} )}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent
value="split-screen"
className="flex flex-col flex-1 overflow-hidden"
>
<div className="space-y-4 flex-1 overflow-y-auto overflow-x-hidden pb-4">
{/* Split Mode Tabs */}
<Tabs
value={splitMode}
onValueChange={(value) =>
handleSplitModeChange(
value as "none" | "2" | "3" | "4",
)
}
className="w-full"
>
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger value="none">
{t("splitScreen.none", { defaultValue: "None" })}
</TabsTrigger>
<TabsTrigger value="2">
{t("splitScreen.twoSplit", {
defaultValue: "2-Split",
})}
</TabsTrigger>
<TabsTrigger value="3">
{t("splitScreen.threeSplit", {
defaultValue: "3-Split",
})}
</TabsTrigger>
<TabsTrigger value="4">
{t("splitScreen.fourSplit", {
defaultValue: "4-Split",
})}
</TabsTrigger>
</TabsList>
</Tabs>
{/* Drag-and-Drop Interface */}
{splitMode !== "none" && (
<>
<Separator />
{/* Available Tabs List */}
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("splitScreen.availableTabs", {
defaultValue: "Available Tabs",
})}
</label>
<p className="text-xs text-muted-foreground mb-2">
{t("splitScreen.dragTabsHint", {
defaultValue:
"Drag tabs into the grid below to position them",
})}
</p>
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{splittableTabs.map((tab) => {
const isAssigned = Array.from(
splitAssignments.values(),
).includes(tab.id);
const isDragging = draggedTabId === tab.id;
return (
<div
key={tab.id}
draggable={!isAssigned}
onDragStart={() => handleDragStart(tab.id)}
onDragEnd={handleDragEnd}
className={`
px-3 py-2 rounded-md text-sm cursor-move transition-all
${
isAssigned
? "bg-dark-bg/50 text-muted-foreground cursor-not-allowed opacity-50"
: "bg-dark-bg border border-dark-border hover:border-gray-400 hover:bg-dark-bg-input"
}
${isDragging ? "opacity-50" : ""}
`}
>
<span className="truncate">
{tab.title}
</span>
</div>
);
})}
</div>
</div>
<Separator />
{/* Drop Grid */}
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("splitScreen.layout", {
defaultValue: "Layout",
})}
</label>
<div
className={`grid gap-2 ${
splitMode === "2"
? "grid-cols-2"
: splitMode === "3"
? "grid-cols-2 grid-rows-2"
: "grid-cols-2 grid-rows-2"
}`}
>
{Array.from(
{ length: parseInt(splitMode) },
(_, idx) => {
const assignedTabId =
splitAssignments.get(idx);
const assignedTab = assignedTabId
? tabs.find((t) => t.id === assignedTabId)
: null;
const isHovered = dragOverCellIndex === idx;
const isEmpty = !assignedTabId;
return (
<div
key={idx}
onDragOver={(e) => handleDragOver(e, idx)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(idx)}
className={`
relative bg-dark-bg border-2 rounded-md p-3 min-h-[100px]
flex flex-col items-center justify-center transition-all
${splitMode === "3" && idx === 2 ? "col-span-2" : ""}
${
isEmpty
? "border-dashed border-dark-border"
: "border-solid border-gray-400 bg-gray-500/10"
}
${
isHovered && draggedTabId
? "border-gray-500 bg-gray-500/20 ring-2 ring-gray-500/50"
: ""
}
`}
>
{assignedTab ? (
<>
<span className="text-sm text-white truncate w-full text-center mb-2">
{assignedTab.title}
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRemoveFromCell(idx)
}
className="h-6 text-xs hover:bg-red-500/20"
>
Remove
</Button>
</>
) : (
<span className="text-xs text-muted-foreground">
{t("splitScreen.dropHere", {
defaultValue: "Drop tab here",
})}
</span>
)}
</div>
);
},
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button
onClick={handleApplySplit}
className="flex-1"
disabled={splitAssignments.size === 0}
>
{t("splitScreen.apply", {
defaultValue: "Apply Split",
})}
</Button>
<Button
variant="outline"
onClick={handleClearSplit}
className="flex-1"
>
{t("splitScreen.clear", {
defaultValue: "Clear",
})}
</Button>
</div>
</>
)}
{/* Help Text for None mode */}
{splitMode === "none" && (
<div className="text-center py-8">
<LayoutGrid className="h-12 w-12 mb-4 opacity-20 mx-auto" />
<p className="text-sm text-muted-foreground mb-2">
{t("splitScreen.selectMode", {
defaultValue:
"Select a split mode to get started",
})}
</p>
<p className="text-xs text-muted-foreground">
{t("splitScreen.helpText", {
defaultValue:
"Choose how many tabs you want to display at once",
})}
</p>
</div>
)}
</div>
</TabsContent>
</Tabs> </Tabs>
</SidebarContent> </SidebarContent>
{isOpen && ( {isOpen && (

View File

@@ -555,7 +555,11 @@ export function Auth({
const error = urlParams.get("error"); const error = urlParams.get("error");
if (error) { if (error) {
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`); if (error === "registration_disabled") {
toast.error(t("messages.registrationDisabled"));
} else {
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
}
setOidcLoading(false); setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
return; return;

View File

@@ -42,7 +42,7 @@ interface TerminalViewProps {
export function AppView({ export function AppView({
isTopbarOpen = true, isTopbarOpen = true,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 300, rightSidebarWidth = 400,
}: TerminalViewProps): React.ReactElement { }: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as { const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as {
tabs: TabData[]; tabs: TabData[];
@@ -204,16 +204,12 @@ export function AppView({
const renderTerminalsLayer = () => { const renderTerminalsLayer = () => {
const styles: Record<number, React.CSSProperties> = {}; const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: TabData) => // Use allSplitScreenTab order directly - it maintains the order tabs were added
allSplitScreenTab.includes(tab.id), const layoutTabs = allSplitScreenTab
); .map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
.filter((t): t is TabData => t !== null && t !== undefined);
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab); const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
const layoutTabs = [
mainTab,
...splitTabs.filter(
(t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id),
),
].filter((t): t is TabData => t !== null && t !== undefined);
if (allSplitScreenTab.length === 0 && mainTab) { if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === "file_manager"; const isFileManagerTab = mainTab.type === "file_manager";
@@ -358,16 +354,10 @@ export function AppView({
}; };
const renderSplitOverlays = () => { const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: TabData) => // Use allSplitScreenTab order directly - it maintains the order tabs were added
allSplitScreenTab.includes(tab.id), const layoutTabs = allSplitScreenTab
); .map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab); .filter((t): t is TabData => t !== null && t !== undefined);
const layoutTabs = [
mainTab,
...splitTabs.filter(
(t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id),
),
].filter((t): t is TabData => t !== null && t !== undefined);
if (allSplitScreenTab.length === 0) return null; if (allSplitScreenTab.length === 0) return null;
const handleStyle = { const handleStyle = {

View File

@@ -60,9 +60,10 @@ export function TopNavbar({
const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false); const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false); const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
const [splitScreenTabActive, setSplitScreenTabActive] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => { const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("rightSidebarWidth"); const saved = localStorage.getItem("rightSidebarWidth");
const defaultWidth = 350; const defaultWidth = 400;
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth; const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2)); const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.floor(window.innerWidth * 0.3); const maxWidth = Math.floor(window.innerWidth * 0.3);
@@ -132,7 +133,11 @@ export function TopNavbar({
}; };
const handleTabSplit = (tabId: number) => { const handleTabSplit = (tabId: number) => {
setSplitScreenTab(tabId); // Open the sidebar to the split-screen tab
setToolsSidebarOpen(true);
setCommandHistoryTabActive(false);
setSplitScreenTabActive(true);
// Optional: could pass tabId to pre-select this tab in the sidebar
}; };
const handleTabClose = (tabId: number) => { const handleTabClose = (tabId: number) => {
@@ -371,17 +376,7 @@ export function TopNavbar({
const isAdmin = tab.type === "admin"; const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile"; const isUserProfile = tab.type === "user_profile";
const isSplittable = isTerminal || isServer || isFileManager; const isSplittable = isTerminal || isServer || isFileManager;
const isSplitButtonDisabled = const disableSplit = !isSplittable;
(isActive && !isSplitScreenActive) ||
((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit =
!isSplittable ||
isSplitButtonDisabled ||
isActive ||
currentTabIsHome ||
currentTabIsSshManager ||
currentTabIsAdmin ||
currentTabIsUserProfile;
const disableActivate = const disableActivate =
isSplit || isSplit ||
((tab.type === "home" || ((tab.type === "home" ||
@@ -390,7 +385,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 = (isSplitScreenActive && isActive) || isHome; const disableClose = isHome;
const isDraggingThisTab = dragState.draggedIndex === index; const isDraggingThisTab = dragState.draggedIndex === index;
const isTheDraggedTab = tab.id === dragState.draggedId; const isTheDraggedTab = tab.id === dragState.draggedId;
@@ -566,12 +561,17 @@ export function TopNavbar({
onSnippetExecute={handleSnippetExecute} onSnippetExecute={handleSnippetExecute}
sidebarWidth={rightSidebarWidth} sidebarWidth={rightSidebarWidth}
setSidebarWidth={setRightSidebarWidth} setSidebarWidth={setRightSidebarWidth}
commandHistory={commandHistory.commandHistory} initialTab={
onSelectCommand={commandHistory.onSelectCommand} commandHistoryTabActive
onDeleteCommand={commandHistory.onDeleteCommand} ? "command-history"
isHistoryLoading={commandHistory.isLoading} : splitScreenTabActive
initialTab={commandHistoryTabActive ? "command-history" : undefined} ? "split-screen"
onTabChange={() => setCommandHistoryTabActive(false)} : undefined
}
onTabChange={() => {
setCommandHistoryTabActive(false);
setSplitScreenTabActive(false);
}}
/> />
</div> </div>
); );

View File

@@ -143,7 +143,7 @@ export function Tab({
onClick={!disableActivate ? onActivate : undefined} onClick={!disableActivate ? onActivate : undefined}
style={{ style={{
marginBottom: "-2px", marginBottom: "-2px",
borderBottom: isActive ? "2px solid white" : "none", borderBottom: isActive || isSplit ? "2px solid white" : "none",
}} }}
> >
<div className="flex items-center gap-1.5 flex-1 min-w-0"> <div className="flex items-center gap-1.5 flex-1 min-w-0">
@@ -175,7 +175,10 @@ export function Tab({
} }
> >
<SeparatorVertical <SeparatorVertical
className={cn("h-4 w-4", isSplit && "text-white")} className={cn(
"h-4 w-4",
isSplit ? "text-white" : "text-muted-foreground",
)}
/> />
</Button> </Button>
)} )}

View File

@@ -156,11 +156,32 @@ export function TabProvider({ children }: TabProviderProps) {
} }
setTabs((prev) => prev.filter((tab) => tab.id !== tabId)); setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
// Remove from split screen
setAllSplitScreenTab((prev) => {
const newSplits = prev.filter((id) => id !== tabId);
// Auto-clear split mode if only 1 or fewer tabs remain in split
if (newSplits.length <= 1) {
return [];
}
return newSplits;
});
if (currentTab === tabId) { if (currentTab === tabId) {
const remainingTabs = tabs.filter((tab) => tab.id !== tabId); const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1); if (remainingTabs.length > 0) {
// Try to set current tab to another split tab first, if any remain
const remainingSplitTabs = allSplitScreenTab.filter(
(id) => id !== tabId,
);
if (remainingSplitTabs.length > 0) {
setCurrentTab(remainingSplitTabs[0]);
} else {
setCurrentTab(remainingTabs[0].id);
}
} else {
setCurrentTab(1); // Home tab
}
} }
}; };
@@ -168,7 +189,7 @@ export function TabProvider({ children }: TabProviderProps) {
setAllSplitScreenTab((prev) => { setAllSplitScreenTab((prev) => {
if (prev.includes(tabId)) { if (prev.includes(tabId)) {
return prev.filter((id) => id !== tabId); return prev.filter((id) => id !== tabId);
} else if (prev.length < 3) { } else if (prev.length < 4) {
return [...prev, tabId]; return [...prev, tabId];
} }
return prev; return prev;

View File

@@ -74,7 +74,7 @@ async function handleLogout() {
export function UserProfile({ export function UserProfile({
isTopbarOpen = true, isTopbarOpen = true,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 300, rightSidebarWidth = 400,
}: UserProfileProps) { }: UserProfileProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
@@ -97,6 +97,9 @@ export function UserProfile({
const [fileColorCoding, setFileColorCoding] = useState<boolean>( const [fileColorCoding, setFileColorCoding] = useState<boolean>(
localStorage.getItem("fileColorCoding") !== "false", localStorage.getItem("fileColorCoding") !== "false",
); );
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
localStorage.getItem("commandAutocomplete") !== "false",
);
useEffect(() => { useEffect(() => {
fetchUserInfo(); fetchUserInfo();
@@ -145,6 +148,11 @@ export function UserProfile({
window.dispatchEvent(new Event("fileColorCodingChanged")); window.dispatchEvent(new Event("fileColorCodingChanged"));
}; };
const handleCommandAutocompleteToggle = (enabled: boolean) => {
setCommandAutocomplete(enabled);
localStorage.setItem("commandAutocomplete", enabled.toString());
};
const handleDeleteAccount = async (e: React.FormEvent) => { const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setDeleteLoading(true); setDeleteLoading(true);
@@ -363,6 +371,23 @@ export function UserProfile({
</div> </div>
</div> </div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.commandAutocomplete")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.commandAutocompleteDesc")}
</p>
</div>
<Switch
checked={commandAutocomplete}
onCheckedChange={handleCommandAutocompleteToggle}
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border"> <div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>

View File

@@ -77,6 +77,7 @@ interface UserInfo {
is_admin: boolean; is_admin: boolean;
is_oidc: boolean; is_oidc: boolean;
data_unlocked: boolean; data_unlocked: boolean;
password_hash?: string;
} }
interface UserCount { interface UserCount {
@@ -396,10 +397,12 @@ function createApiInstance(
errorMessage === "Authentication required"; errorMessage === "Authentication required";
if (isSessionExpired || isSessionNotFound) { if (isSessionExpired || isSessionNotFound) {
// Clear token from localStorage
localStorage.removeItem("jwt");
// Clear Electron settings cache
if (isElectron()) { if (isElectron()) {
localStorage.removeItem("jwt"); electronSettingsCache.delete("jwt");
} else {
localStorage.removeItem("jwt");
} }
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -420,6 +423,12 @@ function createApiInstance(
"Authentication error - token may be invalid", "Authentication error - token may be invalid",
errorMessage, errorMessage,
); );
// Clear invalid token
localStorage.removeItem("jwt");
if (isElectron()) {
electronSettingsCache.delete("jwt");
}
} }
} }
@@ -873,6 +882,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
defaultPath: hostData.defaultPath || "/", defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [], jumpHosts: hostData.jumpHosts || [],
quickActions: hostData.quickActions || [],
statsConfig: hostData.statsConfig statsConfig: hostData.statsConfig
? typeof hostData.statsConfig === "string" ? typeof hostData.statsConfig === "string"
? hostData.statsConfig ? hostData.statsConfig
@@ -938,6 +948,7 @@ export async function updateSSHHost(
defaultPath: hostData.defaultPath || "/", defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [], jumpHosts: hostData.jumpHosts || [],
quickActions: hostData.quickActions || [],
statsConfig: hostData.statsConfig statsConfig: hostData.statsConfig
? typeof hostData.statsConfig === "string" ? typeof hostData.statsConfig === "string"
? hostData.statsConfig ? hostData.statsConfig
@@ -2874,6 +2885,21 @@ export async function deleteSnippet(
} }
} }
export async function executeSnippet(
snippetId: number,
hostId: number,
): Promise<{ success: boolean; output: string; error?: string }> {
try {
const response = await authApi.post("/snippets/execute", {
snippetId,
hostId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "execute snippet");
}
}
// ============================================================================ // ============================================================================
// HOMEPAGE API // HOMEPAGE API
// ============================================================================ // ============================================================================
@@ -2966,10 +2992,17 @@ export async function saveCommandToHistory(
/** /**
* Get command history for a specific host * Get command history for a specific host
* Returns array of unique commands ordered by most recent * Returns array of unique commands ordered by most recent
* @param hostId - The host ID to fetch history for
* @param limit - Maximum number of commands to return (default: 100)
*/ */
export async function getCommandHistory(hostId: number): Promise<string[]> { export async function getCommandHistory(
hostId: number,
limit: number = 100,
): Promise<string[]> {
try { try {
const response = await authApi.get(`/terminal/command_history/${hostId}`); const response = await authApi.get(`/terminal/command_history/${hostId}`, {
params: { limit },
});
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "fetch command history"); throw handleApiError(error, "fetch command history");
@@ -3011,25 +3044,23 @@ export async function clearCommandHistory(
} }
// ============================================================================ // ============================================================================
// OIDC TO PASSWORD CONVERSION // OIDC ACCOUNT LINKING
// ============================================================================ // ============================================================================
/** /**
* Convert an OIDC user to a password-based user * Link an OIDC user to an existing password account (merges OIDC into password account)
*/ */
export async function convertOIDCToPassword( export async function linkOIDCToPasswordAccount(
targetUserId: string, oidcUserId: string,
newPassword: string, targetUsername: string,
totpCode?: string,
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
try { try {
const response = await authApi.post("/users/convert-oidc-to-password", { const response = await authApi.post("/users/link-oidc-to-password", {
targetUserId, oidcUserId,
newPassword, targetUsername,
totpCode,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "convert OIDC user to password"); throw handleApiError(error, "link OIDC account to password account");
} }
} }

View File

@@ -495,7 +495,12 @@ export function Auth({
const error = urlParams.get("error"); const error = urlParams.get("error");
if (error) { if (error) {
const errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`; let errorMessage: string;
if (error === "registration_disabled") {
errorMessage = t("messages.registrationDisabled");
} else {
errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`;
}
setError(errorMessage); setError(errorMessage);
setOidcLoading(false); setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);