diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 2bc5ee67..8fdf9e97 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -99,6 +99,9 @@ export const sshData = sqliteTable("ssh_data", { export const fileManagerRecent = sqliteTable("file_manager_recent", { id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() .references(() => sshData.id, { onDelete: "cascade" }), @@ -132,6 +135,9 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", { hostId: integer("host_id") .notNull() .references(() => sshData.id, { onDelete: "cascade" }), + name: text("name").notNull(), + path: text("path").notNull(), + createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); @@ -245,6 +251,7 @@ export const commandHistory = sqliteTable("command_history", { hostId: integer("host_id") .notNull() .references(() => sshData.id, { onDelete: "cascade" }), + command: text("command").notNull(), executedAt: text("executed_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 808d4d40..a6432f1e 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -9,6 +9,7 @@ import { fileManagerPinned, fileManagerShortcuts, sshFolders, + commandHistory, } from "../db/schema.js"; import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import type { Request, Response } from "express"; @@ -355,6 +356,29 @@ router.post( }, ); + // Notify stats server to start polling this host + try { + const axios = (await import("axios")).default; + const statsPort = process.env.STATS_PORT || 30005; + await axios.post( + `http://localhost:${statsPort}/host-updated`, + { hostId: createdHost.id }, + { + headers: { + Authorization: req.headers.authorization || "", + Cookie: req.headers.cookie || "", + }, + timeout: 5000, + }, + ); + } catch (err) { + sshLogger.warn("Failed to notify stats server of new host", { + operation: "host_create", + hostId: createdHost.id as number, + error: err instanceof Error ? err.message : String(err), + }); + } + res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { @@ -570,6 +594,29 @@ router.put( }, ); + // Notify stats server to refresh polling for this host + try { + const axios = (await import("axios")).default; + const statsPort = process.env.STATS_PORT || 30005; + await axios.post( + `http://localhost:${statsPort}/host-updated`, + { hostId: parseInt(hostId) }, + { + headers: { + Authorization: req.headers.authorization || "", + Cookie: req.headers.cookie || "", + }, + timeout: 5000, + }, + ); + } catch (err) { + sshLogger.warn("Failed to notify stats server of host update", { + operation: "host_update", + hostId: parseInt(hostId), + error: err instanceof Error ? err.message : String(err), + }); + } + res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { @@ -1224,6 +1271,94 @@ router.delete( }, ); +// Route: Get command history for a host +// GET /ssh/command-history/:hostId +router.get( + "/command-history/:hostId", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const hostId = parseInt(req.params.hostId, 10); + + if (!isNonEmptyString(userId) || !hostId) { + sshLogger.warn("Invalid userId or hostId for command history fetch", { + operation: "command_history_fetch", + hostId, + userId, + }); + return res.status(400).json({ error: "Invalid userId or hostId" }); + } + + try { + const history = await db + .select({ + id: commandHistory.id, + command: commandHistory.command, + }) + .from(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostId), + ), + ) + .orderBy(desc(commandHistory.executedAt)) + .limit(200); + + res.json(history.map((h) => h.command)); + } catch (err) { + sshLogger.error("Failed to fetch command history from database", err, { + operation: "command_history_fetch", + hostId, + userId, + }); + res.status(500).json({ error: "Failed to fetch command history" }); + } + }, +); + +// Route: Delete command from history +// DELETE /ssh/command-history +router.delete( + "/command-history", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId, command } = req.body; + + if (!isNonEmptyString(userId) || !hostId || !command) { + sshLogger.warn("Invalid data for command history deletion", { + operation: "command_history_delete", + hostId, + userId, + }); + return res.status(400).json({ error: "Invalid data" }); + } + + try { + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostId), + eq(commandHistory.command, command), + ), + ); + + res.json({ message: "Command deleted from history" }); + } catch (err) { + sshLogger.error("Failed to delete command from history", err, { + operation: "command_history_delete", + hostId, + userId, + command, + }); + res.status(500).json({ error: "Failed to delete command" }); + } + }, +); + async function resolveHostCredentials( host: Record, ): Promise> { diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c81aa64d..46fa5097 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -2517,4 +2517,216 @@ 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) => { + const adminUserId = (req as AuthenticatedRequest).userId; + const { targetUserId, newPassword, totpCode } = req.body; + + if (!isNonEmptyString(targetUserId) || !isNonEmptyString(newPassword)) { + 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", + }); + } + + try { + // Verify admin permissions + const adminUser = await db + .select() + .from(users) + .where(eq(users.id, adminUserId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({ error: "Admin access required" }); + } + + // Get target user + const targetUserRecords = await db + .select() + .from(users) + .where(eq(users.id, targetUserId)); + if (!targetUserRecords || targetUserRecords.length === 0) { + return res.status(404).json({ error: "Target user not found" }); + } + + const targetUser = targetUserRecords[0]; + + // Verify user is OIDC + if (!targetUser.is_oidc) { + return res.status(400).json({ + error: "User is already a password-based user", + }); + } + + // 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, + }); + + 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, + targetUsername: targetUser.username, + }); + + // 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((resolve, reject) => { + crypto.pbkdf2( + newPassword, + kekSalt, + 100000, + 32, + "sha256", + (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + }, + ); + }); + + // Encrypt the existing DEK with new password-derived KEK + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv); + let encryptedDEK = cipher.update(currentDEK); + encryptedDEK = Buffer.concat([encryptedDEK, cipher.final()]); + const authTag = cipher.getAuthTag(); + + const encryptedDEKData = JSON.stringify({ + iv: iv.toString("hex"), + encryptedKey: encryptedDEK.toString("hex"), + authTag: authTag.toString("hex"), + }); + + // Step 3: Hash the new password + const saltRounds = parseInt(process.env.SALT || "10", 10); + const password_hash = await bcrypt.hash(newPassword, saltRounds); + + // Step 4: Update user record atomically + await db + .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", + }) + .where(eq(users.id, targetUserId)); + + // Step 5: Update KEK salt and encrypted DEK in 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); + + 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.success( + `OIDC user converted to password user: ${targetUser.username}`, + { + operation: "convert_oidc_to_password_success", + targetUserId, + adminUserId, + targetUsername: targetUser.username, + }, + ); + + res.json({ + success: true, + message: `User ${targetUser.username} has been converted to password authentication. All sessions have been revoked.`, + }); + } catch (err) { + authLogger.error("Failed to convert OIDC user to password user", err, { + operation: "convert_oidc_to_password_failed", + targetUserId, + adminUserId, + }); + res.status(500).json({ + error: "Failed to convert user account", + details: err instanceof Error ? err.message : "Unknown error", + }); + } +}); + export default router; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 84258fc9..79948efc 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -820,11 +820,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { port, }); jumpClient.end(); - return res - .status(500) - .json({ - error: "Failed to forward through jump host: " + err.message, - }); + return res.status(500).json({ + error: "Failed to forward through jump host: " + err.message, + }); } config.sock = stream; @@ -2732,9 +2730,12 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { }); } + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + const octalPerms = permissions.slice(-3); const escapedPath = path.replace(/'/g, "'\"'\"'"); - const command = `chmod ${octalPerms} '${escapedPath}'`; + const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`; fileLogger.info("Changing file permissions", { operation: "change_permissions", @@ -2743,24 +2744,66 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { permissions: octalPerms, }); + const commandTimeout = setTimeout(() => { + if (!res.headersSent) { + fileLogger.error("changePermissions command timeout", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + res.status(408).json({ + error: "Permission change timed out. SSH connection may be unstable.", + }); + } + }, 10000); + sshConn.client.exec(command, (err, stream) => { if (err) { + clearTimeout(commandTimeout); fileLogger.error("SSH changePermissions exec error:", err, { operation: "change_permissions", sessionId, path, permissions: octalPerms, }); - return res.status(500).json({ error: "Failed to change permissions" }); + if (!res.headersSent) { + return res.status(500).json({ error: "Failed to change permissions" }); + } + return; } + let outputData = ""; let errorOutput = ""; - stream.stderr.on("data", (data) => { + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (data: Buffer) => { errorOutput += data.toString(); }); stream.on("close", (code) => { + clearTimeout(commandTimeout); + + if (outputData.includes("SUCCESS")) { + fileLogger.success("File permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + + if (!res.headersSent) { + res.json({ + success: true, + message: "Permissions changed successfully", + }); + } + return; + } + if (code !== 0) { fileLogger.error("chmod command failed", { operation: "change_permissions", @@ -2770,9 +2813,12 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { exitCode: code, error: errorOutput, }); - return res.status(500).json({ - error: errorOutput || "Failed to change permissions", - }); + if (!res.headersSent) { + return res.status(500).json({ + error: errorOutput || "Failed to change permissions", + }); + } + return; } fileLogger.success("File permissions changed successfully", { @@ -2782,13 +2828,16 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { permissions: octalPerms, }); - res.json({ - success: true, - message: "Permissions changed successfully", - }); + if (!res.headersSent) { + res.json({ + success: true, + message: "Permissions changed successfully", + }); + } }); stream.on("error", (streamErr) => { + clearTimeout(commandTimeout); fileLogger.error("SSH changePermissions stream error:", streamErr, { operation: "change_permissions", sessionId, diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 1b93b123..2298a201 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -665,6 +665,7 @@ class PollingManager { const statsConfig = this.parseStatsConfig(host.statsConfig); const existingConfig = this.pollingConfigs.get(host.id); + // Always clear existing timers first if (existingConfig) { if (existingConfig.statusTimer) { clearInterval(existingConfig.statusTimer); @@ -674,10 +675,19 @@ class PollingManager { } } + // If both checks are disabled, stop all polling and clean up if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) { this.pollingConfigs.delete(host.id); this.statusStore.delete(host.id); this.metricsStore.delete(host.id); + statsLogger.info( + `Stopped all polling for host ${host.id} (${host.name || host.ip}) - both checks disabled`, + { + operation: "polling_stopped", + hostId: host.id, + hostName: host.name || host.ip, + }, + ); return; } @@ -694,8 +704,21 @@ 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}`, { + operation: "status_polling_disabled", + hostId: host.id, + }); } if (statsConfig.metricsEnabled) { @@ -706,8 +729,21 @@ 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}`, { + operation: "metrics_polling_disabled", + hostId: host.id, + }); } this.pollingConfigs.set(host.id, config); @@ -731,6 +767,12 @@ class PollingManager { } private async pollHostMetrics(host: SSHHostWithCredentials): Promise { + // Double-check that metrics are still enabled before collecting + const config = this.pollingConfigs.get(host.id); + if (!config || !config.statsConfig.metricsEnabled) { + return; + } + try { const metrics = await collectMetrics(host); this.metricsStore.set(host.id, { @@ -738,12 +780,19 @@ class PollingManager { timestamp: Date.now(), }); } catch (error) { - statsLogger.warn("Failed to collect metrics for host", { - operation: "metrics_poll_failed", - hostId: host.id, - hostName: host.name, - error: error instanceof Error ? error.message : String(error), - }); + const errorMessage = + error instanceof Error ? error.message : String(error); + + // Only log errors if metrics collection is actually enabled + // Don't spam logs with errors for hosts that have metrics disabled + if (config.statsConfig.metricsEnabled) { + statsLogger.warn("Failed to collect metrics for host", { + operation: "metrics_poll_failed", + hostId: host.id, + hostName: host.name, + error: errorMessage, + }); + } } } @@ -1374,6 +1423,39 @@ app.post("/refresh", async (req, res) => { res.json({ message: "Polling refreshed" }); }); +app.post("/host-updated", async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.body; + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + + if (!hostId || typeof hostId !== "number") { + return res.status(400).json({ error: "Invalid hostId" }); + } + + try { + const host = await fetchHostById(hostId, userId); + if (host) { + await pollingManager.startPollingForHost(host); + res.json({ message: "Host polling started" }); + } else { + res.status(404).json({ error: "Host not found" }); + } + } catch (error) { + statsLogger.error("Failed to start polling for host", error, { + operation: "host_updated", + hostId, + userId, + }); + res.status(500).json({ error: "Failed to start polling" }); + } +}); + app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index 74442274..513d5984 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -22,6 +22,7 @@ interface EncryptedDEK { interface UserSession { dataKey: Buffer; expiresAt: number; + lastActivity?: number; } class UserCrypto { diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index ce3a1028..a456c21e 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -774,7 +774,18 @@ "noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.", "noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern.", "forceKeyboardInteractive": "Tastatur-Interaktiv erzwingen", - "forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden." + "forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden.", + "overrideCredentialUsername": "Benutzernamen der Anmeldedaten überschreiben", + "overrideCredentialUsernameDesc": "Verwenden Sie einen anderen Benutzernamen als den, der in den Anmeldedaten gespeichert ist. Dadurch können Sie dieselben Anmeldedaten mit unterschiedlichen Benutzernamen verwenden.", + "jumpHosts": "Jump-Hosts", + "jumpHostsDescription": "Jump-Hosts (auch bekannt als Bastion-Hosts) ermöglichen es Ihnen, sich über einen oder mehrere Zwischen-Server mit einem Ziel-Server zu verbinden. Dies ist nützlich für den Zugriff auf Server hinter Firewalls oder in privaten Netzwerken.", + "jumpHostChain": "Jump-Host-Kette", + "addJumpHost": "Jump-Host hinzufügen", + "selectServer": "Server auswählen", + "searchServers": "Server durchsuchen...", + "noServerFound": "Kein Server gefunden", + "jumpHostsOrder": "Verbindungen werden in dieser Reihenfolge hergestellt: Jump-Host 1 → Jump-Host 2 → ... → Ziel-Server", + "advancedAuthSettings": "Erweiterte Authentifizierungseinstellungen" }, "terminal": { "title": "Terminal", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 08559665..34b1ee3e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -482,6 +482,26 @@ "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", "databaseSecurity": "Database Security", "encryptionStatus": "Encryption Status", "encryptionEnabled": "Encryption Enabled", @@ -860,7 +880,16 @@ "forceKeyboardInteractive": "Force Keyboard-Interactive", "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).", "overrideCredentialUsername": "Override Credential Username", - "overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames." + "overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames.", + "jumpHosts": "Jump Hosts", + "jumpHostsDescription": "Jump hosts (also known as bastion hosts) allow you to connect to a target server through one or more intermediate servers. This is useful for accessing servers behind firewalls or in private networks.", + "jumpHostChain": "Jump Host Chain", + "addJumpHost": "Add Jump Host", + "selectServer": "Select Server", + "searchServers": "Search servers...", + "noServerFound": "No server found", + "jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server", + "advancedAuthSettings": "Advanced Authentication Settings" }, "terminal": { "title": "Terminal", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 7aecae16..bacbf87e 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -765,7 +765,18 @@ "noneAuthDescription": "Cette méthode utilisera l'authentification clavier-interactif lors de la connexion au serveur SSH.", "noneAuthDetails": "L'authentification clavier-interactif permet au serveur de vous demander des informations pendant la connexion. Utile pour le MFA ou si vous ne souhaitez pas stocker d'identifiants localement.", "forceKeyboardInteractive": "Forcer le clavier-interactif", - "forceKeyboardInteractiveDesc": "Force l'utilisation de l'authentification clavier-interactif. Souvent nécessaire pour les serveurs avec 2FA (TOTP/2FA)." + "forceKeyboardInteractiveDesc": "Force l'utilisation de l'authentification clavier-interactif. Souvent nécessaire pour les serveurs avec 2FA (TOTP/2FA).", + "overrideCredentialUsername": "Remplacer le nom d'utilisateur des identifiants", + "overrideCredentialUsernameDesc": "Utilisez un nom d'utilisateur différent de celui stocké dans les identifiants. Cela vous permet d'utiliser les mêmes identifiants avec différents noms d'utilisateur.", + "jumpHosts": "Serveurs de rebond", + "jumpHostsDescription": "Les serveurs de rebond (également appelés bastions) vous permettent de vous connecter à un serveur cible via un ou plusieurs serveurs intermédiaires. Utile pour accéder à des serveurs derrière des pare-feu ou dans des réseaux privés.", + "jumpHostChain": "Chaîne de serveurs de rebond", + "addJumpHost": "Ajouter un serveur de rebond", + "selectServer": "Sélectionner un serveur", + "searchServers": "Rechercher des serveurs...", + "noServerFound": "Aucun serveur trouvé", + "jumpHostsOrder": "Les connexions seront établies dans l'ordre : Serveur de rebond 1 → Serveur de rebond 2 → ... → Serveur cible", + "advancedAuthSettings": "Paramètres d'authentification avancés" }, "terminal": { "title": "Terminal", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 2af8e80e..cd82e350 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -720,7 +720,18 @@ "noneAuthDescription": "Este método de autenticação usará autenticação interativa por teclado ao conectar ao servidor SSH.", "noneAuthDetails": "A autenticação interativa por teclado permite que o servidor solicite credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multifator ou entrada de senha dinâmica.", "forceKeyboardInteractive": "Forçar Interativo com Teclado", - "forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA)." + "forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA).", + "overrideCredentialUsername": "Substituir Nome de Usuário da Credencial", + "overrideCredentialUsernameDesc": "Use um nome de usuário diferente daquele armazenado na credencial. Isso permite que você use a mesma credencial com diferentes nomes de usuário.", + "jumpHosts": "Hosts de Salto", + "jumpHostsDescription": "Hosts de salto (também conhecidos como bastions) permitem que você se conecte a um servidor de destino através de um ou mais servidores intermediários. Isso é útil para acessar servidores atrás de firewalls ou em redes privadas.", + "jumpHostChain": "Cadeia de Hosts de Salto", + "addJumpHost": "Adicionar Host de Salto", + "selectServer": "Selecionar Servidor", + "searchServers": "Pesquisar servidores...", + "noServerFound": "Nenhum servidor encontrado", + "jumpHostsOrder": "As conexões serão feitas na ordem: Host de Salto 1 → Host de Salto 2 → ... → Servidor de Destino", + "advancedAuthSettings": "Configurações Avançadas de Autenticação" }, "terminal": { "title": "Terminal", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index add3f212..06e1b25f 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -831,7 +831,20 @@ "snippetNone": "Нет", "noneAuthTitle": "Интерактивная аутентификация по клавиатуре", "noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию по клавиатуре при подключении к SSH-серверу.", - "noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля." + "noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля.", + "forceKeyboardInteractive": "Принудительная клавиатурная аутентификация", + "forceKeyboardInteractiveDesc": "Принудительно использует интерактивную аутентификацию по клавиатуре. Часто требуется для серверов с двухфакторной аутентификацией (TOTP/2FA).", + "overrideCredentialUsername": "Переопределить имя пользователя учетных данных", + "overrideCredentialUsernameDesc": "Используйте другое имя пользователя, отличное от того, что хранится в учетных данных. Это позволяет использовать одни и те же учетные данные с разными именами пользователей.", + "jumpHosts": "Промежуточные хосты", + "jumpHostsDescription": "Промежуточные хосты (также известные как бастионы) позволяют подключаться к целевому серверу через один или несколько промежуточных серверов. Это полезно для доступа к серверам за брандмауэрами или в частных сетях.", + "jumpHostChain": "Цепочка промежуточных хостов", + "addJumpHost": "Добавить промежуточный хост", + "selectServer": "Выбрать сервер", + "searchServers": "Поиск серверов...", + "noServerFound": "Сервер не найден", + "jumpHostsOrder": "Подключения будут выполнены в порядке: Промежуточный хост 1 → Промежуточный хост 2 → ... → Целевой сервер", + "advancedAuthSettings": "Расширенные настройки аутентификации" }, "terminal": { "title": "Терминал", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 615d1230..dbba8fac 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -868,7 +868,18 @@ "noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。", "noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。", "forceKeyboardInteractive": "强制键盘交互式认证", - "forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。" + "forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。", + "overrideCredentialUsername": "覆盖凭据用户名", + "overrideCredentialUsernameDesc": "使用与凭据中存储的用户名不同的用户名。这允许您对不同的用户名使用相同的凭据。", + "jumpHosts": "跳板主机", + "jumpHostsDescription": "跳板主机(也称为堡垒主机)允许您通过一个或多个中间服务器连接到目标服务器。这对于访问防火墙后或私有网络中的服务器很有用。", + "jumpHostChain": "跳板主机链", + "addJumpHost": "添加跳板主机", + "selectServer": "选择服务器", + "searchServers": "搜索服务器...", + "noServerFound": "未找到服务器", + "jumpHostsOrder": "连接将按顺序进行:跳板主机 1 → 跳板主机 2 → ... → 目标服务器", + "advancedAuthSettings": "高级身份验证设置" }, "terminal": { "title": "终端", diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index c2ae66d7..b77776ca 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -8,7 +8,7 @@ import { useTabs, } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; -import { CommandHistoryProvider } from "@/ui/desktop/contexts/CommandHistoryContext.tsx"; +import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx"; import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; import { Toaster } from "@/components/ui/sonner.tsx"; @@ -31,7 +31,7 @@ function AppContent() { const { currentTab, tabs } = useTabs(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [rightSidebarOpen, setRightSidebarOpen] = useState(false); - const [rightSidebarWidth, setRightSidebarWidth] = useState(400); + const [rightSidebarWidth, setRightSidebarWidth] = useState(300); const lastShiftPressTime = useRef(0); @@ -159,7 +159,7 @@ function AppContent() { const showProfile = currentTabData?.type === "user_profile"; return ( -
+
([]); const [sessionsLoading, setSessionsLoading] = React.useState(false); + const [convertUserDialogOpen, setConvertUserDialogOpen] = + React.useState(false); + const [convertTargetUser, setConvertTargetUser] = 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 requiresImportPassword = React.useMemo( () => !currentUser?.is_oidc, [currentUser?.is_oidc], @@ -636,6 +655,57 @@ export function AdminSettings({ ); }; + const handleConvertOIDCUser = (user: { id: string; username: string }) => { + setConvertTargetUser(user); + setConvertPassword(""); + setConvertTotpCode(""); + setConvertUserDialogOpen(true); + }; + + const handleConvertSubmit = async () => { + if (!convertTargetUser || !convertPassword) { + toast.error("Password is required"); + return; + } + + if (convertPassword.length < 8) { + toast.error("Password must be at least 8 characters long"); + return; + } + + setConvertLoading(true); + try { + const result = await convertOIDCToPassword( + convertTargetUser.id, + convertPassword, + convertTotpCode || undefined, + ); + + toast.success( + result.message || + `User ${convertTargetUser.username} converted to password authentication`, + ); + setConvertUserDialogOpen(false); + setConvertPassword(""); + setConvertTotpCode(""); + setConvertTargetUser(null); + fetchUsers(); + } 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", + ); + } + } finally { + setConvertLoading(false); + } + }; + const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const bottomMarginPx = 8; @@ -1030,15 +1100,35 @@ export function AdminSettings({ : t("admin.local")} - +
+ {user.is_oidc && ( + + )} + +
))} @@ -1414,6 +1504,79 @@ export function AdminSettings({
+ + {/* Convert OIDC to Password Dialog */} + + + + Convert to Password Authentication + + 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. + + + +
+ + Important + + This action will: +
    +
  • Set a new password for this user
  • +
  • Disable OIDC/SSO login for this account
  • +
  • Log out all active sessions
  • +
  • Preserve all user data (SSH hosts, credentials, etc.)
  • +
+
+
+ +
+ + setConvertPassword(e.target.value)} + placeholder="Enter new password" + disabled={convertLoading} + /> +
+ +
+ + setConvertTotpCode(e.target.value)} + placeholder="000000" + disabled={convertLoading} + maxLength={6} + /> +
+
+ + + + + +
+
); } diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index f1683142..37c79697 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -20,6 +20,7 @@ import { import { useSidebar } from "@/components/ui/sidebar.tsx"; import { Separator } from "@/components/ui/separator.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; +import { Kbd, KbdGroup } from "@/components/ui/kbd"; import { ChartLine, Clock, @@ -61,7 +62,7 @@ export function Dashboard({ isTopbarOpen, onSelectView, rightSidebarOpen = false, - rightSidebarWidth = 400, + rightSidebarWidth = 300, }: DashboardProps): React.ReactElement { const { t } = useTranslation(); const [loggedIn, setLoggedIn] = useState(isAuthenticated); @@ -357,6 +358,11 @@ export function Dashboard({ {t("dashboard.title")}
+
+

+ Press LShift twice to open the command palette +

+
+ + + + + {t("hosts.noServerFound")} + + {hosts + .filter((h) => !editingHost || h.id !== editingHost.id) + .map((host) => ( + { + onUpdate(host.id); + setOpen(false); + }} + > + +
+ + {host.name || `${host.username}@${host.ip}`} + + + {host.username}@{host.ip}:{host.port} + +
+
+ ))} +
+
+
+ +
+ + + ); +} + interface SSHHost { id: number; name: string; @@ -722,8 +810,13 @@ export function HostManagerEditor({ window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); - const { refreshServerPolling } = await import("@/ui/main-axios.ts"); - refreshServerPolling(); + // Notify the stats server to start/update polling for this specific host + if (savedHost?.id) { + const { notifyHostCreatedOrUpdated } = await import( + "@/ui/main-axios.ts" + ); + notifyHostCreatedOrUpdated(savedHost.id); + } } catch { toast.error(t("hosts.failedToSaveHost")); } finally { @@ -1406,169 +1499,102 @@ export function HostManagerEditor({ - ( - -
- - {t("hosts.forceKeyboardInteractive")} - - - {t("hosts.forceKeyboardInteractiveDesc")} - -
- - - -
- )} - /> - - {t("hosts.jumpHosts")} - - - - {t("hosts.jumpHostsDescription")} - - - ( - - {t("hosts.jumpHostChain")} - -
- {field.value.map((jumpHost, index) => { - const selectedHost = hosts.find( - (h) => h.id === jumpHost.hostId, - ); - const [open, setOpen] = React.useState(false); + + + + {t("hosts.advancedAuthSettings")} + + + ( + +
+ + {t("hosts.forceKeyboardInteractive")} + + + {t("hosts.forceKeyboardInteractiveDesc")} + +
+ + + +
+ )} + /> +
+
- return ( -
-
- - {index + 1}. - - - - - - - - - - {t("hosts.noServerFound")} - - - {hosts - .filter( - (h) => - !editingHost || - h.id !== editingHost.id, - ) - .map((host) => ( - { - const newJumpHosts = [ - ...field.value, - ]; - newJumpHosts[index] = { - hostId: host.id, - }; - field.onChange( - newJumpHosts, - ); - setOpen(false); - }} - > - -
- - {host.name || - `${host.username}@${host.ip}`} - - - {host.username}@{host.ip}: - {host.port} - -
-
- ))} -
-
-
-
-
+ + + {t("hosts.jumpHosts")} + + + + + {t("hosts.jumpHostsDescription")} + + + ( + + {t("hosts.jumpHostChain")} + +
+ {field.value.map((jumpHost, index) => ( + { + const newJumpHosts = [...field.value]; + newJumpHosts[index] = { hostId }; + field.onChange(newJumpHosts); + }} + onRemove={() => { + const newJumpHosts = field.value.filter( + (_, i) => i !== index, + ); + field.onChange(newJumpHosts); + }} + t={t} + /> + ))}
- ); - })} - -
- - - {t("hosts.jumpHostsOrder")} - - - )} - /> + + + {t("hosts.jumpHostsOrder")} + + + )} + /> + + +
{ + if (hostConfig?.id !== currentHostConfig?.id) { + // Reset state when switching to a different host + setServerStatus("offline"); + setMetrics(null); + setMetricsHistory([]); + setShowStatsUI(true); + } setCurrentHostConfig(hostConfig); - }, [hostConfig]); + }, [hostConfig?.id]); const renderWidget = (widgetType: WidgetType) => { switch (widgetType) { diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 364d0e48..90f32ab8 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -29,8 +29,8 @@ import { import type { TerminalConfig } from "@/types"; import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory"; -import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx"; -import { CommandAutocomplete } from "./CommandAutocomplete"; +import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx"; +import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; interface HostConfig { @@ -1421,14 +1421,10 @@ export const Terminal = forwardRef( useEffect(() => { if (!isVisible || !isReady || !fitAddonRef.current || !terminal) { - if (!isVisible && isFitted) { - setIsFitted(false); - } return; } - setIsFitted(false); - + // Don't set isFitted to false - keep terminal visible during resize let rafId1: number; let rafId2: number; @@ -1467,8 +1463,10 @@ export const Terminal = forwardRef( ref={xtermRef} className="h-full w-full" style={{ - visibility: - isReady && !isConnecting && isFitted ? "visible" : "hidden", + opacity: isReady && !isConnecting && isFitted ? 1 : 0, + transition: "opacity 100ms ease-in-out", + pointerEvents: + isReady && !isConnecting && isFitted ? "auto" : "none", }} onClick={() => { if (terminal && !splitScreen) { diff --git a/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx b/src/ui/desktop/apps/terminal/command-history/CommandAutocomplete.tsx similarity index 98% rename from src/ui/desktop/apps/terminal/CommandAutocomplete.tsx rename to src/ui/desktop/apps/terminal/command-history/CommandAutocomplete.tsx index d0d77aaf..8d0c907b 100644 --- a/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx +++ b/src/ui/desktop/apps/terminal/command-history/CommandAutocomplete.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils.ts"; interface CommandAutocompleteProps { suggestions: string[]; diff --git a/src/ui/desktop/contexts/CommandHistoryContext.tsx b/src/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx similarity index 100% rename from src/ui/desktop/contexts/CommandHistoryContext.tsx rename to src/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx diff --git a/src/ui/desktop/apps/tools/SSHUtilitySidebar.tsx b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx similarity index 89% rename from src/ui/desktop/apps/tools/SSHUtilitySidebar.tsx rename to src/ui/desktop/apps/tools/SSHToolsSidebar.tsx index 2074d6da..4b9b505c 100644 --- a/src/ui/desktop/apps/tools/SSHUtilitySidebar.tsx +++ b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx @@ -33,6 +33,7 @@ import { RotateCcw, Search, Loader2, + Terminal, } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -44,6 +45,8 @@ import { deleteSnippet, getCookie, setCookie, + getCommandHistory, + deleteCommandFromHistory, } from "@/ui/main-axios.ts"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import type { Snippet, SnippetData } from "../../../../types"; @@ -57,6 +60,10 @@ interface TabData { sendInput?: (data: string) => void; }; }; + hostConfig?: { + id: number; + }; + isActive?: boolean; [key: string]: unknown; } @@ -66,30 +73,25 @@ interface SSHUtilitySidebarProps { onSnippetExecute: (content: string) => void; sidebarWidth: number; setSidebarWidth: (width: number) => void; - commandHistory?: string[]; - onSelectCommand?: (command: string) => void; - onDeleteCommand?: (command: string) => void; - isHistoryLoading?: boolean; initialTab?: string; onTabChange?: () => void; } -export function SSHUtilitySidebar({ +export function SSHToolsSidebar({ isOpen, onClose, onSnippetExecute, sidebarWidth, setSidebarWidth, - commandHistory = [], - onSelectCommand, - onDeleteCommand, - isHistoryLoading = false, initialTab, onTabChange, }: SSHUtilitySidebarProps) { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); - const { tabs } = useTabs() as { tabs: TabData[] }; + const { tabs, currentTab } = useTabs() as { + tabs: TabData[]; + currentTab: number | null; + }; const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools"); // Update active tab when initialTab changes @@ -133,8 +135,9 @@ export function SSHUtilitySidebar({ ); // Command History state + const [commandHistory, setCommandHistory] = useState([]); + const [isHistoryLoading, setIsHistoryLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); // Resize state const [isResizing, setIsResizing] = useState(false); @@ -142,6 +145,31 @@ export function SSHUtilitySidebar({ const startWidthRef = React.useRef(sidebarWidth); const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal"); + const activeUiTab = tabs.find((tab) => tab.id === currentTab); + const activeTerminal = + activeUiTab?.type === "terminal" ? activeUiTab : undefined; + const activeTerminalHostId = activeTerminal?.hostConfig?.id; + + useEffect(() => { + if (isOpen && activeTab === "command-history") { + if (activeTerminalHostId) { + setIsHistoryLoading(true); + getCommandHistory(activeTerminalHostId) + .then((history) => { + setCommandHistory(history); + }) + .catch((err) => { + console.error("Failed to fetch command history", err); + setCommandHistory([]); + }) + .finally(() => { + setIsHistoryLoading(false); + }); + } else { + setCommandHistory([]); + } + } + }, [isOpen, activeTab, activeTerminalHostId]); // Filter command history based on search query const filteredCommands = searchQuery @@ -158,6 +186,21 @@ export function SSHUtilitySidebar({ ); }, [sidebarWidth]); + // Handle window resize to adjust sidebar width + useEffect(() => { + const handleResize = () => { + const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2)); + const maxWidth = Math.floor(window.innerWidth * 0.3); + if (sidebarWidth > maxWidth) { + setSidebarWidth(Math.max(minWidth, maxWidth)); + } else if (sidebarWidth < minWidth) { + setSidebarWidth(minWidth); + } + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [sidebarWidth, setSidebarWidth]); + useEffect(() => { if (isOpen && activeTab === "snippets") { fetchSnippets(); @@ -179,8 +222,8 @@ export function SSHUtilitySidebar({ if (startXRef.current == null) return; const dx = startXRef.current - e.clientX; // Reversed because we're on the right const newWidth = Math.round(startWidthRef.current + dx); - const minWidth = 300; - const maxWidth = Math.round(window.innerWidth * 0.5); + const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2)); + const maxWidth = Math.round(window.innerWidth * 0.3); let finalWidth = newWidth; if (newWidth < minWidth) { @@ -495,25 +538,34 @@ export function SSHUtilitySidebar({ // Command History handlers const handleCommandSelect = (command: string) => { - if (onSelectCommand) { - onSelectCommand(command); + if (activeTerminal?.terminalRef?.current?.sendInput) { + activeTerminal.terminalRef.current.sendInput(command); } }; const handleCommandDelete = (command: string) => { - if (onDeleteCommand) { + if (activeTerminalHostId) { confirmWithToast( t("commandHistory.deleteConfirmDescription", { defaultValue: `Delete "${command}" from history?`, command, }), - () => { - onDeleteCommand(command); - toast.success( - t("commandHistory.deleteSuccess", { - defaultValue: "Command deleted from history", - }), - ); + async () => { + try { + await deleteCommandFromHistory(activeTerminalHostId, command); + setCommandHistory((prev) => prev.filter((c) => c !== command)); + toast.success( + t("commandHistory.deleteSuccess", { + defaultValue: "Command deleted from history", + }), + ); + } catch { + toast.error( + t("commandHistory.deleteFailed", { + defaultValue: "Failed to delete command.", + }), + ); + } }, "destructive", ); @@ -542,7 +594,7 @@ export function SSHUtilitySidebar({
- )} +
))} )} - - - -
- - {filteredCommands.length}{" "} - {t("commandHistory.commandCount", { - defaultValue: - filteredCommands.length !== 1 - ? "commands" - : "command", - })} - -
diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx index 24f28871..1aa26cd6 100644 --- a/src/ui/desktop/navigation/AppView.tsx +++ b/src/ui/desktop/navigation/AppView.tsx @@ -42,7 +42,7 @@ interface TerminalViewProps { export function AppView({ isTopbarOpen = true, rightSidebarOpen = false, - rightSidebarWidth = 400, + rightSidebarWidth = 300, }: TerminalViewProps): React.ReactElement { const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as { tabs: TabData[]; @@ -70,16 +70,17 @@ export function AppView({ ); const [ready, setReady] = useState(true); const [resetKey, setResetKey] = useState(0); + const previousStylesRef = useRef>({}); - const updatePanelRects = () => { + const updatePanelRects = React.useCallback(() => { const next: Record = {}; Object.entries(panelRefs.current).forEach(([id, el]) => { if (el) next[id] = el.getBoundingClientRect(); }); setPanelRects(next); - }; + }, []); - const fitActiveAndNotify = () => { + const fitActiveAndNotify = React.useCallback(() => { const visibleIds: number[] = []; if (allSplitScreenTab.length === 0) { if (currentTab) visibleIds.push(currentTab); @@ -95,10 +96,10 @@ export function AppView({ if (ref?.refresh) ref.refresh(); } }); - }; + }, [allSplitScreenTab, currentTab, terminalTabs]); const layoutScheduleRef = useRef(null); - const scheduleMeasureAndFit = () => { + const scheduleMeasureAndFit = React.useCallback(() => { if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current); layoutScheduleRef.current = requestAnimationFrame(() => { @@ -107,18 +108,17 @@ export function AppView({ fitActiveAndNotify(); }); }); - }; + }, [updatePanelRects, fitActiveAndNotify]); - const hideThenFit = () => { - setReady(false); + const hideThenFit = React.useCallback(() => { + // Don't hide terminals, just fit them immediately requestAnimationFrame(() => { updatePanelRects(); requestAnimationFrame(() => { fitActiveAndNotify(); - setReady(true); }); }); - }; + }, [updatePanelRects, fitActiveAndNotify]); const prevStateRef = useRef({ terminalTabsLength: terminalTabs.length, @@ -158,11 +158,20 @@ export function AppView({ terminalTabs.length, allSplitScreenTab.join(","), terminalTabs, + hideThenFit, ]); useEffect(() => { scheduleMeasureAndFit(); - }, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]); + }, [ + scheduleMeasureAndFit, + allSplitScreenTab.length, + isTopbarOpen, + sidebarState, + resetKey, + rightSidebarOpen, + rightSidebarWidth, + ]); useEffect(() => { const roContainer = containerRef.current @@ -174,7 +183,7 @@ export function AppView({ if (containerRef.current && roContainer) roContainer.observe(containerRef.current); return () => roContainer?.disconnect(); - }, []); + }, [updatePanelRects, fitActiveAndNotify]); useEffect(() => { const onWinResize = () => { @@ -183,7 +192,7 @@ export function AppView({ }; window.addEventListener("resize", onWinResize); return () => window.removeEventListener("resize", onWinResize); - }, []); + }, [updatePanelRects, fitActiveAndNotify]); const HEADER_H = 28; @@ -208,33 +217,39 @@ export function AppView({ if (allSplitScreenTab.length === 0 && mainTab) { const isFileManagerTab = mainTab.type === "file_manager"; - styles[mainTab.id] = { - position: "absolute", + const newStyle = { + position: "absolute" as const, top: isFileManagerTab ? 0 : 4, left: isFileManagerTab ? 0 : 4, right: isFileManagerTab ? 0 : 4, bottom: isFileManagerTab ? 0 : 4, zIndex: 20, - display: "block", - pointerEvents: "auto", - opacity: ready ? 1 : 0, + display: "block" as const, + pointerEvents: "auto" as const, + opacity: 1, + transition: "opacity 150ms ease-in-out", }; + styles[mainTab.id] = newStyle; + previousStylesRef.current[mainTab.id] = newStyle; } else { layoutTabs.forEach((t: TabData) => { const rect = panelRects[String(t.id)]; const parentRect = containerRef.current?.getBoundingClientRect(); if (rect && parentRect) { - styles[t.id] = { - position: "absolute", + const newStyle = { + position: "absolute" as const, top: rect.top - parentRect.top + HEADER_H + 4, left: rect.left - parentRect.left + 4, width: rect.width - 8, height: rect.height - HEADER_H - 8, zIndex: 20, - display: "block", - pointerEvents: "auto", - opacity: ready ? 1 : 0, + display: "block" as const, + pointerEvents: "auto" as const, + opacity: 1, + transition: "opacity 150ms ease-in-out", }; + styles[t.id] = newStyle; + previousStylesRef.current[t.id] = newStyle; } }); } @@ -248,17 +263,31 @@ export function AppView({ const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab); + // Use previous style if available to maintain position + const previousStyle = previousStylesRef.current[t.id]; + + // For non-split screen tabs, always use the standard position + const isFileManagerTab = t.type === "file_manager"; + const standardStyle = { + position: "absolute" as const, + top: isFileManagerTab ? 0 : 4, + left: isFileManagerTab ? 0 : 4, + right: isFileManagerTab ? 0 : 4, + bottom: isFileManagerTab ? 0 : 4, + }; + const finalStyle: React.CSSProperties = hasStyle ? { ...styles[t.id], overflow: "hidden" } : ({ - position: "absolute", - inset: 0, - visibility: "hidden", + ...(previousStyle || standardStyle), + opacity: 0, pointerEvents: "none", zIndex: 0, + transition: "opacity 150ms ease-in-out", + overflow: "hidden", } as React.CSSProperties); - const effectiveVisible = isVisible && ready; + const effectiveVisible = isVisible; const isTerminal = t.type === "terminal"; const terminalConfig = { diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx index 78332324..90724a8c 100644 --- a/src/ui/desktop/navigation/LeftSidebar.tsx +++ b/src/ui/desktop/navigation/LeftSidebar.tsx @@ -289,7 +289,11 @@ export function LeftSidebar({ const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("leftSidebarWidth"); - return saved !== null ? parseInt(saved, 10) : 250; + const defaultWidth = 250; + const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth; + const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15)); + const maxWidth = Math.floor(window.innerWidth * 0.3); + return Math.min(savedWidth, Math.max(minWidth, maxWidth)); }); const [isResizing, setIsResizing] = useState(false); @@ -300,6 +304,20 @@ export function LeftSidebar({ localStorage.setItem("leftSidebarWidth", String(sidebarWidth)); }, [sidebarWidth]); + React.useEffect(() => { + const handleResize = () => { + const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15)); + const maxWidth = Math.floor(window.innerWidth * 0.3); + if (sidebarWidth > maxWidth) { + setSidebarWidth(Math.max(minWidth, maxWidth)); + } else if (sidebarWidth < minWidth) { + setSidebarWidth(minWidth); + } + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [sidebarWidth]); + const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); @@ -314,8 +332,8 @@ export function LeftSidebar({ if (startXRef.current == null) return; const dx = e.clientX - startXRef.current; const newWidth = Math.round(startWidthRef.current + dx); - const minWidth = 200; - const maxWidth = Math.round(window.innerWidth * 0.5); + const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15)); + const maxWidth = Math.round(window.innerWidth * 0.3); if (newWidth >= minWidth && newWidth <= maxWidth) { setSidebarWidth(newWidth); } else if (newWidth < minWidth) { @@ -394,14 +412,14 @@ export function LeftSidebar({ }, []); return ( -
+
-
+
diff --git a/src/ui/desktop/navigation/TOTPDialog.tsx b/src/ui/desktop/navigation/TOTPDialog.tsx index d0527b6b..c18aca59 100644 --- a/src/ui/desktop/navigation/TOTPDialog.tsx +++ b/src/ui/desktop/navigation/TOTPDialog.tsx @@ -25,7 +25,7 @@ export function TOTPDialog({ if (!isOpen) return null; return ( -
+
(() => { const saved = localStorage.getItem("rightSidebarWidth"); - return saved !== null ? parseInt(saved, 10) : 350; + const defaultWidth = 350; + 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); + return Math.min(savedWidth, Math.max(minWidth, maxWidth)); }); React.useEffect(() => { localStorage.setItem("rightSidebarWidth", String(rightSidebarWidth)); }, [rightSidebarWidth]); + React.useEffect(() => { + const handleResize = () => { + const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2)); + const maxWidth = Math.floor(window.innerWidth * 0.3); + if (rightSidebarWidth > maxWidth) { + setRightSidebarWidth(Math.max(minWidth, maxWidth)); + } else if (rightSidebarWidth < minWidth) { + setRightSidebarWidth(minWidth); + } + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [rightSidebarWidth]); + React.useEffect(() => { if (onRightSidebarStateChange) { onRightSidebarStateChange(toolsSidebarOpen, rightSidebarWidth); } }, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]); + const openCommandHistorySidebar = React.useCallback(() => { + setToolsSidebarOpen(true); + setCommandHistoryTabActive(true); + }, []); + // Register function to open command history sidebar React.useEffect(() => { - commandHistory.setOpenCommandHistory(() => { - setToolsSidebarOpen(true); - setCommandHistoryTabActive(true); - }); - }, [commandHistory]); + commandHistory.setOpenCommandHistory(openCommandHistorySidebar); + }, [commandHistory, openCommandHistorySidebar]); const rightPosition = toolsSidebarOpen ? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)` @@ -540,7 +560,7 @@ export function TopNavbar({
)} - setToolsSidebarOpen(false)} onSnippetExecute={handleSnippetExecute} diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index 90695a0c..963a71aa 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -74,7 +74,7 @@ async function handleLogout() { export function UserProfile({ isTopbarOpen = true, rightSidebarOpen = false, - rightSidebarWidth = 400, + rightSidebarWidth = 300, }: UserProfileProps) { const { t } = useTranslation(); const { state: sidebarState } = useSidebar(); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index c1adb3a9..4113b2df 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1920,6 +1920,17 @@ export async function refreshServerPolling(): Promise { } } +export async function notifyHostCreatedOrUpdated( + hostId: number, +): Promise { + try { + await statsApi.post("/host-updated", { hostId }); + } catch (error) { + // Silently fail - this is a background operation + console.warn("Failed to notify stats server of host update:", error); + } +} + // ============================================================================ // AUTHENTICATION // ============================================================================ @@ -2998,3 +3009,27 @@ export async function clearCommandHistory( throw handleApiError(error, "clear command history"); } } + +// ============================================================================ +// OIDC TO PASSWORD CONVERSION +// ============================================================================ + +/** + * Convert an OIDC user to a password-based user + */ +export async function convertOIDCToPassword( + targetUserId: string, + newPassword: string, + totpCode?: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post("/users/convert-oidc-to-password", { + targetUserId, + newPassword, + totpCode, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "convert OIDC user to password"); + } +}