v1.9.0 #437
@@ -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`),
|
||||||
|
|||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface EncryptedDEK {
|
|||||||
interface UserSession {
|
interface UserSession {
|
||||||
dataKey: Buffer;
|
dataKey: Buffer;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
lastActivity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserCrypto {
|
class UserCrypto {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Терминал",
|
||||||
|
|||||||
@@ -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": "终端",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -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>
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user