v1.9.0 #437

Merged
LukeGus merged 33 commits from dev-1.9.0 into main 2025-11-17 15:46:05 +00:00
28 changed files with 1235 additions and 311 deletions
Showing only changes of commit 08aef18989 - Show all commits

View File

@@ -99,6 +99,9 @@ export const sshData = sqliteTable("ssh_data", {
export const fileManagerRecent = sqliteTable("file_manager_recent", { export const fileManagerRecent = sqliteTable("file_manager_recent", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id") hostId: integer("host_id")
.notNull() .notNull()
.references(() => sshData.id, { onDelete: "cascade" }), .references(() => sshData.id, { onDelete: "cascade" }),
@@ -132,6 +135,9 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
hostId: integer("host_id") hostId: integer("host_id")
.notNull() .notNull()
.references(() => sshData.id, { onDelete: "cascade" }), .references(() => sshData.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
}); });
@@ -245,6 +251,7 @@ export const commandHistory = sqliteTable("command_history", {
hostId: integer("host_id") hostId: integer("host_id")
.notNull() .notNull()
.references(() => sshData.id, { onDelete: "cascade" }), .references(() => sshData.id, { onDelete: "cascade" }),
command: text("command").notNull(),
executedAt: text("executed_at") executedAt: text("executed_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),

View File

@@ -9,6 +9,7 @@ import {
fileManagerPinned, fileManagerPinned,
fileManagerShortcuts, fileManagerShortcuts,
sshFolders, sshFolders,
commandHistory,
} from "../db/schema.js"; } from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response } from "express"; 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); res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error("Failed to save SSH host to database", 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); res.json(resolvedHost);
} catch (err) { } catch (err) {
sshLogger.error("Failed to update SSH host in database", 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( async function resolveHostCredentials(
host: Record<string, unknown>, host: Record<string, unknown>,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {

View File

@@ -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<Buffer>((resolve, reject) => {
crypto.pbkdf2(
newPassword,
kekSalt,
100000,
32,
"sha256",
(err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
},
);
});
// Encrypt the existing DEK with new password-derived KEK
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv);
let encryptedDEK = cipher.update(currentDEK);
encryptedDEK = Buffer.concat([encryptedDEK, cipher.final()]);
const authTag = cipher.getAuthTag();
const encryptedDEKData = JSON.stringify({
iv: iv.toString("hex"),
encryptedKey: encryptedDEK.toString("hex"),
authTag: authTag.toString("hex"),
});
// Step 3: Hash the new password
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
// Step 4: Update user record atomically
await db
.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; export default router;

View File

@@ -820,11 +820,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
port, port,
}); });
jumpClient.end(); jumpClient.end();
return res return res.status(500).json({
.status(500) error: "Failed to forward through jump host: " + err.message,
.json({ });
error: "Failed to forward through jump host: " + err.message,
});
} }
config.sock = stream; 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 octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'"); const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}'`; const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
fileLogger.info("Changing file permissions", { fileLogger.info("Changing file permissions", {
operation: "change_permissions", operation: "change_permissions",
@@ -2743,24 +2744,66 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
permissions: octalPerms, 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) => { sshConn.client.exec(command, (err, stream) => {
if (err) { if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions exec error:", err, { fileLogger.error("SSH changePermissions exec error:", err, {
operation: "change_permissions", operation: "change_permissions",
sessionId, sessionId,
path, path,
permissions: octalPerms, 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 = ""; let errorOutput = "";
stream.stderr.on("data", (data) => { stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString(); errorOutput += data.toString();
}); });
stream.on("close", (code) => { 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) { if (code !== 0) {
fileLogger.error("chmod command failed", { fileLogger.error("chmod command failed", {
operation: "change_permissions", operation: "change_permissions",
@@ -2770,9 +2813,12 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
exitCode: code, exitCode: code,
error: errorOutput, error: errorOutput,
}); });
return res.status(500).json({ if (!res.headersSent) {
error: errorOutput || "Failed to change permissions", return res.status(500).json({
}); error: errorOutput || "Failed to change permissions",
});
}
return;
} }
fileLogger.success("File permissions changed successfully", { fileLogger.success("File permissions changed successfully", {
@@ -2782,13 +2828,16 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
permissions: octalPerms, permissions: octalPerms,
}); });
res.json({ if (!res.headersSent) {
success: true, res.json({
message: "Permissions changed successfully", success: true,
}); message: "Permissions changed successfully",
});
}
}); });
stream.on("error", (streamErr) => { stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions stream error:", streamErr, { fileLogger.error("SSH changePermissions stream error:", streamErr, {
operation: "change_permissions", operation: "change_permissions",
sessionId, sessionId,

View File

@@ -665,6 +665,7 @@ class PollingManager {
const statsConfig = this.parseStatsConfig(host.statsConfig); const statsConfig = this.parseStatsConfig(host.statsConfig);
const existingConfig = this.pollingConfigs.get(host.id); const existingConfig = this.pollingConfigs.get(host.id);
// Always clear existing timers first
if (existingConfig) { if (existingConfig) {
if (existingConfig.statusTimer) { if (existingConfig.statusTimer) {
clearInterval(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) { if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
this.pollingConfigs.delete(host.id); this.pollingConfigs.delete(host.id);
this.statusStore.delete(host.id); this.statusStore.delete(host.id);
this.metricsStore.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; return;
} }
@@ -694,8 +704,21 @@ class PollingManager {
config.statusTimer = setInterval(() => { config.statusTimer = setInterval(() => {
this.pollHostStatus(host); this.pollHostStatus(host);
}, intervalMs); }, intervalMs);
statsLogger.debug(
`Started status polling for host ${host.id} (interval: ${statsConfig.statusCheckInterval}s)`,
{
operation: "status_polling_started",
hostId: host.id,
interval: statsConfig.statusCheckInterval,
},
);
} else { } else {
this.statusStore.delete(host.id); this.statusStore.delete(host.id);
statsLogger.debug(`Status polling disabled for host ${host.id}`, {
operation: "status_polling_disabled",
hostId: host.id,
});
} }
if (statsConfig.metricsEnabled) { if (statsConfig.metricsEnabled) {
@@ -706,8 +729,21 @@ class PollingManager {
config.metricsTimer = setInterval(() => { config.metricsTimer = setInterval(() => {
this.pollHostMetrics(host); this.pollHostMetrics(host);
}, intervalMs); }, intervalMs);
statsLogger.debug(
`Started metrics polling for host ${host.id} (interval: ${statsConfig.metricsInterval}s)`,
{
operation: "metrics_polling_started",
hostId: host.id,
interval: statsConfig.metricsInterval,
},
);
} else { } else {
this.metricsStore.delete(host.id); this.metricsStore.delete(host.id);
statsLogger.debug(`Metrics polling disabled for host ${host.id}`, {
operation: "metrics_polling_disabled",
hostId: host.id,
});
} }
this.pollingConfigs.set(host.id, config); this.pollingConfigs.set(host.id, config);
@@ -731,6 +767,12 @@ class PollingManager {
} }
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> { private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
// Double-check that metrics are still enabled before collecting
const config = this.pollingConfigs.get(host.id);
if (!config || !config.statsConfig.metricsEnabled) {
return;
}
try { try {
const metrics = await collectMetrics(host); const metrics = await collectMetrics(host);
this.metricsStore.set(host.id, { this.metricsStore.set(host.id, {
@@ -738,12 +780,19 @@ class PollingManager {
timestamp: Date.now(), timestamp: Date.now(),
}); });
} catch (error) { } catch (error) {
statsLogger.warn("Failed to collect metrics for host", { const errorMessage =
operation: "metrics_poll_failed", error instanceof Error ? error.message : String(error);
hostId: host.id,
hostName: host.name, // Only log errors if metrics collection is actually enabled
error: error instanceof Error ? error.message : String(error), // 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" }); 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) => { app.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;

View File

@@ -22,6 +22,7 @@ interface EncryptedDEK {
interface UserSession { interface UserSession {
dataKey: Buffer; dataKey: Buffer;
expiresAt: number; expiresAt: number;
lastActivity?: number;
} }
class UserCrypto { class UserCrypto {

View File

@@ -774,7 +774,18 @@
"noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.", "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.", "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", "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": { "terminal": {
"title": "Terminal", "title": "Terminal",

View File

@@ -482,6 +482,26 @@
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?", "confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
"failedToRevokeSessions": "Failed to revoke sessions", "failedToRevokeSessions": "Failed to revoke sessions",
"sessionsRevokedSuccessfully": "Sessions revoked successfully", "sessionsRevokedSuccessfully": "Sessions revoked successfully",
"convertToPasswordAuth": "Convert to Password Authentication",
"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", "databaseSecurity": "Database Security",
"encryptionStatus": "Encryption Status", "encryptionStatus": "Encryption Status",
"encryptionEnabled": "Encryption Enabled", "encryptionEnabled": "Encryption Enabled",
@@ -860,7 +880,16 @@
"forceKeyboardInteractive": "Force Keyboard-Interactive", "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).", "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", "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": { "terminal": {
"title": "Terminal", "title": "Terminal",

View File

@@ -765,7 +765,18 @@
"noneAuthDescription": "Cette méthode utilisera l'authentification clavier-interactif lors de la connexion au serveur SSH.", "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.", "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", "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": { "terminal": {
"title": "Terminal", "title": "Terminal",

View File

@@ -720,7 +720,18 @@
"noneAuthDescription": "Este método de autenticação usará autenticação interativa por teclado ao conectar ao servidor SSH.", "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.", "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", "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": { "terminal": {
"title": "Terminal", "title": "Terminal",

View File

@@ -831,7 +831,20 @@
"snippetNone": "Нет", "snippetNone": "Нет",
"noneAuthTitle": "Интерактивная аутентификация по клавиатуре", "noneAuthTitle": "Интерактивная аутентификация по клавиатуре",
"noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию по клавиатуре при подключении к SSH-серверу.", "noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию по клавиатуре при подключении к SSH-серверу.",
"noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля." "noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля.",
"forceKeyboardInteractive": "Принудительная клавиатурная аутентификация",
"forceKeyboardInteractiveDesc": "Принудительно использует интерактивную аутентификацию по клавиатуре. Часто требуется для серверов с двухфакторной аутентификацией (TOTP/2FA).",
"overrideCredentialUsername": "Переопределить имя пользователя учетных данных",
"overrideCredentialUsernameDesc": "Используйте другое имя пользователя, отличное от того, что хранится в учетных данных. Это позволяет использовать одни и те же учетные данные с разными именами пользователей.",
"jumpHosts": "Промежуточные хосты",
"jumpHostsDescription": "Промежуточные хосты (также известные как бастионы) позволяют подключаться к целевому серверу через один или несколько промежуточных серверов. Это полезно для доступа к серверам за брандмауэрами или в частных сетях.",
"jumpHostChain": "Цепочка промежуточных хостов",
"addJumpHost": "Добавить промежуточный хост",
"selectServer": "Выбрать сервер",
"searchServers": "Поиск серверов...",
"noServerFound": "Сервер не найден",
"jumpHostsOrder": "Подключения будут выполнены в порядке: Промежуточный хост 1 → Промежуточный хост 2 → ... → Целевой сервер",
"advancedAuthSettings": "Расширенные настройки аутентификации"
}, },
"terminal": { "terminal": {
"title": "Терминал", "title": "Терминал",

View File

@@ -868,7 +868,18 @@
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。", "noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。", "noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
"forceKeyboardInteractive": "强制键盘交互式认证", "forceKeyboardInteractive": "强制键盘交互式认证",
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证TOTP/2FA的服务器所必需的。" "forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证TOTP/2FA的服务器所必需的。",
"overrideCredentialUsername": "覆盖凭据用户名",
"overrideCredentialUsernameDesc": "使用与凭据中存储的用户名不同的用户名。这允许您对不同的用户名使用相同的凭据。",
"jumpHosts": "跳板主机",
"jumpHostsDescription": "跳板主机(也称为堡垒主机)允许您通过一个或多个中间服务器连接到目标服务器。这对于访问防火墙后或私有网络中的服务器很有用。",
"jumpHostChain": "跳板主机链",
"addJumpHost": "添加跳板主机",
"selectServer": "选择服务器",
"searchServers": "搜索服务器...",
"noServerFound": "未找到服务器",
"jumpHostsOrder": "连接将按顺序进行:跳板主机 1 → 跳板主机 2 → ... → 目标服务器",
"advancedAuthSettings": "高级身份验证设置"
}, },
"terminal": { "terminal": {
"title": "终端", "title": "终端",

View File

@@ -8,7 +8,7 @@ import {
useTabs, useTabs,
} from "@/ui/desktop/navigation/tabs/TabContext.tsx"; } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { CommandHistoryProvider } from "@/ui/desktop/contexts/CommandHistoryContext.tsx"; import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx"; import { Toaster } from "@/components/ui/sonner.tsx";
@@ -31,7 +31,7 @@ function AppContent() {
const { currentTab, tabs } = useTabs(); const { currentTab, tabs } = useTabs();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [rightSidebarOpen, setRightSidebarOpen] = useState(false); const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(400); const [rightSidebarWidth, setRightSidebarWidth] = useState(300);
const lastShiftPressTime = useRef(0); const lastShiftPressTime = useRef(0);
@@ -159,7 +159,7 @@ function AppContent() {
const showProfile = currentTabData?.type === "user_profile"; const showProfile = currentTabData?.type === "user_profile";
return ( return (
<div> <div className="h-screen w-screen overflow-hidden">
<CommandPalette <CommandPalette
isOpen={isCommandPaletteOpen} isOpen={isCommandPaletteOpen}
setIsOpen={setIsCommandPaletteOpen} setIsOpen={setIsCommandPaletteOpen}

View File

@@ -13,6 +13,14 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@/components/ui/tabs.tsx"; } from "@/components/ui/tabs.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import { import {
Table, Table,
TableBody, TableBody,
@@ -55,6 +63,7 @@ import {
getSessions, getSessions,
revokeSession, revokeSession,
revokeAllUserSessions, revokeAllUserSessions,
convertOIDCToPassword,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
interface AdminSettingsProps { interface AdminSettingsProps {
@@ -66,7 +75,7 @@ interface AdminSettingsProps {
export function AdminSettings({ export function AdminSettings({
isTopbarOpen = true, isTopbarOpen = true,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 400, rightSidebarWidth = 300,
}: AdminSettingsProps): React.ReactElement { }: AdminSettingsProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
@@ -138,6 +147,16 @@ export function AdminSettings({
>([]); >([]);
const [sessionsLoading, setSessionsLoading] = React.useState(false); 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( const requiresImportPassword = React.useMemo(
() => !currentUser?.is_oidc, () => !currentUser?.is_oidc,
[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 topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;
@@ -1030,15 +1100,35 @@ export function AdminSettings({
: t("admin.local")} : t("admin.local")}
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell className="px-4">
<Button <div className="flex gap-2">
variant="ghost" {user.is_oidc && (
size="sm" <Button
onClick={() => handleDeleteUser(user.username)} variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50" size="sm"
disabled={user.is_admin} onClick={() =>
> handleConvertOIDCUser({
<Trash2 className="h-4 w-4" /> id: user.id,
</Button> username: user.username,
})
}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
title="Convert to password authentication"
>
<Lock className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteUser(user.username)
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -1414,6 +1504,79 @@ export function AdminSettings({
</Tabs> </Tabs>
</div> </div>
</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>
<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-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>
<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>
</div> </div>
); );
} }

View File

@@ -20,6 +20,7 @@ import {
import { useSidebar } from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { import {
ChartLine, ChartLine,
Clock, Clock,
@@ -61,7 +62,7 @@ export function Dashboard({
isTopbarOpen, isTopbarOpen,
onSelectView, onSelectView,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 400, rightSidebarWidth = 300,
}: DashboardProps): React.ReactElement { }: DashboardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
@@ -357,6 +358,11 @@ export function Dashboard({
{t("dashboard.title")} {t("dashboard.title")}
</div> </div>
<div className="flex flex-row gap-3"> <div className="flex flex-row gap-3">
<div className="flex flex-col items-center gap-4 justify-center mr-5">
<p className="text-muted-foreground text-sm">
Press <Kbd>LShift</Kbd> twice to open the command palette
</p>
</div>
<Button <Button
className="font-semibold" className="font-semibold"
variant="outline" variant="outline"

View File

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

View File

@@ -81,6 +81,94 @@ import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx"
import type { TerminalConfig } from "@/types"; import type { TerminalConfig } from "@/types";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
interface JumpHostItemProps {
jumpHost: { hostId: number };
index: number;
hosts: SSHHost[];
editingHost?: SSHHost | null;
onUpdate: (hostId: number) => void;
onRemove: () => void;
t: (key: string) => string;
}
function JumpHostItem({
jumpHost,
index,
hosts,
editingHost,
onUpdate,
onRemove,
t,
}: JumpHostItemProps) {
const [open, setOpen] = React.useState(false);
const selectedHost = hosts.find((h) => h.id === jumpHost.hostId);
return (
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium text-muted-foreground">
{index + 1}.
</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex-1 justify-between"
>
{selectedHost
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}`
: t("hosts.selectServer")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder={t("hosts.searchServers")} />
<CommandEmpty>{t("hosts.noServerFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{hosts
.filter((h) => !editingHost || h.id !== editingHost.id)
.map((host) => (
<CommandItem
key={host.id}
value={`${host.name} ${host.ip} ${host.username}`}
onSelect={() => {
onUpdate(host.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
jumpHost.hostId === host.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{host.name || `${host.username}@${host.ip}`}
</span>
<span className="text-xs text-muted-foreground">
{host.username}@{host.ip}:{host.port}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button type="button" variant="ghost" size="icon" onClick={onRemove}>
<X className="h-4 w-4" />
</Button>
</div>
);
}
interface SSHHost { interface SSHHost {
id: number; id: number;
name: string; name: string;
@@ -722,8 +810,13 @@ export function HostManagerEditor({
window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
const { refreshServerPolling } = await import("@/ui/main-axios.ts"); // Notify the stats server to start/update polling for this specific host
refreshServerPolling(); if (savedHost?.id) {
const { notifyHostCreatedOrUpdated } = await import(
"@/ui/main-axios.ts"
);
notifyHostCreatedOrUpdated(savedHost.id);
}
} catch { } catch {
toast.error(t("hosts.failedToSaveHost")); toast.error(t("hosts.failedToSaveHost"));
} finally { } finally {
@@ -1406,169 +1499,102 @@ export function HostManagerEditor({
</Alert> </Alert>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<FormField
control={form.control}
name="forceKeyboardInteractive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 mt-4">
<div className="space-y-0.5">
<FormLabel>
{t("hosts.forceKeyboardInteractive")}
</FormLabel>
<FormDescription>
{t("hosts.forceKeyboardInteractiveDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator className="my-6" /> <Separator className="my-6" />
<FormLabel className="mb-3 font-bold"> <Accordion type="multiple" className="w-full">
{t("hosts.jumpHosts")} <AccordionItem value="advanced-auth">
</FormLabel> <AccordionTrigger>
<Alert className="mt-2 mb-4"> {t("hosts.advancedAuthSettings")}
<AlertDescription> </AccordionTrigger>
{t("hosts.jumpHostsDescription")} <AccordionContent className="space-y-4 pt-4">
</AlertDescription> <FormField
</Alert> control={form.control}
<FormField name="forceKeyboardInteractive"
control={form.control} render={({ field }) => (
name="jumpHosts" <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
render={({ field }) => ( <div className="space-y-0.5">
<FormItem className="mt-4"> <FormLabel>
<FormLabel>{t("hosts.jumpHostChain")}</FormLabel> {t("hosts.forceKeyboardInteractive")}
<FormControl> </FormLabel>
<div className="space-y-3"> <FormDescription>
{field.value.map((jumpHost, index) => { {t("hosts.forceKeyboardInteractiveDesc")}
const selectedHost = hosts.find( </FormDescription>
(h) => h.id === jumpHost.hostId, </div>
); <FormControl>
const [open, setOpen] = React.useState(false); <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
return ( <AccordionItem value="jump-hosts">
<div <AccordionTrigger>
key={index} {t("hosts.jumpHosts")}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30" </AccordionTrigger>
> <AccordionContent className="space-y-4 pt-4">
<div className="flex items-center gap-2 flex-1"> <Alert>
<span className="text-sm font-medium text-muted-foreground"> <AlertDescription>
{index + 1}. {t("hosts.jumpHostsDescription")}
</span> </AlertDescription>
<Popover open={open} onOpenChange={setOpen}> </Alert>
<PopoverTrigger asChild> <FormField
<Button control={form.control}
variant="outline" name="jumpHosts"
role="combobox" render={({ field }) => (
aria-expanded={open} <FormItem>
className="flex-1 justify-between" <FormLabel>{t("hosts.jumpHostChain")}</FormLabel>
> <FormControl>
{selectedHost <div className="space-y-3">
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}` {field.value.map((jumpHost, index) => (
: t("hosts.selectServer")} <JumpHostItem
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> key={index}
</Button> jumpHost={jumpHost}
</PopoverTrigger> index={index}
<PopoverContent className="w-[400px] p-0"> hosts={hosts}
<Command> editingHost={editingHost}
<CommandInput onUpdate={(hostId) => {
placeholder={t( const newJumpHosts = [...field.value];
"hosts.searchServers", newJumpHosts[index] = { hostId };
)} field.onChange(newJumpHosts);
/> }}
<CommandEmpty> onRemove={() => {
{t("hosts.noServerFound")} const newJumpHosts = field.value.filter(
</CommandEmpty> (_, i) => i !== index,
<CommandGroup className="max-h-[300px] overflow-y-auto"> );
{hosts field.onChange(newJumpHosts);
.filter( }}
(h) => t={t}
!editingHost || />
h.id !== editingHost.id, ))}
)
.map((host) => (
<CommandItem
key={host.id}
value={`${host.name} ${host.ip} ${host.username}`}
onSelect={() => {
const newJumpHosts = [
...field.value,
];
newJumpHosts[index] = {
hostId: host.id,
};
field.onChange(
newJumpHosts,
);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
jumpHost.hostId ===
host.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{host.name ||
`${host.username}@${host.ip}`}
</span>
<span className="text-xs text-muted-foreground">
{host.username}@{host.ip}:
{host.port}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
size="icon" size="sm"
onClick={() => { onClick={() => {
const newJumpHosts = field.value.filter( field.onChange([
(_, i) => i !== index, ...field.value,
); { hostId: 0 },
field.onChange(newJumpHosts); ]);
}} }}
> >
<X className="h-4 w-4" /> <Plus className="h-4 w-4 mr-2" />
{t("hosts.addJumpHost")}
</Button> </Button>
</div> </div>
); </FormControl>
})} <FormDescription>
<Button {t("hosts.jumpHostsOrder")}
type="button" </FormDescription>
variant="outline" </FormItem>
size="sm" )}
onClick={() => { />
field.onChange([...field.value, { hostId: 0 }]); </AccordionContent>
}} </AccordionItem>
> </Accordion>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addJumpHost")}
</Button>
</div>
</FormControl>
<FormDescription>
{t("hosts.jumpHostsOrder")}
</FormDescription>
</FormItem>
)}
/>
</TabsContent> </TabsContent>
<TabsContent value="terminal" className="space-y-1"> <TabsContent value="terminal" className="space-y-1">
<FormField <FormField

View File

@@ -103,8 +103,15 @@ export function Server({
const metricsEnabled = statsConfig.metricsEnabled !== false; const metricsEnabled = statsConfig.metricsEnabled !== false;
React.useEffect(() => { React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
// Reset state when switching to a different host
setServerStatus("offline");
setMetrics(null);
setMetricsHistory([]);
setShowStatsUI(true);
}
setCurrentHostConfig(hostConfig); setCurrentHostConfig(hostConfig);
}, [hostConfig]); }, [hostConfig?.id]);
const renderWidget = (widgetType: WidgetType) => { const renderWidget = (widgetType: WidgetType) => {
switch (widgetType) { switch (widgetType) {

View File

@@ -29,8 +29,8 @@ import {
import type { TerminalConfig } from "@/types"; import type { TerminalConfig } from "@/types";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory"; import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx"; import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
import { CommandAutocomplete } from "./CommandAutocomplete"; import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface HostConfig { interface HostConfig {
@@ -1421,14 +1421,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
useEffect(() => { useEffect(() => {
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) { if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
if (!isVisible && isFitted) {
setIsFitted(false);
}
return; return;
} }
setIsFitted(false); // Don't set isFitted to false - keep terminal visible during resize
let rafId1: number; let rafId1: number;
let rafId2: number; let rafId2: number;
@@ -1467,8 +1463,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
ref={xtermRef} ref={xtermRef}
className="h-full w-full" className="h-full w-full"
style={{ style={{
visibility: opacity: isReady && !isConnecting && isFitted ? 1 : 0,
isReady && !isConnecting && isFitted ? "visible" : "hidden", transition: "opacity 100ms ease-in-out",
pointerEvents:
isReady && !isConnecting && isFitted ? "auto" : "none",
}} }}
onClick={() => { onClick={() => {
if (terminal && !splitScreen) { if (terminal && !splitScreen) {

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils.ts";
interface CommandAutocompleteProps { interface CommandAutocompleteProps {
suggestions: string[]; suggestions: string[];

View File

@@ -33,6 +33,7 @@ import {
RotateCcw, RotateCcw,
Search, Search,
Loader2, Loader2,
Terminal,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -44,6 +45,8 @@ import {
deleteSnippet, deleteSnippet,
getCookie, getCookie,
setCookie, setCookie,
getCommandHistory,
deleteCommandFromHistory,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { Snippet, SnippetData } from "../../../../types"; import type { Snippet, SnippetData } from "../../../../types";
@@ -57,6 +60,10 @@ interface TabData {
sendInput?: (data: string) => void; sendInput?: (data: string) => void;
}; };
}; };
hostConfig?: {
id: number;
};
isActive?: boolean;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -66,30 +73,25 @@ interface SSHUtilitySidebarProps {
onSnippetExecute: (content: string) => void; onSnippetExecute: (content: string) => void;
sidebarWidth: number; sidebarWidth: number;
setSidebarWidth: (width: number) => void; setSidebarWidth: (width: number) => void;
commandHistory?: string[];
onSelectCommand?: (command: string) => void;
onDeleteCommand?: (command: string) => void;
isHistoryLoading?: boolean;
initialTab?: string; initialTab?: string;
onTabChange?: () => void; onTabChange?: () => void;
} }
export function SSHUtilitySidebar({ export function SSHToolsSidebar({
isOpen, isOpen,
onClose, onClose,
onSnippetExecute, onSnippetExecute,
sidebarWidth, sidebarWidth,
setSidebarWidth, setSidebarWidth,
commandHistory = [],
onSelectCommand,
onDeleteCommand,
isHistoryLoading = false,
initialTab, initialTab,
onTabChange, onTabChange,
}: SSHUtilitySidebarProps) { }: SSHUtilitySidebarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); 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"); const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
// Update active tab when initialTab changes // Update active tab when initialTab changes
@@ -133,8 +135,9 @@ export function SSHUtilitySidebar({
); );
// Command History state // Command History state
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
// Resize state // Resize state
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
@@ -142,6 +145,31 @@ export function SSHUtilitySidebar({
const startWidthRef = React.useRef<number>(sidebarWidth); const startWidthRef = React.useRef<number>(sidebarWidth);
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal"); 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 // Filter command history based on search query
const filteredCommands = searchQuery const filteredCommands = searchQuery
@@ -158,6 +186,21 @@ export function SSHUtilitySidebar({
); );
}, [sidebarWidth]); }, [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(() => { useEffect(() => {
if (isOpen && activeTab === "snippets") { if (isOpen && activeTab === "snippets") {
fetchSnippets(); fetchSnippets();
@@ -179,8 +222,8 @@ export function SSHUtilitySidebar({
if (startXRef.current == null) return; if (startXRef.current == null) return;
const dx = startXRef.current - e.clientX; // Reversed because we're on the right const dx = startXRef.current - e.clientX; // Reversed because we're on the right
const newWidth = Math.round(startWidthRef.current + dx); const newWidth = Math.round(startWidthRef.current + dx);
const minWidth = 300; const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.round(window.innerWidth * 0.5); const maxWidth = Math.round(window.innerWidth * 0.3);
let finalWidth = newWidth; let finalWidth = newWidth;
if (newWidth < minWidth) { if (newWidth < minWidth) {
@@ -495,25 +538,34 @@ export function SSHUtilitySidebar({
// Command History handlers // Command History handlers
const handleCommandSelect = (command: string) => { const handleCommandSelect = (command: string) => {
if (onSelectCommand) { if (activeTerminal?.terminalRef?.current?.sendInput) {
onSelectCommand(command); activeTerminal.terminalRef.current.sendInput(command);
} }
}; };
const handleCommandDelete = (command: string) => { const handleCommandDelete = (command: string) => {
if (onDeleteCommand) { if (activeTerminalHostId) {
confirmWithToast( confirmWithToast(
t("commandHistory.deleteConfirmDescription", { t("commandHistory.deleteConfirmDescription", {
defaultValue: `Delete "${command}" from history?`, defaultValue: `Delete "${command}" from history?`,
command, command,
}), }),
() => { async () => {
onDeleteCommand(command); try {
toast.success( await deleteCommandFromHistory(activeTerminalHostId, command);
t("commandHistory.deleteSuccess", { setCommandHistory((prev) => prev.filter((c) => c !== command));
defaultValue: "Command deleted from history", toast.success(
}), t("commandHistory.deleteSuccess", {
); defaultValue: "Command deleted from history",
}),
);
} catch {
toast.error(
t("commandHistory.deleteFailed", {
defaultValue: "Failed to delete command.",
}),
);
}
}, },
"destructive", "destructive",
); );
@@ -542,7 +594,7 @@ export function SSHUtilitySidebar({
<div className="absolute right-5 flex gap-1"> <div className="absolute right-5 flex gap-1">
<Button <Button
variant="outline" variant="outline"
onClick={() => setSidebarWidth(400)} onClick={() => setSidebarWidth(300)}
className="w-[28px] h-[28px]" className="w-[28px] h-[28px]"
title="Reset sidebar width" title="Reset sidebar width"
> >
@@ -855,7 +907,6 @@ export function SSHUtilitySidebar({
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
setSelectedCommandIndex(0);
}} }}
className="pl-10 pr-10" className="pl-10 pr-10"
/> />
@@ -874,7 +925,7 @@ export function SSHUtilitySidebar({
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{isHistoryLoading ? ( {isHistoryLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse py-8"> <div className="flex flex-row items-center justify-center text-muted-foreground text-sm animate-pulse py-8">
<Loader2 className="animate-spin mr-2" size={16} /> <Loader2 className="animate-spin mr-2" size={16} />
<span> <span>
{t("commandHistory.loading", { {t("commandHistory.loading", {
@@ -882,6 +933,21 @@ export function SSHUtilitySidebar({
})} })}
</span> </span>
</div> </div>
) : !activeTerminal ? (
<div className="text-center text-muted-foreground py-8">
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noTerminal", {
defaultValue: "No active terminal",
})}
</p>
<p className="text-sm">
{t("commandHistory.noTerminalHint", {
defaultValue:
"Open a terminal to see its command history.",
})}
</p>
</div>
) : filteredCommands.length === 0 ? ( ) : filteredCommands.length === 0 ? (
<div className="text-center text-muted-foreground py-8"> <div className="text-center text-muted-foreground py-8">
{searchQuery ? ( {searchQuery ? (
@@ -909,14 +975,14 @@ export function SSHUtilitySidebar({
<p className="text-sm"> <p className="text-sm">
{t("commandHistory.emptyHint", { {t("commandHistory.emptyHint", {
defaultValue: defaultValue:
"Execute commands to build your history", "Execute commands in the active terminal to build its history.",
})} })}
</p> </p>
</> </>
)} )}
</div> </div>
) : ( ) : (
<div className="space-y-2 overflow-y-auto max-h-[calc(100vh-300px)]"> <div className="space-y-2 overflow-y-auto max-h-[calc(100vh-280px)]">
{filteredCommands.map((command, index) => ( {filteredCommands.map((command, index) => (
<div <div
key={index} key={index}
@@ -929,42 +995,26 @@ export function SSHUtilitySidebar({
> >
{command} {command}
</span> </span>
{onDeleteCommand && ( <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400" onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); handleCommandDelete(command);
handleCommandDelete(command); }}
}} title={t("commandHistory.deleteTooltip", {
title={t("commandHistory.deleteTooltip", { defaultValue: "Delete command",
defaultValue: "Delete command", })}
})} >
> <Trash2 className="h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" /> </Button>
</Button>
)}
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
<Separator />
<div className="text-xs text-muted-foreground">
<span>
{filteredCommands.length}{" "}
{t("commandHistory.commandCount", {
defaultValue:
filteredCommands.length !== 1
? "commands"
: "command",
})}
</span>
</div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</SidebarContent> </SidebarContent>

View File

@@ -42,7 +42,7 @@ interface TerminalViewProps {
export function AppView({ export function AppView({
isTopbarOpen = true, isTopbarOpen = true,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 400, rightSidebarWidth = 300,
}: TerminalViewProps): React.ReactElement { }: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as { const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as {
tabs: TabData[]; tabs: TabData[];
@@ -70,16 +70,17 @@ export function AppView({
); );
const [ready, setReady] = useState<boolean>(true); const [ready, setReady] = useState<boolean>(true);
const [resetKey, setResetKey] = useState<number>(0); const [resetKey, setResetKey] = useState<number>(0);
const previousStylesRef = useRef<Record<number, React.CSSProperties>>({});
const updatePanelRects = () => { const updatePanelRects = React.useCallback(() => {
const next: Record<string, DOMRect | null> = {}; const next: Record<string, DOMRect | null> = {};
Object.entries(panelRefs.current).forEach(([id, el]) => { Object.entries(panelRefs.current).forEach(([id, el]) => {
if (el) next[id] = el.getBoundingClientRect(); if (el) next[id] = el.getBoundingClientRect();
}); });
setPanelRects(next); setPanelRects(next);
}; }, []);
const fitActiveAndNotify = () => { const fitActiveAndNotify = React.useCallback(() => {
const visibleIds: number[] = []; const visibleIds: number[] = [];
if (allSplitScreenTab.length === 0) { if (allSplitScreenTab.length === 0) {
if (currentTab) visibleIds.push(currentTab); if (currentTab) visibleIds.push(currentTab);
@@ -95,10 +96,10 @@ export function AppView({
if (ref?.refresh) ref.refresh(); if (ref?.refresh) ref.refresh();
} }
}); });
}; }, [allSplitScreenTab, currentTab, terminalTabs]);
const layoutScheduleRef = useRef<number | null>(null); const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => { const scheduleMeasureAndFit = React.useCallback(() => {
if (layoutScheduleRef.current) if (layoutScheduleRef.current)
cancelAnimationFrame(layoutScheduleRef.current); cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => { layoutScheduleRef.current = requestAnimationFrame(() => {
@@ -107,18 +108,17 @@ export function AppView({
fitActiveAndNotify(); fitActiveAndNotify();
}); });
}); });
}; }, [updatePanelRects, fitActiveAndNotify]);
const hideThenFit = () => { const hideThenFit = React.useCallback(() => {
setReady(false); // Don't hide terminals, just fit them immediately
requestAnimationFrame(() => { requestAnimationFrame(() => {
updatePanelRects(); updatePanelRects();
requestAnimationFrame(() => { requestAnimationFrame(() => {
fitActiveAndNotify(); fitActiveAndNotify();
setReady(true);
}); });
}); });
}; }, [updatePanelRects, fitActiveAndNotify]);
const prevStateRef = useRef({ const prevStateRef = useRef({
terminalTabsLength: terminalTabs.length, terminalTabsLength: terminalTabs.length,
@@ -158,11 +158,20 @@ export function AppView({
terminalTabs.length, terminalTabs.length,
allSplitScreenTab.join(","), allSplitScreenTab.join(","),
terminalTabs, terminalTabs,
hideThenFit,
]); ]);
useEffect(() => { useEffect(() => {
scheduleMeasureAndFit(); scheduleMeasureAndFit();
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]); }, [
scheduleMeasureAndFit,
allSplitScreenTab.length,
isTopbarOpen,
sidebarState,
resetKey,
rightSidebarOpen,
rightSidebarWidth,
]);
useEffect(() => { useEffect(() => {
const roContainer = containerRef.current const roContainer = containerRef.current
@@ -174,7 +183,7 @@ export function AppView({
if (containerRef.current && roContainer) if (containerRef.current && roContainer)
roContainer.observe(containerRef.current); roContainer.observe(containerRef.current);
return () => roContainer?.disconnect(); return () => roContainer?.disconnect();
}, []); }, [updatePanelRects, fitActiveAndNotify]);
useEffect(() => { useEffect(() => {
const onWinResize = () => { const onWinResize = () => {
@@ -183,7 +192,7 @@ export function AppView({
}; };
window.addEventListener("resize", onWinResize); window.addEventListener("resize", onWinResize);
return () => window.removeEventListener("resize", onWinResize); return () => window.removeEventListener("resize", onWinResize);
}, []); }, [updatePanelRects, fitActiveAndNotify]);
const HEADER_H = 28; const HEADER_H = 28;
@@ -208,33 +217,39 @@ export function AppView({
if (allSplitScreenTab.length === 0 && mainTab) { if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === "file_manager"; const isFileManagerTab = mainTab.type === "file_manager";
styles[mainTab.id] = { const newStyle = {
position: "absolute", position: "absolute" as const,
top: isFileManagerTab ? 0 : 4, top: isFileManagerTab ? 0 : 4,
left: isFileManagerTab ? 0 : 4, left: isFileManagerTab ? 0 : 4,
right: isFileManagerTab ? 0 : 4, right: isFileManagerTab ? 0 : 4,
bottom: isFileManagerTab ? 0 : 4, bottom: isFileManagerTab ? 0 : 4,
zIndex: 20, zIndex: 20,
display: "block", display: "block" as const,
pointerEvents: "auto", pointerEvents: "auto" as const,
opacity: ready ? 1 : 0, opacity: 1,
transition: "opacity 150ms ease-in-out",
}; };
styles[mainTab.id] = newStyle;
previousStylesRef.current[mainTab.id] = newStyle;
} else { } else {
layoutTabs.forEach((t: TabData) => { layoutTabs.forEach((t: TabData) => {
const rect = panelRects[String(t.id)]; const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect(); const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) { if (rect && parentRect) {
styles[t.id] = { const newStyle = {
position: "absolute", position: "absolute" as const,
top: rect.top - parentRect.top + HEADER_H + 4, top: rect.top - parentRect.top + HEADER_H + 4,
left: rect.left - parentRect.left + 4, left: rect.left - parentRect.left + 4,
width: rect.width - 8, width: rect.width - 8,
height: rect.height - HEADER_H - 8, height: rect.height - HEADER_H - 8,
zIndex: 20, zIndex: 20,
display: "block", display: "block" as const,
pointerEvents: "auto", pointerEvents: "auto" as const,
opacity: ready ? 1 : 0, 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 = const isVisible =
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab); 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 const finalStyle: React.CSSProperties = hasStyle
? { ...styles[t.id], overflow: "hidden" } ? { ...styles[t.id], overflow: "hidden" }
: ({ : ({
position: "absolute", ...(previousStyle || standardStyle),
inset: 0, opacity: 0,
visibility: "hidden",
pointerEvents: "none", pointerEvents: "none",
zIndex: 0, zIndex: 0,
transition: "opacity 150ms ease-in-out",
overflow: "hidden",
} as React.CSSProperties); } as React.CSSProperties);
const effectiveVisible = isVisible && ready; const effectiveVisible = isVisible;
const isTerminal = t.type === "terminal"; const isTerminal = t.type === "terminal";
const terminalConfig = { const terminalConfig = {

View File

@@ -289,7 +289,11 @@ export function LeftSidebar({
const [sidebarWidth, setSidebarWidth] = useState<number>(() => { const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("leftSidebarWidth"); 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); const [isResizing, setIsResizing] = useState(false);
@@ -300,6 +304,20 @@ export function LeftSidebar({
localStorage.setItem("leftSidebarWidth", String(sidebarWidth)); localStorage.setItem("leftSidebarWidth", String(sidebarWidth));
}, [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) => { const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
setIsResizing(true); setIsResizing(true);
@@ -314,8 +332,8 @@ export function LeftSidebar({
if (startXRef.current == null) return; if (startXRef.current == null) return;
const dx = e.clientX - startXRef.current; const dx = e.clientX - startXRef.current;
const newWidth = Math.round(startWidthRef.current + dx); const newWidth = Math.round(startWidthRef.current + dx);
const minWidth = 200; const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
const maxWidth = Math.round(window.innerWidth * 0.5); const maxWidth = Math.round(window.innerWidth * 0.3);
if (newWidth >= minWidth && newWidth <= maxWidth) { if (newWidth >= minWidth && newWidth <= maxWidth) {
setSidebarWidth(newWidth); setSidebarWidth(newWidth);
} else if (newWidth < minWidth) { } else if (newWidth < minWidth) {
@@ -394,14 +412,14 @@ export function LeftSidebar({
}, []); }, []);
return ( return (
<div className="min-h-svh"> <div className="h-screen w-screen overflow-hidden">
<SidebarProvider <SidebarProvider
open={isSidebarOpen} open={isSidebarOpen}
style={ style={
{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties
} }
> >
<div className="flex h-screen w-full"> <div className="flex h-screen w-screen overflow-hidden">
<Sidebar variant="floating"> <Sidebar variant="floating">
<SidebarHeader> <SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white"> <SidebarGroupLabel className="text-lg font-bold text-white">

View File

@@ -25,7 +25,7 @@ export function TOTPDialog({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="absolute inset-0 flex items-center justify-center z-50 animate-in fade-in duration-200"> <div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div <div
className="absolute inset-0 bg-dark-bg rounded-md" className="absolute inset-0 bg-dark-bg rounded-md"
style={{ backgroundColor: backgroundColor || undefined }} style={{ backgroundColor: backgroundColor || undefined }}

View File

@@ -7,8 +7,8 @@ import { Tab } from "@/ui/desktop/navigation/tabs/Tab.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx"; import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
import { SSHUtilitySidebar } from "@/ui/desktop/apps/tools/SSHUtilitySidebar.tsx"; import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx"; import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
interface TabData { interface TabData {
id: number; id: number;
@@ -62,26 +62,46 @@ export function TopNavbar({
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false); const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => { const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("rightSidebarWidth"); const saved = localStorage.getItem("rightSidebarWidth");
return saved !== null ? parseInt(saved, 10) : 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(() => { React.useEffect(() => {
localStorage.setItem("rightSidebarWidth", String(rightSidebarWidth)); localStorage.setItem("rightSidebarWidth", String(rightSidebarWidth));
}, [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(() => { React.useEffect(() => {
if (onRightSidebarStateChange) { if (onRightSidebarStateChange) {
onRightSidebarStateChange(toolsSidebarOpen, rightSidebarWidth); onRightSidebarStateChange(toolsSidebarOpen, rightSidebarWidth);
} }
}, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]); }, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]);
const openCommandHistorySidebar = React.useCallback(() => {
setToolsSidebarOpen(true);
setCommandHistoryTabActive(true);
}, []);
// Register function to open command history sidebar // Register function to open command history sidebar
React.useEffect(() => { React.useEffect(() => {
commandHistory.setOpenCommandHistory(() => { commandHistory.setOpenCommandHistory(openCommandHistorySidebar);
setToolsSidebarOpen(true); }, [commandHistory, openCommandHistorySidebar]);
setCommandHistoryTabActive(true);
});
}, [commandHistory]);
const rightPosition = toolsSidebarOpen const rightPosition = toolsSidebarOpen
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)` ? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
@@ -540,7 +560,7 @@ export function TopNavbar({
</div> </div>
)} )}
<SSHUtilitySidebar <SSHToolsSidebar
isOpen={toolsSidebarOpen} isOpen={toolsSidebarOpen}
onClose={() => setToolsSidebarOpen(false)} onClose={() => setToolsSidebarOpen(false)}
onSnippetExecute={handleSnippetExecute} onSnippetExecute={handleSnippetExecute}

View File

@@ -74,7 +74,7 @@ async function handleLogout() {
export function UserProfile({ export function UserProfile({
isTopbarOpen = true, isTopbarOpen = true,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 400, rightSidebarWidth = 300,
}: UserProfileProps) { }: UserProfileProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();

View File

@@ -1920,6 +1920,17 @@ export async function refreshServerPolling(): Promise<void> {
} }
} }
export async function notifyHostCreatedOrUpdated(
hostId: number,
): Promise<void> {
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 // AUTHENTICATION
// ============================================================================ // ============================================================================
@@ -2998,3 +3009,27 @@ export async function clearCommandHistory(
throw handleApiError(error, "clear command history"); 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");
}
}