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,
type TEXT NOT NULL,
host_id INTEGER NOT NULL,
host_name TEXT NOT NULL,
host_name TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (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", "stats_config", "TEXT");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");

View File

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

View File

@@ -524,6 +524,8 @@ router.delete(
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
.select()
.from(sshData)
@@ -552,14 +554,8 @@ router.delete(
);
}
await db
.delete(sshCredentialUsage)
.where(
and(
eq(sshCredentialUsage.credentialId, parseInt(id)),
eq(sshCredentialUsage.userId, userId),
),
);
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
// No need for manual deletion
await db
.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;

View File

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

View File

@@ -762,6 +762,44 @@ router.get("/oidc/callback", async (req, res) => {
.get();
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();
await db.insert(users).values({
id,
@@ -1681,6 +1719,7 @@ router.get("/list", authenticateJWT, async (req, res) => {
username: users.username,
is_admin: users.is_admin,
is_oidc: users.is_oidc,
password_hash: users.password_hash,
})
.from(users);
@@ -2517,21 +2556,15 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
}
});
// Route: Convert OIDC user to password user (link accounts)
// POST /users/convert-oidc-to-password
router.post("/convert-oidc-to-password", authenticateJWT, async (req, res) => {
// Route: Link OIDC user to existing password account (merge accounts)
// POST /users/link-oidc-to-password
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
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({
error: "Target user ID and new password are required",
});
}
if (newPassword.length < 8) {
return res.status(400).json({
error: "New password must be at least 8 characters long",
error: "OIDC user ID and target username are required",
});
}
@@ -2545,185 +2578,128 @@ router.post("/convert-oidc-to-password", authenticateJWT, async (req, res) => {
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
.select()
.from(users)
.where(eq(users.id, targetUserId));
.where(eq(users.username, targetUsername));
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];
// Verify user is OIDC
if (!targetUser.is_oidc) {
// Verify target user has password authentication
if (targetUser.is_oidc || !targetUser.password_hash) {
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
if (targetUser.totp_enabled && targetUser.totp_secret) {
if (!totpCode) {
return res.status(400).json({
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,
// Check if target user already has OIDC configured
if (targetUser.client_id && targetUser.oidc_identifier) {
return res.status(400).json({
error: "Target user already has OIDC authentication configured",
});
if (!verified) {
return res.status(401).json({ error: "Invalid TOTP code" });
}
}
authLogger.info("Converting OIDC user to password user", {
operation: "convert_oidc_to_password",
targetUserId,
adminUserId,
authLogger.info("Linking OIDC user to password account", {
operation: "link_oidc_to_password",
oidcUserId,
oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
// Step 1: Get current DEK from memory (requires user to be logged in)
// 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
// Copy OIDC configuration from OIDC user to target password user
await db
.update(users)
.set({
password_hash,
is_oidc: false,
oidc_identifier: null,
client_id: "",
client_secret: "",
issuer_url: "",
authorization_url: "",
token_url: "",
identifier_path: "",
name_path: "",
scopes: "openid email profile",
is_oidc: true, // Enable OIDC login for this account
oidc_identifier: oidcUser.oidc_identifier,
client_id: oidcUser.client_id,
client_secret: oidcUser.client_secret,
issuer_url: oidcUser.issuer_url,
authorization_url: oidcUser.authorization_url,
token_url: oidcUser.token_url,
identifier_path: oidcUser.identifier_path,
name_path: oidcUser.name_path,
scopes: oidcUser.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
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(`user_kek_salt_${targetUserId}`, kekSaltHex);
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);
.prepare("DELETE FROM settings WHERE key LIKE ?")
.run(`user_%_${oidcUserId}`);
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist conversion to disk", saveError, {
operation: "convert_oidc_save_failed",
targetUserId,
authLogger.error("Failed to persist account linking to disk", saveError, {
operation: "link_oidc_save_failed",
oidcUserId,
targetUserId: targetUser.id,
});
}
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",
targetUserId,
adminUserId,
operation: "link_oidc_to_password_success",
oidcUserId,
oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
},
);
res.json({
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) {
authLogger.error("Failed to convert OIDC user to password user", err, {
operation: "convert_oidc_to_password_failed",
targetUserId,
authLogger.error("Failed to link OIDC user to password account", err, {
operation: "link_oidc_to_password_failed",
oidcUserId,
targetUsername,
adminUserId,
});
res.status(500).json({
error: "Failed to convert user account",
error: "Failed to link accounts",
details: err instanceof Error ? err.message : "Unknown error",
});
}

View File

@@ -704,15 +704,6 @@ class PollingManager {
config.statusTimer = setInterval(() => {
this.pollHostStatus(host);
}, 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 {
this.statusStore.delete(host.id);
statsLogger.debug(`Status polling disabled for host ${host.id}`, {
@@ -729,15 +720,6 @@ class PollingManager {
config.metricsTimer = setInterval(() => {
this.pollHostMetrics(host);
}, 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 {
this.metricsStore.delete(host.id);
statsLogger.debug(`Metrics polling disabled for host ${host.id}`, {

View File

@@ -189,9 +189,23 @@
"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"
},
"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": {
"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",
"failedToDismissAlert": "Benachrichtigung konnte nicht geschlossen werden"
},
@@ -1374,6 +1388,10 @@
"local": "Lokal",
"external": "Extern (OIDC)",
"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",
"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."

View File

@@ -226,6 +226,20 @@
"editTooltip": "Edit 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": {
"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.",
@@ -482,26 +496,20 @@
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
"failedToRevokeSessions": "Failed to revoke sessions",
"sessionsRevokedSuccessfully": "Sessions revoked successfully",
"convertToPasswordAuth": "Convert to Password Authentication",
"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.",
"convertUserDialogTitle": "Convert to Password Authentication",
"convertUserDialogDescription": "This action will set a new password, disable OIDC/SSO login, log out all sessions, and preserve all user data.",
"convertActionWillSetPassword": "Set a new password for this user",
"convertActionWillDisableOIDC": "Disable OIDC/SSO login for this account",
"convertActionWillLogout": "Log out all active sessions",
"convertActionWillPreserveData": "Preserve all user data (SSH hosts, credentials, etc.)",
"convertPasswordLabel": "New Password (min 8 chars)",
"convertPasswordPlaceholder": "Enter new password",
"convertTotpLabel": "TOTP Code (if user has 2FA enabled)",
"convertTotpPlaceholder": "000000",
"convertUserButton": "Convert User",
"convertingUser": "Converting...",
"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",
"linkToPasswordAccount": "Link to Password Account",
"linkOIDCDialogTitle": "Link OIDC Account to Password Account",
"linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.",
"linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted",
"linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data",
"linkOIDCActionAddCapability": "Add OIDC login capability to the target password account",
"linkOIDCActionDualAuth": "Allow the password account to login with both password and OIDC",
"linkTargetUsernameLabel": "Target Password Account Username",
"linkTargetUsernamePlaceholder": "Enter username of password account",
"linkAccountsButton": "Link Accounts",
"linkingAccounts": "Linking...",
"accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}",
"failedToLinkAccounts": "Failed to link accounts",
"linkTargetUsernameRequired": "Target username is required",
"databaseSecurity": "Database Security",
"encryptionStatus": "Encryption Status",
"encryptionEnabled": "Encryption Enabled",
@@ -889,6 +897,13 @@
"searchServers": "Search servers...",
"noServerFound": "No server found",
"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"
},
"terminal": {
@@ -1370,7 +1385,13 @@
"recentSuccessfulLogins": "Recent Successful Logins",
"recentFailedAttempts": "Recent Failed Attempts",
"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": {
"tagline": "SSH SERVER MANAGER",
@@ -1553,6 +1574,8 @@
"selectPreferredLanguage": "Select your preferred language for the interface",
"fileColorCoding": "File Color Coding",
"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",
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again."

View File

@@ -223,6 +223,20 @@
"editTooltip": "Modifier 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": {
"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.",
@@ -1368,6 +1382,10 @@
"local": "Local",
"external": "Externe (OIDC)",
"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",
"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."

View File

@@ -191,6 +191,20 @@
"enableRightClickCopyPaste": "Habilitar copiar/colar com botão direito",
"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": {
"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.",
@@ -1322,6 +1336,10 @@
"local": "Local",
"external": "Externo (OIDC)",
"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",
"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."

View File

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

View File

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

View File

@@ -9,6 +9,11 @@ export interface JumpHost {
hostId: number;
}
export interface QuickAction {
name: string;
snippetId: number;
}
export interface SSHHost {
id: number;
name: string;
@@ -38,6 +43,7 @@ export interface SSHHost {
defaultPath: string;
tunnelConnections: TunnelConnection[];
jumpHosts?: JumpHost[];
quickActions?: QuickAction[];
statsConfig?: string;
terminalConfig?: TerminalConfig;
createdAt: string;
@@ -48,6 +54,11 @@ export interface JumpHostData {
hostId: number;
}
export interface QuickActionData {
name: string;
snippetId: number;
}
export interface SSHHostData {
name?: string;
ip: string;
@@ -70,6 +81,7 @@ export interface SSHHostData {
forceKeyboardInteractive?: boolean;
tunnelConnections?: TunnelConnection[];
jumpHosts?: JumpHostData[];
quickActions?: QuickActionData[];
statsConfig?: string | Record<string, unknown>;
terminalConfig?: TerminalConfig;
}
@@ -317,6 +329,22 @@ export interface TabContextTab {
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
// ============================================================================

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -110,12 +110,12 @@ function JumpHostItem({
{index + 1}.
</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<PopoverTrigger asChild className="flex-1">
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex-1 justify-between"
className="w-full justify-between"
>
{selectedHost
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}`
@@ -123,7 +123,10 @@ function JumpHostItem({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("hosts.searchServers")} />
<CommandEmpty>{t("hosts.noServerFound")}</CommandEmpty>
@@ -133,7 +136,7 @@ function JumpHostItem({
.map((host) => (
<CommandItem
key={host.id}
value={`${host.name} ${host.ip} ${host.username}`}
value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
onSelect={() => {
onUpdate(host.id);
setOpen(false);
@@ -162,7 +165,112 @@ function JumpHostItem({
</PopoverContent>
</Popover>
</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" />
</Button>
</div>
@@ -198,6 +306,10 @@ interface SSHHost {
jumpHosts?: Array<{
hostId: number;
}>;
quickActions?: Array<{
name: string;
snippetId: number;
}>;
statsConfig?: StatsConfig;
terminalConfig?: TerminalConfig;
createdAt: string;
@@ -440,6 +552,14 @@ export function HostManagerEditor({
}),
)
.default([]),
quickActions: z
.array(
z.object({
name: z.string().min(1),
snippetId: z.number().min(1),
}),
)
.default([]),
})
.superRefine((data, ctx) => {
if (data.authType === "none") {
@@ -528,6 +648,7 @@ export function HostManagerEditor({
defaultPath: "/",
tunnelConnections: [],
jumpHosts: [],
quickActions: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
@@ -612,6 +733,9 @@ export function HostManagerEditor({
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
? cleanedHost.jumpHosts
: [],
quickActions: Array.isArray(cleanedHost.quickActions)
? cleanedHost.quickActions
: [],
statsConfig: parsedStatsConfig,
terminalConfig: {
...DEFAULT_TERMINAL_CONFIG,
@@ -670,6 +794,7 @@ export function HostManagerEditor({
defaultPath: "/",
tunnelConnections: [],
jumpHosts: [],
quickActions: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
@@ -730,6 +855,7 @@ export function HostManagerEditor({
defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || [],
jumpHosts: data.jumpHosts || [],
quickActions: data.quickActions || [],
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
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>
</Tabs>
</div>

View File

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

View File

@@ -7,6 +7,7 @@ import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import {
getServerStatusById,
getServerMetricsById,
executeSnippet,
type ServerMetrics,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
@@ -29,6 +30,11 @@ import {
} from "./widgets";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface QuickAction {
name: string;
snippetId: number;
}
interface HostConfig {
id: number;
name: string;
@@ -37,6 +43,7 @@ interface HostConfig {
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
quickActions?: QuickAction[];
statsConfig?: string | StatsConfig;
[key: string]: unknown;
}
@@ -81,6 +88,9 @@ export function Server({
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
);
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
@@ -450,38 +460,147 @@ export function Server({
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-y-auto min-h-0">
{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">
{!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) ||
(currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0) ? (
<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">
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
<h3 className="text-sm font-semibold text-gray-400 mb-2">
{t("serverStats.quickActions")}
</h3>
<div className="flex flex-wrap gap-2">
{currentHostConfig.quickActions.map((action, index) => {
const isExecuting = executingActions.has(
action.snippetId,
);
return (
<Button
key={index}
variant="outline"
size="sm"
className="font-semibold"
disabled={isExecuting}
onClick={async () => {
if (!currentHostConfig) return;
<SimpleLoader
visible={isLoadingMetrics && !metrics}
message={t("serverStats.loadingMetrics")}
/>
setExecutingActions((prev) =>
new Set(prev).add(action.snippetId),
);
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>
)}
) : null}
{currentHostConfig?.tunnelConnections &&
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)
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")
.then((module) => module.getCommandHistory(hostConfig.id!))
.then((history) => {
@@ -231,6 +235,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
console.error("Failed to load autocomplete history:", error);
autocompleteHistory.current = [];
});
} else {
autocompleteHistory.current = [];
}
}, [hostConfig.id]);
@@ -1318,6 +1324,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
e.preventDefault();
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();
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
// Filter commands that start with current input
@@ -1328,7 +1348,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
cmd !== currentCmd &&
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) {
// Only one match - auto-complete directly
@@ -1359,21 +1379,31 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const cellWidth =
terminal.cols > 0 ? rect.width / terminal.cols : 10;
// Estimate autocomplete menu height (max-h-[240px] from component)
const menuHeight = 240;
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
const spaceBelow = window.innerHeight - cursorBottomY;
const spaceAbove = rect.top + cursorY * cellHeight;
// Calculate actual menu height based on number of items
// Each item is ~32px (py-1.5), footer is ~32px, max total 240px
const itemHeight = 32;
const footerHeight = 32;
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 =
spaceBelow < menuHeight && spaceAbove > spaceBelow;
spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
setAutocompletePosition({
top: showAbove
? rect.top + cursorY * cellHeight - menuHeight
? Math.max(0, cursorTopY - estimatedMenuHeight)
: 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;
}
// 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 (
<div
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={{
top: `${position.top}px`,
left: `${position.left}px`,
maxHeight: "240px",
}}
>
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
"hover:bg-dark-hover",
index === selectedIndex && "bg-blue-500/20 text-blue-400",
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {
// Optional: update selected index on hover
}}
>
{suggestion}
</div>
))}
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50">
<div
className="overflow-y-auto"
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
>
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
"hover:bg-dark-hover",
index === selectedIndex && "bg-gray-500/20 text-gray-400",
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {
// Optional: update selected index on hover
}}
>
{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
</div>
</div>

View File

@@ -34,6 +34,8 @@ import {
Search,
Loader2,
Terminal,
LayoutGrid,
MonitorCheck,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -67,7 +69,7 @@ interface TabData {
[key: string]: unknown;
}
interface SSHUtilitySidebarProps {
interface SSHToolsSidebarProps {
isOpen: boolean;
onClose: () => void;
onSnippetExecute: (content: string) => void;
@@ -85,12 +87,21 @@ export function SSHToolsSidebar({
setSidebarWidth,
initialTab,
onTabChange,
}: SSHUtilitySidebarProps) {
}: SSHToolsSidebarProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { tabs, currentTab } = useTabs() as {
const {
tabs,
currentTab,
allSplitScreenTab,
setSplitScreenTab,
setCurrentTab,
} = useTabs() as {
tabs: TabData[];
currentTab: number | null;
allSplitScreenTab: number[];
setSplitScreenTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void;
};
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
@@ -141,6 +152,17 @@ export function SSHToolsSidebar({
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
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
const [isResizing, setIsResizing] = useState(false);
const startXRef = React.useRef<number | null>(null);
@@ -152,6 +174,15 @@ export function SSHToolsSidebar({
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
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
useEffect(() => {
if (isOpen && activeTab === "command-history") {
@@ -567,6 +598,148 @@ export function SSHToolsSidebar({
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
const handleCommandSelect = (command: string) => {
if (activeTerminal?.terminalRef?.current?.sendInput) {
@@ -616,7 +789,7 @@ export function SSHToolsSidebar({
<div className="absolute right-5 flex gap-1">
<Button
variant="outline"
onClick={() => setSidebarWidth(300)}
onClick={() => setSidebarWidth(400)}
className="w-[28px] h-[28px]"
title="Reset sidebar width"
>
@@ -640,7 +813,7 @@ export function SSHToolsSidebar({
onValueChange={handleTabChange}
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">
{t("sshTools.title")}
</TabsTrigger>
@@ -650,6 +823,9 @@ export function SSHToolsSidebar({
<TabsTrigger value="command-history">
{t("commandHistory.title", { defaultValue: "History" })}
</TabsTrigger>
<TabsTrigger value="split-screen">
{t("splitScreen.title", { defaultValue: "Split Screen" })}
</TabsTrigger>
</TabsList>
<TabsContent value="ssh-tools" className="space-y-4">
@@ -755,7 +931,7 @@ export function SSHToolsSidebar({
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
className="gray-500 hover:underline"
>
GitHub
</a>
@@ -832,7 +1008,7 @@ export function SSHToolsSidebar({
{snippets.map((snippet) => (
<div
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">
<h3 className="text-sm font-medium text-white mb-1">
@@ -843,6 +1019,9 @@ export function SSHToolsSidebar({
{snippet.description}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
ID: {snippet.id}
</p>
</div>
<div className="bg-muted/30 rounded p-2 mb-3">
@@ -950,6 +1129,12 @@ export function SSHToolsSidebar({
</Button>
)}
</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 className="flex-1 overflow-hidden min-h-0">
@@ -1040,6 +1225,219 @@ export function SSHToolsSidebar({
)}
</div>
</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>
</SidebarContent>
{isOpen && (

View File

@@ -555,7 +555,11 @@ export function Auth({
const error = urlParams.get("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);
window.history.replaceState({}, document.title, window.location.pathname);
return;

View File

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

View File

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

View File

@@ -143,7 +143,7 @@ export function Tab({
onClick={!disableActivate ? onActivate : undefined}
style={{
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">
@@ -175,7 +175,10 @@ export function Tab({
}
>
<SeparatorVertical
className={cn("h-4 w-4", isSplit && "text-white")}
className={cn(
"h-4 w-4",
isSplit ? "text-white" : "text-muted-foreground",
)}
/>
</Button>
)}

View File

@@ -156,11 +156,32 @@ export function TabProvider({ children }: TabProviderProps) {
}
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) {
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) => {
if (prev.includes(tabId)) {
return prev.filter((id) => id !== tabId);
} else if (prev.length < 3) {
} else if (prev.length < 4) {
return [...prev, tabId];
}
return prev;

View File

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

View File

@@ -77,6 +77,7 @@ interface UserInfo {
is_admin: boolean;
is_oidc: boolean;
data_unlocked: boolean;
password_hash?: string;
}
interface UserCount {
@@ -396,10 +397,12 @@ function createApiInstance(
errorMessage === "Authentication required";
if (isSessionExpired || isSessionNotFound) {
// Clear token from localStorage
localStorage.removeItem("jwt");
// Clear Electron settings cache
if (isElectron()) {
localStorage.removeItem("jwt");
} else {
localStorage.removeItem("jwt");
electronSettingsCache.delete("jwt");
}
if (typeof window !== "undefined") {
@@ -420,6 +423,12 @@ function createApiInstance(
"Authentication error - token may be invalid",
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 || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
quickActions: hostData.quickActions || [],
statsConfig: hostData.statsConfig
? typeof hostData.statsConfig === "string"
? hostData.statsConfig
@@ -938,6 +948,7 @@ export async function updateSSHHost(
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
quickActions: hostData.quickActions || [],
statsConfig: hostData.statsConfig
? typeof hostData.statsConfig === "string"
? 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
// ============================================================================
@@ -2966,10 +2992,17 @@ export async function saveCommandToHistory(
/**
* Get command history for a specific host
* 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 {
const response = await authApi.get(`/terminal/command_history/${hostId}`);
const response = await authApi.get(`/terminal/command_history/${hostId}`, {
params: { limit },
});
return response.data;
} catch (error) {
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(
targetUserId: string,
newPassword: string,
totpCode?: string,
export async function linkOIDCToPasswordAccount(
oidcUserId: string,
targetUsername: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await authApi.post("/users/convert-oidc-to-password", {
targetUserId,
newPassword,
totpCode,
const response = await authApi.post("/users/link-oidc-to-password", {
oidcUserId,
targetUsername,
});
return response.data;
} 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");
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);
setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname);